function anglesForBladeCount(count) { var safeCount = SUPPORTED_BLADE_COUNTS.includes(Number(count)) ? Number(count) : 11; var half = (safeCount - 1) / 2; return Array.from({ length: safeCount }, (_, index) => (index - half) * BLADE_ANGLE_STEP); } function syncActiveBladeConfigs() { var start = Math.floor((COMMON_ANGLES.length - state.bladeCount) / 2); state.bladeConfigs = state.bladeConfigSlots.slice(start, start + state.bladeCount); } function outlineExtents(angles) { var minX = Infinity , maxX = -Infinity , minY = Infinity , maxY = -Infinity; angles.forEach( (angle) => { FAN.outline.forEach( ([x,y]) => { var point = rotatePoint(x, y, angle); minX = Math.min(minX, point[0]); maxX = Math.max(maxX, point[0]); minY = Math.min(minY, point[1]); maxY = Math.max(maxY, point[1]); } ); } ); return { minX, maxX, minY, maxY }; } function updateActiveBounds() { var full = outlineExtents(anglesForBladeCount(11)); var active = outlineExtents(FAN.angles); var lowerPadding = full.minY - FAN.yMin; var upperPadding = FAN.yMax - full.maxY; state.activeBounds = { xMin: FAN.xMin, xMax: FAN.xMax, yMin: active.minY - lowerPadding, yMax: active.maxY + upperPadding }; } function modelBounds() { return state.activeBounds || { xMin: FAN.xMin, xMax: FAN.xMax, yMin: FAN.yMin, yMax: FAN.yMax }; } function widthAt(radius) { if (FAN.headArc && radius >= FAN.headArc.startRadius) { var offset = radius - FAN.headArc.centerRadius; return Math.sqrt(Math.max(0, FAN.headArc.radius ** 2 - offset ** 2)); } var p = FAN.profile; for (let i = 0; i < p.length - 1; i++) { if (radius >= p[i][0] && radius <= p[i + 1][0]) { var t = (radius - p[i][0]) / (p[i + 1][0] - p[i][0]); return p[i][1] + (p[i + 1][1] - p[i][1]) * t; } } return radius < p[0][0] ? p[0][1] : p[p.length - 1][1]; } function rotatePoint(x, y, angleDegrees) { var a = deg(angleDegrees) , c = Math.cos(a) , s = Math.sin(a); return [x * c - y * s, x * s + y * c]; } function decodeBase64(base64) { var binary = atob(base64); var bytes = new Uint8Array(binary.length); var chunk = 1 << 15; for (let start = 0; start < binary.length; start += chunk) { var end = Math.min(start + chunk, binary.length); for (let i = start; i < end; i++) bytes[i] = binary.charCodeAt(i); } return bytes; } function parseBinarySTL(base64) { var bytes = decodeBase64(base64); var view = new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength); var count = view.getUint32(80, true); if (84 + count * 50 > bytes.byteLength) throw new Error('内置骨架 STL 数据不完整'); var positions = new Float32Array(count * 9); var normals = new Float32Array(count * 9); var offset = 84; for (let t = 0; t < count; t++, offset += 50) { var nx = view.getFloat32(offset, true) , ny = view.getFloat32(offset + 4, true) , nz = view.getFloat32(offset + 8, true); var out = t * 9; for (let v = 0; v < 3; v++) { var source = offset + 12 + v * 12; positions[out + v * 3] = view.getFloat32(source, true); positions[out + v * 3 + 1] = view.getFloat32(source + 4, true); positions[out + v * 3 + 2] = view.getFloat32(source + 8, true); normals[out + v * 3] = nx; normals[out + v * 3 + 1] = ny; normals[out + v * 3 + 2] = nz; } } return { positions, normals, count }; } function assembleFrameFromTemplates() { if (!state.frameTemplates) throw new Error('扇叶骨架模板尚未载入'); // 叶片编号按预览画面从右向左排列:第 1 片位于右端,最后一片位于左端。 // 首尾护骨是非对称件,必须与画面方位一致,否则导出后会装反。 var roles = FAN.angles.map( (angle, index) => index === 0 ? 'right' : (index === FAN.angles.length - 1 ? 'left' : 'inner')); var totalLength = roles.reduce( (sum, role) => sum + state.frameTemplates[role].positions.length, 0); var positions = new Float32Array(totalLength); var normals = new Float32Array(totalLength); var output = 0; roles.forEach( (role, bladeIndex) => { var template = state.frameTemplates[role]; var radians = deg(FAN.angles[bladeIndex]); var cosine = Math.cos(radians) , sine = Math.sin(radians); for (let index = 0; index < template.positions.length; index += 3) { var x = template.positions[index] , y = template.positions[index + 1]; positions[output + index] = x * cosine - y * sine; positions[output + index + 1] = x * sine + y * cosine; positions[output + index + 2] = template.positions[index + 2]; var nx = template.normals[index] , ny = template.normals[index + 1]; normals[output + index] = nx * cosine - ny * sine; normals[output + index + 1] = nx * sine + ny * cosine; normals[output + index + 2] = template.normals[index + 2]; } output += template.positions.length; } ); return { positions, normals, count: totalLength / 9 }; } function rebuildFrameFromTemplates() { var frame = assembleFrameFromTemplates(); state.frameOriginalPositions = frame.positions; state.frameOriginalNormals = frame.normals; state.framePositions = frame.positions; state.frameNormals = frame.normals; state.frameRemovedTriangles = 0; state.frameWelds = null; prepareFrameWelds(); } function removeDetachedPivotParts(mesh) { var triangleCount = mesh.count; if (triangleCount < 2) return { ...mesh, removedTriangles: 0, componentCount: 1 }; var parent = new Int32Array(triangleCount); var rank = new Uint8Array(triangleCount); for (let triangle = 0; triangle < triangleCount; triangle++) parent[triangle] = triangle; var find = (item) => { var root = item; while (parent[root] !== root) root = parent[root]; while (parent[item] !== item) { var next = parent[item]; parent[item] = root; item = next; } return root; } ; var union = (a, b) => { var rootA = find(a) , rootB = find(b); if (rootA === rootB) return; if (rank[rootA] < rank[rootB]) [rootA,rootB] = [rootB, rootA]; parent[rootB] = rootA; if (rank[rootA] === rank[rootB]) rank[rootA]++; } ; var vertexIds = new Int32Array(triangleCount * 3); var vertexTable = new Map(); var vertexCount = 0; for (let triangle = 0; triangle < triangleCount; triangle++) { var triangleOffset = triangle * 9; for (let vertex = 0; vertex < 3; vertex++) { var offset = triangleOffset + vertex * 3; var key = `${Math.round(mesh.positions[offset] * 10000)},${Math.round(mesh.positions[offset + 1] * 10000)},${Math.round(mesh.positions[offset + 2] * 10000)}`; var vertexId = vertexTable.get(key); if (vertexId === undefined) { vertexId = vertexCount++; vertexTable.set(key, vertexId); } vertexIds[triangle * 3 + vertex] = vertexId; } } var edgeOwners = new Map(); for (let triangle = 0; triangle < triangleCount; triangle++) { var offset = triangle * 3; var ids = [vertexIds[offset], vertexIds[offset + 1], vertexIds[offset + 2]]; for (const [aIndex,bIndex] of [[0, 1], [1, 2], [2, 0]]) { var a = ids[aIndex] , b = ids[bIndex]; var key = a < b ? `${a}:${b}` : `${b}:${a}`; var owner = edgeOwners.get(key); if (owner === undefined) edgeOwners.set(key, triangle); else union(triangle, owner); } } var componentSizes = new Map(); var largestRoot = 0; var largestSize = 0; for (let triangle = 0; triangle < triangleCount; triangle++) { var root = find(triangle); var size = (componentSizes.get(root) || 0) + 1; componentSizes.set(root, size); if (size > largestSize) { largestSize = size; largestRoot = root; } } if (componentSizes.size === 1) return { ...mesh, removedTriangles: 0, componentCount: 1 }; var positions = new Float32Array(largestSize * 9); var normals = new Float32Array(largestSize * 9); var outputOffset = 0; for (let triangle = 0; triangle < triangleCount; triangle++) { if (find(triangle) !== largestRoot) continue; var inputOffset = triangle * 9; positions.set(mesh.positions.subarray(inputOffset, inputOffset + 9), outputOffset); normals.set(mesh.normals.subarray(inputOffset, inputOffset + 9), outputOffset); outputOffset += 9; } return { positions, normals, count: largestSize, removedTriangles: triangleCount - largestSize, componentCount: componentSizes.size }; } function triangleNormal(a, b, c) { var ux = b[0] - a[0] , uy = b[1] - a[1] , uz = b[2] - a[2]; var vx = c[0] - a[0] , vy = c[1] - a[1] , vz = c[2] - a[2]; var nx = uy * vz - uz * vy; var ny = uz * vx - ux * vz; var nz = ux * vy - uy * vx; var len = Math.hypot(nx, ny, nz) || 1; return [nx / len, ny / len, nz / len]; } function createMeshWriter(triangleCapacity) { return { positions: new Float32Array(triangleCapacity * 9), normals: new Float32Array(triangleCapacity * 9), offset: 0 }; } function pushTri(mesh, a, b, c) { var n = triangleNormal(a, b, c); var offset = mesh.offset; mesh.positions[offset] = a[0]; mesh.positions[offset + 1] = a[1]; mesh.positions[offset + 2] = a[2]; mesh.positions[offset + 3] = b[0]; mesh.positions[offset + 4] = b[1]; mesh.positions[offset + 5] = b[2]; mesh.positions[offset + 6] = c[0]; mesh.positions[offset + 7] = c[1]; mesh.positions[offset + 8] = c[2]; for (let vertex = 0; vertex < 3; vertex++) { mesh.normals[offset] = n[0]; mesh.normals[offset + 1] = n[1]; mesh.normals[offset + 2] = n[2]; offset += 3; } mesh.offset = offset; } function pushTriFacing(mesh, a, b, c, upward) { var normal = triangleNormal(a, b, c); var facesUp = normal[2] >= 0; if (facesUp === upward) pushTri(mesh, a, b, c); else pushTri(mesh, a, c, b); } function triangulatePairPolygon(pairs) { var cross2D = (a, b, c) => { var pa = a.bottom , pb = b.bottom , pc = c.bottom; return (pb[0] - pa[0]) * (pc[1] - pa[1]) - (pb[1] - pa[1]) * (pc[0] - pa[0]); } ; var signedArea = pairs.reduce( (sum, pair, index) => { var next = pairs[(index + 1) % pairs.length]; return sum + pair.bottom[0] * next.bottom[1] - next.bottom[0] * pair.bottom[1]; } , 0); var indices = pairs.map( (pair, index) => index); if (signedArea < 0) indices.reverse(); var triangles = []; var pointInside = (point, a, b, c) => { var c1 = cross2D(a, b, point); var c2 = cross2D(b, c, point); var c3 = cross2D(c, a, point); return c1 >= -1e-8 && c2 >= -1e-8 && c3 >= -1e-8; } ; while (indices.length > 3) { var clipped = false; for (let position = 0; position < indices.length; position++) { var previous = indices[(position + indices.length - 1) % indices.length]; var current = indices[position]; var next = indices[(position + 1) % indices.length]; if (cross2D(pairs[previous], pairs[current], pairs[next]) <= 1e-8) continue; var containsPoint = indices.some( (candidate) => candidate !== previous && candidate !== current && candidate !== next && pointInside(pairs[candidate], pairs[previous], pairs[current], pairs[next])); if (containsPoint) continue; triangles.push([pairs[previous], pairs[current], pairs[next]]); indices.splice(position, 1); clipped = true; break; } if (!clipped) throw new Error('扇叶根部轮廓三角化失败'); } triangles.push(indices.map( (index) => pairs[index])); return triangles; } function prepareFrameWelds() { if (!state.framePositions || state.frameWelds) return; var positions = state.framePositions; var normals = state.frameNormals; var used = new Set(); var welds = []; for (let bladeIndex = 0; bladeIndex < FAN.angles.length; bladeIndex++) { var target = rotatePoint(-(FAN.rootTipRadius + 1.3), 0, FAN.angles[bladeIndex]); var bestTriangle = -1; var bestScore = Infinity; for (let triangle = 0; triangle < positions.length; triangle += 9) { var triangleIndex = triangle / 9; if (used.has(triangleIndex)) continue; var z0 = positions[triangle + 2] , z1 = positions[triangle + 5] , z2 = positions[triangle + 8]; if (Math.abs(z0 - .1) > .0005 || Math.abs(z1 - .1) > .0005 || Math.abs(z2 - .1) > .0005) continue; var cx = (positions[triangle] + positions[triangle + 3] + positions[triangle + 6]) / 3; var cy = (positions[triangle + 1] + positions[triangle + 4] + positions[triangle + 7]) / 3; var score = (cx - target[0]) ** 2 + (cy - target[1]) ** 2; if (score < bestScore) { bestScore = score; bestTriangle = triangleIndex; } } if (bestTriangle < 0) continue; used.add(bestTriangle); var offset = bestTriangle * 9; welds[bladeIndex] = [[positions[offset], positions[offset + 1], positions[offset + 2]], [positions[offset + 3], positions[offset + 4], positions[offset + 5]], [positions[offset + 6], positions[offset + 7], positions[offset + 8]]]; } var filteredPositions = []; var filteredNormals = []; for (let triangle = 0; triangle < positions.length; triangle += 9) { if (used.has(triangle / 9)) continue; for (let index = 0; index < 9; index++) { filteredPositions.push(positions[triangle + index]); filteredNormals.push(normals[triangle + index]); } } state.framePositions = new Float32Array(filteredPositions); state.frameNormals = new Float32Array(filteredNormals); state.frameWelds = welds; } function matchTriangleLoop(frameLoop, reliefLoop) { var permutations = [[0, 1, 2], [1, 2, 0], [2, 0, 1], [0, 2, 1], [2, 1, 0], [1, 0, 2]]; var best = permutations[0]; var bestScore = Infinity; for (const permutation of permutations) { var score = 0; for (let index = 0; index < 3; index++) { var frame = frameLoop[index]; var relief = reliefLoop[permutation[index]]; score += (frame[0] - relief[0]) ** 2 + (frame[1] - relief[1]) ** 2 + (frame[2] - relief[2]) ** 2; } if (score < bestScore) { bestScore = score; best = permutation; } } return best.map( (index) => reliefLoop[index]); }