// 测量两个白母版的 alpha 包围盒、尖端(最低不透明点)、圆帽几何(帽心/帽径) // 输出将作为 markerAssets.js 的实测依据(规格 10:锚点/内层几何按素材属性登记) import { readFileSync } from 'node:fs' import { inflateSync } from 'node:zlib' function decodePng(path) { const buf = readFileSync(path) if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('非 PNG') let pos = 8 const idat = [] let width = 0, height = 0, bitDepth = 0, colorType = 0, palette = null, trns = null while (pos < buf.length) { const len = buf.readUInt32BE(pos) const type = buf.toString('ascii', pos + 4, pos + 8) const data = buf.subarray(pos + 8, pos + 8 + len) if (type === 'IHDR') { width = data.readUInt32BE(0); height = data.readUInt32BE(4) bitDepth = data[8]; colorType = data[9] } else if (type === 'PLTE') palette = Buffer.from(data) else if (type === 'tRNS') trns = Buffer.from(data) else if (type === 'IDAT') idat.push(data) else if (type === 'IEND') break pos += 12 + len } const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : 1 const stride = width * channels const raw = inflateSync(Buffer.concat(idat)) const img = Buffer.alloc(height * stride) for (let y = 0; y < height; y++) { const filter = raw[y * (stride + 1)] const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)) const prev = y > 0 ? img.subarray((y - 1) * stride, y * stride) : null const out = img.subarray(y * stride, (y + 1) * stride) for (let x = 0; x < stride; x++) { const a = x >= channels ? out[x - channels] : 0 const b = prev ? prev[x] : 0 const c = prev && x >= channels ? prev[x - channels] : 0 let v = row[x] if (filter === 1) v += a else if (filter === 2) v += b else if (filter === 3) v += (a + b) >> 1 else if (filter === 4) { const p = a + b - c const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c) v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c } out[x] = v & 0xff } } // 提取 alpha 通道函数 const alphaAt = (x, y) => { if (colorType === 6) return img[y * stride + x * 4 + 3] if (colorType === 2) return 255 const idx = img[y * stride + x] if (trns && idx < trns.length) return trns[idx] return 255 } return { width, height, alphaAt } } for (const file of ['public/cockpit/assets/icons/T_MarkPoint_WhiteFull.png', 'public/cockpit/assets/icons/T_MarkPoint_White.png']) { const { width, height, alphaAt } = decodePng(file) let minX = width, maxX = -1, minY = height, maxY = -1 for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { if (alphaAt(x, y) > 8) { if (x < minX) minX = x if (x > maxX) maxX = x if (y < minY) minY = y if (y > maxY) maxY = y } } } const bw = maxX - minX + 1 const bh = maxY - minY + 1 // 尖端:包围盒底部一行的不透明质心 x let tipSum = 0, tipCount = 0 for (let x = minX; x <= maxX; x++) { if (alphaAt(x, maxY) > 8) { tipSum += x; tipCount++ } } const tipX = tipCount ? tipSum / tipCount : -1 // 圆帽几何:从顶往下找「宽度最大的行」——实心版即圆帽直径线;镂空版因孔洞偏下,取首次接近 95% 最大宽的行 let widest = 0, widestY = minY let firstNearMaxY = -1 for (let y = minY; y <= maxY; y++) { let cnt = 0 for (let x = minX; x <= maxX; x++) if (alphaAt(x, y) > 8) cnt++ if (cnt > widest) { widest = cnt; widestY = y } if (firstNearMaxY < 0 && widest > 0 && cnt >= widest * 0.95) firstNearMaxY = y } const capCY = firstNearMaxY const capCX = (() => { let s = 0, n = 0 for (let x = minX; x <= maxX; x++) if (alphaAt(x, capCY) > 8) { s += x; n++ } return n ? s / n : -1 })() console.log(`${file.split('/').pop()}:`) console.log(` 画布 ${width}x${height} | bbox x${minX}..${maxX} y${minY}..${maxY} (${bw}x${bh})`) console.log(` 宽高比 ${ (bh / bw).toFixed(3) }`) console.log(` 尖端 x=${tipX.toFixed(1)} (bbox 相对 ${(100 * (tipX - minX) / bw).toFixed(1)}%) y=${maxY}`) console.log(` 圆帽线 y=${capCY}(帽心相对高度 ${((capCY - minY) / bh).toFixed(3)})帽心x=${capCX.toFixed(1)}(bbox 相对 ${(100 * (capCX - minX) / bw).toFixed(1)}%)`) console.log(` 锚点(尖端)相对 bbox: x=${((tipX - minX) / bw).toFixed(4)}, y=1.0`) }