Skip to content

Commit 554e08a

Browse files
authored
Merge pull request #11 from flycran/feat/block-physics
fix: 修复方块物理模式的历史性问题,优化模拟效果
2 parents dc60a3d + 84735a8 commit 554e08a

30 files changed

Lines changed: 3524 additions & 1073 deletions

‎.mise.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
[tools]
2-
bun = "latest"
2+
bun = "1.4.2"

‎__tests__/integration.test.ts‎

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ mock.module('../src/utils/githubContributions.ts', () => ({
2626
INTENSITY_COLORS: ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39']
2727
}))
2828

29-
const { gensvg } = await import('../src/gensvg.js')
29+
const { gensvg } = await import('../src/utils/gensvg.js')
3030

3131
describe('full pipeline integration', () => {
3232
beforeEach(() => {
@@ -72,6 +72,25 @@ describe('full pipeline integration', () => {
7272
expect(svg).not.toContain('<circle')
7373
})
7474

75+
it('adds rotation animation for square shapes only', async () => {
76+
const square = await gensvg({
77+
username: 'testuser',
78+
text: 'Hi',
79+
fontSize: 40,
80+
shape: 'square'
81+
})
82+
// 方形会翻滚:平移之外再叠加绕方块中心的自转
83+
expect(square).toContain('type="rotate"')
84+
expect(square).toContain('additive="sum"')
85+
86+
const circle = await gensvg({
87+
username: 'testuser',
88+
text: 'Hi',
89+
fontSize: 40
90+
})
91+
expect(circle).not.toContain('type="rotate"')
92+
})
93+
7594
it('handles empty text gracefully', async () => {
7695
const svg = await gensvg({
7796
username: 'testuser',
@@ -198,8 +217,10 @@ describe('full pipeline integration', () => {
198217
text: 'Hi',
199218
fontSize: 40
200219
})
220+
// viewBox 使用物理坐标(SCALE=1),显示尺寸再乘 scale 选项放大
201221
expect(dflt).toContain('width="900"')
202-
expect(dflt).toContain(`r="${(4 / 2) * 3}"`)
222+
expect(dflt).toContain('viewBox="0 0 300 150"')
223+
expect(dflt).toContain(`r="${4 / 2}"`)
203224

204225
const custom = await gensvg({
205226
username: 'testuser',
@@ -213,8 +234,41 @@ describe('full pipeline integration', () => {
213234

214235
expect(custom).toContain('width="1200"')
215236
expect(custom).toContain('height="600"')
216-
expect(custom).toContain(`r="${(6 / 2) * 3}"`)
217-
expect(custom).toContain('viewBox="0 0 1200 600"')
237+
expect(custom).toContain(`r="${6 / 2}"`)
238+
expect(custom).toContain('viewBox="0 0 400 200"')
239+
})
240+
241+
it('treats explicitly undefined options as unset', async () => {
242+
const omitted = await gensvg({
243+
username: 'testuser',
244+
text: 'Hi',
245+
fontSize: 40
246+
})
247+
const explicitUndefined = await gensvg({
248+
username: 'testuser',
249+
text: 'Hi',
250+
fontSize: 40,
251+
compressionTolerance: undefined,
252+
maxSimulationTime: undefined
253+
})
254+
255+
// 键携带 undefined 与键不存在等效
256+
expect(explicitUndefined).toBe(omitted)
257+
})
258+
259+
it('stops the simulation once maxSimulationTime is reached', async () => {
260+
const svg = await gensvg({
261+
username: 'testuser',
262+
text: 'Hi',
263+
fontSize: 40,
264+
maxSimulationTime: 0.2
265+
})
266+
267+
// dur = 播放时长 = 模拟时长 / speed:0.2s × 3 倍速 = 0.6s 模拟时长 ≈ 18 步,
268+
// dur ≈ 200ms(浮点边界可能多走 1 步);未截断时为数千毫秒
269+
const dur = parseFloat(svg.match(/dur="(\d+(?:\.\d+)?)ms"/)?.[1] ?? '0')
270+
expect(dur).toBeGreaterThanOrEqual(200)
271+
expect(dur).toBeLessThan(250)
218272
})
219273

220274
it('renders with an explicitly provided font path', async () => {

‎__tests__/main.test.ts‎

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,29 +15,32 @@ import {
1515
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
1616
import { tmpdir } from 'node:os'
1717
import { join } from 'node:path'
18-
import * as gensvgModule from '../src/gensvg'
1918
import { run } from '../src/main'
20-
import { SimulationOptions } from '../src/physics'
19+
import * as gensvgModule from '../src/utils/gensvg'
20+
import { GensvgOptions } from '../src/utils/gensvg'
2121

22-
let captured: SimulationOptions | null
22+
let captured: GensvgOptions | null
2323

24-
/** main.ts 读取的全部输入(@actions/core 按 INPUT_<NAME> 大写读取) */
24+
/** main.ts 读取的全部输入(@actions/core 按 INPUT_<NAME> 大写读取,短横线原样保留) */
2525
const INPUT_NAMES = [
2626
'USERNAME',
2727
'TEXT',
28-
'OUTPUT_PATH',
29-
'FONT_SIZE',
28+
'OUTPUT-PATH',
29+
'FONT-SIZE',
3030
'COMPRESSION-TOLERANCE',
3131
'COMPRESSION-ALGORITHM',
3232
'SHAPE',
33-
'TEXT_TOP',
34-
'BACKGROUND_COLOR',
35-
'TEXT_COLOR',
33+
'TEXT-TOP',
34+
'BACKGROUND-COLOR',
35+
'TEXT-COLOR',
3636
'WIDTH',
3737
'HEIGHT',
3838
'SIZE',
3939
'INTERVAL',
40-
'FONT_PATH'
40+
'FONT-PATH',
41+
'MAX-SIMULATION-TIME',
42+
'SCALE',
43+
'SPEED'
4144
]
4245

4346
function setInputs(values: Record<string, string>) {
@@ -84,14 +87,17 @@ describe('action input plumbing', () => {
8487
backgroundColor: 'transparent',
8588
textColor: 'black'
8689
})
87-
// 未填写的可选参数不下传,交给库使用默认值
88-
expect(captured).not.toHaveProperty('compressionTolerance')
89-
expect(captured).not.toHaveProperty('compressionAlgorithm')
90-
expect(captured).not.toHaveProperty('width')
91-
expect(captured).not.toHaveProperty('height')
92-
expect(captured).not.toHaveProperty('size')
93-
expect(captured).not.toHaveProperty('interval')
94-
expect(captured).not.toHaveProperty('font')
90+
// 未填写的可选输入以 undefined 下传
91+
expect(captured?.compressionTolerance).toBeUndefined()
92+
expect(captured?.compressionAlgorithm).toBeUndefined()
93+
expect(captured?.maxSimulationTime).toBeUndefined()
94+
expect(captured?.scale).toBeUndefined()
95+
expect(captured?.speed).toBeUndefined()
96+
expect(captured?.width).toBeUndefined()
97+
expect(captured?.height).toBeUndefined()
98+
expect(captured?.size).toBeUndefined()
99+
expect(captured?.interval).toBeUndefined()
100+
expect(captured?.font).toBeUndefined()
95101
})
96102

97103
it('forwards compression options and world geometry as numbers', async () => {
@@ -127,6 +133,30 @@ describe('action input plumbing', () => {
127133
expect(process.exitCode).toBe(1)
128134
})
129135

136+
it('forwards max-simulation-time as a number', async () => {
137+
setInputs({ 'MAX-SIMULATION-TIME': '5' })
138+
139+
await run()
140+
141+
expect(captured).toMatchObject({ maxSimulationTime: 5 })
142+
})
143+
144+
it('leaves max-simulation-time undefined when the input is empty', async () => {
145+
setInputs({ 'MAX-SIMULATION-TIME': '' })
146+
147+
await run()
148+
149+
expect(captured?.maxSimulationTime).toBeUndefined()
150+
})
151+
152+
it('forwards scale and speed as numbers', async () => {
153+
setInputs({ SCALE: '2', SPEED: '4' })
154+
155+
await run()
156+
157+
expect(captured).toMatchObject({ scale: 2, speed: 4 })
158+
})
159+
130160
it('forwards a custom font path', async () => {
131161
setInputs({ 'FONT-PATH': 'assets/Inter-Bold.ttf' })
132162

@@ -140,7 +170,7 @@ describe('action input plumbing', () => {
140170

141171
await run()
142172

143-
expect(captured).not.toHaveProperty('font')
173+
expect(captured?.font).toBeUndefined()
144174
})
145175

146176
it('fails without calling the generator when a numeric input is invalid', async () => {

‎__tests__/parseTextPath.test.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
import { describe, expect, it } from 'bun:test'
55
import { readFileSync } from 'node:fs'
66
import { resolve } from 'node:path'
7+
import { DEFAULT_OPTIONS } from '../src/const'
78
import {
89
getTextPaths,
910
loadFontFromPath,
1011
loadFontFromUrl
11-
} from '../src/parseTextPath'
12+
} from '../src/utils/parseTextPath'
1213

1314
const RELATIVE_FONT = 'public/LiberationSerif-Bold.ttf'
1415
const ABSOLUTE_FONT = resolve(RELATIVE_FONT)
@@ -19,7 +20,7 @@ const BUNDLED_FONT_URL = new URL(
1920

2021
describe('getTextPaths', () => {
2122
it('uses the bundled font when no source is provided', async () => {
22-
const implicit = await getTextPaths('Hi', 40)
23+
const implicit = await getTextPaths('Hi', 40, DEFAULT_OPTIONS.font)
2324
const explicit = await getTextPaths('Hi', 40, ABSOLUTE_FONT)
2425

2526
expect(implicit.length).toBe(2)
@@ -69,7 +70,7 @@ describe('getTextPaths', () => {
6970
})
7071

7172
it('emits path data, a bounding box and a triangulated mesh per glyph', async () => {
72-
const [glyph] = await getTextPaths('H', 40)
73+
const [glyph] = await getTextPaths('H', 40, DEFAULT_OPTIONS.font)
7374

7475
expect(glyph.pathData.startsWith('M')).toBe(true)
7576
expect(glyph.rect.width).toBeGreaterThan(0)
@@ -84,10 +85,10 @@ describe('getTextPaths', () => {
8485
})
8586

8687
it('returns one entry per glyph and scales with font size', async () => {
87-
expect((await getTextPaths('Hi', 40)).length).toBe(2)
88+
expect((await getTextPaths('Hi', 40, DEFAULT_OPTIONS.font)).length).toBe(2)
8889

89-
const [small] = await getTextPaths('H', 20)
90-
const [large] = await getTextPaths('H', 40)
90+
const [small] = await getTextPaths('H', 20, DEFAULT_OPTIONS.font)
91+
const [large] = await getTextPaths('H', 40, DEFAULT_OPTIONS.font)
9192
expect(large.rect.height / small.rect.height).toBeCloseTo(2, 5)
9293
})
9394

@@ -105,7 +106,7 @@ describe('getTextPaths', () => {
105106
})
106107

107108
it('tolerates text without glyphs', async () => {
108-
expect(await getTextPaths('', 40)).toEqual([])
109+
expect(await getTextPaths('', 40, DEFAULT_OPTIONS.font)).toEqual([])
109110
})
110111
})
111112

‎__tests__/simplify.test.ts‎

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,20 @@
33
* 使用时间插值偏差度量。
44
*/
55
import { describe, expect, it } from 'bun:test'
6-
import { Point } from '../src/utils'
6+
import { Point } from '../src/utils/physics'
77
import {
88
buildKeyTimes,
99
CompressedTrajectoryPoint,
1010
simplifyTrajectoryDP,
1111
simplifyTrajectoryGreedy,
1212
simplifyTrajectoryRDP
1313
} from '../src/utils/simplifyTrajectory'
14-
import { maxLerpDeviation, sumSpans } from './simplifyHelpers'
14+
import { maxLerpDeviation, maxSpinDeviation, sumSpans } from './simplifyHelpers'
1515

1616
type SimplifyFn = (
1717
trajectory: Point[],
18-
tolerance: number
18+
tolerance: number,
19+
rotationRadius?: number
1920
) => CompressedTrajectoryPoint[]
2021

2122
const algorithms: [string, SimplifyFn][] = [
@@ -191,6 +192,64 @@ describe('simplifyTrajectory algorithms', () => {
191192
}
192193
})
193194

195+
describe('rotation-aware trajectories', () => {
196+
// 位置匀速右移 + 自转匀速:位置与角度的插值都精确命中
197+
const linearSpin: Point[] = Array.from({ length: 5 }, (_, i) => ({
198+
x: i,
199+
y: 0,
200+
rotation: i * 0.5
201+
}))
202+
203+
for (const [name, simplify] of algorithms) {
204+
describe(name, () => {
205+
it('collapses uniform spin and drift to endpoints at tolerance 0', () => {
206+
expect(simplify(linearSpin, 0, 0.1)).toEqual([
207+
{ x: 0, y: 0, rotation: 0, span: 0 },
208+
{ x: 4, y: 0, rotation: 2, span: 4 }
209+
])
210+
})
211+
212+
it('keeps a keyframe where spin jumps far beyond the rotation error', () => {
213+
// 位置匀速但自转角在中点大跳变:丢弃中点的任何分段在该处的
214+
// 角度插值误差都是跳变幅度本身,远超上限,违反误差上界承诺,
215+
// 因此中点必须保留为关键帧——结论只依赖承诺,与算法实现无关
216+
const track: Point[] = [
217+
{ x: 0, y: 0, rotation: 0 },
218+
{ x: 1, y: 0, rotation: 10 },
219+
{ x: 2, y: 0, rotation: 0 }
220+
]
221+
expect(simplify(track, 0, 0.1)).toEqual([
222+
{ x: 0, y: 0, rotation: 0, span: 0 },
223+
{ x: 1, y: 0, rotation: 10, span: 1 },
224+
{ x: 2, y: 0, rotation: 0, span: 1 }
225+
])
226+
})
227+
228+
it('ignores rotation when maxRotationError is 0', () => {
229+
// 上限 0 表示消费端不渲染旋转:只按位置度量,轨迹仍可并段
230+
expect(simplify(linearSpin, 0)).toEqual([
231+
{ x: 0, y: 0, rotation: 0, span: 0 },
232+
{ x: 4, y: 0, rotation: 2, span: 4 }
233+
])
234+
})
235+
236+
it('bounds spin deviation on an accelerating spin', () => {
237+
const track: Point[] = Array.from({ length: 30 }, (_, i) => ({
238+
x: 0,
239+
y: 0,
240+
rotation: 0.1 * i * i
241+
}))
242+
const maxRotationError = 0.4
243+
const result = simplify(track, 0.4, maxRotationError)
244+
expect(sumSpans(result)).toBe(track.length - 1)
245+
expect(maxSpinDeviation(track, result)).toBeLessThanOrEqual(
246+
maxRotationError + 1e-9
247+
)
248+
})
249+
})
250+
}
251+
})
252+
194253
describe('buildKeyTimes', () => {
195254
it('stays strictly increasing for very long trajectories', () => {
196255
// 2000 段等长 span:小数舍入可能把相邻值舍成相同(SMIL 要求严格递增)

‎__tests__/simplifyHelpers.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,30 @@ export function maxLerpDeviation(
3737
return max
3838
}
3939

40+
/**
41+
* 原始轨迹相对简化结果的最大时刻插值角度误差(弧度),
42+
* 与 maxLerpDeviation 同口径,用于校验旋转轨迹压缩的角度误差上界。
43+
*/
44+
export function maxSpinDeviation(
45+
track: { rotation?: number }[],
46+
simplified: CompressedTrajectoryPoint[]
47+
): number {
48+
const indices = restoreIndices(simplified)
49+
50+
let max = 0
51+
// k 定位 i 所属的关键帧段 [indices[k], indices[k + 1]]
52+
let k = 0
53+
for (let i = 0; i < track.length; i++) {
54+
while (k + 2 < indices.length && indices[k + 1] <= i) k++
55+
const a = track[indices[k]].rotation ?? 0
56+
const b = track[indices[k + 1]].rotation ?? 0
57+
const t = (i - indices[k]) / (indices[k + 1] - indices[k])
58+
const deviation = Math.abs((track[i].rotation ?? 0) - (a + (b - a) * t))
59+
max = Math.max(max, deviation)
60+
}
61+
return max
62+
}
63+
4064
/** 时间守恒:span 总和必须等于原始间隔数,播放总时长不变 */
4165
export function sumSpans(simplified: CompressedTrajectoryPoint[]): number {
4266
return simplified.reduce((sum, p) => sum + p.span, 0)

0 commit comments

Comments
 (0)