import { normalizeThemeSettings, resolveThemeFillColor } from "./themeSettings.js"; import { CAD_DISPLAY_MODE, displayModeAllowsEdges, displayModeForcesEdges, displayModeIsWireframe, displayModeShowsThroughEdges, displayModeSurfaceOpacity, normalizeDisplayEdgeSettings, displayModeUsesUnlitSurfaces, normalizeDisplayMode } from "./displaySettings.js"; import { createDisplayEdgeObject, syncScreenSpaceLineMaterialResolution } from "./renderEdges.js"; import { resolveStepModuleFeatures } from "./stepModule.js"; import { applyStepModuleEffectsToRecords, buildPartTransformMatrix, buildStepModuleContext, createStepModuleEffectsApi, displayTransformForPart } from "./stepModuleEffects.js"; import { applyDisplayRecordTransform } from "./displayRecordTransform.js"; import { axisIndex, normalizeStepClipSettings } from "../lib/viewer/clipPlane.js"; import { clampSceneModelRadius, getSceneScaleSettings, normalizeSceneScaleMode, VIEWER_SCENE_SCALE } from "../lib/viewer/sceneScale.js"; export { CAD_DISPLAY_MODE, normalizeDisplayMode }; export { applyDisplayRecordTransform } from "./displayRecordTransform.js"; export const CAD_SCENE_SCALE = VIEWER_SCENE_SCALE; const CAD_EDGE_OPACITY = 0.84; const CAD_EDGE_THRESHOLD_DEG = 16; const REFERENCE_HOVER_COLOR = "#8dc5ff"; const REFERENCE_SELECTED_COLOR = "#4f9dff"; const PART_HOVER_OPACITY_BOOST = 0.08; const PART_SELECTED_OPACITY_BOOST = 0.12; const PART_HIGHLIGHT_SURFACE_RENDER_ORDER = 23; const PART_HIGHLIGHT_EDGE_RENDER_ORDER = 26; const FOCUSED_DIMMED_SURFACE_OPACITY = 0.035; const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/; const MODEL_PART_ID = "__model__"; const DEFAULT_THEME = Object.freeze({ surface: "#f4f4f5", surfaceRoughness: 0.92, surfaceMetalness: 0.03, surfaceClearcoat: 0, surfaceClearcoatRoughness: 0.6, edge: "#18181b", edgeThickness: 1, edgeOpacity: CAD_EDGE_OPACITY }); const SURFACE_EDGE_BARYCENTRIC_ATTRIBUTE = "_cad_edge_barycentric"; const SURFACE_EDGE_CLASS_ATTRIBUTE = "_cad_edge_class"; const SURFACE_EDGE_CLASS_IDS = Object.freeze(["feature", "tangent", "seam", "degenerate"]); const SURFACE_EDGE_CLASS_DEFAULTS = Object.freeze({ feature: Object.freeze({ color: "#132232", thickness: 1.15, opacity: 1 }), tangent: Object.freeze({ color: "#132232", thickness: 1.15, opacity: 0.5 }), seam: Object.freeze({ color: "#132232", thickness: 1.15, opacity: 0.85 }), degenerate: Object.freeze({ color: "#132232", thickness: 0, opacity: 1 }) }); const meshGeometryCache = new WeakMap(); function cacheOwnerForMeshData(meshData) { const geometrySource = meshData?.geometrySource; return geometrySource && typeof geometrySource === "object" ? geometrySource : meshData; } function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function toNumber(value, fallback = 0) { return Number.isFinite(Number(value)) ? Number(value) : fallback; } function toArray(value) { return Array.isArray(value) ? value : []; } function isNumericArray(value, stride = 1) { return ( (Array.isArray(value) || ArrayBuffer.isView(value)) && value.length >= stride && value.length % stride === 0 ); } function normalizeBoolean(value, fallback = false) { return typeof value === "boolean" ? value : fallback; } export function normalizeCadSceneScale(value) { return normalizeSceneScaleMode(value); } export function boundsFromVertices(vertices) { const min = [Infinity, Infinity, Infinity]; const max = [-Infinity, -Infinity, -Infinity]; for (let index = 0; index + 2 < (vertices?.length || 0); index += 3) { const x = Number(vertices[index]); const y = Number(vertices[index + 1]); const z = Number(vertices[index + 2]); if (![x, y, z].every(Number.isFinite)) { continue; } min[0] = Math.min(min[0], x); min[1] = Math.min(min[1], y); min[2] = Math.min(min[2], z); max[0] = Math.max(max[0], x); max[1] = Math.max(max[1], y); max[2] = Math.max(max[2], z); } if (!min.every(Number.isFinite) || !max.every(Number.isFinite)) { return { min: [0, 0, 0], max: [1, 1, 1] }; } return { min, max }; } export function centerAndRadiusFromBounds(THREE, bounds, scale = CAD_SCENE_SCALE.CAD) { const sceneScale = normalizeCadSceneScale(scale); const settings = getSceneScaleSettings(sceneScale); const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0]; const max = Array.isArray(bounds?.max) ? bounds.max : [1, 1, 1]; const center = new THREE.Vector3( (toNumber(min[0]) + toNumber(max[0], 1)) / 2, (toNumber(min[1]) + toNumber(max[1], 1)) / 2, (toNumber(min[2]) + toNumber(max[2], 1)) / 2 ); const size = new THREE.Vector3( Math.max(toNumber(max[0], 1) - toNumber(min[0]), settings.minModelRadius), Math.max(toNumber(max[1], 1) - toNumber(min[1]), settings.minModelRadius), Math.max(toNumber(max[2], 1) - toNumber(min[2]), settings.minModelRadius) ); return { center, size, radius: clampSceneModelRadius(size.length() / 2, sceneScale) }; } function cacheForMeshData(meshData) { const cacheOwner = cacheOwnerForMeshData(meshData); let cache = meshGeometryCache.get(cacheOwner); if (!cache) { cache = { whole: new Map(), part: new Map(), edge: new Map() }; meshGeometryCache.set(cacheOwner, cache); } return cache; } function cacheKey(parts) { return parts.map((part, index) => String(part?.id || part?.occurrenceId || `part:${index}`)).join("|"); } function markCachedGeometry(geometry) { if (geometry) { geometry.userData = { ...(geometry.userData || {}), cadSceneCachedGeometry: true }; } return geometry; } function disposeMaterial(material) { const materials = Array.isArray(material) ? material : [material]; for (const item of materials) { item?.map?.dispose?.(); item?.alphaMap?.dispose?.(); item?.dispose?.(); } } function disposeSceneObject(object, { disposeCachedGeometry = false } = {}) { if (!object) { return; } while (object.children?.length) { disposeSceneObject(object.children[0], { disposeCachedGeometry }); } object.parent?.remove(object); if (typeof object.userData?.beforeDispose === "function") { object.userData.beforeDispose(object); delete object.userData.beforeDispose; } if (disposeCachedGeometry || object.geometry?.userData?.cadSceneCachedGeometry !== true) { object.geometry?.dispose?.(); } disposeMaterial(object.material); } function clearGroup(group, options = {}) { while (group?.children?.length) { disposeSceneObject(group.children[0], options); } } function applyGeometryNormals(THREE, geometry, normals, recomputeNormals) { const hasNormals = isNumericArray(normals, 3); if (!recomputeNormals && hasNormals) { geometry.setAttribute("normal", new THREE.BufferAttribute(new Float32Array(normals), 3)); return; } geometry.computeVertexNormals(); } function setSurfaceEdgeAttributes(THREE, geometry, meshData, vertexOffset = 0, vertexCount = 0) { const barycentric = meshData?.surfaceEdgeBarycentric; const edgeClass = meshData?.surfaceEdgeClass; const componentOffset = Math.max(0, Math.floor(Number(vertexOffset) || 0)) * 3; const componentCount = Math.max(0, Math.floor(Number(vertexCount) || 0)) * 3; if ( !(barycentric instanceof Float32Array) || !(edgeClass instanceof Uint8Array) || componentCount <= 0 || barycentric.length < componentOffset + componentCount || edgeClass.length < componentOffset + componentCount ) { return; } geometry.setAttribute( SURFACE_EDGE_BARYCENTRIC_ATTRIBUTE, new THREE.BufferAttribute(barycentric.slice(componentOffset, componentOffset + componentCount), 3) ); geometry.setAttribute( SURFACE_EDGE_CLASS_ATTRIBUTE, new THREE.BufferAttribute(edgeClass.slice(componentOffset, componentOffset + componentCount), 3) ); } function geometryHasSurfaceEdgeAttributes(geometry) { const barycentric = geometry?.getAttribute?.(SURFACE_EDGE_BARYCENTRIC_ATTRIBUTE); const edgeClass = geometry?.getAttribute?.(SURFACE_EDGE_CLASS_ATTRIBUTE); return Boolean( barycentric?.itemSize === 3 && edgeClass?.itemSize === 3 && barycentric.count > 0 && barycentric.count === edgeClass.count ); } function readSourceColor(THREE, value) { const normalized = String(value || "").trim(); if (!HEX_COLOR_PATTERN.test(normalized)) { return null; } const expanded = normalized.length === 4 ? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}` : normalized; return new THREE.Color(expanded); } function shapeSourceColor(THREE, sourceColor, materialSettings = {}, { applyTint = true } = {}) { const shaped = (sourceColor || new THREE.Color("#ffffff")).clone(); const tintStrength = clamp(Number(materialSettings.tintStrength) || 0, 0, 1); if (applyTint && tintStrength > 0) { const tintColor = new THREE.Color(materialSettings.defaultColor || materialSettings.tintColor || "#ffffff"); if (materialSettings.tintMode === "blend") { shaped.lerp(tintColor, tintStrength); } else { shaped.lerp(shaped.clone().multiply(tintColor), tintStrength); } } const saturation = clamp(Number(materialSettings.saturation) || 1, 0, 2.5); if (Math.abs(saturation - 1) > 1e-4) { const hsl = {}; shaped.getHSL(hsl); shaped.setHSL(hsl.h, clamp(hsl.s * saturation, 0, 1), hsl.l); } const contrast = clamp(Number(materialSettings.contrast) || 1, 0, 2.5); const brightness = clamp(Number(materialSettings.brightness) || 1, 0, 2); shaped.r = clamp(((shaped.r - 0.5) * contrast + 0.5) * brightness, 0, 1); shaped.g = clamp(((shaped.g - 0.5) * contrast + 0.5) * brightness, 0, 1); shaped.b = clamp(((shaped.b - 0.5) * contrast + 0.5) * brightness, 0, 1); return shaped; } function shapeSourceColorBuffer(THREE, colors, materialSettings = {}) { if (!isNumericArray(colors, 3)) { return null; } const shapedColors = new Float32Array(colors.length); const color = new THREE.Color(); for (let index = 0; index + 2 < colors.length; index += 3) { color.setRGB( clamp(Number(colors[index]) || 0, 0, 1), clamp(Number(colors[index + 1]) || 0, 0, 1), clamp(Number(colors[index + 2]) || 0, 0, 1) ); const shaped = shapeSourceColor(THREE, color, materialSettings); shapedColors[index] = shaped.r; shapedColors[index + 1] = shaped.g; shapedColors[index + 2] = shaped.b; } return shapedColors; } function shouldUseDisplayVertexColors(meshData) { return !!meshData?.has_source_colors && isNumericArray(meshData?.colors, 3); } function partUsesDisplayVertexColors(meshData, part) { if (!shouldUseDisplayVertexColors(meshData)) { return false; } if (part && Object.hasOwn(part, "hasSourceColors")) { return !!part.hasSourceColors; } return true; } function createMaterialFillColor(THREE, materialSettings = {}, fillIndex = 0) { return new THREE.Color(resolveThemeFillColor(materialSettings, fillIndex)); } function resolveMaterialFillBaseColor(THREE, materialSettings = {}, fillIndex = 0) { return shapeSourceColor( THREE, createMaterialFillColor(THREE, materialSettings, fillIndex), materialSettings, { applyTint: false } ); } function resolveSourceBaseColor(THREE, { hasVertexColors = false, sourceColor = null, materialSettings, fallbackColor = "#ffffff", fillIndex = 0, forceFill = false }) { if (forceFill) { return resolveMaterialFillBaseColor(THREE, materialSettings, fillIndex); } if (hasVertexColors) { return new THREE.Color("#ffffff"); } if (!sourceColor) { return resolveMaterialFillBaseColor(THREE, { ...materialSettings, defaultColor: fallbackColor || materialSettings?.defaultColor }, fillIndex); } return shapeSourceColor(THREE, sourceColor, materialSettings); } function surfaceEdgeClassSetting(edgeSettings = {}, classId, fallbackColor = "#132232") { const fallback = SURFACE_EDGE_CLASS_DEFAULTS[classId] || SURFACE_EDGE_CLASS_DEFAULTS.feature; const source = edgeSettings?.classes?.[classId] || {}; return { color: source.color || fallbackColor || fallback.color, thickness: clamp( Number.isFinite(Number(source.thickness)) ? Number(source.thickness) : fallback.thickness, 0, 6 ), opacity: clamp( Number.isFinite(Number(source.opacity)) ? Number(source.opacity) : fallback.opacity, 0, 1 ) }; } function addCadSurfaceEdgeShader(THREE, material, edgeSettings = {}, baseTheme = DEFAULT_THEME) { const baseEdgeColor = edgeSettings?.color || baseTheme?.edge || DEFAULT_THEME.edge; const classSettings = Object.fromEntries( SURFACE_EDGE_CLASS_IDS.map((classId) => [classId, surfaceEdgeClassSetting(edgeSettings, classId, baseEdgeColor)]) ); const edgeColor = new THREE.Color(baseEdgeColor); const classColors = Object.fromEntries( Object.entries(classSettings).map(([classId, setting]) => [ classId, readSourceColor(THREE, setting.color) || edgeColor.clone() ]) ); material.userData.cadSurfaceEdges = true; material.userData.cadSurfaceEdgeBaseColor = edgeColor.clone(); material.userData.cadSurfaceEdgeColor = edgeColor.clone(); material.userData.cadSurfaceEdgeBaseClassSettings = Object.fromEntries( Object.entries(classSettings).map(([classId, setting]) => [classId, { ...setting }]) ); material.extensions = { ...(material.extensions || {}), derivatives: true }; material.onBeforeCompile = (shader) => { const activeEdgeColor = material.userData.cadSurfaceEdgeColor?.isColor ? material.userData.cadSurfaceEdgeColor : edgeColor; material.userData.cadSurfaceEdgeShader = shader; shader.uniforms.cadSurfaceEdgeColor = { value: activeEdgeColor.clone() }; shader.uniforms.cadSurfaceFeatureColor = { value: classColors.feature.clone() }; shader.uniforms.cadSurfaceTangentColor = { value: classColors.tangent.clone() }; shader.uniforms.cadSurfaceSeamColor = { value: classColors.seam.clone() }; shader.uniforms.cadSurfaceDegenerateColor = { value: classColors.degenerate.clone() }; shader.uniforms.cadSurfaceFeatureThickness = { value: classSettings.feature.thickness }; shader.uniforms.cadSurfaceTangentThickness = { value: classSettings.tangent.thickness }; shader.uniforms.cadSurfaceSeamThickness = { value: classSettings.seam.thickness }; shader.uniforms.cadSurfaceDegenerateThickness = { value: classSettings.degenerate.thickness }; shader.uniforms.cadSurfaceFeatureOpacity = { value: classSettings.feature.opacity }; shader.uniforms.cadSurfaceTangentOpacity = { value: classSettings.tangent.opacity }; shader.uniforms.cadSurfaceSeamOpacity = { value: classSettings.seam.opacity }; shader.uniforms.cadSurfaceDegenerateOpacity = { value: classSettings.degenerate.opacity }; shader.vertexShader = shader.vertexShader .replace( "#include ", `#include attribute vec3 ${SURFACE_EDGE_BARYCENTRIC_ATTRIBUTE}; attribute vec3 ${SURFACE_EDGE_CLASS_ATTRIBUTE}; varying vec3 vCadSurfaceEdgeBarycentric; varying vec3 vCadSurfaceEdgeClass;` ) .replace( "#include ", `#include vCadSurfaceEdgeBarycentric = ${SURFACE_EDGE_BARYCENTRIC_ATTRIBUTE}; vCadSurfaceEdgeClass = ${SURFACE_EDGE_CLASS_ATTRIBUTE};` ); shader.fragmentShader = shader.fragmentShader .replace( "#include ", `#include uniform vec3 cadSurfaceEdgeColor; uniform vec3 cadSurfaceFeatureColor; uniform vec3 cadSurfaceTangentColor; uniform vec3 cadSurfaceSeamColor; uniform vec3 cadSurfaceDegenerateColor; uniform float cadSurfaceFeatureThickness; uniform float cadSurfaceTangentThickness; uniform float cadSurfaceSeamThickness; uniform float cadSurfaceDegenerateThickness; uniform float cadSurfaceFeatureOpacity; uniform float cadSurfaceTangentOpacity; uniform float cadSurfaceSeamOpacity; uniform float cadSurfaceDegenerateOpacity; varying vec3 vCadSurfaceEdgeBarycentric; varying vec3 vCadSurfaceEdgeClass; float cadSurfaceEdgeThicknessFor(float classCode) { if (classCode < 0.5) { return 0.0; } if (abs(classCode - 2.0) < 0.5) { return cadSurfaceTangentThickness; } if (abs(classCode - 3.0) < 0.5) { return cadSurfaceSeamThickness; } if (abs(classCode - 4.0) < 0.5) { return cadSurfaceDegenerateThickness; } return cadSurfaceFeatureThickness; } float cadSurfaceEdgeOpacityFor(float classCode) { if (classCode < 0.5) { return 0.0; } if (abs(classCode - 2.0) < 0.5) { return cadSurfaceTangentOpacity; } if (abs(classCode - 3.0) < 0.5) { return cadSurfaceSeamOpacity; } if (abs(classCode - 4.0) < 0.5) { return cadSurfaceDegenerateOpacity; } return cadSurfaceFeatureOpacity; } vec3 cadSurfaceEdgeColorFor(float classCode) { if (classCode < 0.5) { return cadSurfaceEdgeColor; } if (abs(classCode - 2.0) < 0.5) { return cadSurfaceTangentColor; } if (abs(classCode - 3.0) < 0.5) { return cadSurfaceSeamColor; } if (abs(classCode - 4.0) < 0.5) { return cadSurfaceDegenerateColor; } return cadSurfaceFeatureColor; } float cadSurfaceEdgeCoverage(float barycentric, float classCode) { float thickness = cadSurfaceEdgeThicknessFor(classCode); float opacity = cadSurfaceEdgeOpacityFor(classCode); if (thickness <= 0.0 || opacity <= 0.0) { return 0.0; } float pixelDistance = barycentric / max(fwidth(barycentric), 1e-6); float halfWidth = max(thickness * 0.5, 0.001); float coverage = 1.0 - smoothstep(max(halfWidth - 0.75, 0.0), halfWidth + 0.75, pixelDistance); return clamp(coverage * opacity, 0.0, 1.0); } vec4 cadSurfaceEdgeLayerFor(float barycentric, float classCode) { float edgeAlpha = cadSurfaceEdgeCoverage(barycentric, classCode); return vec4(cadSurfaceEdgeColorFor(classCode), edgeAlpha); } vec4 cadSurfaceEdgeLayer() { vec4 edge0 = cadSurfaceEdgeLayerFor(vCadSurfaceEdgeBarycentric.x, vCadSurfaceEdgeClass.x); vec4 edge1 = cadSurfaceEdgeLayerFor(vCadSurfaceEdgeBarycentric.y, vCadSurfaceEdgeClass.y); vec4 edge2 = cadSurfaceEdgeLayerFor(vCadSurfaceEdgeBarycentric.z, vCadSurfaceEdgeClass.z); vec4 edge = edge0; if (edge1.a > edge.a) { edge = edge1; } if (edge2.a > edge.a) { edge = edge2; } return edge; }` ) .replace( "#include ", `vec4 cadSurfaceEdgeMix = cadSurfaceEdgeLayer(); if (cadSurfaceEdgeMix.a > 0.0) { outgoingLight = mix(outgoingLight, cadSurfaceEdgeMix.rgb, cadSurfaceEdgeMix.a); } #include ` ); }; material.customProgramCacheKey = () => "cad-surface-edges-v2"; } function createSurfaceMaterial(THREE, baseTheme, { color, useVertexColors = false, edgeSettings = null } = {}) { const opacity = Number.isFinite(Number(baseTheme?.surfaceOpacity)) ? Number(baseTheme.surfaceOpacity) : 1; const material = new THREE.MeshPhysicalMaterial({ color: color || baseTheme?.surface || DEFAULT_THEME.surface, roughness: Number.isFinite(Number(baseTheme?.surfaceRoughness)) ? Number(baseTheme.surfaceRoughness) : DEFAULT_THEME.surfaceRoughness, metalness: Number.isFinite(Number(baseTheme?.surfaceMetalness)) ? Number(baseTheme.surfaceMetalness) : DEFAULT_THEME.surfaceMetalness, clearcoat: Number.isFinite(Number(baseTheme?.surfaceClearcoat)) ? Number(baseTheme.surfaceClearcoat) : DEFAULT_THEME.surfaceClearcoat, clearcoatRoughness: Number.isFinite(Number(baseTheme?.surfaceClearcoatRoughness)) ? Number(baseTheme.surfaceClearcoatRoughness) : DEFAULT_THEME.surfaceClearcoatRoughness, side: THREE.DoubleSide, vertexColors: useVertexColors, transparent: opacity < 0.999, opacity, emissive: 0x000000, emissiveIntensity: 0, polygonOffset: false, polygonOffsetFactor: 0, polygonOffsetUnits: 0 }); if (edgeSettings?.enabled) { addCadSurfaceEdgeShader(THREE, material, edgeSettings, baseTheme); } return material; } function createWireframeSurfaceMaterial(THREE, materialSettings, fillIndex = 0) { return new THREE.MeshBasicMaterial({ color: resolveThemeFillColor(materialSettings || {}, fillIndex), transparent: true, opacity: 0.035, depthWrite: false }); } function createUnshadedSurfaceMaterial(THREE, { color, useVertexColors = false, opacity = 1 } = {}) { return new THREE.MeshBasicMaterial({ color: color || DEFAULT_THEME.surface, side: THREE.DoubleSide, vertexColors: useVertexColors, transparent: opacity < 0.999, opacity, depthWrite: opacity >= 0.999 }); } function sourceColorForPart(THREE, part, meshData) { return readSourceColor(THREE, part?.color || meshData?.sourceColor); } function sourceOpacityForPart(part, fallback = 1) { const opacity = Number(part?.opacity); return Number.isFinite(opacity) ? clamp(opacity, 0, 1) : fallback; } function meshUsesPartSourceColors(meshData, parts) { const renderableParts = Array.isArray(parts) ? parts : []; const partColors = renderableParts .map((part) => String(part?.color || "").trim().toLowerCase()) .filter(Boolean); if (!partColors.length) { return false; } return partColors.length !== renderableParts.length || new Set(partColors).size > 1; } function meshUsesPartSourceOpacity(parts) { const renderableParts = Array.isArray(parts) ? parts : []; return renderableParts.some((part) => { const opacity = Number(part?.opacity); return Number.isFinite(opacity) && clamp(opacity, 0, 1) < 0.999; }); } function emptyLineGeometry(THREE) { const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(0), 3)); return geometry; } function stablePartFillKey(part, index) { return [ String(part?.occurrenceId || ""), String(part?.id || ""), String(part?.partSourcePath || part?.sourcePath || ""), String(part?.label || part?.name || ""), String(index).padStart(8, "0") ].join("\u0000"); } export function buildPartFillIndexMap(parts = []) { return new Map( [...parts] .map((part, index) => ({ part, key: stablePartFillKey(part, index) })) .sort((left, right) => left.key.localeCompare(right.key)) .map(({ part }, index) => [part, index]) ); } function geometryCacheEntry(THREE, meshData, key, createGeometry) { const cache = cacheForMeshData(meshData); const cached = cache.part.get(key) || cache.whole.get(key); if (cached) { return cached; } const entry = createGeometry(); if (!entry?.geometry) { return null; } markCachedGeometry(entry.geometry); if (key === MODEL_PART_ID) { cache.whole.set(key, entry); } else { cache.part.set(key, entry); } return entry; } function buildPartGeometryEntry(THREE, meshData, part, recomputeNormals = false) { const partId = String(part?.id || part?.occurrenceId || "").trim(); const sourceMesh = part?.sourceMesh && typeof part.sourceMesh === "object" ? part.sourceMesh : null; const sourceMeshColorMode = sourceMesh && part?.hasSourceColors ? "source-colors" : "flat"; const sourceMeshKey = sourceMesh ? `source:${String(part?.sourceMeshKey || part?.meshUrl || part?.partFileRef || partId || "").trim()}:${sourceMeshColorMode}` : ""; const key = sourceMeshKey || partId || `${toNumber(part?.vertexOffset)}:${toNumber(part?.triangleOffset)}`; return geometryCacheEntry(THREE, meshData, key, () => { const vertexOffset = sourceMesh ? 0 : toNumber(part?.vertexOffset, 0); const vertexCount = sourceMesh ? Math.floor((sourceMesh.vertices?.length || 0) / 3) : toNumber(part?.vertexCount, 0); const triangleOffset = sourceMesh ? 0 : toNumber(part?.triangleOffset, 0); const triangleCount = sourceMesh ? Math.floor((sourceMesh.indices?.length || 0) / 3) : toNumber(part?.triangleCount, 0); if (vertexCount <= 0 || triangleCount <= 0) { return null; } let localVertices; let rawColors; let localNormals; let localIndices; if (sourceMesh) { localVertices = sourceMesh.vertices || new Float32Array(0); rawColors = part?.hasSourceColors && isNumericArray(sourceMesh.colors, 3) && sourceMesh.colors.length === localVertices.length ? new Float32Array(sourceMesh.colors) : null; localNormals = isNumericArray(sourceMesh.normals, 3) ? sourceMesh.normals : null; localIndices = sourceMesh.indices || new Uint32Array(0); } else { const positionStart = vertexOffset * 3; const positionEnd = positionStart + vertexCount * 3; localVertices = meshData.vertices.slice(positionStart, positionEnd); rawColors = partUsesDisplayVertexColors(meshData, part) ? new Float32Array(meshData.colors.slice(positionStart, positionEnd)) : null; localNormals = isNumericArray(meshData.normals, 3) ? meshData.normals.slice(positionStart, positionEnd) : null; const rawIndices = meshData.indices.slice(triangleOffset * 3, triangleOffset * 3 + triangleCount * 3); localIndices = new Uint32Array(rawIndices.length); for (let index = 0; index < rawIndices.length; index += 1) { localIndices[index] = Math.max(0, Number(rawIndices[index]) - vertexOffset); } } if (!localIndices.length) { return null; } const geometry = new THREE.BufferGeometry(); geometry.setAttribute( "position", new THREE.BufferAttribute(localVertices instanceof Float32Array ? localVertices : new Float32Array(localVertices), 3) ); geometry.setIndex(new THREE.BufferAttribute(localIndices instanceof Uint32Array ? localIndices : new Uint32Array(localIndices), 1)); if (rawColors && rawColors.length === localVertices.length) { geometry.setAttribute( "color", new THREE.BufferAttribute(new Float32Array(rawColors), 3) ); } if (!sourceMesh) { setSurfaceEdgeAttributes(THREE, geometry, meshData, vertexOffset, vertexCount); } applyGeometryNormals(THREE, geometry, localNormals, recomputeNormals); geometry.computeBoundingSphere(); return { geometry, rawColors }; }); } function buildWholeGeometryEntry(THREE, meshData, recomputeNormals = false) { return geometryCacheEntry(THREE, meshData, MODEL_PART_ID, () => { const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(meshData.vertices || []), 3)); geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(meshData.indices || []), 1)); const rawColors = shouldUseDisplayVertexColors(meshData) && meshData.colors?.length === meshData.vertices?.length ? new Float32Array(meshData.colors) : null; if (rawColors) { geometry.setAttribute("color", new THREE.BufferAttribute(new Float32Array(rawColors), 3)); } setSurfaceEdgeAttributes(THREE, geometry, meshData, 0, Math.floor((meshData.vertices?.length || 0) / 3)); applyGeometryNormals(THREE, geometry, meshData.normals, recomputeNormals); geometry.computeBoundingSphere(); return { geometry, rawColors }; }); } function syncRecordVertexColors(THREE, record, materialSettings) { if (!record?.geometry || !record.rawColors || !record.hasVertexColors) { return; } const shapedColors = shapeSourceColorBuffer(THREE, record.rawColors, materialSettings); if (!shapedColors) { return; } const attribute = record.geometry.getAttribute("color"); if (attribute?.array?.length === shapedColors.length) { attribute.array.set(shapedColors); attribute.needsUpdate = true; return; } record.geometry.setAttribute("color", new THREE.BufferAttribute(shapedColors, 3)); } function buildEdgeGeometryFromIndices(THREE, vertices, edgeIndices) { if (!isNumericArray(vertices, 3) || !isNumericArray(edgeIndices, 2)) { return null; } const vertexCount = Math.floor(vertices.length / 3); const segmentCount = Math.floor(edgeIndices.length / 2); if (segmentCount <= 0) { return null; } const linePositions = new Float32Array(segmentCount * 6); let writeOffset = 0; for (let index = 0; index + 1 < edgeIndices.length; index += 2) { const a = Number(edgeIndices[index]); const b = Number(edgeIndices[index + 1]); if (!Number.isInteger(a) || !Number.isInteger(b) || a < 0 || b < 0 || a >= vertexCount || b >= vertexCount) { continue; } const aOffset = a * 3; const bOffset = b * 3; linePositions[writeOffset] = Number(vertices[aOffset]); linePositions[writeOffset + 1] = Number(vertices[aOffset + 1]); linePositions[writeOffset + 2] = Number(vertices[aOffset + 2]); linePositions[writeOffset + 3] = Number(vertices[bOffset]); linePositions[writeOffset + 4] = Number(vertices[bOffset + 1]); linePositions[writeOffset + 5] = Number(vertices[bOffset + 2]); writeOffset += 6; } if (!writeOffset) { return null; } const geometry = new THREE.BufferGeometry(); const packedPositions = writeOffset === linePositions.length ? linePositions : linePositions.subarray(0, writeOffset); geometry.setAttribute("position", new THREE.BufferAttribute(packedPositions, 3)); return geometry; } function buildEdgeGeometry(THREE, meshData, part, sourceGeometry, displayMode, edgeSettings = {}) { void edgeSettings; const cache = cacheForMeshData(meshData); const partId = part ? String(part?.id || part?.occurrenceId || "").trim() : MODEL_PART_ID; const sourceMeshKey = part?.sourceMesh ? String(part?.sourceMeshKey || part?.meshUrl || part?.partFileRef || "").trim() : ""; const edgeKey = `${displayMode}:${sourceMeshKey ? `source:${sourceMeshKey}` : (partId || MODEL_PART_ID)}`; const cached = cache.edge.get(edgeKey); if (cached) { return cached; } let geometry = null; if (displayModeIsWireframe(displayMode)) { geometry = new THREE.WireframeGeometry(sourceGeometry); } else if (part) { const edgeIndexOffset = toNumber(part?.edgeIndexOffset, 0); const edgeIndexCount = toNumber(part?.edgeIndexCount, 0); const hasExplicitPartEdges = edgeIndexCount >= 2 && isNumericArray(meshData?.edge_indices, 2); if (hasExplicitPartEdges) { const partEdgeIndices = typeof meshData.edge_indices.subarray === "function" ? meshData.edge_indices.subarray(edgeIndexOffset, edgeIndexOffset + edgeIndexCount) : meshData.edge_indices.slice(edgeIndexOffset, edgeIndexOffset + edgeIndexCount); geometry = buildEdgeGeometryFromIndices(THREE, meshData.vertices, partEdgeIndices); } geometry ||= new THREE.EdgesGeometry(sourceGeometry, CAD_EDGE_THRESHOLD_DEG); } else if (isNumericArray(meshData?.edge_indices, 2)) { geometry = buildEdgeGeometryFromIndices(THREE, meshData.vertices, meshData.edge_indices); } geometry ||= new THREE.EdgesGeometry(sourceGeometry, CAD_EDGE_THRESHOLD_DEG); if (!geometry.getAttribute("position")?.count) { geometry.dispose(); geometry = emptyLineGeometry(THREE); } markCachedGeometry(geometry); cache.edge.set(edgeKey, geometry); return geometry; } function getEdgeThickness(edgeSettings = null, baseTheme = null) { const fallbackThickness = Number.isFinite(Number(baseTheme?.edgeThickness)) ? Number(baseTheme.edgeThickness) : DEFAULT_THEME.edgeThickness; return Number.isFinite(Number(edgeSettings?.thickness)) ? clamp(Number(edgeSettings.thickness), 0.5, 6) : fallbackThickness; } 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; } } function createDefaultEdgeObject(THREE, geometry, baseTheme, edgeSettings, partId, displayMode) { const wireframeMode = displayModeIsWireframe(displayMode); const depthTest = edgeSettings?.depthTest === false ? false : !wireframeMode; const material = new THREE.LineBasicMaterial({ color: edgeSettings?.color || baseTheme?.edge || DEFAULT_THEME.edge, transparent: true, opacity: wireframeMode ? Math.max(toNumber(edgeSettings?.opacity, 0.92), 0.9) : toNumber(edgeSettings?.opacity, baseTheme?.edgeOpacity ?? CAD_EDGE_OPACITY), depthTest, depthWrite: false }); const object = new THREE.LineSegments(geometry, material); object.userData.partId = partId; return { object, material }; } function normalizeEdgeResult(result) { if (!result) { return { object: null, material: null }; } const object = result.object || result.edgeMesh || result.mesh || result.line || null; return { object, material: result.material || result.edgeMaterial || object?.material || null }; } function normalizeEdgeRendering(edgeRendering = null) { if (!edgeRendering || typeof edgeRendering !== "object") { return { mode: "basic" }; } const mode = String(edgeRendering.mode || edgeRendering.type || "").trim().toLowerCase(); return { ...edgeRendering, mode: mode === "screen-space" || mode === "screenspace" ? "screen-space" : "basic", wireframeEdgeColor: String(edgeRendering.wireframeEdgeColor || "").trim() }; } function applyEdgeRenderingToRuntime(runtime, edgeRendering = {}) { runtime.edgeRendering = edgeRendering; for (const key of ["Line2", "LineGeometry", "LineSegments2", "LineSegmentsGeometry", "LineMaterial"]) { runtime[key] = edgeRendering[key] || edgeRendering.constructors?.[key] || null; } } function createSilhouetteMesh(THREE, geometry, edgeSettings, radius) { const offset = radius * clamp(toNumber(edgeSettings?.silhouetteScale, 0.004), 0, 0.04); if (!(offset > 0)) { return null; } const material = new THREE.ShaderMaterial({ uniforms: { color: { value: new THREE.Color(edgeSettings?.color || DEFAULT_THEME.edge) }, opacity: { value: clamp(toNumber(edgeSettings?.opacity, 0.9), 0, 1) }, offset: { value: offset } }, vertexShader: ` uniform float offset; void main() { vec3 displaced = position + normal * offset; gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0); } `, fragmentShader: ` uniform vec3 color; uniform float opacity; void main() { gl_FragColor = vec4(color, opacity); } `, side: THREE.BackSide, transparent: true, depthTest: true, depthWrite: false }); const mesh = new THREE.Mesh(geometry, material); mesh.renderOrder = -1; return mesh; } function shouldBuildSilhouette(edgeSettings, displayMode, settings = {}) { return ( settings.silhouette !== false && !displayModeIsWireframe(displayMode) && edgeSettings.silhouette === true && (edgeSettings.enabled === true || settings.silhouette === true) ); } export function readBoundsCenter(THREE, bounds) { const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0]; const max = Array.isArray(bounds?.max) ? bounds.max : min; return new THREE.Vector3( (toNumber(min[0]) + toNumber(max[0])) / 2, (toNumber(min[1]) + toNumber(max[1])) / 2, (toNumber(min[2]) + toNumber(max[2])) / 2 ); } function safeColor(THREE, value, fallback = null) { const text = String(value || "").trim(); if (!text) { return fallback; } try { return new THREE.Color(text); } catch { return fallback; } } export function applyMaterialSettingsToRecord(THREE, record, materialSettings, { baseTheme = DEFAULT_THEME, displayMode = CAD_DISPLAY_MODE.SOLID } = {}) { if (!record?.material || !materialSettings) { return; } const wireframeMode = displayModeIsWireframe(displayMode); const forceFill = materialSettings.overrideSourceColors === true || wireframeMode; const hasVertexColors = !forceFill && !!record.hasVertexColors; record.useVertexColors = hasVertexColors; record.baseColor = resolveSourceBaseColor(THREE, { hasVertexColors, sourceColor: forceFill ? null : record.sourceColor || null, materialSettings, fallbackColor: materialSettings?.defaultColor || baseTheme?.surface || DEFAULT_THEME.surface, fillIndex: record.fillIndex || 0, forceFill: forceFill || !record.hasSourceColor }); record.material.vertexColors = hasVertexColors; if (wireframeMode) { if (record.material.color && record.baseColor) { record.material.color.copy(record.baseColor); } record.baseOpacity = displayModeSurfaceOpacity(displayMode, 0.035); record.material.opacity = record.baseOpacity; record.material.transparent = true; record.material.depthWrite = false; record.material.needsUpdate = true; return; } syncRecordVertexColors(THREE, record, materialSettings); record.material.roughness = clamp(Number(materialSettings.roughness) || 0, 0, 1); record.material.metalness = clamp(Number(materialSettings.metalness) || 0, 0, 1); record.material.clearcoat = clamp(Number(materialSettings.clearcoat) || 0, 0, 1); record.material.clearcoatRoughness = clamp(Number(materialSettings.clearcoatRoughness) || 0, 0, 1); const sourceOpacity = Number.isFinite(Number(record.sourceOpacity)) ? clamp(Number(record.sourceOpacity), 0, 1) : 1; record.baseOpacity = clamp(displayModeSurfaceOpacity(displayMode, materialSettings.opacity) * sourceOpacity, 0, 1); record.material.opacity = record.baseOpacity; record.material.transparent = record.baseOpacity < 0.999; record.material.depthWrite = displayMode === CAD_DISPLAY_MODE.TRANSPARENT ? false : record.baseOpacity >= 0.999; record.material.envMapIntensity = Math.max(Number(materialSettings.envMapIntensity) || 0, 0); if (record.material.color && record.baseColor) { record.material.color.copy(record.baseColor); } record.baseEmissiveIntensity = clamp(Number(materialSettings.emissiveIntensity) || 0, 0, 2); record.baseEmissiveColor = record.baseColor ? record.baseColor.clone() : null; if ("emissive" in record.material && record.material.emissive) { if (record.baseEmissiveColor && record.baseEmissiveIntensity > 0) { record.material.emissive.copy(record.baseEmissiveColor); } else { record.material.emissive.set(0x000000); } record.material.emissiveIntensity = record.baseEmissiveIntensity; } record.material.needsUpdate = true; } function normalizePartIdList(value) { return (Array.isArray(value) ? value : [value]) .map((id) => String(id || "").trim()) .filter(Boolean); } function normalizePartSelector(value) { const text = String(value || "").trim(); return text.startsWith("#") ? text.slice(1).trim() : text; } function partIdMatchesSet(partId, set) { if (!set?.size) { return false; } if (set.has(MODEL_PART_ID)) { return true; } const normalizedPartId = normalizePartSelector(partId); if (!normalizedPartId) { return false; } for (const candidate of set) { const normalizedCandidate = normalizePartSelector(candidate); if ( normalizedCandidate && ( normalizedPartId === normalizedCandidate || normalizedPartId.startsWith(`${normalizedCandidate}.`) ) ) { return true; } } return false; } function baseObjectRenderOrder(record, object, fieldName) { if (!object) { return 0; } if (!Number.isFinite(Number(record[fieldName]))) { record[fieldName] = Number.isFinite(Number(object.renderOrder)) ? Number(object.renderOrder) : 0; } return record[fieldName]; } function syncHighlightRenderOrder(record, object, fieldName, highlighted, highlightRenderOrder) { if (!object) { return; } const baseRenderOrder = baseObjectRenderOrder(record, object, fieldName); object.renderOrder = highlighted ? highlightRenderOrder : baseRenderOrder; } function syncSurfaceTransparency(record, forceTransparent, opacity, { writeTransparentDepth = true } = {}) { const material = record?.material; if (!material) { return; } if (!Object.hasOwn(record, "baseDepthWrite")) { record.baseDepthWrite = material.depthWrite !== false; } const nextTransparent = forceTransparent || opacity < 0.999; if (material.transparent !== nextTransparent) { material.transparent = nextTransparent; material.needsUpdate = true; } material.depthWrite = nextTransparent && !writeTransparentDepth ? false : record.baseDepthWrite; } const CAD_SURFACE_EDGE_OPACITY_UNIFORMS = Object.freeze({ feature: "cadSurfaceFeatureOpacity", tangent: "cadSurfaceTangentOpacity", seam: "cadSurfaceSeamOpacity", degenerate: "cadSurfaceDegenerateOpacity" }); const CAD_SURFACE_EDGE_COLOR_UNIFORMS = Object.freeze({ feature: "cadSurfaceFeatureColor", tangent: "cadSurfaceTangentColor", seam: "cadSurfaceSeamColor", degenerate: "cadSurfaceDegenerateColor" }); function syncCadSurfaceEdgeHighlight(THREE, record, edgeColor, edgeOpacity = null) { const material = record?.material; const userData = material?.userData; if (!material || userData?.cadSurfaceEdges !== true) { return; } const nextColor = edgeColor?.isColor ? edgeColor : readSourceColor(THREE, edgeColor) || userData.cadSurfaceEdgeBaseColor; if (nextColor?.isColor) { userData.cadSurfaceEdgeColor = nextColor.clone(); const colorUniform = userData.cadSurfaceEdgeShader?.uniforms?.cadSurfaceEdgeColor; if (colorUniform?.value?.copy) { colorUniform.value.copy(nextColor); } } const highlightedOpacity = edgeOpacity !== null && edgeOpacity !== undefined && Number.isFinite(Number(edgeOpacity)) ? clamp(Number(edgeOpacity), 0, 1) : null; const baseClassSettings = userData.cadSurfaceEdgeBaseClassSettings || {}; const uniforms = userData.cadSurfaceEdgeShader?.uniforms || null; const overrideClassColor = highlightedOpacity !== null || (nextColor?.isColor && userData.cadSurfaceEdgeBaseColor?.isColor && !nextColor.equals(userData.cadSurfaceEdgeBaseColor)); for (const [classId, uniformName] of Object.entries(CAD_SURFACE_EDGE_COLOR_UNIFORMS)) { const baseClassColor = readSourceColor(THREE, baseClassSettings[classId]?.color) || userData.cadSurfaceEdgeBaseColor; const nextClassColor = overrideClassColor ? nextColor : baseClassColor; if (nextClassColor?.isColor && uniforms?.[uniformName]?.value?.copy) { uniforms[uniformName].value.copy(nextClassColor); } } for (const [classId, uniformName] of Object.entries(CAD_SURFACE_EDGE_OPACITY_UNIFORMS)) { const baseOpacity = Number(baseClassSettings[classId]?.opacity); const nextOpacity = highlightedOpacity === null ? (Number.isFinite(baseOpacity) ? baseOpacity : null) : highlightedOpacity; if (nextOpacity === null) { continue; } userData[`cadSurfaceEdge${classId}Opacity`] = nextOpacity; if (uniforms?.[uniformName]) { uniforms[uniformName].value = nextOpacity; } } } export function applyPartVisualState(THREE, records, { baseTheme = DEFAULT_THEME, edgeSettings, hiddenPartIds, hoveredPartId, focusedPartId, selectedPartIds, showEdges = true } = {}) { const hidden = new Set(Array.isArray(hiddenPartIds) ? hiddenPartIds : []); const selected = new Set(Array.isArray(selectedPartIds) ? selectedPartIds : []); const hovered = new Set( (Array.isArray(hoveredPartId) ? hoveredPartId : [hoveredPartId]) .map((id) => String(id || "").trim()) .filter(Boolean) ); const baseEdgeColor = edgeSettings?.color || baseTheme?.edge || DEFAULT_THEME.edge; const defaultSurfaceOpacity = Number.isFinite(Number(baseTheme?.surfaceOpacity)) ? Number(baseTheme.surfaceOpacity) : 1; const focusIds = new Set(normalizePartIdList(focusedPartId)); const hasFocus = focusIds.size > 0; const baseEdgeOpacity = Number.isFinite(Number(edgeSettings?.opacity)) ? clamp(Number(edgeSettings.opacity), 0, 1) : (baseTheme?.edgeOpacity ?? DEFAULT_THEME.edgeOpacity ?? CAD_EDGE_OPACITY); const highlightEdgeOpacity = Number.isFinite(Number(edgeSettings?.highlightOpacity)) ? clamp(Number(edgeSettings.highlightOpacity), 0, 1) : 1; const edgeHighlightColor = String(edgeSettings?.highlightColor || REFERENCE_SELECTED_COLOR).trim() || REFERENCE_SELECTED_COLOR; const hoveredSurfaceColor = new THREE.Color(REFERENCE_HOVER_COLOR); const hoveredEdgeColor = new THREE.Color(edgeHighlightColor); const selectedSurfaceColor = new THREE.Color(REFERENCE_SELECTED_COLOR); const selectedEdgeColor = new THREE.Color(edgeHighlightColor); for (const record of Array.isArray(records) ? records : []) { if (!record?.mesh || !record?.material) { continue; } const effectStyle = record.effectStyle && typeof record.effectStyle === "object" ? record.effectStyle : {}; const effectHidden = record.effectVisible === false; const effectColor = readSourceColor(THREE, effectStyle.color); const effectEdgeColor = readSourceColor(THREE, effectStyle.edgeColor); const effectEmissive = readSourceColor(THREE, effectStyle.emissive); const isHidden = partIdMatchesSet(record.partId, hidden); const isSelected = !isHidden && (partIdMatchesSet(record.partId, selected) || record.effectHighlighted === true); const isHovered = !isHidden && !effectHidden && partIdMatchesSet(record.partId, hovered); const isFocused = !isHidden && !effectHidden && hasFocus && partIdMatchesSet(record.partId, focusIds); const isDimmed = !isHidden && !effectHidden && hasFocus && !isFocused; const isHighlighted = isSelected || isHovered; record.mesh.visible = !effectHidden; if (record.edges) { record.edges.visible = showEdges && !effectHidden; } if (record.silhouette) { record.silhouette.visible = !effectHidden; } syncHighlightRenderOrder(record, record.mesh, "baseMeshRenderOrder", isHighlighted, PART_HIGHLIGHT_SURFACE_RENDER_ORDER); syncHighlightRenderOrder(record, record.edges, "baseEdgeRenderOrder", isHighlighted, PART_HIGHLIGHT_EDGE_RENDER_ORDER); const baseSurfaceOpacity = Number.isFinite(Number(record.baseOpacity)) ? Number(record.baseOpacity) : defaultSurfaceOpacity; const effectOpacity = Number.isFinite(Number(effectStyle.opacity)) ? clamp(Number(effectStyle.opacity), 0, 1) : 1; const effectEdgeOpacity = Number.isFinite(Number(effectStyle.edgeOpacity)) ? clamp(Number(effectStyle.edgeOpacity), 0, 1) : effectOpacity; const highlightedEdgeOpacity = (isSelected || isHovered) ? highlightEdgeOpacity * effectEdgeOpacity : null; const dimmedSurfaceOpacity = Math.min(baseSurfaceOpacity * effectOpacity, FOCUSED_DIMMED_SURFACE_OPACITY); const highlightedSurfaceOpacity = isSelected ? clamp((baseSurfaceOpacity * effectOpacity) + PART_SELECTED_OPACITY_BOOST, 0, 1) : isHovered ? clamp((baseSurfaceOpacity * effectOpacity) + PART_HOVER_OPACITY_BOOST, 0, 1) : baseSurfaceOpacity * effectOpacity; const nextSurfaceOpacity = isHidden || isDimmed ? dimmedSurfaceOpacity : highlightedSurfaceOpacity; syncSurfaceTransparency(record, isHidden || isDimmed || isHighlighted, nextSurfaceOpacity, { writeTransparentDepth: !isHidden && !isDimmed }); record.material.opacity = nextSurfaceOpacity; if (record.baseColor && record.material.color) { record.material.color.copy( isSelected ? selectedSurfaceColor : isHovered ? hoveredSurfaceColor : effectColor || record.baseColor ); } if ("emissive" in record.material && record.material.emissive) { if (isSelected) { record.material.emissive.set(REFERENCE_SELECTED_COLOR); } else if (isHovered) { record.material.emissive.set(REFERENCE_HOVER_COLOR); } else if (record.baseEmissiveColor && record.baseEmissiveIntensity > 0) { record.material.emissive.copy(record.baseEmissiveColor); } else { record.material.emissive.set(0x000000); } record.material.emissiveIntensity = isSelected ? 0.08 : isHovered ? 0.12 : effectEmissive ? clamp(Number(effectStyle.emissiveIntensity) || 0.22, 0, 2) : clamp(Number(record.baseEmissiveIntensity) || 0, 0, 2); if (!isSelected && !isHovered && effectEmissive) { record.material.emissive.copy(effectEmissive); } } const nextEdgeColor = isSelected ? selectedEdgeColor : isHovered ? hoveredEdgeColor : effectEdgeColor || baseEdgeColor; syncCadSurfaceEdgeHighlight(THREE, record, nextEdgeColor, highlightedEdgeOpacity); if (record.edgeMaterial) { record.edgeMaterial.color?.set?.(nextEdgeColor); syncLineMaterialOpacity(record.edgeMaterial, isSelected ? highlightEdgeOpacity * effectEdgeOpacity : isHovered ? highlightEdgeOpacity * effectEdgeOpacity : isHidden || isDimmed ? nextSurfaceOpacity : baseEdgeOpacity * effectEdgeOpacity); } } } function resetParameterEffects(records) { for (const record of Array.isArray(records) ? records : []) { record.effectMatrix = null; record.effectStyle = null; record.effectVisible = null; record.effectHighlighted = false; } } function boundsCorners(THREE, bounds) { const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0]; const max = Array.isArray(bounds?.max) ? bounds.max : [1, 1, 1]; return [ [min[0], min[1], min[2]], [min[0], min[1], max[2]], [min[0], max[1], min[2]], [min[0], max[1], max[2]], [max[0], min[1], min[2]], [max[0], min[1], max[2]], [max[0], max[1], min[2]], [max[0], max[1], max[2]] ].map((corner) => new THREE.Vector3( toNumber(corner[0]), toNumber(corner[1]), toNumber(corner[2]) )); } function transformedBounds(THREE, bounds, matrix = null) { if (!bounds || !Array.isArray(bounds.min) || !Array.isArray(bounds.max)) { return null; } if (!(matrix instanceof THREE.Matrix4)) { return { min: [...bounds.min], max: [...bounds.max] }; } const corners = boundsCorners(THREE, bounds).map((corner) => corner.applyMatrix4(matrix)); const min = [Infinity, Infinity, Infinity]; const max = [-Infinity, -Infinity, -Infinity]; for (const corner of corners) { min[0] = Math.min(min[0], corner.x); min[1] = Math.min(min[1], corner.y); min[2] = Math.min(min[2], corner.z); max[0] = Math.max(max[0], corner.x); max[1] = Math.max(max[1], corner.y); max[2] = Math.max(max[2], corner.z); } return min.every(Number.isFinite) && max.every(Number.isFinite) ? { min, max } : null; } function mergeBoundsList(boundsList) { const min = [Infinity, Infinity, Infinity]; const max = [-Infinity, -Infinity, -Infinity]; let count = 0; for (const bounds of Array.isArray(boundsList) ? boundsList : []) { if (!bounds || !Array.isArray(bounds.min) || !Array.isArray(bounds.max)) { continue; } count += 1; for (let axis = 0; axis < 3; axis += 1) { min[axis] = Math.min(min[axis], toNumber(bounds.min[axis])); max[axis] = Math.max(max[axis], toNumber(bounds.max[axis])); } } return count > 0 && min.every(Number.isFinite) && max.every(Number.isFinite) ? { min, max } : null; } function effectiveBoundsFromRecords(THREE, records, fallbackBounds) { const boundsList = []; for (const record of Array.isArray(records) ? records : []) { if (record.effectVisible === false) { continue; } const baseMatrix = buildPartTransformMatrix(THREE, record.baseTransform); const effectMatrix = record.effectMatrix instanceof THREE.Matrix4 ? record.effectMatrix.clone() : null; const combinedMatrix = effectMatrix ? effectMatrix.multiply(baseMatrix) : baseMatrix; boundsList.push(transformedBounds(THREE, record.partBounds, combinedMatrix)); } return mergeBoundsList(boundsList) || fallbackBounds; } function runParameterSetup(THREE, runtime, parameters, meshData, callbacks = {}) { const definition = parameters?.definition || null; const module = definition?.module || null; if (!definition || !module?.setup) { return; } const effectsByPartId = new Map(); const features = resolveStepModuleFeatures(definition, { meshData, selectorRuntime: parameters?.selectorRuntime || null }); const ctx = buildStepModuleContext({ runtime, stepModuleRuntime: parameters, features, effects: createStepModuleEffectsApi(THREE, { meshData, features, runtime, effectsByPartId }), cleanup: (cleanup) => { if (typeof cleanup === "function") { runtime.cleanups.push(cleanup); } } }); try { module.setup(ctx); } catch (error) { callbacks.onWarning?.({ title: "STEP parameter setup failed", message: error instanceof Error ? error.message : String(error), error }); } } function cleanupParameterRuntime(runtime, parameters, callbacks = {}) { while (runtime.cleanups.length) { try { runtime.cleanups.pop()?.(); } catch (error) { callbacks.onWarning?.({ title: "STEP parameter cleanup failed", message: error instanceof Error ? error.message : String(error), error }); } } const module = parameters?.definition?.module || null; if (!module?.dispose) { return; } const ctx = buildStepModuleContext({ runtime, stepModuleRuntime: parameters, features: {}, effects: {}, cleanup: () => {} }); try { module.dispose(ctx); } catch (error) { callbacks.onWarning?.({ title: "STEP parameter dispose failed", message: error instanceof Error ? error.message : String(error), error }); } } function applyParameters(THREE, runtime, parameters, meshData, callbacks = {}) { const definition = parameters?.definition || null; const module = definition?.module || null; if (!definition || !module) { resetParameterEffects(runtime.displayRecords); for (const record of runtime.displayRecords) { applyDisplayRecordTransform(THREE, record); } return runtime.baseBounds; } const effectsByPartId = new Map(); const features = resolveStepModuleFeatures(definition, { meshData, selectorRuntime: parameters?.selectorRuntime || null }); const effects = createStepModuleEffectsApi(THREE, { meshData, features, runtime, effectsByPartId }); const ctx = buildStepModuleContext({ runtime, stepModuleRuntime: parameters, features, effects, cleanup: (cleanup) => { if (typeof cleanup === "function") { runtime.cleanups.push(cleanup); } } }); try { module.update?.(ctx); module.render?.(ctx); } catch (error) { callbacks.onWarning?.({ title: "STEP parameter update failed", message: error instanceof Error ? error.message : String(error), error }); } applyStepModuleEffectsToRecords(THREE, runtime.displayRecords, effectsByPartId); for (const record of runtime.displayRecords) { applyDisplayRecordTransform(THREE, record); } return effectiveBoundsFromRecords(THREE, runtime.displayRecords, runtime.baseBounds); } export function buildStepClipPlane(THREE, clip, bounds, modelOffset = null) { const normalized = normalizeStepClipSettings(clip); if (!normalized.enabled || !bounds) { return null; } const index = axisIndex(normalized.axis); const boundsMin = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0]; const boundsMax = Array.isArray(bounds?.max) ? bounds.max : boundsMin; const min = toNumber(boundsMin[index]); const max = toNumber(boundsMax[index]); const low = Math.min(min, max); const high = Math.max(min, max); const position = low + ((high - low) * normalized.offset); const normal = new THREE.Vector3( index === 0 ? 1 : 0, index === 1 ? 1 : 0, index === 2 ? 1 : 0 ); if (normalized.invert) { normal.multiplyScalar(-1); } const point = modelOffset?.clone ? modelOffset.clone() : new THREE.Vector3(0, 0, 0); point.setComponent(index, point.getComponent(index) + position); return new THREE.Plane().setFromNormalAndCoplanarPoint(normal, point); } export function syncMaterialClipPlanes(material, clipPlanes) { if (!material) { return; } const materials = Array.isArray(material) ? material : [material]; const clippingEnabled = Array.isArray(clipPlanes) && clipPlanes.length > 0; for (const item of materials) { if (!item) { continue; } const previousEnabled = item.userData?.cadClipPlaneEnabled === true; const previousCount = Number(item.userData?.cadClipPlaneCount) || 0; const previousShaderClipping = item.clipping === true; item.clippingPlanes = clippingEnabled ? clipPlanes : null; item.clipIntersection = false; item.clipShadows = clippingEnabled; if ("clipping" in item) { item.clipping = clippingEnabled; } item.userData = { ...(item.userData || {}), cadClipPlaneEnabled: clippingEnabled, cadClipPlaneCount: clippingEnabled ? clipPlanes.length : 0 }; if ( previousEnabled !== clippingEnabled || previousCount !== (clippingEnabled ? clipPlanes.length : 0) || previousShaderClipping !== (item.clipping === true) ) { item.needsUpdate = true; } } } function syncClip(runtime, clip, bounds, modelOffset = null) { const clipPlane = buildStepClipPlane(runtime.THREE, clip, bounds, modelOffset); const clipPlanes = clipPlane ? [clipPlane] : []; runtime.activeClipPlane = clipPlane; runtime.activeClipPlanes = clipPlanes; for (const record of runtime.displayRecords) { syncMaterialClipPlanes(record.material, clipPlanes); syncMaterialClipPlanes(record.edgeMaterial, clipPlanes); syncMaterialClipPlanes(record.silhouette?.material, clipPlanes); } } function normalizeSelection(selection = {}) { return selection && typeof selection === "object" ? selection : {}; } function selectorEntries(value) { if (Array.isArray(value)) { return value; } const text = String(value || "").trim(); return text.split(","); } function selectorValuesFromEntry(value) { const text = String(value || "").trim(); if (!text) { return []; } const selectorText = text.startsWith("#") ? text.slice(1) : text; return selectorText.split(",") .map((entry) => entry.trim()) .filter(Boolean); } function normalizedSelectorValues(value) { return selectorEntries(value).flatMap(selectorValuesFromEntry); } function valueMatchesSelector(value, selector, { descendants = false } = {}) { const normalizedValue = String(value || "").trim(); const normalizedSelector = String(selector || "").trim(); if (!normalizedValue || !normalizedSelector) { return false; } return normalizedValue === normalizedSelector || (descendants && normalizedValue.startsWith(`${normalizedSelector}.`)); } function partMatchesSelector(part, selector) { const normalized = String(selector || "").trim(); if (!normalized) { return false; } if ([ part?.id, part?.occurrenceId ].some((value) => valueMatchesSelector(value, normalized, { descendants: true }))) { return true; } return [ part?.name, part?.label, part?.linkName ].some((value) => valueMatchesSelector(value, normalized)); } function mergePartBounds(parts) { const min = [Infinity, Infinity, Infinity]; const max = [-Infinity, -Infinity, -Infinity]; let count = 0; for (const part of Array.isArray(parts) ? parts : []) { const bounds = part?.bounds; if (!Array.isArray(bounds?.min) || !Array.isArray(bounds?.max)) { continue; } count += 1; for (let axis = 0; axis < 3; axis += 1) { min[axis] = Math.min(min[axis], Number(bounds.min[axis])); max[axis] = Math.max(max[axis], Number(bounds.max[axis])); } } return count > 0 && min.every(Number.isFinite) && max.every(Number.isFinite) ? { min, max } : null; } function filterMeshDataForSelection(meshData, selection = {}) { const parts = Array.isArray(meshData?.parts) ? meshData.parts : []; if (!parts.length) { return meshData; } const focus = [ ...normalizedSelectorValues(selection.focus), ...normalizedSelectorValues(selection.refs) ]; const hide = normalizedSelectorValues(selection.hide); if (!focus.length && !hide.length) { return meshData; } const nextParts = parts.filter((part) => { if (focus.length && !focus.some((selector) => partMatchesSelector(part, selector))) { return false; } return !hide.some((selector) => partMatchesSelector(part, selector)); }); if (!nextParts.length) { throw new Error("No renderable parts remain after applying focus/hide filters"); } return { ...meshData, parts: nextParts, bounds: mergePartBounds(nextParts) || meshData.bounds }; } function resolveMaterialSettings(theme, settings = {}) { if (settings.materialSettings && typeof settings.materialSettings === "object") { return settings.materialSettings; } return theme.materials || {}; } function resolvePartsToRender(meshData, theme, settings) { if (Array.isArray(settings.parts)) { return settings.parts.filter((part) => toNumber(part?.vertexCount) > 0 && toNumber(part?.triangleCount) > 0); } const parts = toArray(meshData?.parts).filter((part) => toNumber(part?.vertexCount) > 0 && toNumber(part?.triangleCount) > 0); if (!parts.length) { return []; } if (settings.renderPartsIndividually === true) { return parts; } if (settings.renderPartsIndividually === false) { const pickableParts = toArray(settings.pickableParts).filter((part) => toNumber(part?.vertexCount) > 0 && toNumber(part?.triangleCount) > 0); if (pickableParts.length) { return pickableParts; } if (meshUsesPartSourceColors(meshData, parts) || meshUsesPartSourceOpacity(parts)) { return parts; } const hasFillRotation = theme?.materials?.cycleColors === true && Array.isArray(theme?.materials?.fillColors) && theme.materials.fillColors.length > 1; return hasFillRotation ? parts : []; } return parts; } function addEdgeObject(THREE, runtime, record, edgeGeometry, settings) { const edgeSettings = runtime.edgeSettings; const baseTheme = runtime.baseTheme; const displayMode = runtime.displayMode; const useScreenSpaceEdges = runtime.edgeRendering?.mode === "screen-space"; const rawResult = useScreenSpaceEdges ? createDisplayEdgeObject(runtime, { THREE, geometry: edgeGeometry, edgeSettings, baseTheme, partId: record.partId, displayMode, thickness: getEdgeThickness(edgeSettings, baseTheme), wireframeEdgeColor: runtime.edgeRendering?.wireframeEdgeColor || "" }, runtime.screenSpaceLineMaterials) : createDefaultEdgeObject(THREE, edgeGeometry, baseTheme, edgeSettings, record.partId, displayMode); const { object, material } = normalizeEdgeResult(rawResult); if (!object) { return; } object.userData.partId = record.partId; record.edges = object; record.edgeMaterial = material; record.baseEdgeColor = material?.color?.isColor ? material.color.clone() : new THREE.Color(edgeSettings?.color || baseTheme?.edge || DEFAULT_THEME.edge); record.baseEdgeOpacity = Number.isFinite(Number(material?.opacity)) ? Number(material.opacity) : 1; runtime.edgesGroup.add(object); } function buildDisplayRecords(THREE, runtime, meshData, settings) { const theme = runtime.theme; const materialSettings = runtime.materialSettings; const displayMode = runtime.displayMode; const baseTheme = runtime.baseTheme; const edgeSettings = runtime.edgeSettings; const bounds = meshData.bounds || boundsFromVertices(meshData.vertices || []); const { radius } = centerAndRadiusFromBounds(THREE, bounds, runtime.scale); const useSilhouette = shouldBuildSilhouette(edgeSettings, displayMode, settings); const renderParts = resolvePartsToRender(meshData, theme, settings); const partFillIndexMap = buildPartFillIndexMap(renderParts); const useWholeMesh = renderParts.length === 0; const records = []; const makeRecord = ({ part = null, geometryEntry, fillIndex = 0, baseTransform = null }) => { const partId = part ? String(part?.id || part?.occurrenceId || `part:${records.length}`) : MODEL_PART_ID; const wireframeMode = displayModeIsWireframe(displayMode); const forceFill = materialSettings.overrideSourceColors === true || wireframeMode; const sourceVertexColors = !!geometryEntry.geometry.getAttribute("color"); const useSurfaceEdges = !wireframeMode && !displayModeShowsThroughEdges(displayMode) && edgeSettings.enabled && geometryHasSurfaceEdgeAttributes(geometryEntry.geometry); const sourceColor = sourceColorForPart(THREE, part, meshData); const sourceOpacity = sourceOpacityForPart(part); const hasSourceColor = sourceVertexColors || !!sourceColor; const hasVertexColors = !forceFill && sourceVertexColors; const baseColor = resolveSourceBaseColor(THREE, { hasVertexColors, sourceColor: forceFill ? null : sourceColor, materialSettings, fallbackColor: materialSettings.defaultColor || baseTheme?.surface || DEFAULT_THEME.surface, fillIndex, forceFill: forceFill || !hasSourceColor }); const material = wireframeMode ? createWireframeSurfaceMaterial(THREE, materialSettings, fillIndex) : displayModeUsesUnlitSurfaces(displayMode) ? createUnshadedSurfaceMaterial(THREE, { color: baseColor, useVertexColors: hasVertexColors, opacity: displayModeSurfaceOpacity(displayMode, materialSettings.opacity) }) : createSurfaceMaterial(THREE, baseTheme, { color: baseColor, useVertexColors: hasVertexColors, edgeSettings: useSurfaceEdges ? edgeSettings : null }); if (edgeSettings.enabled && !wireframeMode && !useSurfaceEdges) { material.polygonOffset = true; material.polygonOffsetFactor = 1; material.polygonOffsetUnits = 1; } const mesh = new THREE.Mesh(geometryEntry.geometry, material); mesh.castShadow = true; mesh.receiveShadow = false; mesh.userData.partId = partId; const faceIds = part ? settings.callbacks?.faceIdsForPart?.(part) : settings.callbacks?.faceIdsForMesh?.(meshData); if (faceIds) { mesh.userData.faceIds = faceIds; } runtime.modelGroup.add(mesh); const record = { partId, sourcePart: part || null, mesh, edges: null, silhouette: null, material, edgeMaterial: null, baseColor, sourceColor, sourceOpacity, baseTransform, partCenter: readBoundsCenter(THREE, part?.bounds || bounds), partBounds: part?.bounds || part?.sourceBounds || bounds, effectMatrix: null, effectStyle: null, effectVisible: null, effectHighlighted: false, fillIndex, hasSourceColor, hasVertexColors, useVertexColors: hasVertexColors, rawColors: geometryEntry.rawColors, geometry: geometryEntry.geometry, baseOpacity: Number.isFinite(Number(material.opacity)) ? Number(material.opacity) : 1, baseEmissiveColor: baseColor ? baseColor.clone() : null, baseEmissiveIntensity: 0, baseEdgeColor: null, baseEdgeOpacity: 1 }; if (useSilhouette) { const silhouette = createSilhouetteMesh(THREE, geometryEntry.geometry, edgeSettings, radius); if (silhouette) { record.silhouette = silhouette; runtime.modelGroup.add(silhouette); } } if (settings.selection?.showEdges !== false && !useSurfaceEdges && (edgeSettings.enabled || wireframeMode)) { addEdgeObject( THREE, runtime, record, buildEdgeGeometry(THREE, meshData, part, geometryEntry.geometry, displayMode, edgeSettings), settings ); } applyMaterialSettingsToRecord(THREE, record, materialSettings, { baseTheme, displayMode }); applyDisplayRecordTransform(THREE, record); records.push(record); }; if (useWholeMesh) { const geometryEntry = buildWholeGeometryEntry(THREE, meshData, settings.recomputeNormals === true); if (geometryEntry) { makeRecord({ geometryEntry, fillIndex: 0 }); } } else { for (const part of renderParts) { const geometryEntry = buildPartGeometryEntry(THREE, meshData, part, settings.recomputeNormals === true); if (!geometryEntry) { continue; } makeRecord({ part, geometryEntry, fillIndex: partFillIndexMap.get(part) ?? records.length, baseTransform: displayTransformForPart(meshData, part, settings.renderPartsIndividually === true) }); } } return records; } function settingsSignature(meshData, theme, settings) { const edgeSettings = normalizeDisplayEdgeSettings(theme?.edges); return JSON.stringify({ meshData: meshData ? "mesh" : "", displayMode: normalizeDisplayMode(settings.displayMode), parts: cacheKey(resolvePartsToRender(meshData, theme, settings)), recomputeNormals: settings.recomputeNormals === true, edgeSettings, silhouette: settings.silhouette !== false && edgeSettings.silhouette === true && (edgeSettings.enabled !== false || settings.silhouette === true), edgeRendering: settings.edgeRendering?.mode || "basic", wireframeEdgeColor: settings.edgeRendering?.wireframeEdgeColor || "" }); } function normalizeSettings(settings = {}) { const displayMode = normalizeDisplayMode(settings.displayMode); const sourceTheme = settings.theme || settings.themeSettings || settings.settings || undefined; const normalizedTheme = normalizeThemeSettings(sourceTheme); const themeEdgeSettings = normalizeDisplayEdgeSettings(sourceTheme?.edges); const applyDisplayModeEdgePolicy = settings.applyDisplayModeEdgePolicy !== false; const theme = { ...normalizedTheme, edges: applyDisplayModeEdgePolicy ? { ...themeEdgeSettings, enabled: displayModeAllowsEdges(displayMode) && (displayModeForcesEdges(displayMode) || themeEdgeSettings.enabled === true), depthTest: displayModeShowsThroughEdges(displayMode) ? false : themeEdgeSettings.depthTest } : themeEdgeSettings }; const scale = normalizeCadSceneScale(settings.scale ?? settings.sceneScale ?? settings.sceneScaleMode); const callbacks = settings.callbacks && typeof settings.callbacks === "object" ? settings.callbacks : {}; const baseTheme = settings.baseTheme && typeof settings.baseTheme === "object" ? settings.baseTheme : DEFAULT_THEME; return { ...settings, theme, displayMode, scale, callbacks, baseTheme, selection: normalizeSelection(settings.selection), filterSelection: settings.filterSelection === false ? {} : normalizeSelection(settings.filterSelection ?? settings.selection), clip: normalizeStepClipSettings(settings.clip), stepParameters: settings.stepParameters || null, parameterSetup: settings.parameterSetup !== false, materialSettings: resolveMaterialSettings(theme, settings), edgeRendering: normalizeEdgeRendering(settings.edgeRendering) }; } function setRuntimeTheme(runtime, settings) { runtime.theme = settings.theme; runtime.displayMode = settings.displayMode; runtime.scale = settings.scale; runtime.baseTheme = settings.baseTheme; runtime.edgeSettings = { ...normalizeDisplayEdgeSettings(settings.theme?.edges), depthTest: displayModeShowsThroughEdges(settings.displayMode) ? false : undefined }; runtime.materialSettings = settings.materialSettings; applyEdgeRenderingToRuntime(runtime, settings.edgeRendering); } function meshDataFromSource(source) { return source?.meshData || source; } export function buildModel(THREE, source, settings = {}) { if (!THREE) { throw new Error("buildModel requires THREE"); } const rawMeshData = meshDataFromSource(source); const normalized = normalizeSettings(settings); const meshData = filterMeshDataForSelection(rawMeshData, normalized.filterSelection); const root = new THREE.Group(); const modelGroup = new THREE.Group(); const edgesGroup = new THREE.Group(); root.name = "CadSceneRoot"; modelGroup.name = "CadSceneModel"; edgesGroup.name = "CadSceneEdges"; root.add(modelGroup); root.add(edgesGroup); const baseBounds = meshData?.bounds || boundsFromVertices(meshData?.vertices || []); const runtime = { THREE, root, modelGroup, edgesGroup, displayRecords: [], records: [], baseBounds, bounds: baseBounds, modelBounds: baseBounds, modelRadius: centerAndRadiusFromBounds(THREE, baseBounds, normalized.scale).radius, cleanups: [], activeClipPlane: null, activeClipPlanes: [], screenSpaceLineMaterials: new Set(), syncScreenSpaceLineMaterials(width, height) { syncScreenSpaceLineMaterialResolution(runtime.screenSpaceLineMaterials, width, height); }, registerScreenSpaceLineMaterial(material) { if (!material?.resolution?.set) { return; } runtime.screenSpaceLineMaterials.add(material); }, unregisterScreenSpaceLineMaterial(material) { runtime.screenSpaceLineMaterials.delete(material); }, requestRender: () => {} }; setRuntimeTheme(runtime, normalized); let disposed = false; let currentSettings = normalized; let currentSignature = ""; let activeParameters = null; let activeParameterSetup = false; const rebuild = (nextSettings = currentSettings) => { clearGroup(modelGroup); clearGroup(edgesGroup); setRuntimeTheme(runtime, nextSettings); runtime.baseBounds = meshData?.bounds || boundsFromVertices(meshData?.vertices || []); runtime.displayRecords = buildDisplayRecords(THREE, runtime, meshData, nextSettings); runtime.records = runtime.displayRecords; runtime.bounds = runtime.baseBounds; runtime.modelBounds = runtime.baseBounds; runtime.modelRadius = centerAndRadiusFromBounds(THREE, runtime.baseBounds, runtime.scale).radius; currentSignature = settingsSignature(meshData, runtime.theme, nextSettings); }; const applyMutableState = (nextSettings = currentSettings) => { setRuntimeTheme(runtime, nextSettings); for (const record of runtime.displayRecords) { applyMaterialSettingsToRecord(THREE, record, runtime.materialSettings, { baseTheme: runtime.baseTheme, displayMode: runtime.displayMode }); } const nextParameterSetup = nextSettings.parameterSetup !== false; const nextParameters = nextSettings.stepParameters || null; if (activeParameters !== nextParameters || activeParameterSetup !== nextParameterSetup) { if (activeParameterSetup) { cleanupParameterRuntime(runtime, activeParameters, nextSettings.callbacks); } activeParameters = nextParameters; activeParameterSetup = nextParameterSetup; if (activeParameterSetup) { runParameterSetup(THREE, runtime, activeParameters, meshData, nextSettings.callbacks); } } const effectiveBounds = applyParameters(THREE, runtime, activeParameters, meshData, nextSettings.callbacks); runtime.bounds = effectiveBounds || runtime.baseBounds; runtime.modelBounds = runtime.bounds; runtime.modelRadius = centerAndRadiusFromBounds(THREE, runtime.bounds, runtime.scale).radius; applyPartVisualState(THREE, runtime.displayRecords, { baseTheme: runtime.baseTheme, edgeSettings: runtime.edgeSettings, ...nextSettings.selection, showEdges: nextSettings.selection?.showEdges !== false }); syncClip(runtime, nextSettings.clip, runtime.bounds, nextSettings.modelOffset || modelGroup.position); }; rebuild(currentSettings); applyMutableState(currentSettings); const api = { source, meshData, root, modelGroup, edgesGroup, get displayRecords() { return runtime.displayRecords; }, get records() { return runtime.displayRecords; }, get bounds() { return runtime.bounds; }, get radius() { return runtime.modelRadius; }, get runtime() { return runtime; }, update(nextSettings = {}) { if (disposed) { return api; } const mergedSettings = { ...currentSettings, ...nextSettings, selection: { ...(currentSettings.selection || {}), ...(nextSettings.selection || {}) }, callbacks: { ...(currentSettings.callbacks || {}), ...(nextSettings.callbacks || {}) } }; if ( Object.prototype.hasOwnProperty.call(nextSettings, "theme") && !Object.prototype.hasOwnProperty.call(nextSettings, "materialSettings") ) { delete mergedSettings.materialSettings; } currentSettings = normalizeSettings(mergedSettings); const nextSignature = settingsSignature(meshData, currentSettings.theme, currentSettings); if (nextSignature !== currentSignature) { rebuild(currentSettings); } applyMutableState(currentSettings); return api; }, dispose() { if (disposed) { return; } disposed = true; if (activeParameterSetup) { cleanupParameterRuntime(runtime, activeParameters, currentSettings.callbacks); } clearGroup(root); } }; return api; } export function fitCameraToModel(THREE, camera, bounds, { direction = [1, -1, 0.8], up = [0, 0, 1], width = 1400, height = 900, padding = 0.12, scale = CAD_SCENE_SCALE.CAD, lockedHalfHeight = null } = {}) { const sceneScale = normalizeCadSceneScale(scale); const settings = getSceneScaleSettings(sceneScale); const { center, radius } = centerAndRadiusFromBounds(THREE, bounds, sceneScale); const viewDirection = new THREE.Vector3(...direction).normalize(); const viewUp = new THREE.Vector3(...up).normalize(); const distance = Math.max(radius * 3.2, settings.minModelRadius * 10); camera.position.copy(center).add(viewDirection.multiplyScalar(distance)); camera.up.copy(viewUp); camera.lookAt(center); const aspect = Math.max(width / Math.max(height, 1), 0.01); const right = new THREE.Vector3().crossVectors(viewDirection, viewUp).normalize(); const screenUp = new THREE.Vector3().crossVectors(right, viewDirection).normalize(); const corners = boundsCorners(THREE, bounds); const xs = corners.map((corner) => corner.dot(right)); const ys = corners.map((corner) => corner.dot(screenUp)); const minSpan = settings.minModelRadius; const spanX = Math.max(Math.max(...xs) - Math.min(...xs), minSpan); const spanY = Math.max(Math.max(...ys) - Math.min(...ys), minSpan); const safeContentScale = Math.max(1 - (clamp(Number(padding) || 0, 0.1, 0.4) * 2), 0.1); const halfHeight = lockedHalfHeight || Math.max( spanY / (2 * safeContentScale), spanX / (2 * aspect * safeContentScale), minSpan / 2 ); camera.top = halfHeight; camera.bottom = -halfHeight; camera.left = -halfHeight * aspect; camera.right = halfHeight * aspect; camera.near = 0.01; camera.far = Math.max(distance + radius * 6, sceneScale === CAD_SCENE_SCALE.URDF ? 10 : 1000); camera.updateProjectionMatrix?.(); return { center, radius, halfHeight, distance }; }