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(`');
}
var buildObjectIds;
if (structure === 'parts') {
var assemblyId = meshEntries.length + 1;
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);
}
}