The viewer JS/HTML/bundles are now committed in javascript/ (declared via pyproject [tool.comfy] web = "javascript") instead of being copied from the shared comfy-3d-viewers pip package at prestartup. Consequences: - requirements.txt is now just comfy-env (host env installs nothing else, per the never-touch-the-host-env principle) - prestartup_script.py no longer copies viewers or web_src; it keeps only setup_env() and the input-asset copies - WEB_DIRECTORY attribute removed; the pyproject declaration is the single source (served at /extensions/comfyui-geometrypack/, keyed by project name) - web_src/ deleted; its 6 widgets now live in javascript/js/ as the tracked source, edited directly - viewer_pbr.html hardcoded-fallback URL updated to the new mount name Iframe URLs auto-detect the mount from import.meta.url, so the folder rename and mount-name change are transparent at runtime.
658 lines
20 KiB
JavaScript
658 lines
20 KiB
JavaScript
/**
|
|
* BaseViewer - Foundation class for VTK viewers
|
|
*
|
|
* Provides core lifecycle management, VTK setup, and feature composition.
|
|
* Subclasses configure features and behavior through options.
|
|
*/
|
|
|
|
import { CameraController } from './CameraController.js';
|
|
import { ActorManager, Representation } from './ActorManager.js';
|
|
import { SettingsPanel } from '../ui/SettingsPanel.js';
|
|
import { ControlsBar, StandardControls } from '../ui/ControlsBar.js';
|
|
import { loadMesh } from '../loaders/LoaderFactory.js';
|
|
import { ScreenshotCapture } from '../features/ScreenshotCapture.js';
|
|
import { AxisIndicator } from '../features/AxisIndicator.js';
|
|
import { FieldVisualization } from '../features/FieldVisualization.js';
|
|
import { TextureManager } from '../features/TextureManager.js';
|
|
import { sendMeshLoaded, sendError, createMessageListener, extractFilename } from '../utils/MessageHandler.js';
|
|
import { getCenter, getMaxDimension, isValidBounds } from '../utils/BoundsUtils.js';
|
|
|
|
/**
|
|
* Default viewer configuration
|
|
*/
|
|
const DEFAULT_CONFIG = {
|
|
// Appearance
|
|
backgroundColor: [0.15, 0.15, 0.15],
|
|
defaultMeshColor: [0.6, 0.8, 1.0],
|
|
edgeColor: [0.2, 0.2, 0.2],
|
|
edgeWidth: 1.0,
|
|
showEdges: false,
|
|
|
|
// Camera
|
|
cameraDistanceMultiplier: 2.5,
|
|
parallelProjection: false,
|
|
|
|
// Lighting
|
|
twoSidedLighting: false,
|
|
|
|
// Features (enabled/disabled)
|
|
enableFields: true,
|
|
enableTextures: false,
|
|
enableScreenshot: true,
|
|
enableAxisIndicator: false,
|
|
enableSettings: true,
|
|
enableControls: true,
|
|
|
|
// UI
|
|
showLoadingOverlay: true,
|
|
showInfoOverlay: true
|
|
};
|
|
|
|
export class BaseViewer {
|
|
/**
|
|
* Create a BaseViewer
|
|
* @param {HTMLElement} container - Container element for the viewer
|
|
* @param {Object} config - Configuration options
|
|
*/
|
|
constructor(container, config = {}) {
|
|
this.container = container;
|
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
|
|
// VTK objects (initialized in initialize())
|
|
this.vtk = null;
|
|
this.renderWindow = null;
|
|
this.renderer = null;
|
|
this.interactor = null;
|
|
this.openGLRenderWindow = null;
|
|
|
|
// Feature modules
|
|
this.cameraController = null;
|
|
this.actorManager = null;
|
|
this.settingsPanel = null;
|
|
this.controlsBar = null;
|
|
this.screenshotCapture = null;
|
|
this.axisIndicator = null;
|
|
this.fieldVisualization = null;
|
|
this.textureManager = null;
|
|
|
|
// State
|
|
this.actors = [];
|
|
this.polyDatas = [];
|
|
this.currentFilename = null;
|
|
this.isInitialized = false;
|
|
|
|
// UI elements
|
|
this.loadingOverlay = null;
|
|
this.infoOverlay = null;
|
|
|
|
// Message listener cleanup
|
|
this._messageCleanup = null;
|
|
}
|
|
|
|
/**
|
|
* Initialize the viewer
|
|
* Must be called before loading meshes
|
|
*/
|
|
async initialize() {
|
|
if (this.isInitialized) {
|
|
console.warn('[BaseViewer] Already initialized');
|
|
return;
|
|
}
|
|
|
|
// Get VTK from global
|
|
this.vtk = window.vtk;
|
|
if (!this.vtk) {
|
|
throw new Error('VTK.js not loaded. Include vtk-gltf.js before initializing viewer.');
|
|
}
|
|
|
|
// Create VTK rendering pipeline
|
|
this._createRenderingPipeline();
|
|
|
|
// Initialize feature modules
|
|
this._initializeFeatures();
|
|
|
|
// Create UI elements
|
|
this._createUI();
|
|
|
|
// Setup resize handling
|
|
this._setupResizeHandler();
|
|
|
|
// Setup message listener for loading meshes
|
|
this._setupMessageListener();
|
|
|
|
this.isInitialized = true;
|
|
console.log('[BaseViewer] Initialized');
|
|
}
|
|
|
|
/**
|
|
* Create VTK rendering pipeline
|
|
* @private
|
|
*/
|
|
_createRenderingPipeline() {
|
|
// Create render window
|
|
this.renderWindow = this.vtk.Rendering.Core.vtkRenderWindow.newInstance();
|
|
|
|
// Create renderer
|
|
this.renderer = this.vtk.Rendering.Core.vtkRenderer.newInstance();
|
|
this.renderer.setBackground(...this.config.backgroundColor);
|
|
this.renderWindow.addRenderer(this.renderer);
|
|
|
|
// Two-sided lighting
|
|
if (this.config.twoSidedLighting) {
|
|
this.renderer.setTwoSidedLighting(true);
|
|
}
|
|
|
|
// Create OpenGL render window
|
|
this.openGLRenderWindow = this.vtk.Rendering.OpenGL.vtkRenderWindow.newInstance();
|
|
this.openGLRenderWindow.setContainer(this.container);
|
|
this.renderWindow.addView(this.openGLRenderWindow);
|
|
|
|
// Create interactor
|
|
this.interactor = this.vtk.Rendering.Core.vtkRenderWindowInteractor.newInstance();
|
|
this.interactor.setView(this.openGLRenderWindow);
|
|
this.interactor.initialize();
|
|
this.interactor.bindEvents(this.container);
|
|
|
|
// Set interactor style
|
|
const interactorStyle = this.vtk.Interaction.Style.vtkInteractorStyleTrackballCamera.newInstance();
|
|
this.interactor.setInteractorStyle(interactorStyle);
|
|
|
|
// Size to container
|
|
const { width, height } = this.container.getBoundingClientRect();
|
|
this.openGLRenderWindow.setSize(width, height);
|
|
}
|
|
|
|
/**
|
|
* Initialize feature modules
|
|
* @private
|
|
*/
|
|
_initializeFeatures() {
|
|
// Camera controller
|
|
this.cameraController = new CameraController(this.renderer, {
|
|
distanceMultiplier: this.config.cameraDistanceMultiplier,
|
|
parallelProjection: this.config.parallelProjection
|
|
});
|
|
|
|
// Actor manager
|
|
this.actorManager = new ActorManager(this.vtk, {
|
|
defaultColor: this.config.defaultMeshColor,
|
|
edgeColor: this.config.edgeColor,
|
|
edgeWidth: this.config.edgeWidth,
|
|
showEdges: this.config.showEdges
|
|
});
|
|
|
|
// Screenshot capture
|
|
if (this.config.enableScreenshot) {
|
|
this.screenshotCapture = new ScreenshotCapture(this.renderWindow);
|
|
}
|
|
|
|
// Axis indicator
|
|
if (this.config.enableAxisIndicator) {
|
|
this.axisIndicator = new AxisIndicator(this.vtk, this.interactor, {
|
|
enabled: true
|
|
});
|
|
}
|
|
|
|
// Field visualization
|
|
if (this.config.enableFields) {
|
|
this.fieldVisualization = new FieldVisualization(this.vtk, this.renderWindow);
|
|
}
|
|
|
|
// Texture manager
|
|
if (this.config.enableTextures) {
|
|
this.textureManager = new TextureManager(this.vtk, this.renderWindow);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create UI elements
|
|
* @private
|
|
*/
|
|
_createUI() {
|
|
// Loading overlay
|
|
if (this.config.showLoadingOverlay) {
|
|
this.loadingOverlay = this._createLoadingOverlay();
|
|
}
|
|
|
|
// Info overlay
|
|
if (this.config.showInfoOverlay) {
|
|
this.infoOverlay = this._createInfoOverlay();
|
|
}
|
|
|
|
// Settings panel
|
|
if (this.config.enableSettings && this.config.settingsConfig) {
|
|
this.settingsPanel = new SettingsPanel(this.config.settingsConfig);
|
|
this.settingsPanel.onApply((settings) => this._onSettingsApply(settings));
|
|
}
|
|
|
|
// Controls bar
|
|
if (this.config.enableControls && this.config.controlsConfig) {
|
|
this.controlsBar = new ControlsBar(this.config.controlsConfig);
|
|
this._setupControlsHandlers();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create loading overlay element
|
|
* @private
|
|
* @returns {HTMLElement}
|
|
*/
|
|
_createLoadingOverlay() {
|
|
const overlay = document.createElement('div');
|
|
overlay.id = 'loading';
|
|
overlay.className = 'loading';
|
|
overlay.innerHTML = `
|
|
<div class="spinner"></div>
|
|
<div class="loading-text">Loading mesh...</div>
|
|
<div class="loading-progress" style="display:none;width:140px;height:6px;margin-top:8px;
|
|
background:rgba(255,255,255,0.18);border-radius:3px;overflow:hidden;">
|
|
<div class="loading-progress-bar" style="width:0%;height:100%;
|
|
background:#00c8ff;transition:width 0.1s linear;"></div>
|
|
</div>
|
|
`;
|
|
this.container.parentElement.appendChild(overlay);
|
|
return overlay;
|
|
}
|
|
|
|
/**
|
|
* Update the download progress bar.
|
|
* @param {number|null} fraction - 0..1, or null for indeterminate (hides the bar)
|
|
*/
|
|
setProgress(fraction) {
|
|
if (!this.loadingOverlay) return;
|
|
const wrap = this.loadingOverlay.querySelector('.loading-progress');
|
|
const bar = this.loadingOverlay.querySelector('.loading-progress-bar');
|
|
const textEl = this.loadingOverlay.querySelector('.loading-text');
|
|
if (!wrap || !bar) return;
|
|
if (fraction == null || isNaN(fraction)) {
|
|
wrap.style.display = 'none'; // indeterminate -> spinner only
|
|
return;
|
|
}
|
|
const pct = Math.max(0, Math.min(100, Math.round(fraction * 100)));
|
|
wrap.style.display = 'block';
|
|
bar.style.width = pct + '%';
|
|
if (textEl) textEl.textContent = `Downloading mesh… ${pct}%`;
|
|
}
|
|
|
|
/**
|
|
* Create info overlay element
|
|
* @private
|
|
* @returns {HTMLElement}
|
|
*/
|
|
_createInfoOverlay() {
|
|
const overlay = document.createElement('div');
|
|
overlay.id = 'meshInfo';
|
|
overlay.className = 'mesh-info';
|
|
this.container.parentElement.appendChild(overlay);
|
|
return overlay;
|
|
}
|
|
|
|
/**
|
|
* Setup resize handler
|
|
* @private
|
|
*/
|
|
_setupResizeHandler() {
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
const { width, height } = this.container.getBoundingClientRect();
|
|
if (width > 0 && height > 0) {
|
|
this.openGLRenderWindow.setSize(width, height);
|
|
this.renderWindow.render();
|
|
}
|
|
});
|
|
resizeObserver.observe(this.container);
|
|
this._resizeObserver = resizeObserver;
|
|
}
|
|
|
|
/**
|
|
* Setup message listener for postMessage API
|
|
* @private
|
|
*/
|
|
_setupMessageListener() {
|
|
this._messageCleanup = createMessageListener((data) => {
|
|
if (data.type === 'loadMesh' && data.url) {
|
|
this.loadMesh(data.url);
|
|
} else if (data.type === 'screenshot') {
|
|
this.captureScreenshot();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Setup controls bar handlers
|
|
* @private
|
|
*/
|
|
_setupControlsHandlers() {
|
|
if (!this.controlsBar) return;
|
|
|
|
// Camera view buttons
|
|
this.controlsBar.onAction('camera-view', (direction) => {
|
|
this.setCameraView(direction);
|
|
});
|
|
|
|
// Screenshot button
|
|
this.controlsBar.onAction('screenshot', () => {
|
|
this.captureScreenshot();
|
|
});
|
|
|
|
// Settings button
|
|
this.controlsBar.onAction('settings', () => {
|
|
if (this.settingsPanel) {
|
|
this.settingsPanel.open();
|
|
}
|
|
});
|
|
|
|
// Edges toggle
|
|
this.controlsBar.onAction('edges-toggle', (show) => {
|
|
this.setEdgeVisibility(show);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle settings panel apply
|
|
* @private
|
|
* @param {Object} settings - Applied settings
|
|
*/
|
|
_onSettingsApply(settings) {
|
|
// Apply to actor manager
|
|
if (this.actorManager) {
|
|
this.actorManager.applySettings(this.actors, settings);
|
|
}
|
|
|
|
// Apply background color
|
|
if (settings.backgroundColor) {
|
|
this.renderer.setBackground(...settings.backgroundColor);
|
|
}
|
|
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Load a mesh from URL
|
|
* @param {string} url - Mesh file URL
|
|
* @returns {Promise<Object>} Load result with polyData, actors, fields
|
|
*/
|
|
async loadMesh(url) {
|
|
this.showLoading(true, 'Downloading mesh…');
|
|
this.setProgress(0);
|
|
|
|
try {
|
|
// Clear existing actors
|
|
this.clearScene();
|
|
|
|
// Detect format and load (with download progress)
|
|
const result = await loadMesh(url, this.vtk, {
|
|
renderer: this.renderer,
|
|
onProgress: (frac) => this.setProgress(frac)
|
|
});
|
|
|
|
// Store state
|
|
this.actors = result.actors;
|
|
this.polyDatas = result.polyData ? [result.polyData] : [];
|
|
this.currentFilename = extractFilename(url);
|
|
|
|
// Add actors to renderer
|
|
result.actors.forEach(actor => {
|
|
this.renderer.addActor(actor);
|
|
});
|
|
|
|
// Configure actors
|
|
this._configureLoadedActors(result);
|
|
|
|
// Setup camera
|
|
this._setupCameraForMesh();
|
|
|
|
// Update UI
|
|
this._updateInfoOverlay(result);
|
|
this.showLoading(false);
|
|
|
|
// Notify parent
|
|
sendMeshLoaded({
|
|
filename: this.currentFilename,
|
|
format: result.format,
|
|
actors: result.actors.length,
|
|
hasTexture: result.hasTexture || false,
|
|
hasVertexColors: result.hasVertexColors || false,
|
|
fields: result.fields || []
|
|
});
|
|
|
|
this.render();
|
|
return result;
|
|
|
|
} catch (error) {
|
|
console.error('[BaseViewer] Error loading mesh:', error);
|
|
this.showLoading(false);
|
|
sendError(error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configure actors after loading
|
|
* @private
|
|
* @param {Object} result - Load result
|
|
*/
|
|
_configureLoadedActors(result) {
|
|
// Apply texture fixes if enabled
|
|
if (this.config.enableTextures && this.textureManager && result.hasTexture) {
|
|
this.textureManager.applyTextureFixesMultiple(this.actors);
|
|
this.textureManager.configureRenderer(this.renderer);
|
|
} else if (result.hasVertexColors) {
|
|
// Mesh has vertex colors - don't override with default color
|
|
// Just configure edges if needed, colors are already set up by loader
|
|
this.actors.forEach(actor => {
|
|
const property = actor.getProperty();
|
|
if (property) {
|
|
if (this.config.showEdges) {
|
|
property.setEdgeVisibility(true);
|
|
if (this.config.edgeColor) {
|
|
property.setEdgeColor(...this.config.edgeColor);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
// Apply standard actor configuration
|
|
this.actorManager.configureActors(this.actors, {
|
|
color: this.config.defaultMeshColor,
|
|
showEdges: this.config.showEdges,
|
|
edgeColor: this.config.edgeColor
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setup camera position for loaded mesh
|
|
* @private
|
|
*/
|
|
_setupCameraForMesh() {
|
|
if (this.actors.length === 0) return;
|
|
|
|
// Get combined bounds
|
|
let bounds = this.actors[0].getBounds();
|
|
for (let i = 1; i < this.actors.length; i++) {
|
|
const actorBounds = this.actors[i].getBounds();
|
|
bounds = [
|
|
Math.min(bounds[0], actorBounds[0]),
|
|
Math.max(bounds[1], actorBounds[1]),
|
|
Math.min(bounds[2], actorBounds[2]),
|
|
Math.max(bounds[3], actorBounds[3]),
|
|
Math.min(bounds[4], actorBounds[4]),
|
|
Math.max(bounds[5], actorBounds[5])
|
|
];
|
|
}
|
|
|
|
if (isValidBounds(bounds)) {
|
|
this.cameraController.positionInitialCamera(bounds);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update info overlay with mesh information
|
|
* @private
|
|
* @param {Object} result - Load result
|
|
*/
|
|
_updateInfoOverlay(result) {
|
|
if (!this.infoOverlay) return;
|
|
|
|
const lines = [`File: ${this.currentFilename}`];
|
|
|
|
if (result.polyData) {
|
|
const numPoints = result.polyData.getNumberOfPoints();
|
|
const numCells = result.polyData.getNumberOfCells();
|
|
lines.push(`Points: ${numPoints.toLocaleString()}`);
|
|
lines.push(`Cells: ${numCells.toLocaleString()}`);
|
|
}
|
|
|
|
if (result.hasTexture) {
|
|
lines.push('Textured: Yes');
|
|
}
|
|
|
|
this.infoOverlay.innerHTML = lines.join('<br>');
|
|
}
|
|
|
|
/**
|
|
* Clear the scene
|
|
*/
|
|
clearScene() {
|
|
this.actors.forEach(actor => {
|
|
this.renderer.removeActor(actor);
|
|
});
|
|
this.actors = [];
|
|
this.polyDatas = [];
|
|
}
|
|
|
|
/**
|
|
* Set camera view direction
|
|
* @param {string} direction - '+X', '-X', '+Y', '-Y', '+Z', '-Z'
|
|
*/
|
|
setCameraView(direction) {
|
|
if (this.actors.length === 0) return;
|
|
|
|
const bounds = this.actors[0].getBounds();
|
|
this.cameraController.setCameraView(direction, bounds);
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Set edge visibility
|
|
* @param {boolean} visible
|
|
*/
|
|
setEdgeVisibility(visible) {
|
|
this.actors.forEach(actor => {
|
|
this.actorManager.setEdgeVisibility(actor, visible);
|
|
});
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Capture screenshot
|
|
* @returns {Promise<string>} Base64 image data
|
|
*/
|
|
async captureScreenshot() {
|
|
if (this.screenshotCapture) {
|
|
return this.screenshotCapture.captureAndSend();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show/hide loading overlay
|
|
* @param {boolean} show
|
|
* @param {string} text - Optional loading text
|
|
*/
|
|
showLoading(show, text = 'Loading mesh...') {
|
|
if (this.loadingOverlay) {
|
|
this.loadingOverlay.style.display = show ? 'flex' : 'none';
|
|
const textEl = this.loadingOverlay.querySelector('.loading-text');
|
|
if (textEl) {
|
|
textEl.textContent = text;
|
|
}
|
|
if (!show) {
|
|
// reset the progress bar for the next load
|
|
const wrap = this.loadingOverlay.querySelector('.loading-progress');
|
|
const bar = this.loadingOverlay.querySelector('.loading-progress-bar');
|
|
if (wrap) wrap.style.display = 'none';
|
|
if (bar) bar.style.width = '0%';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render the scene
|
|
*/
|
|
render() {
|
|
this.renderWindow.render();
|
|
}
|
|
|
|
/**
|
|
* Resize the viewer
|
|
* @param {number} width
|
|
* @param {number} height
|
|
*/
|
|
resize(width, height) {
|
|
this.openGLRenderWindow.setSize(width, height);
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Get current actors
|
|
* @returns {Object[]}
|
|
*/
|
|
getActors() {
|
|
return this.actors;
|
|
}
|
|
|
|
/**
|
|
* Get renderer
|
|
* @returns {Object}
|
|
*/
|
|
getRenderer() {
|
|
return this.renderer;
|
|
}
|
|
|
|
/**
|
|
* Cleanup and destroy viewer
|
|
*/
|
|
destroy() {
|
|
// Cleanup message listener
|
|
if (this._messageCleanup) {
|
|
this._messageCleanup();
|
|
}
|
|
|
|
// Cleanup resize observer
|
|
if (this._resizeObserver) {
|
|
this._resizeObserver.disconnect();
|
|
}
|
|
|
|
// Cleanup axis indicator
|
|
if (this.axisIndicator) {
|
|
this.axisIndicator.destroy();
|
|
}
|
|
|
|
// Clear scene
|
|
this.clearScene();
|
|
|
|
// Delete VTK objects
|
|
if (this.interactor) {
|
|
this.interactor.unbindEvents();
|
|
}
|
|
if (this.openGLRenderWindow) {
|
|
this.openGLRenderWindow.delete();
|
|
}
|
|
if (this.renderWindow) {
|
|
this.renderWindow.delete();
|
|
}
|
|
|
|
// Remove UI elements
|
|
if (this.loadingOverlay && this.loadingOverlay.parentElement) {
|
|
this.loadingOverlay.parentElement.removeChild(this.loadingOverlay);
|
|
}
|
|
if (this.infoOverlay && this.infoOverlay.parentElement) {
|
|
this.infoOverlay.parentElement.removeChild(this.infoOverlay);
|
|
}
|
|
|
|
console.log('[BaseViewer] Destroyed');
|
|
}
|
|
}
|
|
|
|
export default BaseViewer;
|