Skip to content

Commit 8405ae6

Browse files
committed
使用path代替text渲染
1 parent cbd0684 commit 8405ae6

10 files changed

Lines changed: 152 additions & 9532 deletions

File tree

‎src/app.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { gensvg } from './gensvg'
22
import { startSimulation } from './server/physics'
3-
import { TEXT } from './utils'
3+
import { GITHUB_ID, TEXT } from './utils'
44
import pageApp from './web/index.html'
55

6-
const res = await startSimulation('yyx990803')
6+
const res = await startSimulation(GITHUB_ID, TEXT)
77

88
Bun.serve({
99
port: 3000,
@@ -17,7 +17,7 @@ Bun.serve({
1717
return Response.json(res)
1818
},
1919
'/svg': async () => {
20-
const svg = await gensvg({ name: 'Platane' })
20+
const svg = await gensvg({ username: GITHUB_ID, text: TEXT })
2121
return new Response(svg, {
2222
headers: { 'Content-Type': 'image/svg+xml' }
2323
})

‎src/gensvg.ts‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { startSimulation } from './server/physics'
22
import type { ContributionJson, SimulationResult } from './utils'
3-
import { HEIGHT, SCALE, SIZE, WIDTH } from './utils'
3+
import { FONT_SIZE, HEIGHT, SCALE, SIZE, TEXT, TEXT_TOP, WIDTH } from './utils'
44

55
export interface GensvgOptions {
6-
name: string
6+
username: string
7+
text: string
78
}
89

910
/**
@@ -13,9 +14,11 @@ export interface GensvgOptions {
1314
* @param options - 包含 GitHub 用户名
1415
* @returns 完整的 SVG 字符串
1516
*/
16-
export async function gensvg(options: GensvgOptions): Promise<string> {
17-
const { name } = options
18-
const result: SimulationResult = await startSimulation(name)
17+
export async function gensvg({
18+
username,
19+
text
20+
}: GensvgOptions): Promise<string> {
21+
const result: SimulationResult = await startSimulation(username, text)
1922

2023
const svgWidth = WIDTH * SCALE
2124
const svgHeight = HEIGHT * SCALE
@@ -44,20 +47,40 @@ export async function gensvg(options: GensvgOptions): Promise<string> {
4447
attributeType="XML"
4548
type="translate"
4649
values="${values}"
47-
dur="${result.stepCount * 16}ms"
50+
dur="${result.stepCount * 8}ms"
4851
fill="freeze"
4952
/>
5053
</g>`
5154
})
5255
.join('\n')
5356

57+
const textSvg = `
58+
<text
59+
x="${(WIDTH / 2) * SCALE}"
60+
y="${TEXT_TOP * SCALE}"
61+
font-size="${FONT_SIZE}"
62+
font-family="ArialCustom, sans-serif"
63+
text-anchor="middle"
64+
dominant-baseline="hanging"
65+
fill="black"
66+
>
67+
${TEXT}
68+
</text>`
69+
5470
const svg = `<svg
5571
xmlns="http://www.w3.org/2000/svg"
5672
width="${svgWidth}"
5773
height="${svgHeight}"
5874
viewBox="0 0 ${svgWidth} ${svgHeight}"
5975
>
76+
<style>
77+
@font-face {
78+
font-family: "ArialCustom";
79+
src: url("/assets/ARIAL.ttf") format("truetype");
80+
}
81+
</style>
6082
${contributionsSvg}
83+
${textSvg}
6184
</svg>`
6285

6386
return svg

‎src/server/parseTextPath.ts‎

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import earcut from 'earcut'
22
import opentype from 'opentype.js'
33
import { pointsOnPath } from 'points-on-path'
4-
import { FONT_SIZE, SCALE } from '../utils'
4+
import { FONT_SIZE, SCALE, TextPathResult } from '../utils'
55

66
function svgPathToRapierTrimesh(path: string, scale = 1, tolerance = 1) {
77
// pointsOnPath returns Point[][] — one array per sub-path, flatten them
@@ -27,26 +27,15 @@ const font = opentype.parse(
2727
await Bun.file('src/assets/ARIAL.ttf').arrayBuffer()
2828
)
2929

30-
export interface TextPathResult {
31-
paths: {
32-
vertices: Float32Array
33-
indices: Uint32Array
34-
}
35-
rect: {
36-
x: number
37-
y: number
38-
width: number
39-
height: number
40-
}
41-
}
42-
4330
export function getTextPaths(text: string): TextPathResult[] {
4431
const paths = font.getPaths(text, 0, 0, FONT_SIZE / SCALE)
4532

4633
return paths.map((path) => {
4734
const bbox = path.getBoundingBox()
35+
const pathData = path.toPathData(2)
4836
return {
49-
paths: svgPathToRapierTrimesh(path.toPathData(2)),
37+
paths: svgPathToRapierTrimesh(pathData),
38+
pathData: pathData,
5039
rect: {
5140
x: bbox.x1,
5241
y: bbox.y1,

‎src/server/physics.ts‎

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
INTERVAL,
1212
SIZE,
1313
SimulationResult,
14+
TEXT_TOP,
1415
WIDTH
1516
} from '../utils'
1617
import {
@@ -66,15 +67,13 @@ export class Contribution implements ContributionJson {
6667
async function createWord() {
6768
await init()
6869
// 重力
69-
const world = new World({ x: 0.0, y: -16 })
70-
world.lengthUnit = 10
70+
const world = new World({ x: 0.0, y: -30 })
71+
world.lengthUnit = 16
7172

7273
// 地面
7374
world.createCollider(
7475
ColliderDesc.cuboid(WIDTH, 1),
75-
world.createRigidBody(
76-
RigidBodyDesc.fixed().setCcdEnabled(true).setTranslation(0, -1)
77-
)
76+
world.createRigidBody(RigidBodyDesc.fixed().setTranslation(0, -1))
7877
)
7978
// 墙壁
8079
world.createCollider(
@@ -93,36 +92,61 @@ async function createWord() {
9392
const VELOCITY_THRESHOLD = 1e-3
9493

9594
export async function startSimulation(
96-
username: string
95+
username: string,
96+
text: string = username
9797
): Promise<SimulationResult> {
9898
const world = await createWord()
9999
const contributionsSet = new Set<Contribution>()
100100

101101
// 创建用户名文字静止刚体(居中对齐),同时收集三角网格数据
102-
const textPaths = getTextPaths(username)
103-
const totalWidth = textPaths.reduce((sum, { rect }) => sum + rect.width, 0)
104-
const offsetX = (WIDTH - totalWidth) / 2
105-
const centerY = HEIGHT / 2
102+
const textPaths = getTextPaths(text)
103+
104+
// 计算所有字形的整体包围盒(翻转 Y 轴后)
105+
let minX = Infinity,
106+
maxX = -Infinity,
107+
minY = Infinity,
108+
maxY = -Infinity
109+
for (const { rect } of textPaths) {
110+
if (rect.x < minX) minX = rect.x
111+
if (rect.x + rect.width > maxX) maxX = rect.x + rect.width
112+
// 翻转 Y:opentype 字形在基线下方(y 为负),碰撞体需要正向
113+
const top = -rect.y
114+
const bottom = -(rect.y + rect.height)
115+
if (bottom < minY) minY = bottom
116+
if (top > maxY) maxY = top
117+
}
118+
const bboxCenterX = (minX + maxX) / 2
119+
const offsetX = WIDTH / 2 - bboxCenterX
120+
// 文字顶部距离世界顶部的距离
121+
const centerY = HEIGHT - TEXT_TOP - maxY
106122
const textTriangles: number[][] = []
107123

108124
for (const { paths } of textPaths) {
109125
const { vertices, indices } = paths
110-
const cx = offsetX
126+
if (!vertices.length && !indices.length) continue
127+
// 翻转 Y 轴:opentype 字形在基线下方(y 为负),物理世界需要正向
128+
const flippedVertices = new Float32Array(vertices.length)
129+
for (let i = 0; i < vertices.length; i += 2) {
130+
flippedVertices[i] = vertices[i]
131+
flippedVertices[i + 1] = -vertices[i + 1]
132+
}
111133
world.createCollider(
112-
ColliderDesc.trimesh(vertices, indices),
113-
world.createRigidBody(RigidBodyDesc.fixed().setTranslation(cx, centerY))
134+
ColliderDesc.trimesh(flippedVertices, indices),
135+
world.createRigidBody(
136+
RigidBodyDesc.fixed().setTranslation(offsetX, centerY)
137+
)
114138
)
115139
for (let i = 0; i < indices.length; i += 3) {
116140
const i0 = indices[i] * 2,
117141
i1 = indices[i + 1] * 2,
118142
i2 = indices[i + 2] * 2
119143
textTriangles.push([
120-
cx + vertices[i0],
121-
centerY + vertices[i0 + 1],
122-
cx + vertices[i1],
123-
centerY + vertices[i1 + 1],
124-
cx + vertices[i2],
125-
centerY + vertices[i2 + 1]
144+
offsetX + flippedVertices[i0],
145+
centerY + flippedVertices[i0 + 1],
146+
offsetX + flippedVertices[i1],
147+
centerY + flippedVertices[i1 + 1],
148+
offsetX + flippedVertices[i2],
149+
centerY + flippedVertices[i2 + 1]
126150
])
127151
}
128152
}
@@ -134,7 +158,7 @@ export async function startSimulation(
134158
) {
135159
// 动态刚体
136160
const boxCollider = world.createCollider(
137-
ColliderDesc.cuboid(SIZE / 2, SIZE / 2),
161+
ColliderDesc.cuboid(SIZE / 2, SIZE / 2).setFriction(0.2),
138162
world.createRigidBody(
139163
RigidBodyDesc.dynamic().setCcdEnabled(true).setTranslation(x, y)
140164
)
@@ -148,7 +172,7 @@ export async function startSimulation(
148172
const rowLen = githubContributions.contributions.length
149173
const rowWidth = SIZE * rowLen + INTERVAL * (rowLen - 1)
150174
const left = (WIDTH - rowWidth) / 2
151-
const top = HEIGHT * 0.9
175+
const top = HEIGHT * 1
152176

153177
for (let rowIndex = 0; rowIndex < rowLen; rowIndex++) {
154178
const col = githubContributions.contributions[rowIndex]
@@ -211,6 +235,10 @@ export async function startSimulation(
211235
contributions: Array.from(contributionsSet).map((contribution) =>
212236
contribution.toJSON()
213237
),
238+
textPaths: textPaths.map(({ pathData, rect }) => ({
239+
pathData: pathData,
240+
rect: rect
241+
})),
214242
textTriangles
215243
}
216244
}

‎src/utils/index.ts‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,26 @@
1-
export const WIDTH = 200
2-
export const HEIGHT = 100
3-
export const SIZE = 2
4-
export const SCALE = 6
5-
export const INTERVAL = SIZE / 2
6-
export const FONT_SIZE = 120
7-
export const TEXT = 'Flycran'
1+
export const WIDTH = 300
2+
export const HEIGHT = 150
3+
export const SCALE = 3
4+
export const SIZE = 4
5+
export const INTERVAL = 1.5
6+
export const FONT_SIZE = 80
7+
export const TEXT_TOP = 50
8+
export const GITHUB_ID = 'Orta'
9+
export const TEXT = 'Orta Therox'
10+
11+
export interface TextPathResult {
12+
paths: {
13+
vertices: Float32Array
14+
indices: Uint32Array
15+
}
16+
pathData: string
17+
rect: {
18+
x: number
19+
y: number
20+
width: number
21+
height: number
22+
}
23+
}
824

925
export interface ContributionJson {
1026
date: string
@@ -18,6 +34,7 @@ export interface ContributionJson {
1834
export interface SimulationResult {
1935
stepCount: number
2036
time: number
37+
textPaths: Pick<TextPathResult, 'rect' | 'pathData'>[]
2138
contributions: ContributionJson[]
2239
/** 用户名文字碰撞体的三角网格(世界坐标),用于 web 端渲染 */
2340
textTriangles: number[][]

‎src/web/assets/ARIAL.bin‎

-1020 KB
Binary file not shown.

‎src/web/assets/ARIAL.ttf‎

-1020 KB
Binary file not shown.

0 commit comments

Comments
 (0)