function buildRelief() { var detail = DETAIL[ui.detail.value]; var minT = Number(ui.minThickness.value); var maxT = Number(ui.maxThickness.value); var margin = currentReliefMargin(); var gamma = Number(ui.reliefGamma.value); var nr = detail.radial; var nv = detail.across; var weldedBlades = ui.includeFrame.checked && state.frameWelds ? state.frameWelds.filter(Boolean).length : 0; var baseTrianglesPerBlade = 4 * nr * nv + rootCapTriangleCount(); var triangleCapacity = baseTrianglesPerBlade * FAN.angles.length + weldedBlades * 5; var mesh = createMeshWriter(triangleCapacity); for (let bladeIndex = 0; bladeIndex < FAN.angles.length; bladeIndex++) { var angle = FAN.angles[bladeIndex]; var frameHole = ui.includeFrame.checked && state.frameWelds ? state.frameWelds[bladeIndex] : null; var weldJ = Math.floor((nv - 1) / 2); var reliefHole = null; var top = Array.from({ length: nr }, () => new Array(nv)); var bottom = Array.from({ length: nr }, () => new Array(nv)); var makeReliefPair = (radius, localY, normalizedCross=null) => { var world = rotatePoint(-radius, localY, angle); var cross = normalizedCross === null ? clamp(localY / reliefHalfAt(radius, margin), -1, 1) : normalizedCross; var lum = state.imageMode === 'blade' ? sampleBladeLuminance(bladeIndex, radius, cross) : sampleLuminance(world[0], world[1]); var darkness = Math.pow(1 - lum, gamma); var thickness = minT + darkness * (maxT - minT); return { top: [world[0], world[1], FAN.baseZ + thickness], bottom: [world[0], world[1], FAN.baseZ] }; } ; for (let i = 0; i < nr; i++) { var r = reliefRadiusAt(i, nr); var half = reliefHalfAt(r, margin); for (let j = 0; j < nv; j++) { var cross = -1 + 2 * j / (nv - 1); var localY = cross * half; var pair = makeReliefPair(r, localY, cross); top[i][j] = pair.top; bottom[i][j] = pair.bottom; } } for (let i = 0; i < nr - 1; i++) { for (let j = 0; j < nv - 1; j++) { var a = top[i][j] , b = top[i + 1][j] , c = top[i + 1][j + 1] , d = top[i][j + 1]; pushTri(mesh, a, c, b); pushTri(mesh, a, d, c); var ba = bottom[i][j] , bb = bottom[i + 1][j] , bc = bottom[i + 1][j + 1] , bd = bottom[i][j + 1]; pushTri(mesh, ba, bb, bc); if (frameHole && i === 0 && j === weldJ) { reliefHole = [ba, bc, bd]; } else { pushTri(mesh, ba, bc, bd); } } } for (let i = 0; i < nr - 1; i++) { var ta = top[i][0] , tb = top[i + 1][0] , ba = bottom[i][0] , bb = bottom[i + 1][0]; pushTri(mesh, ta, tb, bb); pushTri(mesh, ta, bb, ba); var j = nv - 1; var tc = top[i][j] , td = top[i + 1][j] , bc = bottom[i][j] , bd = bottom[i + 1][j]; pushTri(mesh, tc, bd, td); pushTri(mesh, tc, bc, bd); } var outerI = nr - 1; var outerTip = makeReliefPair(outerReliefTipRadius(), 0, 0); for (let j = 0; j < nv - 1; j++) { var ta = top[outerI][j] , tb = top[outerI][j + 1]; var ba = bottom[outerI][j] , bb = bottom[outerI][j + 1]; pushTriFacing(mesh, ta, outerTip.top, tb, true); pushTriFacing(mesh, ba, bb, outerTip.bottom, false); } var outerLower = { top: top[outerI][0], bottom: bottom[outerI][0] }; var outerUpper = { top: top[outerI][nv - 1], bottom: bottom[outerI][nv - 1] }; for (const [current,next] of [[outerLower, outerTip], [outerTip, outerUpper]]) { pushTri(mesh, current.top, next.bottom, current.bottom); pushTri(mesh, current.top, next.top, next.bottom); } var tip = makeReliefPair(FAN.rootTipRadius, 0, 0); var upperRoot = FAN.rootInnerUpper.map( ([radius,half]) => makeReliefPair(radius, Math.max(.25, half - margin))); var lowerRoot = FAN.rootInnerUpper.map( ([radius,half]) => makeReliefPair(radius, -Math.max(.25, half - margin))); var joinBottom = { top: top[0][0], bottom: bottom[0][0] }; var joinTop = { top: top[0][nv - 1], bottom: bottom[0][nv - 1] }; for (let j = 0; j < nv - 1; j++) { pushTriFacing(mesh, tip.top, top[0][j], top[0][j + 1], true); pushTriFacing(mesh, tip.bottom, bottom[0][j], bottom[0][j + 1], false); } for (const polygon of [[tip, ...upperRoot, joinTop], [tip, ...lowerRoot, joinBottom]]) { for (const triangle of triangulatePairPolygon(polygon)) { pushTriFacing(mesh, triangle[0].top, triangle[1].top, triangle[2].top, true); pushTriFacing(mesh, triangle[0].bottom, triangle[1].bottom, triangle[2].bottom, false); } } var rootSide = [joinBottom, ...lowerRoot.slice().reverse(), tip, ...upperRoot, joinTop]; for (let index = 0; index < rootSide.length - 1; index++) { var current = rootSide[index] , next = rootSide[index + 1]; pushTri(mesh, current.top, current.bottom, next.bottom); pushTri(mesh, current.top, next.bottom, next.top); } if (frameHole && reliefHole) { var matchedRelief = matchTriangleLoop(frameHole, reliefHole); for (let index = 0; index < 3; index++) { var next = (index + 1) % 3; var frameA = frameHole[index] , frameB = frameHole[next]; var reliefA = matchedRelief[index] , reliefB = matchedRelief[next]; pushTri(mesh, frameA, frameB, reliefB); pushTri(mesh, frameA, reliefB, reliefA); } } } if (mesh.offset !== triangleCapacity * 9) { throw new Error(`网格面数校验失败:预计 ${triangleCapacity.toLocaleString()},实际 ${(mesh.offset / 9).toLocaleString()}`); } state.reliefPositions = mesh.positions; state.reliefNormals = mesh.normals; updateMeshBuffers(); var reliefTriangles = state.reliefPositions.length / 9; var frameTriangles = ui.includeFrame.checked && state.framePositions ? state.framePositions.length / 9 : 0; var total = reliefTriangles + frameTriangles; var weldStatus = ui.includeFrame.checked ? ' · 浮雕已焊接' : ''; ui.status.textContent = `${DETAIL[ui.detail.value].label}${weldStatus} · ${total.toLocaleString()} 个三角面 · 预计 STL ${(84 + total * 50) / 1048576 < 1 ? ((84 + total * 50) / 1024).toFixed(0) + ' KB' : ((84 + total * 50) / 1048576).toFixed(1) + ' MB'}`; } function closedReliefBladeSources() { var detail = DETAIL[ui.detail.value]; var nr = detail.radial; var nv = detail.across; var baseTrianglesPerBlade = 4 * nr * nv + rootCapTriangleCount(); var sources = []; var triangleCursor = 0; for (let bladeIndex = 0; bladeIndex < FAN.angles.length; bladeIndex++) { var wasWelded = Boolean(ui.includeFrame.checked && state.frameWelds && state.frameWelds[bladeIndex]); var storedTriangleCount = baseTrianglesPerBlade + (wasWelded ? 5 : 0); var sourceStart = triangleCursor * 9; var sourceEnd = sourceStart + storedTriangleCount * 9; if (!wasWelded) { sources.push(state.reliefPositions.slice(sourceStart, sourceEnd)); } else { var closed = new Float32Array(baseTrianglesPerBlade * 9); var coreTriangleCount = baseTrianglesPerBlade - 1; closed.set(state.reliefPositions.subarray(sourceStart, sourceStart + coreTriangleCount * 9)); var weldJ = Math.floor((nv - 1) / 2); var r0 = FAN.rMin; var r1 = reliefRadiusAt(1, nr); var half0 = reliefHalfAt(r0); var half1 = reliefHalfAt(r1); var cross0 = -1 + 2 * weldJ / (nv - 1); var cross1 = -1 + 2 * (weldJ + 1) / (nv - 1); var angle = FAN.angles[bladeIndex]; var ba2 = rotatePoint(-r0, cross0 * half0, angle); var bc2 = rotatePoint(-r1, cross1 * half1, angle); var bd2 = rotatePoint(-r0, cross1 * half0, angle); var offset = coreTriangleCount * 9; closed.set([ba2[0], ba2[1], FAN.baseZ, bc2[0], bc2[1], FAN.baseZ, bd2[0], bd2[1], FAN.baseZ], offset); sources.push(closed); } triangleCursor += storedTriangleCount; } if (triangleCursor * 9 !== state.reliefPositions.length) { throw new Error('浮雕叶片拆分校验失败,请重新生成网格后再试'); } return sources; } function writeSTL() { if (!state.reliefPositions) return; var sources = ui.includeFrame.checked ? [state.framePositions, state.reliefPositions] : [state.reliefPositions]; var triangleCount = sources.reduce( (sum, source) => sum + source.length / 9, 0); var buffer = new ArrayBuffer(84 + triangleCount * 50); var bytes = new Uint8Array(buffer); var header = new TextEncoder().encode(`DreamLife Translucent Relief - ${FAN.name} - millimeters`); bytes.set(header.slice(0, 80), 0); var view = new DataView(buffer); view.setUint32(80, triangleCount, true); var offset = 84; for (const source of sources) { for (let i = 0; i < source.length; i += 9) { var a = [source[i], source[i + 1], source[i + 2]]; var b = [source[i + 3], source[i + 4], source[i + 5]]; var c = [source[i + 6], source[i + 7], source[i + 8]]; var n = triangleNormal(a, b, c); view.setFloat32(offset, n[0], true); view.setFloat32(offset + 4, n[1], true); view.setFloat32(offset + 8, n[2], true); var p = offset + 12; for (const vertex of [a, b, c]) { view.setFloat32(p, vertex[0], true); view.setFloat32(p + 4, vertex[1], true); view.setFloat32(p + 8, vertex[2], true); p += 12; } view.setUint16(offset + 48, 0, true); offset += 50; } } var blob = new Blob([buffer],{ type: 'model/stl' }); var url = URL.createObjectURL(blob); var link = document.createElement('a'); link.href = url; var assigned = state.bladeConfigs.filter( (config) => config.image).length; var sourceName = state.imageMode === 'blade' ? `${assigned}片独立图片` : state.globalConfig.name; link.download = `${sanitizeName(sourceName)}_${FAN.name}_${state.bladeCount}片_DreamLife_不含转轴.stl`; document.body.appendChild(link); link.click(); link.remove(); setTimeout( () => URL.revokeObjectURL(url), 2000); ui.status.textContent = `已导出 ${link.download} · 不含转轴 · ${triangleCount.toLocaleString()} 个三角面`; } var CRC32_TABLE = ( () => { var table = new Uint32Array(256); for (let index = 0; index < 256; index++) { var value = index; for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0); table[index] = value >>> 0; } return table; } )(); function crc32(chunks) { var crc = 0xffffffff; for (const chunk of chunks) { for (let index = 0; index < chunk.length; index++) { crc = CRC32_TABLE[(crc ^ chunk[index]) & 0xff] ^ (crc >>> 8); } } return (crc ^ 0xffffffff) >>> 0; } function currentSourceName() { var assigned = state.bladeConfigs.filter( (config) => config.image).length; return state.imageMode === 'blade' ? `${assigned}片独立图片` : state.globalConfig.name; } function xmlEscape(value) { return String(value).replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]); } function format3MFNumber(value) { var normalized = value === 0 ? 0 : Math.fround(value); return Number(normalized.toPrecision(9)).toString(); } function index3MFMesh(sources) { var triangleCount = sources.reduce( (sum, source) => sum + source.length / 9, 0); var triangleIndices = new Uint32Array(triangleCount * 3); var vertices = new Float32Array(triangleIndices.length * 3); var tableSize = 1024; while (tableSize < triangleCount * 2) tableSize *= 2; var slots = new Int32Array(tableSize); var mask = tableSize - 1; var floatBits = new Float32Array(3); var uintBits = new Uint32Array(floatBits.buffer); var boundsMin = [Infinity, Infinity, Infinity]; var boundsMax = [-Infinity, -Infinity, -Infinity]; var vertexCount = 0; var occurrence = 0; var findOrAddVertex = (rawX, rawY, rawZ) => { var x = rawX === 0 ? 0 : rawX; var y = rawY === 0 ? 0 : rawY; var z = rawZ === 0 ? 0 : rawZ; floatBits[0] = x; floatBits[1] = y; floatBits[2] = z; var hash = Math.imul(uintBits[0] ^ (uintBits[0] >>> 16), 0x45d9f3b); hash ^= Math.imul(uintBits[1] ^ (uintBits[1] >>> 16), 0x119de1f3); hash ^= Math.imul(uintBits[2] ^ (uintBits[2] >>> 16), 0x344b1a4d); var slot = hash & mask; while (slots[slot]) { var existing = slots[slot] - 1; var base = existing * 3; if (vertices[base] === x && vertices[base + 1] === y && vertices[base + 2] === z) return existing; slot = (slot + 1) & mask; } var base = vertexCount * 3; vertices[base] = x; vertices[base + 1] = y; vertices[base + 2] = z; slots[slot] = vertexCount + 1; boundsMin[0] = Math.min(boundsMin[0], x); boundsMax[0] = Math.max(boundsMax[0], x); boundsMin[1] = Math.min(boundsMin[1], y); boundsMax[1] = Math.max(boundsMax[1], y); boundsMin[2] = Math.min(boundsMin[2], z); boundsMax[2] = Math.max(boundsMax[2], z); return vertexCount++; } ; for (const source of sources) { for (let index = 0; index < source.length; index += 3) { triangleIndices[occurrence++] = findOrAddVertex(source[index], source[index + 1], source[index + 2]); } } return { triangleCount, triangleIndices, vertexCount, vertices: vertices.subarray(0, vertexCount * 3), boundsMin, boundsMax }; } function build3MFModelChunks(meshEntries, title, structure) { var encoder = new TextEncoder(); var chunks = []; var lines = []; var characterCount = 0; var flush = () => { if (!lines.length) return; chunks.push(encoder.encode(lines.join(''))); lines = []; characterCount = 0; } ; var append = (line) => { lines.push(line); characterCount += line.length; if (characterCount >= 1024 * 1024) flush(); } ; var safeTitle = xmlEscape(title); append(''); append(''); append(`${safeTitle}`); append('DreamLife-Translucent_Relief'); append(`${xmlEscape(FAN.name)}折扇骨架与透光浮雕可拆分打印模型,不含转轴`); append(`${new Date().toISOString().slice(0, 10)}`); append(''); var boundsMin = [Infinity, Infinity, Infinity]; for (let entryIndex = 0; entryIndex < meshEntries.length; entryIndex++) { var entry = meshEntries[entryIndex]; var mesh = entry.mesh; var objectId = entryIndex + 1; for (let axis = 0; axis < 3; axis++) boundsMin[axis] = Math.min(boundsMin[axis], mesh.boundsMin[axis]); append(``); for (let vertex = 0; vertex < mesh.vertexCount; vertex++) { var offset = vertex * 3; append(``); } append(''); for (let triangle = 0; triangle < mesh.triangleCount; triangle++) { var offset = triangle * 3; append(``); } append(''); } var buildObjectIds; if (structure === 'parts') { var assemblyId = meshEntries.length + 1; append(``); for (let entryIndex = 0; entryIndex < meshEntries.length; entryIndex++) { append(``); } append(''); buildObjectIds = [assemblyId]; } else if (structure === 'objects') { buildObjectIds = meshEntries.map( (entry, index) => index + 1); } else { buildObjectIds = [1]; } append(''); var tx = Math.max(0, -boundsMin[0]); var ty = Math.max(0, -boundsMin[1]); var tz = Math.max(0, -boundsMin[2]); var transform = `1 0 0 0 1 0 0 0 1 ${format3MFNumber(tx)} ${format3MFNumber(ty)} ${format3MFNumber(tz)}`; append(''); for (const objectId of buildObjectIds) { var partNumber = structure === 'objects' ? meshEntries[objectId - 1].partNumber : 'FAN-ASSEMBLY'; append(``); } append(''); flush(); return chunks; } function zipStored(entries) { var encoder = new TextEncoder(); var now = new Date(); var dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1); var dosDate = ((Math.max(1980, now.getFullYear()) - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate(); var parts = []; var records = []; var offset = 0; for (const entry of entries) { var name = encoder.encode(entry.name); var size = entry.chunks.reduce( (sum, chunk) => sum + chunk.length, 0); if (size > 0xffffffff) throw new Error('3MF 内容超过普通 ZIP 的 4 GB 上限'); var checksum = crc32(entry.chunks); var header = new Uint8Array(30 + name.length); var view = new DataView(header.buffer); view.setUint32(0, 0x04034b50, true); view.setUint16(4, 20, true); view.setUint16(6, 0x0800, true); view.setUint16(8, 0, true); view.setUint16(10, dosTime, true); view.setUint16(12, dosDate, true); view.setUint32(14, checksum, true); view.setUint32(18, size, true); view.setUint32(22, size, true); view.setUint16(26, name.length, true); header.set(name, 30); parts.push(header, ...entry.chunks); records.push({ name, size, checksum, offset }); offset += header.length + size; } var centralStart = offset; for (const record of records) { var header = new Uint8Array(46 + record.name.length); var view = new DataView(header.buffer); view.setUint32(0, 0x02014b50, true); view.setUint16(4, 20, true); view.setUint16(6, 20, true); view.setUint16(8, 0x0800, true); view.setUint16(10, 0, true); view.setUint16(12, dosTime, true); view.setUint16(14, dosDate, true); view.setUint32(16, record.checksum, true); view.setUint32(20, record.size, true); view.setUint32(24, record.size, true); view.setUint16(28, record.name.length, true); view.setUint32(42, record.offset, true); header.set(record.name, 46); parts.push(header); offset += header.length; } var end = new Uint8Array(22); var endView = new DataView(end.buffer); endView.setUint32(0, 0x06054b50, true); endView.setUint16(8, records.length, true); endView.setUint16(10, records.length, true); endView.setUint32(12, offset - centralStart, true); endView.setUint32(16, centralStart, true); parts.push(end); return new Blob(parts,{ type: 'model/3mf' }); } function downloadBlob(blob, filename) { var url = URL.createObjectURL(blob); var link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); setTimeout( () => URL.revokeObjectURL(url), 5000); } function nextPaint() { return new Promise( (resolve) => requestAnimationFrame( () => window.setTimeout(resolve, 0))); } function prepare3MFRawEntries(structure, title) { if (structure === 'merged') { return [{ name: title, partNumber: 'FAN-MERGED', sources: ui.includeFrame.checked ? [state.framePositions, state.reliefPositions] : [state.reliefPositions] }]; } var entries = []; if (ui.includeFrame.checked) { entries.push({ name: `${FAN.name}${state.bladeCount}片骨架`, partNumber: 'FAN-FRAME', sources: [state.frameOriginalPositions || state.framePositions] }); } closedReliefBladeSources().forEach( (source, bladeIndex) => { var serial = String(bladeIndex + 1).padStart(2, '0'); entries.push({ name: `浮雕扇叶 ${serial}`, partNumber: `FAN-BLADE-${serial}`, sources: [source] }); } ); return entries; } async function write3MF() { if (!state.reliefPositions) return; showBusy(true, '正在整理 3MF 网格'); try { await nextPaint(); var structure = ui.threeMFStructure.value; var title = `${sanitizeName(currentSourceName())}_${FAN.name}_${state.bladeCount}片_DreamLife_不含转轴`; var rawEntries = prepare3MFRawEntries(structure, title); var meshEntries = []; for (let entryIndex = 0; entryIndex < rawEntries.length; entryIndex++) { var entry = rawEntries[entryIndex]; ui.busyText.textContent = structure === 'merged' ? '正在整理整扇网格' : `正在整理零件 ${entryIndex + 1} / ${rawEntries.length}`; if (entryIndex % 3 === 0) await nextPaint(); meshEntries.push({ name: entry.name, partNumber: entry.partNumber, mesh: index3MFMesh(entry.sources) }); } ui.busyText.textContent = '正在打包 3MF 文件'; await nextPaint(); var modelChunks = build3MFModelChunks(meshEntries, title, structure); var encoder = new TextEncoder(); var blob = zipStored([{ name: '[Content_Types].xml', chunks: [encoder.encode('')] }, { name: '_rels/.rels', chunks: [encoder.encode('')] }, { name: '3D/3dmodel.model', chunks: modelChunks }]); var suffix = structure === 'parts' ? '_总成可拆零件' : structure === 'objects' ? '_独立对象' : ''; var filename = `${title}${suffix}.model.3mf`; downloadBlob(blob, filename); var vertexCount = meshEntries.reduce( (sum, entry) => sum + entry.mesh.vertexCount, 0); var triangleCount = meshEntries.reduce( (sum, entry) => sum + entry.mesh.triangleCount, 0); var structureLabel = structure === 'parts' ? `1 个总成 · ${meshEntries.length} 个可拆零件` : structure === 'objects' ? `${meshEntries.length} 个独立对象` : '1 个完整对象'; ui.status.textContent = `已导出 ${filename} · 不含转轴 · ${structureLabel} · ${vertexCount.toLocaleString()} 个顶点 · ${triangleCount.toLocaleString()} 个三角面`; } catch (error) { console.error(error); ui.status.textContent = `3MF 导出失败:${error.message}`; } finally { showBusy(false); } }