612 lines
34 KiB
JavaScript
612 lines
34 KiB
JavaScript
|
|
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('<?xml version="1.0" encoding="UTF-8"?>');
|
|||
|
|
append('<model unit="millimeter" xml:lang="zh-CN" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">');
|
|||
|
|
append(`<metadata name="Title">${safeTitle}</metadata>`);
|
|||
|
|
append('<metadata name="Application">DreamLife-Translucent_Relief</metadata>');
|
|||
|
|
append(`<metadata name="Description">${xmlEscape(FAN.name)}折扇骨架与透光浮雕可拆分打印模型,不含转轴</metadata>`);
|
|||
|
|
append(`<metadata name="CreationDate">${new Date().toISOString().slice(0, 10)}</metadata>`);
|
|||
|
|
append('<resources>');
|
|||
|
|
|
|||
|
|
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(`<object id="${objectId}" type="model" name="${xmlEscape(entry.name)}" partnumber="${xmlEscape(entry.partNumber)}"><mesh><vertices>`);
|
|||
|
|
for (let vertex = 0; vertex < mesh.vertexCount; vertex++) {
|
|||
|
|
var offset = vertex * 3;
|
|||
|
|
append(`<vertex x="${format3MFNumber(mesh.vertices[offset])}" y="${format3MFNumber(mesh.vertices[offset + 1])}" z="${format3MFNumber(mesh.vertices[offset + 2])}"/>`);
|
|||
|
|
}
|
|||
|
|
append('</vertices><triangles>');
|
|||
|
|
for (let triangle = 0; triangle < mesh.triangleCount; triangle++) {
|
|||
|
|
var offset = triangle * 3;
|
|||
|
|
append(`<triangle v1="${mesh.triangleIndices[offset]}" v2="${mesh.triangleIndices[offset + 1]}" v3="${mesh.triangleIndices[offset + 2]}"/>`);
|
|||
|
|
}
|
|||
|
|
append('</triangles></mesh></object>');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var buildObjectIds;
|
|||
|
|
if (structure === 'parts') {
|
|||
|
|
var assemblyId = meshEntries.length + 1;
|
|||
|
|
append(`<object id="${assemblyId}" type="model" name="${safeTitle} 总成" partnumber="FAN-ASSEMBLY"><components>`);
|
|||
|
|
for (let entryIndex = 0; entryIndex < meshEntries.length; entryIndex++) {
|
|||
|
|
append(`<component objectid="${entryIndex + 1}"/>`);
|
|||
|
|
}
|
|||
|
|
append('</components></object>');
|
|||
|
|
buildObjectIds = [assemblyId];
|
|||
|
|
} else if (structure === 'objects') {
|
|||
|
|
buildObjectIds = meshEntries.map( (entry, index) => index + 1);
|
|||
|
|
} else {
|
|||
|
|
buildObjectIds = [1];
|
|||
|
|
}
|
|||
|
|
append('</resources>');
|
|||
|
|
|
|||
|
|
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('<build>');
|
|||
|
|
for (const objectId of buildObjectIds) {
|
|||
|
|
var partNumber = structure === 'objects' ? meshEntries[objectId - 1].partNumber : 'FAN-ASSEMBLY';
|
|||
|
|
append(`<item objectid="${objectId}" partnumber="${xmlEscape(partNumber)}" transform="${transform}"/>`);
|
|||
|
|
}
|
|||
|
|
append('</build></model>');
|
|||
|
|
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('<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/></Types>')]
|
|||
|
|
}, {
|
|||
|
|
name: '_rels/.rels',
|
|||
|
|
chunks: [encoder.encode('<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/></Relationships>')]
|
|||
|
|
}, {
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
}
|