/** * Analysis panel utilities for mesh analysis nodes * (Connected components, degenerate faces, open edges, self-intersections) */ /** * Build table header HTML * @param {string[]} columns - Column headers * @returns {string} HTML string */ export function buildTableHeader(columns) { let html = ''; for (const col of columns) { const align = col.align || 'left'; html += `${col.label}`; } html += ''; return html; } /** * Build table row HTML * @param {Object[]} cells - Cell data [{value, color, align}] * @returns {string} HTML string */ export function buildTableRow(cells) { let html = ''; for (const cell of cells) { const color = cell.color || '#ccc'; const align = cell.align || 'left'; const value = cell.value; html += `${value}`; } html += ''; return html; } /** * Build "and N more" row * @param {number} remaining - Number of remaining items * @param {number} colSpan - Number of columns to span * @param {string} itemName - Name of items (e.g., "components", "faces") * @returns {string} HTML string */ export function buildMoreRow(remaining, colSpan, itemName = "items") { return ` ... and ${remaining} more ${itemName} `; } /** * Build connected components HTML * @param {Object} meshData - Mesh component data * @param {number} maxDisplay - Max components to display (default: 20) * @returns {string} HTML string */ export function buildConnectedComponentsHTML(meshData, maxDisplay = 20) { const { mesh_name, num_components, total_faces, total_vertices, components } = meshData; let html = '
'; // Header html += `
`; html += `${mesh_name}: ${num_components} component(s)`; html += `
`; html += `
`; html += `Total: ${total_vertices.toLocaleString()} verts, ${total_faces.toLocaleString()} faces`; html += `
`; // Table html += ``; html += buildTableHeader([ { label: '#', align: 'left' }, { label: 'Faces', align: 'right' }, { label: 'Vertices', align: 'right' } ]); const displayComponents = components.slice(0, maxDisplay); const maxFaces = Math.max(...components.map(c => c.faces)); for (const comp of displayComponents) { const color = comp.faces === maxFaces ? '#6f6' : '#ccc'; html += buildTableRow([ { value: comp.id, color: '#888', align: 'left' }, { value: comp.faces.toLocaleString(), color: color, align: 'right' }, { value: comp.vertices.toLocaleString(), color: '#ccc', align: 'right' } ]); // Face indices for small components if (comp.face_indices && comp.face_indices.length > 0) { html += ``; html += ``; } } if (components.length > maxDisplay) { html += buildMoreRow(components.length - maxDisplay, 3, "components"); } html += '
`; html += `faces: ${comp.face_indices.join(', ')}`; html += `
'; return html; } /** * Build self-intersections HTML * @param {Object} data - Self-intersection data * @param {number} maxDisplay - Max faces to display (default: 20) * @returns {string} HTML string */ export function buildSelfIntersectionsHTML(data, maxDisplay = 20) { const { mesh_name, num_intersecting_faces, num_intersection_pairs, total_faces, total_vertices, has_cgal, faces } = data; let html = '
'; const statusColor = num_intersecting_faces === 0 ? '#6f6' : '#f66'; const statusText = num_intersecting_faces === 0 ? 'Clean' : `${num_intersecting_faces} intersecting face(s)`; html += `
`; html += `${mesh_name}: ${statusText}`; html += `
`; if (!has_cgal) { html += `
`; html += `CGAL not available - results may be incomplete`; html += `
`; } if (num_intersecting_faces > 0) { html += `
`; html += `${num_intersection_pairs} intersection pair(s) detected`; html += `
`; html += ``; html += buildTableHeader([ { label: 'Face', align: 'left' }, { label: 'Vertices', align: 'left' } ]); const displayFaces = faces.slice(0, maxDisplay); for (const face of displayFaces) { html += buildTableRow([ { value: face.id, color: '#f66', align: 'left' }, { value: `[${face.vertices.join(', ')}]`, color: '#888', align: 'left' } ]); } if (faces.length > maxDisplay) { html += buildMoreRow(faces.length - maxDisplay, 2, "faces"); } html += '
'; } else { html += `
`; html += `Total: ${total_vertices.toLocaleString()} vertices, ${total_faces.toLocaleString()} faces`; html += `
`; } html += '
'; return html; } /** * Build degenerate faces HTML * @param {Object} data - Degenerate faces data * @param {number} maxDisplay - Max faces to display (default: 20) * @returns {string} HTML string */ export function buildDegenerateFacesHTML(data, maxDisplay = 20) { const { mesh_name, num_degenerate, total_faces, total_vertices, faces } = data; let html = '
'; const statusColor = num_degenerate === 0 ? '#6f6' : '#f66'; const statusText = num_degenerate === 0 ? 'Clean' : `${num_degenerate} degenerate face(s)`; html += `
`; html += `${mesh_name}: ${statusText}`; html += `
`; if (num_degenerate > 0 && faces && faces.length > 0) { html += ``; html += buildTableHeader([ { label: 'Face', align: 'left' }, { label: 'Type', align: 'left' }, { label: 'Vertices', align: 'left' } ]); const displayFaces = faces.slice(0, maxDisplay); for (const face of displayFaces) { html += buildTableRow([ { value: face.id, color: '#f66', align: 'left' }, { value: face.type || 'degenerate', color: '#888', align: 'left' }, { value: `[${face.vertices.join(', ')}]`, color: '#888', align: 'left' } ]); } if (faces.length > maxDisplay) { html += buildMoreRow(faces.length - maxDisplay, 3, "faces"); } html += '
'; } else { html += `
`; html += `Total: ${total_vertices.toLocaleString()} vertices, ${total_faces.toLocaleString()} faces`; html += `
`; } html += '
'; return html; } /** * Build open edges HTML * @param {Object} data - Open edges data * @param {number} maxDisplay - Max edges to display (default: 20) * @returns {string} HTML string */ export function buildOpenEdgesHTML(data, maxDisplay = 20) { const { mesh_name, num_open_edges, total_faces, total_vertices, edges } = data; let html = '
'; const statusColor = num_open_edges === 0 ? '#6f6' : '#fc6'; const statusText = num_open_edges === 0 ? 'Watertight' : `${num_open_edges} open edge(s)`; html += `
`; html += `${mesh_name}: ${statusText}`; html += `
`; if (num_open_edges > 0 && edges && edges.length > 0) { html += ``; html += buildTableHeader([ { label: 'Edge', align: 'left' }, { label: 'Vertices', align: 'left' } ]); const displayEdges = edges.slice(0, maxDisplay); for (let i = 0; i < displayEdges.length; i++) { const edge = displayEdges[i]; html += buildTableRow([ { value: i, color: '#fc6', align: 'left' }, { value: `[${edge.vertices.join(', ')}]`, color: '#888', align: 'left' } ]); } if (edges.length > maxDisplay) { html += buildMoreRow(edges.length - maxDisplay, 2, "edges"); } html += '
'; } else { html += `
`; html += `Total: ${total_vertices.toLocaleString()} vertices, ${total_faces.toLocaleString()} faces`; html += `
`; } html += '
'; return html; } /** * Calculate dynamic height for analysis panel based on content * @param {number} numRows - Number of data rows * @param {number} maxRows - Maximum rows before truncation (default: 20) * @param {number} baseHeight - Base height for headers (default: 40) * @param {number} rowHeight - Height per row (default: 16) * @param {number} minHeight - Minimum panel height (default: 80) * @param {number} maxHeight - Maximum panel height (default: 250) * @returns {number} Panel height in pixels */ export function calculatePanelHeight(numRows, maxRows = 20, baseHeight = 40, rowHeight = 16, minHeight = 80, maxHeight = 250) { const displayRows = Math.min(numRows, maxRows) + 3; // +3 for header rows return Math.min(Math.max(minHeight, displayRows * rowHeight + baseHeight), maxHeight); } /** * Build text report HTML (for nodes that output plain text info/reports) * @param {string} text - Plain text report * @returns {string} HTML string with styling */ export function buildTextReportHTML(text) { // Convert newlines to
, escape HTML const escaped = text .replace(/&/g, '&') .replace(//g, '>') .replace(/\n/g, '
'); return `
${escaped}
`; }