@flighthq/particles-formats 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/detect.d.ts +18 -0
  2. package/dist/detect.d.ts.map +1 -0
  3. package/dist/detect.js +75 -0
  4. package/dist/detect.js.map +1 -0
  5. package/dist/formatRegistry.d.ts +60 -0
  6. package/dist/formatRegistry.d.ts.map +1 -0
  7. package/dist/formatRegistry.js +75 -0
  8. package/dist/formatRegistry.js.map +1 -0
  9. package/dist/index.d.ts +21 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +21 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/libgdxParse.d.ts +27 -0
  14. package/dist/libgdxParse.d.ts.map +1 -0
  15. package/dist/libgdxParse.js +316 -0
  16. package/dist/libgdxParse.js.map +1 -0
  17. package/dist/libgdxSchema.d.ts +68 -0
  18. package/dist/libgdxSchema.d.ts.map +1 -0
  19. package/dist/libgdxSchema.js +12 -0
  20. package/dist/libgdxSchema.js.map +1 -0
  21. package/dist/libgdxSerialize.d.ts +17 -0
  22. package/dist/libgdxSerialize.d.ts.map +1 -0
  23. package/dist/libgdxSerialize.js +295 -0
  24. package/dist/libgdxSerialize.js.map +1 -0
  25. package/dist/parseParticleConfig.d.ts +34 -0
  26. package/dist/parseParticleConfig.d.ts.map +1 -0
  27. package/dist/parseParticleConfig.js +96 -0
  28. package/dist/parseParticleConfig.js.map +1 -0
  29. package/dist/particleDesignerParse.d.ts +23 -0
  30. package/dist/particleDesignerParse.d.ts.map +1 -0
  31. package/dist/particleDesignerParse.js +214 -0
  32. package/dist/particleDesignerParse.js.map +1 -0
  33. package/dist/particleDesignerSchema.d.ts +51 -0
  34. package/dist/particleDesignerSchema.d.ts.map +1 -0
  35. package/dist/particleDesignerSchema.js +5 -0
  36. package/dist/particleDesignerSchema.js.map +1 -0
  37. package/dist/particleDesignerSerialize.d.ts +22 -0
  38. package/dist/particleDesignerSerialize.d.ts.map +1 -0
  39. package/dist/particleDesignerSerialize.js +209 -0
  40. package/dist/particleDesignerSerialize.js.map +1 -0
  41. package/dist/pixiParse.d.ts +18 -0
  42. package/dist/pixiParse.d.ts.map +1 -0
  43. package/dist/pixiParse.js +173 -0
  44. package/dist/pixiParse.js.map +1 -0
  45. package/dist/serializeResult.d.ts +15 -0
  46. package/dist/serializeResult.d.ts.map +1 -0
  47. package/dist/serializeResult.js +2 -0
  48. package/dist/serializeResult.js.map +1 -0
  49. package/dist/spineParse.d.ts +18 -0
  50. package/dist/spineParse.d.ts.map +1 -0
  51. package/dist/spineParse.js +263 -0
  52. package/dist/spineParse.js.map +1 -0
  53. package/dist/spineSchema.d.ts +41 -0
  54. package/dist/spineSchema.d.ts.map +1 -0
  55. package/dist/spineSchema.js +8 -0
  56. package/dist/spineSchema.js.map +1 -0
  57. package/dist/spineSerialize.d.ts +16 -0
  58. package/dist/spineSerialize.d.ts.map +1 -0
  59. package/dist/spineSerialize.js +108 -0
  60. package/dist/spineSerialize.js.map +1 -0
  61. package/dist/starlingPexParse.d.ts +29 -0
  62. package/dist/starlingPexParse.d.ts.map +1 -0
  63. package/dist/starlingPexParse.js +254 -0
  64. package/dist/starlingPexParse.js.map +1 -0
  65. package/dist/starlingPexSchema.d.ts +49 -0
  66. package/dist/starlingPexSchema.d.ts.map +1 -0
  67. package/dist/starlingPexSchema.js +15 -0
  68. package/dist/starlingPexSchema.js.map +1 -0
  69. package/dist/starlingPexSerialize.d.ts +20 -0
  70. package/dist/starlingPexSerialize.d.ts.map +1 -0
  71. package/dist/starlingPexSerialize.js +184 -0
  72. package/dist/starlingPexSerialize.js.map +1 -0
  73. package/dist/unityParse.d.ts +23 -0
  74. package/dist/unityParse.d.ts.map +1 -0
  75. package/dist/unityParse.js +347 -0
  76. package/dist/unityParse.js.map +1 -0
  77. package/dist/unitySchema.d.ts +109 -0
  78. package/dist/unitySchema.d.ts.map +1 -0
  79. package/dist/unitySchema.js +9 -0
  80. package/dist/unitySchema.js.map +1 -0
  81. package/dist/unitySerialize.d.ts +20 -0
  82. package/dist/unitySerialize.d.ts.map +1 -0
  83. package/dist/unitySerialize.js +148 -0
  84. package/dist/unitySerialize.js.map +1 -0
  85. package/package.json +38 -0
  86. package/src/detect.test.ts +99 -0
  87. package/src/formatRegistry.test.ts +118 -0
  88. package/src/libgdxParse.test.ts +309 -0
  89. package/src/libgdxSerialize.test.ts +279 -0
  90. package/src/parseParticleConfig.test.ts +447 -0
  91. package/src/particleDesignerParse.test.ts +279 -0
  92. package/src/particleDesignerSerialize.test.ts +113 -0
  93. package/src/pixiParse.test.ts +131 -0
  94. package/src/spineParse.test.ts +289 -0
  95. package/src/spineSerialize.test.ts +81 -0
  96. package/src/starlingPexParse.test.ts +154 -0
  97. package/src/starlingPexSerialize.test.ts +115 -0
  98. package/src/unityParse.test.ts +328 -0
  99. package/src/unitySerialize.test.ts +77 -0
@@ -0,0 +1,328 @@
1
+ import {
2
+ createParticleEmitterConfig,
3
+ particleColorCurveFromKeyframes,
4
+ particleCurveFromKeyframes,
5
+ sampleParticleColorCurve,
6
+ sampleParticleCurve,
7
+ } from '@flighthq/particles';
8
+
9
+ import { parseUnityParticle, parseUnityParticleDocument } from './unityParse';
10
+ import { serializeUnityParticle } from './unitySerialize';
11
+
12
+ const SMOKE_JSON = JSON.stringify({
13
+ name: 'smoke',
14
+ duration: 5.0,
15
+ looping: true,
16
+ prewarm: false,
17
+ maxParticles: 500,
18
+ startLifetime: { mode: 'twoConstants', constantMin: 1.0, constantMax: 2.5 },
19
+ startSpeed: { mode: 'twoConstants', constantMin: 0.5, constantMax: 1.5 },
20
+ startSize: { mode: 'twoConstants', constantMin: 0.8, constantMax: 1.2 },
21
+ startRotation: { mode: 'constant', constant: 0 },
22
+ startColor: { r: 0.8, g: 0.8, b: 0.8, a: 1.0 },
23
+ gravityModifier: 0.1,
24
+ physicsGravity: 9.81,
25
+ emission: {
26
+ rateOverTime: { mode: 'constant', constant: 20 },
27
+ bursts: [],
28
+ },
29
+ shape: {
30
+ enabled: true,
31
+ shapeType: 'Cone',
32
+ radius: 0.5,
33
+ angle: 15,
34
+ scale: { x: 1, y: 1, z: 1 },
35
+ },
36
+ colorOverLifetime: {
37
+ enabled: true,
38
+ colorStart: { r: 0.8, g: 0.8, b: 0.8, a: 1.0 },
39
+ colorEnd: { r: 0.3, g: 0.3, b: 0.3, a: 0.0 },
40
+ },
41
+ sizeOverLifetime: { enabled: true, sizeStart: 1.0, sizeEnd: 2.0 },
42
+ rotationOverLifetime: {
43
+ enabled: true,
44
+ angularVelocity: { mode: 'twoConstants', constantMin: -45, constantMax: 45 },
45
+ },
46
+ });
47
+
48
+ const PPU = 100;
49
+
50
+ describe('parseUnityParticle', () => {
51
+ describe('gradient / size curves bake into curves', () => {
52
+ it('leaves curves null when colorOverLifetime has only start/end', () => {
53
+ const c = parseUnityParticle(SMOKE_JSON, { pixelsPerUnit: PPU });
54
+ expect(c.colorCurve).toBeNull();
55
+ expect(c.alphaCurve).toBeNull();
56
+ expect(c.scaleCurve).toBeNull();
57
+ });
58
+
59
+ it('bakes a multi-stop gradient (colorKeys + alphaKeys) into curves', () => {
60
+ const json = JSON.stringify({
61
+ ...JSON.parse(SMOKE_JSON),
62
+ colorOverLifetime: {
63
+ enabled: true,
64
+ gradient: {
65
+ colorKeys: [
66
+ { time: 0, color: { r: 1, g: 0, b: 0 } },
67
+ { time: 0.5, color: { r: 0, g: 1, b: 0 } },
68
+ { time: 1, color: { r: 0, g: 0, b: 1 } },
69
+ ],
70
+ alphaKeys: [
71
+ { time: 0, alpha: 0 },
72
+ { time: 0.5, alpha: 1 },
73
+ { time: 1, alpha: 0 },
74
+ ],
75
+ },
76
+ },
77
+ });
78
+ const c = parseUnityParticle(json, { pixelsPerUnit: PPU });
79
+ expect(c.colorCurve).not.toBeNull();
80
+ expect(c.alphaCurve).not.toBeNull();
81
+ const out = [0, 0, 0];
82
+ sampleParticleColorCurve(out, 0, c.colorCurve!, 0.5);
83
+ expect(out[1]).toBeGreaterThan(0.8); // green mid-life
84
+ expect(sampleParticleCurve(c.alphaCurve!, 0.5)).toBeGreaterThan(0.9); // alpha peaks mid-life
85
+ });
86
+
87
+ it('bakes a size-over-lifetime AnimationCurve into scaleCurve', () => {
88
+ const json = JSON.stringify({
89
+ ...JSON.parse(SMOKE_JSON),
90
+ sizeOverLifetime: {
91
+ enabled: true,
92
+ curve: {
93
+ keys: [
94
+ { time: 0, value: 0 },
95
+ { time: 0.5, value: 1 },
96
+ { time: 1, value: 0 },
97
+ ],
98
+ },
99
+ },
100
+ });
101
+ const c = parseUnityParticle(json, { pixelsPerUnit: PPU });
102
+ expect(c.scaleCurve).not.toBeNull();
103
+ expect(sampleParticleCurve(c.scaleCurve!, 0.5)).toBeGreaterThan(sampleParticleCurve(c.scaleCurve!, 0));
104
+ });
105
+
106
+ it('does not warn about a gradient now that it is baked', () => {
107
+ const json = JSON.stringify({
108
+ ...JSON.parse(SMOKE_JSON),
109
+ colorOverLifetime: {
110
+ enabled: true,
111
+ gradient: {
112
+ colorKeys: [
113
+ { time: 0, color: { r: 1, g: 1, b: 1 } },
114
+ { time: 0.5, color: { r: 0.5, g: 0.5, b: 0.5 } },
115
+ { time: 1, color: { r: 0, g: 0, b: 0 } },
116
+ ],
117
+ },
118
+ },
119
+ });
120
+ expect(parseUnityParticleDocument(json).warnings.some((w) => w.toLowerCase().includes('gradient'))).toBe(false);
121
+ });
122
+ });
123
+
124
+ describe('lightweight, returns config directly', () => {
125
+ it('returns a ParticleEmitterConfig (not a Parsed object)', () => {
126
+ const result = parseUnityParticle(SMOKE_JSON, { pixelsPerUnit: PPU });
127
+ expect(typeof result.maxParticles).toBe('number');
128
+ expect((result as unknown as Record<string, unknown>).document).toBeUndefined();
129
+ });
130
+
131
+ it('parses maxParticles', () => {
132
+ expect(parseUnityParticle(SMOKE_JSON, { pixelsPerUnit: PPU }).maxParticles).toBe(500);
133
+ });
134
+
135
+ it('maps startLifetime to lifetimeMin/Max', () => {
136
+ const c = parseUnityParticle(SMOKE_JSON, { pixelsPerUnit: PPU });
137
+ expect(c.lifetimeMin).toBeCloseTo(1.0);
138
+ expect(c.lifetimeMax).toBeCloseTo(2.5);
139
+ });
140
+
141
+ it('scales speed by pixelsPerUnit', () => {
142
+ const c = parseUnityParticle(SMOKE_JSON, { pixelsPerUnit: PPU });
143
+ expect(c.speedMin).toBeCloseTo(50);
144
+ expect(c.speedMax).toBeCloseTo(150);
145
+ });
146
+
147
+ it('converts gravity with gravityModifier and physicsGravity', () => {
148
+ const c = parseUnityParticle(SMOKE_JSON, { pixelsPerUnit: PPU });
149
+ expect(c.gravityY).toBeCloseTo(0.1 * 9.81 * PPU, 1);
150
+ });
151
+
152
+ it('maps Cone shape to spread', () => {
153
+ expect(parseUnityParticle(SMOKE_JSON, { pixelsPerUnit: PPU }).spread).toBeCloseTo((15 * Math.PI) / 180, 3);
154
+ });
155
+
156
+ it('maps colorOverLifetime when enabled', () => {
157
+ const c = parseUnityParticle(SMOKE_JSON, { pixelsPerUnit: PPU });
158
+ expect(c.colorStartR).toBeCloseTo(0.8);
159
+ expect(c.colorEndR).toBeCloseTo(0.3);
160
+ expect(c.alphaStart).toBeCloseTo(1.0);
161
+ expect(c.alphaEnd).toBeCloseTo(0.0);
162
+ });
163
+
164
+ it('maps sizeOverLifetime to scaleEnd', () => {
165
+ expect(parseUnityParticle(SMOKE_JSON, { pixelsPerUnit: PPU }).scaleEnd).toBeCloseTo(2.0);
166
+ });
167
+
168
+ it('maps rotationOverLifetime to rotationSpeedMin/Max', () => {
169
+ const c = parseUnityParticle(SMOKE_JSON, { pixelsPerUnit: PPU });
170
+ expect(c.rotationSpeedMin).toBeCloseTo((-45 * Math.PI) / 180, 3);
171
+ expect(c.rotationSpeedMax).toBeCloseTo((45 * Math.PI) / 180, 3);
172
+ });
173
+
174
+ it('maps a looping system to loop=true / duration=0 (emit forever)', () => {
175
+ const c = parseUnityParticle(SMOKE_JSON, { pixelsPerUnit: PPU });
176
+ expect(c.loop).toBe(true);
177
+ expect(c.duration).toBe(0);
178
+ });
179
+
180
+ it('maps a non-looping system to loop=false with its duration', () => {
181
+ const json = JSON.stringify({ ...JSON.parse(SMOKE_JSON), looping: false, duration: 3 });
182
+ const c = parseUnityParticle(json, { pixelsPerUnit: PPU });
183
+ expect(c.loop).toBe(false);
184
+ expect(c.duration).toBeCloseTo(3);
185
+ });
186
+
187
+ it('maps burst count and interval', () => {
188
+ const json = JSON.stringify({
189
+ ...JSON.parse(SMOKE_JSON),
190
+ emission: {
191
+ rateOverTime: { mode: 'constant', constant: 0 },
192
+ bursts: [{ time: 0, count: 25, cycleCount: 0, repeatInterval: 2 }],
193
+ },
194
+ });
195
+ const c = parseUnityParticle(json, { pixelsPerUnit: PPU });
196
+ expect(c.burstCount).toBe(25);
197
+ expect(c.burstInterval).toBe(2);
198
+ });
199
+ });
200
+
201
+ describe('malformed input', () => {
202
+ it('throws a clear, format-tagged error on invalid JSON', () => {
203
+ expect(() => parseUnityParticle('{not valid')).toThrow(/Invalid Unity particle JSON/);
204
+ });
205
+
206
+ it('throws a clear error when the root is not an object', () => {
207
+ expect(() => parseUnityParticle('null')).toThrow(/expected a JSON object/);
208
+ expect(() => parseUnityParticle('42')).toThrow(/expected a JSON object/);
209
+ expect(() => parseUnityParticle('[]')).toThrow(/expected a JSON object/);
210
+ });
211
+
212
+ it('falls back to defaults for an empty object rather than producing NaN', () => {
213
+ const c = parseUnityParticle('{}');
214
+ expect(Number.isFinite(c.maxParticles)).toBe(true);
215
+ expect(Number.isFinite(c.lifetimeMin)).toBe(true);
216
+ expect(Number.isFinite(c.gravityY)).toBe(true);
217
+ expect(Number.isFinite(c.spawnRate)).toBe(true);
218
+ });
219
+ });
220
+ });
221
+
222
+ describe('parseUnityParticleDocument', () => {
223
+ describe('full round-trip, returns { config, document }', () => {
224
+ it('returns the same config values as parseUnityParticle', () => {
225
+ const config = parseUnityParticle(SMOKE_JSON, { pixelsPerUnit: PPU });
226
+ const { config: loaded } = parseUnityParticleDocument(SMOKE_JSON, { pixelsPerUnit: PPU });
227
+ expect(loaded.maxParticles).toBe(config.maxParticles);
228
+ expect(loaded.gravityY).toBeCloseTo(config.gravityY, 5);
229
+ });
230
+
231
+ it('preserves name, looping, and prewarm in document', () => {
232
+ const { document } = parseUnityParticleDocument(SMOKE_JSON, { pixelsPerUnit: PPU });
233
+ expect(document.name).toBe('smoke');
234
+ expect(document.looping).toBe(true);
235
+ expect(document.prewarm).toBe(false);
236
+ });
237
+ });
238
+
239
+ describe('import warnings', () => {
240
+ it('has no warnings for a config-representable system', () => {
241
+ expect(parseUnityParticleDocument(SMOKE_JSON, { pixelsPerUnit: PPU }).warnings).toEqual([]);
242
+ });
243
+
244
+ it('warns about unsupported modules that are enabled', () => {
245
+ const json = JSON.stringify({
246
+ ...JSON.parse(SMOKE_JSON),
247
+ noise: { enabled: true },
248
+ collision: { enabled: true },
249
+ trails: { enabled: false }, // disabled → no warning
250
+ });
251
+ const { warnings } = parseUnityParticleDocument(json, { pixelsPerUnit: PPU });
252
+ expect(warnings.some((w) => w.includes('noise'))).toBe(true);
253
+ expect(warnings.some((w) => w.includes('collision'))).toBe(true);
254
+ expect(warnings.some((w) => w.includes('trails'))).toBe(false);
255
+ });
256
+
257
+ it('warns when more than one burst is present', () => {
258
+ const json = JSON.stringify({
259
+ ...JSON.parse(SMOKE_JSON),
260
+ emission: {
261
+ rateOverTime: { mode: 'constant', constant: 0 },
262
+ bursts: [
263
+ { time: 0, count: 5 },
264
+ { time: 1, count: 5 },
265
+ ],
266
+ },
267
+ });
268
+ expect(parseUnityParticleDocument(json).warnings.some((w) => w.includes('burst'))).toBe(true);
269
+ });
270
+ });
271
+ });
272
+
273
+ describe('serializeUnityParticle', () => {
274
+ it('round-trips key config fields', () => {
275
+ const config = parseUnityParticle(SMOKE_JSON, { pixelsPerUnit: PPU });
276
+ const { document } = parseUnityParticleDocument(SMOKE_JSON, { pixelsPerUnit: PPU });
277
+ const config2 = parseUnityParticle(serializeUnityParticle(config, document, { pixelsPerUnit: PPU }), {
278
+ pixelsPerUnit: PPU,
279
+ });
280
+ expect(config2.maxParticles).toBe(config.maxParticles);
281
+ expect(config2.lifetimeMin).toBeCloseTo(config.lifetimeMin, 3);
282
+ expect(config2.gravityY).toBeCloseTo(config.gravityY, 2);
283
+ expect(config2.alphaEnd).toBeCloseTo(config.alphaEnd, 3);
284
+ expect(config2.scaleEnd).toBeCloseTo(config.scaleEnd, 2);
285
+ });
286
+
287
+ it('produces valid JSON', () => {
288
+ expect(() => JSON.parse(serializeUnityParticle(parseUnityParticle(SMOKE_JSON)))).not.toThrow();
289
+ });
290
+ });
291
+
292
+ describe('serializeUnityParticle — curve round-trip', () => {
293
+ it('emits baked color/alpha/scale curves as gradient + AnimationCurve that re-import identically', () => {
294
+ const colorCurve = particleColorCurveFromKeyframes([
295
+ { time: 0, r: 1, g: 0, b: 0 },
296
+ { time: 0.5, r: 0, g: 1, b: 0 },
297
+ { time: 1, r: 0, g: 0, b: 1 },
298
+ ]);
299
+ const alphaCurve = particleCurveFromKeyframes([
300
+ { time: 0, value: 0 },
301
+ { time: 0.5, value: 1 },
302
+ { time: 1, value: 0 },
303
+ ]);
304
+ const scaleCurve = particleCurveFromKeyframes([
305
+ { time: 0, value: 0 },
306
+ { time: 0.5, value: 1 },
307
+ { time: 1, value: 0 },
308
+ ]);
309
+ const config = createParticleEmitterConfig({ colorCurve, alphaCurve, scaleCurve });
310
+ const json = serializeUnityParticle(config, undefined, { pixelsPerUnit: PPU });
311
+ // The serialized JSON should carry a gradient and a size AnimationCurve.
312
+ const doc = JSON.parse(json) as Record<string, unknown>;
313
+ const col = doc.colorOverLifetime as Record<string, unknown>;
314
+ expect(col.gradient).toBeDefined();
315
+ const size = doc.sizeOverLifetime as Record<string, unknown>;
316
+ expect(size.curve).toBeDefined();
317
+ // ...and re-importing reproduces the curves.
318
+ const c2 = parseUnityParticle(json, { pixelsPerUnit: PPU });
319
+ expect(c2.colorCurve).not.toBeNull();
320
+ expect(c2.alphaCurve).not.toBeNull();
321
+ expect(c2.scaleCurve).not.toBeNull();
322
+ const out = [0, 0, 0];
323
+ sampleParticleColorCurve(out, 0, c2.colorCurve!, 0.5);
324
+ expect(out[1]).toBeGreaterThan(0.8);
325
+ expect(sampleParticleCurve(c2.alphaCurve!, 0.5)).toBeGreaterThan(0.9);
326
+ expect(sampleParticleCurve(c2.scaleCurve!, 0.5)).toBeGreaterThan(sampleParticleCurve(c2.scaleCurve!, 0));
327
+ });
328
+ });
@@ -0,0 +1,77 @@
1
+ import { createParticleEmitterConfig } from '@flighthq/particles';
2
+
3
+ import { parseUnityParticle, parseUnityParticleDocument } from './unityParse';
4
+ import { serializeUnityParticle, serializeUnityParticleDocument } from './unitySerialize';
5
+
6
+ const SMOKE_JSON = JSON.stringify({
7
+ name: 'smoke',
8
+ duration: 5.0,
9
+ looping: true,
10
+ prewarm: false,
11
+ maxParticles: 500,
12
+ startLifetime: { mode: 'twoConstants', constantMin: 1.0, constantMax: 2.5 },
13
+ startSpeed: { mode: 'twoConstants', constantMin: 0.5, constantMax: 1.5 },
14
+ startSize: { mode: 'constant', constant: 1.0 },
15
+ startRotation: { mode: 'constant', constant: 0 },
16
+ startColor: { r: 0.8, g: 0.8, b: 0.8, a: 1.0 },
17
+ gravityModifier: 0.0,
18
+ physicsGravity: 9.81,
19
+ emission: { rateOverTime: { mode: 'constant', constant: 20 }, bursts: [] },
20
+ shape: { enabled: false, shapeType: 'Cone', radius: 0, angle: 25, scale: { x: 1, y: 1, z: 1 } },
21
+ colorOverLifetime: { enabled: false, colorStart: { r: 1, g: 1, b: 1, a: 1 }, colorEnd: { r: 1, g: 1, b: 1, a: 0 } },
22
+ sizeOverLifetime: { enabled: false, sizeStart: 1, sizeEnd: 1 },
23
+ rotationOverLifetime: { enabled: false, angularVelocity: { mode: 'constant', constant: 0 } },
24
+ });
25
+
26
+ describe('serializeUnityParticle', () => {
27
+ it('produces valid JSON', () => {
28
+ const config = parseUnityParticle(SMOKE_JSON);
29
+ expect(() => JSON.parse(serializeUnityParticle(config))).not.toThrow();
30
+ });
31
+
32
+ it('round-trips key config fields through parse → serialize → parse', () => {
33
+ const config = parseUnityParticle(SMOKE_JSON);
34
+ const { document } = parseUnityParticleDocument(SMOKE_JSON);
35
+ const json2 = serializeUnityParticle(config, document);
36
+ const config2 = parseUnityParticle(json2);
37
+ expect(config2.maxParticles).toBe(config.maxParticles);
38
+ expect(config2.lifetimeMin).toBeCloseTo(config.lifetimeMin, 3);
39
+ expect(config2.lifetimeMax).toBeCloseTo(config.lifetimeMax, 3);
40
+ });
41
+
42
+ it('preserves name from existing document', () => {
43
+ const config = parseUnityParticle(SMOKE_JSON);
44
+ const { document } = parseUnityParticleDocument(SMOKE_JSON);
45
+ const json2 = JSON.parse(serializeUnityParticle(config, document)) as Record<string, unknown>;
46
+ expect(json2.name).toBe('smoke');
47
+ });
48
+ });
49
+
50
+ describe('serializeUnityParticleDocument', () => {
51
+ it('returns text and empty warnings for a default config', () => {
52
+ const config = createParticleEmitterConfig();
53
+ const result = serializeUnityParticleDocument(config);
54
+ expect(typeof result.text).toBe('string');
55
+ expect(JSON.parse(result.text)).toBeDefined();
56
+ expect(result.warnings).toEqual([]);
57
+ });
58
+ it('warns when config has horizontal gravity (gravityX)', () => {
59
+ const config = createParticleEmitterConfig({ gravityX: 50 });
60
+ const result = serializeUnityParticleDocument(config);
61
+ expect(result.warnings.some((w) => w.includes('gravityX'))).toBe(true);
62
+ });
63
+ it('warns when config has color end variance', () => {
64
+ const config = createParticleEmitterConfig({ colorEndVarianceR: 0.2 });
65
+ const result = serializeUnityParticleDocument(config);
66
+ expect(result.warnings.some((w) => w.includes('colorEndVariance'))).toBe(true);
67
+ });
68
+ it('has no warnings for a config with standard representable features', () => {
69
+ const config = createParticleEmitterConfig({
70
+ maxParticles: 100,
71
+ blendMode: 'add',
72
+ loop: true,
73
+ });
74
+ const result = serializeUnityParticleDocument(config);
75
+ expect(result.warnings).toEqual([]);
76
+ });
77
+ });