import { buildTopologyDisplayEdgePolylines, buildTopologyDisplayEdgePositions } from "./topologyDisplayEdges.js"; import { displayModeIsWireframe } from "./displaySettings.js"; function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } const DEFAULT_LINE_DEPTH_BIAS = 0; // Keep topology edges slightly above coplanar faces without letting nearby solids lose the depth test. export const TOPOLOGY_LINE_DEPTH_BIAS = 0.0045; const TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL = 0.00025; const MAX_TOPOLOGY_LINE_DEPTH_BIAS = 0.006; const COPLANAR_TOPOLOGY_EDGE_CLASSES = new Set(["tangent", "seam"]); const COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS = 0.006; const COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL = 0.00025; const MAX_COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS = 0.008; const DEFAULT_VISIBLE_TOPOLOGY_EDGE_CLASSES = Object.freeze(["feature"]); const TOPOLOGY_EDGE_CLASS_ORDER = Object.freeze(["feature", "tangent", "seam", "degenerate"]); const MAX_TOPOLOGY_LINE_STRIP_POLYLINES = 1200; const MAX_TOPOLOGY_LINE_STRIP_POSITION_VALUES = 180000; const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/; const DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS = Object.freeze({ feature: Object.freeze({ color: "#132232", opacity: 1, thickness: 1.15 }), tangent: Object.freeze({ color: "#132232", opacity: 0.5, thickness: 1.15 }), seam: Object.freeze({ color: "#132232", opacity: 0.85, thickness: 1.15 }), degenerate: Object.freeze({ color: "#132232", opacity: 1, thickness: 0 }) }); export function topologyLineDepthBiasForWidth(lineWidth = 1, { visibilityClass = "" } = {}) { const width = Number.isFinite(Number(lineWidth)) ? Math.max(1, Number(lineWidth)) : 1; const normalizedClass = String(visibilityClass || "").trim().toLowerCase(); const baseBias = COPLANAR_TOPOLOGY_EDGE_CLASSES.has(normalizedClass) ? COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS : TOPOLOGY_LINE_DEPTH_BIAS; const biasPerExtraPixel = COPLANAR_TOPOLOGY_EDGE_CLASSES.has(normalizedClass) ? COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL : TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL; const maxBias = COPLANAR_TOPOLOGY_EDGE_CLASSES.has(normalizedClass) ? MAX_COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS : MAX_TOPOLOGY_LINE_DEPTH_BIAS; return clamp( baseBias + Math.max(0, width - 1) * biasPerExtraPixel, baseBias, maxBias ); } export function lineSegmentPositionsFromGeometry(geometry) { const positionAttribute = geometry?.getAttribute?.("position"); const rawPositions = positionAttribute?.array; if (!positionAttribute?.count || !rawPositions?.length) { return null; } return rawPositions; } export function syncLineMaterialOpacity(material, opacity) { if (!material) { return; } const nextOpacity = clamp(Number(opacity) || 0, 0, 1); const nextTransparent = nextOpacity < 0.999; material.opacity = nextOpacity; material.depthWrite = false; if (material.transparent !== nextTransparent) { material.transparent = nextTransparent; material.needsUpdate = true; } } export function syncScreenSpaceLineMaterialResolution(materials, width, height) { const nextWidth = Math.max(1, Math.floor(Number(width) || 1)); const nextHeight = Math.max(1, Math.floor(Number(height) || 1)); for (const material of materials || []) { material?.resolution?.set?.(nextWidth, nextHeight); } } function registerLineMaterial(context = {}, material, materials = null) { materials?.add?.(material); context.registerScreenSpaceLineMaterial?.(material); } function unregisterLineMaterial(context = {}, material, materials = null) { materials?.delete?.(material); context.unregisterScreenSpaceLineMaterial?.(material); } function normalizePartIds(value) { return (Array.isArray(value) ? value : [value]) .map((id) => String(id || "").trim()) .filter(Boolean); } function occurrenceMatchesPartIds(occurrenceId, partIds = []) { const normalizedOccurrenceId = String(occurrenceId || "").trim(); if (!normalizedOccurrenceId || !partIds.length) { return false; } return partIds.some((partId) => ( normalizedOccurrenceId === partId || normalizedOccurrenceId.startsWith(`${partId}.`) )); } function topologyEdgeRowMatchesPartIds(row, partIds = []) { return occurrenceMatchesPartIds(row?.occurrenceId || row?.partId || row?.id, partIds); } function topologyEdgeRowMatchesLineStripFilters(row, { includePartIds = [], excludePartIds = [], visibilityClasses = [] } = {}) { if (includePartIds.length && !topologyEdgeRowMatchesPartIds(row, includePartIds)) { return false; } if (excludePartIds.length && topologyEdgeRowMatchesPartIds(row, excludePartIds)) { return false; } if (visibilityClasses.length) { const visibilityClass = String(row?.visibilityClass || "feature").trim().toLowerCase() || "feature"; if (!visibilityClasses.includes(visibilityClass)) { return false; } } return true; } function shouldBuildTopologyLineStrips(selectorRuntime, filters = {}) { const edgeRows = Array.isArray(selectorRuntime?.edges) ? selectorRuntime.edges : []; if (!edgeRows.length) { return false; } let polylineCount = 0; let positionValueCount = 0; for (const row of edgeRows) { if (!topologyEdgeRowMatchesLineStripFilters(row, filters)) { continue; } const segmentCount = Number.isFinite(Number(row?.segmentCount)) ? Math.max(1, Number(row.segmentCount)) : 1; polylineCount += 1; positionValueCount += (segmentCount + 1) * 3; if ( polylineCount > MAX_TOPOLOGY_LINE_STRIP_POLYLINES || positionValueCount > MAX_TOPOLOGY_LINE_STRIP_POSITION_VALUES ) { return false; } } return polylineCount > 0; } function normalizeVisibilityClasses(value, fallback = DEFAULT_VISIBLE_TOPOLOGY_EDGE_CLASSES) { const values = (Array.isArray(value) ? value : [value]) .map((item) => String(item || "").trim().toLowerCase()) .filter(Boolean); return values.length ? values : fallback; } function normalizeColor(value, fallback) { const normalized = String(value || "").trim(); if (!HEX_COLOR_PATTERN.test(normalized)) { return fallback; } return normalized.length === 4 ? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`.toLowerCase() : normalized.toLowerCase(); } function normalizeTopologyEdgeClassSettings(edgeSettings = {}, baseTheme = {}, fallbackClasses = DEFAULT_VISIBLE_TOPOLOGY_EDGE_CLASSES) { const classSettings = edgeSettings?.classes && typeof edgeSettings.classes === "object" ? edgeSettings.classes : null; const explicitVisibilityClasses = classSettings ? [] : normalizeVisibilityClasses(edgeSettings?.visibilityClasses, fallbackClasses); const baseOpacity = Number.isFinite(Number(edgeSettings?.opacity)) ? clamp(Number(edgeSettings.opacity), 0, 1) : (baseTheme?.edgeOpacity ?? 0.84); const baseThickness = Number.isFinite(Number(edgeSettings?.thickness)) ? clamp(Number(edgeSettings.thickness), 0.5, 6) : (baseTheme?.edgeThickness ?? 1); const baseColor = normalizeColor(edgeSettings?.color, baseTheme?.edge || "#18181b"); if (!classSettings) { return explicitVisibilityClasses.map((classId) => ({ classId, color: baseColor, opacity: baseOpacity, thickness: baseThickness })); } return TOPOLOGY_EDGE_CLASS_ORDER .map((classId) => { const classValue = classSettings[classId] || DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS[classId]; const classOpacity = Number.isFinite(Number(classValue.opacity)) ? clamp(Number(classValue.opacity), 0, 1) : DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS[classId].opacity; const classThickness = Number.isFinite(Number(classValue.thickness)) ? clamp(Number(classValue.thickness), 0, 6) : DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS[classId].thickness; const classColor = normalizeColor(classValue.color, baseColor); if (classThickness <= 0 || classOpacity <= 0) { return null; } return { classId, color: classColor, opacity: classOpacity, thickness: classThickness }; }) .filter(Boolean); } function runtimeHasVisibilityClassRows(selectorRuntime) { return Array.isArray(selectorRuntime?.edges) && selectorRuntime.edges.some((row) => String(row?.visibilityClass || "").trim()); } export function createScreenSpaceLineSegments(context = {}, positions, { color, opacity = 1, lineWidth = 1, renderOrder = 3, depthTest = true, depthWrite = false, depthBias = DEFAULT_LINE_DEPTH_BIAS } = {}, materials = null) { const LineSegments2 = context.LineSegments2; const LineSegmentsGeometry = context.LineSegmentsGeometry; const LineMaterial = context.LineMaterial; if ( !LineSegments2 || !LineSegmentsGeometry || !LineMaterial || !(Array.isArray(positions) || ArrayBuffer.isView(positions)) || !positions.length ) { return null; } const lineGeometry = new LineSegmentsGeometry(); lineGeometry.setPositions(positions); const lineMaterial = createScreenSpaceLineMaterial(LineMaterial, { color, opacity, lineWidth, depthTest, depthWrite, depthBias }); registerLineMaterial(context, lineMaterial, materials); const line = new LineSegments2(lineGeometry, lineMaterial); line.renderOrder = renderOrder; line.frustumCulled = false; line.userData.beforeDispose = () => { unregisterLineMaterial(context, lineMaterial, materials); }; line.userData.disposeGeometry = true; line.userData.disposeMaterial = true; return line; } function createScreenSpaceLineMaterial(LineMaterial, { color, opacity = 1, lineWidth = 1, depthTest = true, depthWrite = false, depthBias = DEFAULT_LINE_DEPTH_BIAS } = {}) { const lineMaterial = new LineMaterial({ color, linewidth: lineWidth, opacity, depthTest, depthWrite, toneMapped: false, worldUnits: false }); syncLineMaterialOpacity(lineMaterial, opacity); applyLineDepthBias(lineMaterial, depthBias); return lineMaterial; } export function applyLineDepthBias(material, depthBias = DEFAULT_LINE_DEPTH_BIAS) { const bias = clamp(Number(depthBias) || 0, 0, 0.01); if (!material || bias <= 0) { return material; } material.polygonOffset = true; material.polygonOffsetFactor = 0; material.polygonOffsetUnits = -clamp(Math.round(bias * 1000), 1, 10); material.needsUpdate = true; return material; } export function createScreenSpaceLineStrip(context = {}, positions, { color, opacity = 1, lineWidth = 1, renderOrder = 3, depthTest = true, depthWrite = false, depthBias = DEFAULT_LINE_DEPTH_BIAS } = {}, materials = null) { const Line2 = context.Line2; const LineGeometry = context.LineGeometry; const LineMaterial = context.LineMaterial; if ( !Line2 || !LineGeometry || !LineMaterial || !(Array.isArray(positions) || ArrayBuffer.isView(positions)) || positions.length < 6 ) { return null; } const lineGeometry = new LineGeometry(); lineGeometry.setPositions(positions); const lineMaterial = createScreenSpaceLineMaterial(LineMaterial, { color, opacity, lineWidth, depthTest, depthWrite, depthBias }); registerLineMaterial(context, lineMaterial, materials); const line = new Line2(lineGeometry, lineMaterial); line.renderOrder = renderOrder; line.frustumCulled = false; line.userData.beforeDispose = () => { unregisterLineMaterial(context, lineMaterial, materials); }; line.userData.disposeGeometry = true; line.userData.disposeMaterial = true; return line; } export function createBasicLineStrip(context = {}, positions, { color, opacity = 1, lineWidth = 1, renderOrder = 3, depthTest = true, depthWrite = false, depthBias = DEFAULT_LINE_DEPTH_BIAS } = {}) { const THREE = context.THREE; if ( !THREE || !(Array.isArray(positions) || ArrayBuffer.isView(positions)) || positions.length < 6 ) { return null; } const geometry = new THREE.BufferGeometry(); const positionArray = positions instanceof Float32Array ? positions : new Float32Array(positions); geometry.setAttribute("position", new THREE.BufferAttribute(positionArray, 3)); const material = new THREE.LineBasicMaterial({ color, linewidth: Number.isFinite(Number(lineWidth)) ? Number(lineWidth) : 1, transparent: Number(opacity) < 0.999, opacity: clamp(Number(opacity) || 0, 0, 1), depthTest, depthWrite, toneMapped: false }); applyLineDepthBias(material, depthBias); const line = new THREE.Line(geometry, material); line.renderOrder = renderOrder; line.frustumCulled = false; line.userData.disposeGeometry = true; line.userData.disposeMaterial = true; return line; } export function createBasicLineSegments(context = {}, positions, { color, opacity = 1, renderOrder = 3, depthTest = true, depthWrite = false, depthBias = DEFAULT_LINE_DEPTH_BIAS } = {}) { const THREE = context.THREE; if ( !THREE || !(Array.isArray(positions) || ArrayBuffer.isView(positions)) || positions.length < 6 ) { return null; } const geometry = new THREE.BufferGeometry(); const positionArray = positions instanceof Float32Array ? positions : new Float32Array(positions); geometry.setAttribute("position", new THREE.BufferAttribute(positionArray, 3)); const material = new THREE.LineBasicMaterial({ color, transparent: Number(opacity) < 0.999, opacity: clamp(Number(opacity) || 0, 0, 1), depthTest, depthWrite, toneMapped: false }); applyLineDepthBias(material, depthBias); const line = new THREE.LineSegments(geometry, material); line.renderOrder = renderOrder; line.frustumCulled = false; line.userData.disposeGeometry = true; line.userData.disposeMaterial = true; return line; } export function createScreenSpaceLineSegmentsFromGeometry(context, geometry, options, materials = null) { const positions = lineSegmentPositionsFromGeometry(geometry); return positions ? createScreenSpaceLineSegments(context, positions, options, materials) : null; } export function createDisplayEdgeObject(context = {}, { geometry, edgeSettings, baseTheme, partId, displayMode, thickness, wireframeEdgeColor = "" }, materials = null) { const THREE = context.THREE; const wireframeMode = displayModeIsWireframe(displayMode); const depthTest = edgeSettings?.depthTest === false ? false : true; const edgeOpacity = Number.isFinite(Number(edgeSettings?.opacity)) ? clamp(Number(edgeSettings.opacity), 0, 1) : (baseTheme?.edgeOpacity ?? 0.84); const color = wireframeMode ? (wireframeEdgeColor || edgeSettings?.color || baseTheme?.edge || "#18181b") : (edgeSettings?.color || baseTheme?.edge || "#18181b"); if (wireframeMode && THREE) { const opacity = Math.max(edgeOpacity, 0.9); const material = new THREE.LineBasicMaterial({ color, transparent: opacity < 0.999, opacity, depthTest: false, depthWrite: false, toneMapped: false }); const line = new THREE.LineSegments(geometry, material); line.renderOrder = 4; line.frustumCulled = false; line.userData.partId = partId; return { edgeMesh: line, edgeMaterial: material }; } const line = createScreenSpaceLineSegmentsFromGeometry(context, geometry, { color, opacity: edgeOpacity, lineWidth: Number.isFinite(Number(thickness)) ? Number(thickness) : 1, renderOrder: 3, depthTest, depthWrite: false, depthBias: topologyLineDepthBiasForWidth(thickness) }, materials); if (!line) { return { edgeMesh: null, edgeMaterial: null }; } line.userData.partId = partId; return { edgeMesh: line, edgeMaterial: line.material }; } export function createTopologyDisplayEdgeObject(context = {}, selectorRuntime, edgeSettings, baseTheme, materials = null) { const includePartIds = normalizePartIds(edgeSettings?.includePartIds); const excludePartIds = normalizePartIds(edgeSettings?.excludePartIds); const focusedPartIds = normalizePartIds(edgeSettings?.focusedPartIds); const highlightPartIds = normalizePartIds(edgeSettings?.highlightPartIds); const edgeClassSettings = normalizeTopologyEdgeClassSettings(edgeSettings, baseTheme) .filter((classSetting) => ( classSetting.classId === "feature" || runtimeHasVisibilityClassRows(selectorRuntime) )); const visibilityClasses = edgeClassSettings.map((setting) => setting.classId); const edgeOpacity = Number.isFinite(Number(edgeSettings?.opacity)) ? clamp(Number(edgeSettings.opacity), 0, 1) : (baseTheme?.edgeOpacity ?? 0.84); const highlightOpacity = Number.isFinite(Number(edgeSettings?.highlightOpacity)) ? clamp(Number(edgeSettings.highlightOpacity), 0, 1) : edgeOpacity; const dimmedOpacity = Number.isFinite(Number(edgeSettings?.dimmedOpacity)) ? clamp(Number(edgeSettings.dimmedOpacity), 0, 1) : edgeOpacity; const thickness = Number.isFinite(Number(edgeSettings?.thickness)) ? clamp(Number(edgeSettings.thickness), 0.5, 6) : (baseTheme?.edgeThickness ?? 1); const baseOptions = { color: edgeSettings?.color || baseTheme?.edge || "#18181b", lineWidth: thickness, renderOrder: 3, depthTest: edgeSettings?.depthTest === false ? false : true, depthWrite: false, depthBias: topologyLineDepthBiasForWidth(thickness) }; const createLine = (positions, options) => ( createScreenSpaceLineSegments(context, positions, options, materials) || createBasicLineSegments(context, positions, options) ); const createLineStrip = (positions, options = {}) => { const lineWidth = Number.isFinite(Number(options.lineWidth)) ? Number(options.lineWidth) : 1; if (lineWidth <= 1.05) { return ( createBasicLineStrip(context, positions, options) || createScreenSpaceLineStrip(context, positions, options, materials) ); } return ( createScreenSpaceLineStrip(context, positions, options, materials) || createBasicLineStrip(context, positions, options) ); }; const createClassGroup = (children) => { const lines = children.filter(Boolean); if (!lines.length) { return null; } if (lines.length === 1) { return lines[0]; } const group = context.THREE ? new context.THREE.Group() : null; if (!group) { return lines[0]; } for (const line of lines) { group.add(line); } return group; }; const createLineStripGroup = (polylines, options) => { if (!context.THREE || !Array.isArray(polylines) || !polylines.length) { return null; } const positionValueCount = polylines.reduce((sum, positions) => sum + (positions?.length || 0), 0); if ( polylines.length > MAX_TOPOLOGY_LINE_STRIP_POLYLINES || positionValueCount > MAX_TOPOLOGY_LINE_STRIP_POSITION_VALUES ) { return null; } return createClassGroup(polylines.map((positions) => createLineStrip(positions, options))); }; const createClassLine = (classSetting, options = {}) => { const lineWidth = Number.isFinite(Number(options.lineWidth)) ? Number(options.lineWidth) : classSetting.thickness; const filters = { includePartIds: options.includePartIds || includePartIds, excludePartIds: options.excludePartIds || excludePartIds, visibilityClasses: [classSetting.classId] }; const lineOptions = { ...baseOptions, ...options, color: options.color || classSetting.color || baseOptions.color, lineWidth, opacity: Number.isFinite(Number(options.opacity)) ? options.opacity : classSetting.opacity, depthBias: Number.isFinite(Number(options.depthBias)) ? options.depthBias : topologyLineDepthBiasForWidth(lineWidth, { visibilityClass: classSetting.classId }) }; const polylines = shouldBuildTopologyLineStrips(selectorRuntime, filters) ? buildTopologyDisplayEdgePolylines(selectorRuntime, filters) : []; const stripGroup = createLineStripGroup(polylines, lineOptions); if (stripGroup) { return stripGroup; } const positions = buildTopologyDisplayEdgePositions(selectorRuntime, { ...filters }); if (!positions?.length) { return null; } return createLine(positions, lineOptions); }; if (highlightPartIds.length) { const positions = buildTopologyDisplayEdgePositions(selectorRuntime, { includePartIds: highlightPartIds, visibilityClasses }); if (!positions?.length) { return null; } const line = createLine(positions, { ...baseOptions, color: edgeSettings?.highlightColor || baseOptions.color, opacity: highlightOpacity, depthBias: Number.isFinite(Number(edgeSettings?.highlightDepthBias)) ? clamp(Number(edgeSettings.highlightDepthBias), 0, 0.01) : 0, renderOrder: Number.isFinite(Number(edgeSettings?.highlightRenderOrder)) ? Number(edgeSettings.highlightRenderOrder) : 26 }); if (line) { line.name = "TopologyDisplayEdgeHighlights"; line.userData.partId = "__topology_highlight__"; } return line; } if (focusedPartIds.length && context.THREE) { const group = new context.THREE.Group(); for (const classSetting of edgeClassSettings) { const dimmedLine = createClassLine(classSetting, { excludePartIds: focusedPartIds, opacity: Math.min(dimmedOpacity, classSetting.opacity), renderOrder: 3 }); const focusedLine = createClassLine(classSetting, { includePartIds: focusedPartIds, opacity: classSetting.opacity, renderOrder: 4 }); if (dimmedLine) { group.add(dimmedLine); } if (focusedLine) { group.add(focusedLine); } } if (!group.children.length) { return null; } group.name = "TopologyDisplayEdges"; group.userData.partId = "__topology__"; return group; } const classLines = edgeClassSettings.map((classSetting) => createClassLine(classSetting)); const line = createClassGroup(classLines); if (!line) { return null; } line.name = "TopologyDisplayEdges"; line.userData.partId = "__topology__"; return line; }