fit-pin-bezier.mjs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. // 一次性探针(票 47):母版下半收尖的三次贝塞尔拟合。
  2. // 假设:轮廓在帽圆某切点处离开圆,以一条三次贝塞尔到达尖端;对切点角度扫描取最优。
  3. // 输出 bbox 坐标系(x-67, y-19)的 path 参数,直接嵌进 markerOverlay.js。
  4. import { readFileSync } from 'node:fs'
  5. import { inflateSync } from 'node:zlib'
  6. function decodePng(path) {
  7. const buf = readFileSync(path)
  8. let pos = 8
  9. const idat = []
  10. let width = 0, height = 0, colorType = 0, trns = null
  11. while (pos < buf.length) {
  12. const len = buf.readUInt32BE(pos)
  13. const type = buf.toString('ascii', pos + 4, pos + 8)
  14. const data = buf.subarray(pos + 8, pos + 8 + len)
  15. if (type === 'IHDR') { width = data.readUInt32BE(0); height = data.readUInt32BE(4); colorType = data[9] }
  16. else if (type === 'tRNS') trns = Buffer.from(data)
  17. else if (type === 'IDAT') idat.push(data)
  18. else if (type === 'IEND') break
  19. pos += 12 + len
  20. }
  21. const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : 1
  22. const stride = width * channels
  23. const raw = inflateSync(Buffer.concat(idat))
  24. const img = Buffer.alloc(height * stride)
  25. for (let y = 0; y < height; y++) {
  26. const filter = raw[y * (stride + 1)]
  27. const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * stride)
  28. const prev = y > 0 ? img.subarray((y - 1) * stride, y * stride) : null
  29. const out = img.subarray(y * stride, (y + 1) * stride)
  30. for (let x = 0; x < stride; x++) {
  31. const a = x >= channels ? out[x - channels] : 0
  32. const b = prev ? prev[x] : 0
  33. const c = prev && x >= channels ? prev[x - channels] : 0
  34. let v = row[x]
  35. if (filter === 1) v += a
  36. else if (filter === 2) v += b
  37. else if (filter === 3) v += (a + b) >> 1
  38. else if (filter === 4) {
  39. const p = a + b - c
  40. const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c)
  41. v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
  42. }
  43. out[x] = v & 0xff
  44. }
  45. }
  46. const alphaAt = (x, y) => {
  47. if (colorType === 6) return img[y * stride + x * 4 + 3]
  48. const idx = img[y * stride + x]
  49. if (trns && idx < trns.length) return trns[idx]
  50. return 255
  51. }
  52. return { width, height, alphaAt }
  53. }
  54. // bbox 坐标系(绝对像素减 (67,19)):帽圆 (188.5,189) r189;尖端 (188.5,474)
  55. const CAP = { cx: 188.5, cy: 189, r: 189 }
  56. const TIP = { x: 188.5, y: 474 }
  57. const src = decodePng(process.argv[2] || 'public/cockpit/assets/icons/T_MarkPoint_WhiteFull.png')
  58. // 左缘采样:每行第一个不透明点(bbox 坐标)
  59. function leftEdge(yAbs) {
  60. for (let x = 67; x <= 444; x++) if (src.alphaAt(x, yAbs) > 8) return x - 67
  61. return null
  62. }
  63. const pts = []
  64. for (let yAbs = 380; yAbs <= 492; yAbs += 2) {
  65. const x = leftEdge(yAbs)
  66. if (x != null) pts.push({ x, y: yAbs - 19 })
  67. }
  68. console.log(`左缘采样 ${pts.length} 点(y ${pts[0].y}..${pts[pts.length - 1].y})`)
  69. console.log(pts.map((p) => `(${p.x.toFixed(1)},${p.y.toFixed(1)})`).join(' '))
  70. // 三次贝塞尔最小二乘(P0/P3 固定,求 P1/P2;弦长参数化)
  71. function fitBezier(p0, p3, samples) {
  72. const cum = [0]
  73. for (let i = 1; i < samples.length; i++) {
  74. cum.push(cum[i - 1] + Math.hypot(samples[i].x - samples[i - 1].x, samples[i].y - samples[i - 1].y))
  75. }
  76. const total = cum[cum.length - 1] || 1
  77. const ts = samples.map((_, i) => 0.5 - 0.5 * Math.cos(Math.PI * (cum[i] / total))) // 余弦参数化,端点加密
  78. const n = samples.length
  79. // 残差最小化:对每个样本 i,B(t_i) ≈ sample_i;B 对 P1,P2 线性 → 2×2 正规方程
  80. let a11 = 0, a12 = 0, a22 = 0, b1x = 0, b1y = 0, b2x = 0, b2y = 0
  81. const coef = (t) => {
  82. const mt = 1 - t
  83. return { p0: mt * mt * mt, p1: 3 * mt * mt * t, p2: 3 * mt * t * t, p3: t * t * t }
  84. }
  85. for (let i = 0; i < n; i++) {
  86. const c = coef(ts[i])
  87. const r1 = c.p1, r2 = c.p2
  88. a11 += r1 * r1; a12 += r1 * r2; a22 += r2 * r2
  89. b1x += r1 * (samples[i].x - c.p0 * p0.x - c.p3 * p3.x)
  90. b1y += r1 * (samples[i].y - c.p0 * p0.y - c.p3 * p3.y)
  91. b2x += r2 * (samples[i].x - c.p0 * p0.x - c.p3 * p3.x)
  92. b2y += r2 * (samples[i].y - c.p0 * p0.y - c.p3 * p3.y)
  93. }
  94. const det = a11 * a22 - a12 * a12
  95. const c1x = (b1x * a22 - b2x * a12) / det
  96. const c1y = (b1y * a22 - b2y * a12) / det
  97. const c2x = (b2x * a11 - b1x * a12) / det
  98. const c2y = (b2y * a11 - b1y * a12) / det
  99. const p1 = { x: c1x, y: c1y }, p2 = { x: c2x, y: c2y }
  100. let maxRes = 0
  101. for (let i = 0; i < n; i++) {
  102. const c = coef(ts[i])
  103. const bx = c.p0 * p0.x + c.p1 * p1.x + c.p2 * p2.x + c.p3 * TIP.x
  104. const by = c.p0 * p0.y + c.p1 * p1.y + c.p2 * p2.y + c.p3 * TIP.y
  105. maxRes = Math.max(maxRes, Math.hypot(bx - samples[i].x, by - samples[i].y))
  106. }
  107. return { p1, p2, maxRes }
  108. }
  109. // 切点扫描:P0 在帽圆左半(θ 自 +x 轴、y 向下;左下段 θ∈95°..140°:x<188.5 且 y>189)
  110. let best = null
  111. for (let deg = 95; deg <= 140; deg += 1) {
  112. const rad = (deg * Math.PI) / 180
  113. const p0 = { x: CAP.cx + CAP.r * Math.cos(rad), y: CAP.cy + CAP.r * Math.sin(rad) }
  114. if (p0.y < 300) continue
  115. // 路径方向 = 沿圆逆时针(屏幕系:顶→左→下),θ 递减方向的切向 = (sinθ, −cosθ)
  116. const tan = { x: Math.sin(rad), y: -Math.cos(rad) }
  117. // 样本 = 切点下方 3px 起的左缘点
  118. const samples = pts.filter((p) => p.y >= p0.y + 3 && p.x <= CAP.cx)
  119. if (samples.length < 6) continue
  120. // P1 = p0 + k·tan(G1 方向固定,长度 k 粗扫)
  121. for (let k = 10; k <= 160; k += 5) {
  122. const p1 = { x: p0.x + tan.x * k, y: p0.y + tan.y * k }
  123. const r = fitBezierG1(p0, p1, samples)
  124. if (!best || r.maxRes < best.maxRes) best = { deg, k, p0, p1, p2: r.p2, maxRes: r.maxRes }
  125. }
  126. }
  127. function fitBezierG1(p0, p1, samples) {
  128. // 固定 P0,P1,P3,求 P2(线性)
  129. const cum = [0]
  130. for (let i = 1; i < samples.length; i++) cum.push(cum[i - 1] + Math.hypot(samples[i].x - samples[i - 1].x, samples[i].y - samples[i - 1].y))
  131. const total = cum[cum.length - 1] || 1
  132. const ts = samples.map((_, i) => cum[i] / total)
  133. let aa = 0, ab = 0, bb = 0
  134. const sx = [], sy = []
  135. for (let i = 0; i < samples.length; i++) {
  136. const t = ts[i], mt = 1 - t
  137. const c0 = mt * mt * mt, c1 = 3 * mt * mt * t, c2 = 3 * mt * t * t, c3 = t * t * t
  138. aa += c2 * c2; ab += c2 * c3; bb += c3 * c3
  139. sx.push(samples[i].x - c0 * p0.x - c1 * p1.x)
  140. sy.push(samples[i].y - c0 * p0.y - c1 * p1.y)
  141. }
  142. // 最小化 Σ|c2·P2 + c3·TIP - s|²;未知 P2 二维,正规方程含 c3·TIP 交叉项
  143. let a2t = 0, s2x = 0, s2y = 0, ttx = 0, tty = 0
  144. for (let i = 0; i < samples.length; i++) {
  145. const t = ts[i], mt = 1 - t
  146. const c2 = 3 * mt * t * t, c3 = t * t * t
  147. a2t += c2 * c3; ttx += c3 * c3 * TIP.x; tty += c3 * c3 * TIP.y
  148. s2x += c2 * sx[i]; s2y += c2 * sy[i]
  149. }
  150. // aa·P2 + a2t·TIP = Sx(分量式)→ P2 = (Sx - a2t·TIP.x)/aa
  151. const p2 = { x: (s2x - a2t * TIP.x) / aa, y: (s2y - a2t * TIP.y) / aa }
  152. let maxRes = 0
  153. for (let i = 0; i < samples.length; i++) {
  154. const t = ts[i], mt = 1 - t
  155. const c0 = mt * mt * mt, c1 = 3 * mt * mt * t, c2 = 3 * mt * t * t, c3 = t * t * t
  156. const bx = c0 * p0.x + c1 * p1.x + c2 * p2.x + c3 * TIP.x
  157. const by = c0 * p0.y + c1 * p1.y + c2 * p2.y + c3 * TIP.y
  158. maxRes = Math.max(maxRes, Math.hypot(bx - samples[i].x, by - samples[i].y))
  159. }
  160. return { p2, maxRes }
  161. }
  162. if (!best) { console.log('无有效拟合'); process.exit(1) }
  163. console.log(`\n最优:切点角 ${best.deg}° → P0(${best.p0.x.toFixed(1)}, ${best.p0.y.toFixed(1)}),k=${best.k}`)
  164. console.log(`P1(${best.p1.x.toFixed(1)}, ${best.p1.y.toFixed(1)}) P2(${best.p2.x.toFixed(1)}, ${best.p2.y.toFixed(1)}) → TIP(188.5,474)`)
  165. console.log(`最大残差 ${best.maxRes.toFixed(2)}px`)
  166. // 完整闭合路径(bbox 空间,viewBox 外扩 40 同 billboard 裁剪窗):
  167. // 顶点起,左弧(视觉逆时针 = sweep 0)到切点 → 贝塞尔到尖端 → 镜像贝塞尔(反放:控制点 P2',P1')到右切点 → 右弧回顶。
  168. console.log(`\npath: M188.5 0 A189 189 0 0 0 ${best.p0.x.toFixed(1)} ${best.p0.y.toFixed(1)} C${best.p1.x.toFixed(1)} ${best.p1.y.toFixed(1)} ${best.p2.x.toFixed(1)} ${best.p2.y.toFixed(1)} 188.5 474 C${(377 - best.p2.x).toFixed(1)} ${best.p2.y.toFixed(1)} ${(377 - best.p1.x).toFixed(1)} ${best.p1.y.toFixed(1)} ${(377 - best.p0.x).toFixed(1)} ${best.p0.y.toFixed(1)} A189 189 0 0 0 188.5 0 Z`)