From 41c019934b46b3d53a5c8136d15eef0300789f04 Mon Sep 17 00:00:00 2001 From: nbilyk Date: Thu, 4 Dec 2025 14:18:16 -0800 Subject: [PATCH 1/6] feat: adjust diamond padding --- packages/example/resources/diamond.png | Bin 587 -> 923 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/example/resources/diamond.png b/packages/example/resources/diamond.png index 226b4f28912d3215aff4fe2ab0ee07cdca7ec266..f822c0f3a4f89794baf7e7eea335bc7e3a5e540d 100644 GIT binary patch literal 923 zcmV;M17!S(P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91E}#Pd1ONa40RR91E&u=k08)}91ONa71xZ9fRA>dw*T0JzK@AlviPt9TV@)H$)9Li9 zVzKz4R4Tn|wOTJsgn!Om6txS5LOGdCmNuJ>mC0l}u*MJWb=bvGI~I#o;_BF4Cs(Ce&Gi zJ84sJ6Y4C%Z)sC_ZguV8ZrT)_TU|T27i|j9sjd~=t2PDaRM!eFqfOzt)RlwFYEy77 zb>-kH+7zBcT`9P#HU;NUR|<~WpjLqgDC=^$_(ZSk_3Fcy)6i9y4vyOSe7+11QaMpz zn!6Pm{}^3$>ENgh(v@&H?5IuQowR9QM_sA|!iFH5%|2;U0MXG+9IyFDy#uFGU6Pcz zWm$i~U%FPcPzl<@tO*z}8wE4u_q% zl6Y1s_+&DxA_!qy0o-!>i`8ew}_;$PfHX4mOwOZ}>t$Oy~No|#Sy}la^20iHB z?@*mSgrcLD;Je-K*LuC~0?2oJFt>B5;8+Y6x?e7r-PLOKAPP)#UiP@EHs+8HUaeNQ z!{M+8S~u0*#C5clg5yP~zE~_=)$zSfLq{8HN{iz~s16^f-hRJ#RKF#ztF0UyFC=w1 z3itZ0J+7;*6&%&a{g2QH x?__&PQ9aQdyEra_>hSFHWj33A6)nm!_Xmp-J&l)l{s;g7002ovPDHLkV1fyQoSOgu literal 587 zcmV-R0<`^!P)|zXw zUHXWLxx457{QH9p!sY9SBLEKbegCt|c}6`brSuw&#@GK6=bXq(peP!IH#@Y0haf%ve(!a#1YMe>`5_eJKboxTv zRgKf>3-Pz8aaH<9{HmSCLVoIsy z>2%s;Sq8R;D&OsP-{Rr?? zaR7igj+f{2d7~{6)s=C)`z*^6N?50^j03pob!u&#syC^%aR8d$q&CKFdYjr92Qcex zYHM7lr>L!QfNDKOO^TQGR5d9MP}Eb^^ From 1d48d93396fda4ada3bc4d2dd3135822933b8f19 Mon Sep 17 00:00:00 2001 From: nbilyk Date: Thu, 4 Dec 2025 14:23:46 -0800 Subject: [PATCH 2/6] docs: embed diamond as base 64 --- packages/example/resources/diamond.png | Bin 923 -> 0 bytes packages/example/resources/fire.json | 3 +++ packages/example/src/index.ts | 6 ------ 3 files changed, 3 insertions(+), 6 deletions(-) delete mode 100644 packages/example/resources/diamond.png diff --git a/packages/example/resources/diamond.png b/packages/example/resources/diamond.png deleted file mode 100644 index f822c0f3a4f89794baf7e7eea335bc7e3a5e540d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 923 zcmV;M17!S(P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91E}#Pd1ONa40RR91E&u=k08)}91ONa71xZ9fRA>dw*T0JzK@AlviPt9TV@)H$)9Li9 zVzKz4R4Tn|wOTJsgn!Om6txS5LOGdCmNuJ>mC0l}u*MJWb=bvGI~I#o;_BF4Cs(Ce&Gi zJ84sJ6Y4C%Z)sC_ZguV8ZrT)_TU|T27i|j9sjd~=t2PDaRM!eFqfOzt)RlwFYEy77 zb>-kH+7zBcT`9P#HU;NUR|<~WpjLqgDC=^$_(ZSk_3Fcy)6i9y4vyOSe7+11QaMpz zn!6Pm{}^3$>ENgh(v@&H?5IuQowR9QM_sA|!iFH5%|2;U0MXG+9IyFDy#uFGU6Pcz zWm$i~U%FPcPzl<@tO*z}8wE4u_q% zl6Y1s_+&DxA_!qy0o-!>i`8ew}_;$PfHX4mOwOZ}>t$Oy~No|#Sy}la^20iHB z?@*mSgrcLD;Je-K*LuC~0?2oJFt>B5;8+Y6x?e7r-PLOKAPP)#UiP@EHs+8HUaeNQ z!{M+8S~u0*#C5clg5yP~zE~_=)$zSfLq{8HN{iz~s16^f-hRJ#RKF#ztF0UyFC=w1 z3itZ0J+7;*6&%&a{g2QH x?__&PQ9aQdyEra_>hSFHWj33A6)nm!_Xmp-J&l)l{s;g7002ovPDHLkV1fyQoSOgu diff --git a/packages/example/resources/fire.json b/packages/example/resources/fire.json index 5d90423..a4829bf 100644 --- a/packages/example/resources/fire.json +++ b/packages/example/resources/fire.json @@ -130,5 +130,8 @@ "depthWrite": false, "alphaTest": 0.01 } + }, + "textures": { + "diamond": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAC4AAAAuCAYAAABXuSs3AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAALqADAAQAAAABAAAALgAAAABSkiQEAAADBUlEQVRoBc3Xv4saQRQHcDVB8HexYiEJKCg2KTQpUmqdJnX+Ahsr/xatrKzyH6QLKLE5UiZwiIggQb2QiOJv8Ufey/GOsMy6OzNvIAPesnPum+99bnT3+XyGRrvdthqNhmWovC9gonCz2XwJdd8nEonXJupjTSPB0+n0q2Kx+KFUKr1rtVovTIR/zl0UtQuFQjmZTJY2m42VyWQ6sMYP7nXYxVE7FotV4vG4FYlEcqlUqmJCnTU4acPezqFwMBiMQfgyqL/5r8VJG8Sfvk2i0agRdTZxuzYJm1JnCy7SpvAm1FmCO2lTcBPqLMFvaVN4bnXt4G7aFJxbXTu4F20Kz6muFdyrNgXnVNcKLqNN4bnUlYPLalNwLnXl4CraFJ5DXSm4qjYF51BXCo7aoFaBByjrcrn4RK/r9erDl9PQVZcOjtr5fL4MD1I5UWCauxUa/xhddengqA3SlXA47KhN4Z20aV5HXaoDwoYgm80+aVMA0dFNHK/5R70Dp1JdkpS4ZVl/tUOhkKu22x7H4DhU1aXEj8fjT7/fPzmdTudAIPDscWnxTy/ieOX5fD7A1locDoeduJJ4Vir4ZDK5n8/nXfgXv4WeMi0u+TjrNfh2u/02Go06tVrt96169t9JbZV6vb4bDAY96N7vUJ0+hKKjl+C73e7Xer3uAsh3ezC3c6ngWAwWul8ul93VavUgCkxzXva4qjbmkA5erVa3w+GwB1o31d3EdbSVguNFqL5YLFzV8b1OQ0cba0qL40WoDh+o3n6/d1S/Ja6rrRwcLyR1OAr3+q09rqutFRzVx+Ox4153EufQ1gpO6iDeBcEHPPcyOLRxHaU9TgFJHe56d/A1eKZ5pyOXNtbXCo4FcK97VefSZgnuVZ1TmyW4V3VObbbgburc2mzB3dS5tVmDO6mb0GYN7qRuQps9uF3dlDZ7cLs6NBxK3Q3WcRvaNyD7AqQ+nU4/QcPxWaW7sdcUnUv1nKICojm8m/b7/Y/QCO9ke0lRPdEcuzgugurw7PJlNpt9FS3KMfcHixI9jZd3/ggAAAAASUVORK5CYII=" } } diff --git a/packages/example/src/index.ts b/packages/example/src/index.ts index 9838c03..7714fbb 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -89,12 +89,6 @@ function onResize() { let particleEffect: ParticleEffect | null = null const loader = new ParticleEffectLoader() -// Bind the "diamond" texture key used in fire.json to the local diamond.png file. -const textureLoader = new TextureLoader() -loader.setTextures({ - diamond: textureLoader.load('./diamond.png'), -}) - loader .loadAsync('./fire.json') .then((model) => { From 3568eb8ac98788a1e27cd826f5afcb09a8d0e286 Mon Sep 17 00:00:00 2001 From: nbilyk Date: Thu, 4 Dec 2025 19:47:54 -0800 Subject: [PATCH 3/6] feat: add texture serialization The toJSON will be removed soon; the editor should operate on the Json, not the parsed model --- .../src/model/ParticleEffectModel.ts | 29 ++++++++++++++----- .../three-particles/src/parseTextureJson.ts | 6 ++-- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/three-particles/src/model/ParticleEffectModel.ts b/packages/three-particles/src/model/ParticleEffectModel.ts index 6f0c7ff..d130374 100644 --- a/packages/three-particles/src/model/ParticleEffectModel.ts +++ b/packages/three-particles/src/model/ParticleEffectModel.ts @@ -62,7 +62,7 @@ export const particleEffectDefaults = { export type ParticleEffectModelJson = Omit< PartialDeep, - 'emitters' | 'materials' | 'geometries' + 'emitters' | 'materials' | 'geometries' | 'textures' > & { emitters?: ParticleEmitterModelJson[] @@ -78,7 +78,7 @@ export type ParticleEffectModelJson = Omit< * Allow either TextureJSON blobs or string URLs. * External textures can be set on the ParticleEffectLoader. */ - textures?: Record + textures?: Record | string> /** * Optional geometries bundled with this effect. @@ -126,7 +126,6 @@ export function parseParticleEffect({ geometries: allGeometries, }), ) - return { version: effectJson.version ?? particleEffectDefaults.version, emitters, @@ -173,14 +172,30 @@ export function particleEffectModelToJson({ particleEmitterModelToJson(e, allMaterials, allGeometries), ) + // Build a texture UUID map from both external and bundled textures so + // material JSON can replace UUID references with texture keys. + const textureUuidMap = createTextureUuidMap({ + ...externalTextures, + ...effect.textures, + }) + const materialEntries = Object.entries(effect.materials) if (materialEntries.length) { const materialsJson: Record = {} - const textureUuidMap = createTextureUuidMap(externalTextures) for (const [id, mat] of materialEntries) { materialsJson[id] = materialToJson(mat, textureUuidMap) } - if (Object.keys(materialsJson).length) out.materials = materialsJson + out.materials = materialsJson + } + + // Serialize bundled textures (do not include external textures) + const textureEntries = Object.entries(effect.textures) + if (textureEntries.length) { + const texturesJson: Record> = {} + for (const [id, tex] of textureEntries) { + texturesJson[id] = tex.toJSON() + } + out.textures = texturesJson } // Serialize bundled geometries (do not include external geometries) @@ -189,9 +204,9 @@ export function particleEffectModelToJson({ const geometriesJson: Record = {} for (const [id, geom] of geometryEntries) { // Use three.js BufferGeometry.toJSON to serialize - geometriesJson[id] = geom.toJSON() as unknown as BufferGeometryJSON + geometriesJson[id] = geom.toJSON() } - if (Object.keys(geometriesJson).length) out.geometries = geometriesJson + out.geometries = geometriesJson } return out diff --git a/packages/three-particles/src/parseTextureJson.ts b/packages/three-particles/src/parseTextureJson.ts index 8ba1115..610b733 100644 --- a/packages/three-particles/src/parseTextureJson.ts +++ b/packages/three-particles/src/parseTextureJson.ts @@ -18,9 +18,9 @@ export function parseTextureJson( if (json.uuid) t.uuid = json.uuid // transforms - if (json.repeat) t.repeat.set(json.repeat[0], json.repeat[1]) - if (json.offset) t.offset.set(json.offset[0], json.offset[1]) - if (json.center) t.center.set(json.center[0], json.center[1]) + if (json.repeat) t.repeat.fromArray(json.repeat) + if (json.offset) t.offset.fromArray(json.offset) + if (json.center) t.center.fromArray(json.center) if (typeof json.rotation === 'number') t.rotation = json.rotation // addressing / wrapping From fba93f934fcfed68d28cf7efeffd365b083e5c66 Mon Sep 17 00:00:00 2001 From: nbilyk Date: Thu, 4 Dec 2025 20:11:14 -0800 Subject: [PATCH 4/6] refactor: change ParticleEmitterModelJson No longer allow direct geometry and materials, only JSON --- .../src/model/ParticleEmitterModel.ts | 45 +++++-------------- packages/three-particles/src/util/type.ts | 2 + .../test/model/ParticleEmitterModel.test.ts | 27 ++++++++--- 3 files changed, 33 insertions(+), 41 deletions(-) diff --git a/packages/three-particles/src/model/ParticleEmitterModel.ts b/packages/three-particles/src/model/ParticleEmitterModel.ts index 66aa4c2..9e753af 100644 --- a/packages/three-particles/src/model/ParticleEmitterModel.ts +++ b/packages/three-particles/src/model/ParticleEmitterModel.ts @@ -7,11 +7,11 @@ import { TimelineModelJson, timelineModelToJson, } from './TimelineModel' -import { rangeDefaults, rangeModelToJson, parseRange } from './RangeModel' +import { parseRange, rangeDefaults, rangeModelToJson } from './RangeModel' import cloneDeep from 'lodash/cloneDeep' import { PartialDeep } from 'type-fest' import { isNonNil } from '../util/object' -import { Maybe } from '../util/type' +import { Maybe, MaybeArray } from '../util/type' import { parseZone, Zone, zoneDefaults, zoneToJson } from './Zone' /** @@ -95,14 +95,8 @@ export type ParticleEmitterModelJson = Omit< > & { emissionRate?: TimelineModelJson particleLifeExpectancy?: TimelineModelJson - material?: - | string - | string[] - | Material - | Material[] - | (string | Material)[] - | null - geometry?: string | BufferGeometry | null + material?: MaybeArray + geometry?: string propertyTimelines?: TimelineModelJson[] } @@ -306,44 +300,25 @@ export function durationToJson( * Keeps Material objects as is. */ export function toMaterials( - materialIds: Maybe< - Material | Material[] | string | string[] | (string | Material)[] - >, + materialIds: Maybe>, materials: Record, ): Material[] | Material | null { if (!materialIds) return null if (Array.isArray(materialIds)) { - return materialIds.map((m) => toMaterial(m, materials)).filter(isNonNil) + return materialIds + .map((materialId) => materials[materialId]) + .filter(isNonNil) } else { - return toMaterial(materialIds, materials) + return materials[materialIds] ?? null } } -/** - * Maps a material id to ots respective material. - * If the material id is a Material, returns as is. - */ -export function toMaterial( - materialId: Maybe, - materials: Record, -): Material | null { - if (typeof materialId === 'string') { - const material = materials[materialId] - if (!material) { - console.warn(`Missing material: ${materialId}`) - return null - } - return material - } - return materialId ?? null -} - /** * Reverse of toMaterials: maps Material object(s) to their id(s). * Keeps string id(s) as-is. */ export function toMaterialIds( - mats: Maybe, + mats: Maybe>, materials: Record, ): string | string[] | null { if (!mats) return null diff --git a/packages/three-particles/src/util/type.ts b/packages/three-particles/src/util/type.ts index f362dce..5144417 100644 --- a/packages/three-particles/src/util/type.ts +++ b/packages/three-particles/src/util/type.ts @@ -1 +1,3 @@ export type Maybe = T | undefined | null + +export type MaybeArray = T | T[] diff --git a/packages/three-particles/test/model/ParticleEmitterModel.test.ts b/packages/three-particles/test/model/ParticleEmitterModel.test.ts index 41873f1..c3607e4 100644 --- a/packages/three-particles/test/model/ParticleEmitterModel.test.ts +++ b/packages/three-particles/test/model/ParticleEmitterModel.test.ts @@ -74,28 +74,39 @@ describe('ParticleEmitterModel', () => { expect(json.rotateToOrientation).toBe(true) }) - it('should convert material object to its id using provided materials record', () => { + it('should convert a single material on the model to its id using provided materials record', () => { const matA = new PointsMaterial() const matB = new PointsMaterial() const materials = { a: matA, b: matB } + + // JSON uses ids only; materials are resolved when parsing. const emitter = parseEmitter({ - emitterJson: { material: matA }, + emitterJson: { material: 'a' }, materials, geometries: {}, }) + const json = particleEmitterModelToJson(emitter, materials) expect(json.material).toBe('a') }) - it('should convert array of materials/ids to array of ids', () => { + it('should convert array of materials on the model to array of ids', () => { const matA = new PointsMaterial() const matB = new PointsMaterial() const materials = { a: matA, b: matB } + + // Parse from ids-only JSON, then ensure a Material array on the + // model is converted back to ids. const emitter = parseEmitter({ - emitterJson: { material: [matA, 'b'] }, + emitterJson: { material: ['a', 'b'] }, materials, geometries: {}, }) + + // Simulate runtime modification where the emitter now holds + // actual Materials. + emitter.material = [matA, matB] + const json = particleEmitterModelToJson(emitter, materials) expect(Array.isArray(json.material)).toBe(true) expect(json.material).toEqual(['a', 'b']) @@ -110,13 +121,17 @@ describe('ParticleEmitterModel', () => { jest.restoreAllMocks() }) - it('should omit material and warn', () => { + it('should omit material without an id and warn', () => { const mat = new PointsMaterial() const emitter = parseEmitter({ - emitterJson: { material: mat }, + emitterJson: {}, materials: {}, geometries: {}, }) + + // Assign a material that has no corresponding id in the map. + emitter.material = mat + const json = particleEmitterModelToJson(emitter, {}) expect(json.material).toBeUndefined() expect(console.warn).toHaveBeenCalled() From c7f03d17a83707376552253cac5e4a5550a82ca7 Mon Sep 17 00:00:00 2001 From: nbilyk Date: Thu, 4 Dec 2025 20:23:11 -0800 Subject: [PATCH 5/6] chore: upgrade three --- package-lock.json | 12 ++++++++---- package.json | 5 ++++- packages/three-particles/package.json | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0b41e25..d5ffd99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,9 @@ "workspaces": [ "packages/*" ], + "dependencies": { + "three": "^0.181.2" + }, "devDependencies": { "@types/jest": "^30.0.0", "esbuild": "^0.25.1", @@ -12236,9 +12239,10 @@ } }, "node_modules/three": { - "version": "0.180.0", - "license": "MIT", - "peer": true + "version": "0.181.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.181.2.tgz", + "integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==", + "license": "MIT" }, "node_modules/three-particles": { "resolved": "packages/three-particles", @@ -13496,7 +13500,7 @@ "@types/three": "^0.181.0" }, "peerDependencies": { - "three": ">=0.180.0 <0.181.0" + "three": ">=0.181.0 <0.182.0" } }, "packages/three-particles/node_modules/type-fest": { diff --git a/package.json b/package.json index e7a7e69..c94fe42 100644 --- a/package.json +++ b/package.json @@ -39,5 +39,8 @@ }, "workspaces": [ "packages/*" - ] + ], + "dependencies": { + "three": "^0.181.2" + } } diff --git a/packages/three-particles/package.json b/packages/three-particles/package.json index d8f822c..63e6cc7 100644 --- a/packages/three-particles/package.json +++ b/packages/three-particles/package.json @@ -48,7 +48,7 @@ "globals": "^16.0.0" }, "peerDependencies": { - "three": ">=0.180.0 <0.181.0" + "three": ">=0.181.2 <0.182.0" }, "dependencies": { "@types/lodash": "^4.17.20", From efaa206dd08836d398f47566f23bfef9e62ba93a Mon Sep 17 00:00:00 2001 From: nbilyk Date: Fri, 5 Dec 2025 10:46:34 -0800 Subject: [PATCH 6/6] refactor!: remove toJSON no longer supports toJSON on the model added Json counterparts to all models, made partial --- .../src/ParticleEffectLoader.ts | 8 +- .../src/model/ParticleEffectModel.ts | 138 +----------- .../src/model/ParticleEmitterModel.ts | 209 ++++-------------- .../three-particles/src/model/RangeModel.ts | 25 +-- .../src/model/TimelineModel.ts | 61 ++--- packages/three-particles/src/model/Zone.ts | 2 + .../three-particles/src/parseTextureJson.ts | 19 +- .../test/model/ParticleEffectModel.test.ts | 91 +------- .../test/model/ParticleEmitterModel.test.ts | 133 ++--------- .../test/model/RangeModel.test.ts | 24 +- .../test/model/TimelineModel.test.ts | 49 +--- .../test/state/ParticleState.test.ts | 4 +- 12 files changed, 125 insertions(+), 638 deletions(-) diff --git a/packages/three-particles/src/ParticleEffectLoader.ts b/packages/three-particles/src/ParticleEffectLoader.ts index ed35dfd..5382fff 100644 --- a/packages/three-particles/src/ParticleEffectLoader.ts +++ b/packages/three-particles/src/ParticleEffectLoader.ts @@ -1,4 +1,5 @@ import { + BufferGeometry, BufferGeometryLoader, FileLoader, Loader, @@ -6,18 +7,18 @@ import { MaterialLoader, Texture, TextureLoader, - BufferGeometry, } from 'three' import { parseTextureJson } from './parseTextureJson' import { - ParticleEffectModelJson, parseParticleEffect, ParticleEffectModel, + ParticleEffectModelJson, } from './model' import { LoadingManager } from 'three/src/loaders/LoadingManager' import { decodeText } from './util' import { cloneDeep } from 'lodash' import { getDefaultRadial } from './materialDefaults' +import { ReadonlyDeep } from 'type-fest' /** * Loads a JSON file describing a particle effect. @@ -112,7 +113,7 @@ export class ParticleEffectLoader extends Loader { * @return {ParticleEffect} The parsed ParticleEffect object. */ async parseAsync( - json: ParticleEffectModelJson, + json: ReadonlyDeep, ): Promise { json = cloneDeep(json) @@ -163,7 +164,6 @@ export class ParticleEffectLoader extends Loader { bundledMaterials: bundledMaterials, externalMaterials: this.materials, bundledTextures: bundledTextures, - externalTextures: this.textures, bundledGeometries: bundledGeometries, externalGeometries: this.geometries, }) diff --git a/packages/three-particles/src/model/ParticleEffectModel.ts b/packages/three-particles/src/model/ParticleEffectModel.ts index d130374..8704afc 100644 --- a/packages/three-particles/src/model/ParticleEffectModel.ts +++ b/packages/three-particles/src/model/ParticleEffectModel.ts @@ -8,9 +8,8 @@ import { parseEmitter, ParticleEmitterModel, ParticleEmitterModelJson, - particleEmitterModelToJson, } from './ParticleEmitterModel' -import { PartialDeep } from 'type-fest' +import { PartialDeep, ReadonlyDeep } from 'type-fest' import { BufferGeometry, BufferGeometryJSON, @@ -40,6 +39,8 @@ export interface EmitterDurationModel { delayAfter: RangeModel } +export type EmitterDurationModelJson = PartialDeep + /** * Parameters for creating a new particle effect. */ @@ -49,7 +50,6 @@ export interface ParticleEffectModel { materials: Record textures: Record geometries: Record - toJSON(): ParticleEffectModelJson } /** @@ -61,7 +61,7 @@ export const particleEffectDefaults = { } as const export type ParticleEffectModelJson = Omit< - PartialDeep, + PartialDeep, 'emitters' | 'materials' | 'geometries' | 'textures' > & { emitters?: ParticleEmitterModelJson[] @@ -97,15 +97,13 @@ export function parseParticleEffect({ bundledMaterials, externalMaterials, bundledTextures, - externalTextures, bundledGeometries, externalGeometries, }: { - effectJson: ParticleEffectModelJson + effectJson: ReadonlyDeep bundledMaterials: Record externalMaterials: Record bundledTextures: Record - externalTextures: Record bundledGeometries: Record externalGeometries: Record }): ParticleEffectModel { @@ -132,131 +130,5 @@ export function parseParticleEffect({ materials: bundledMaterials, textures: bundledTextures, geometries: bundledGeometries, - toJSON: function (this: ParticleEffectModel) { - return particleEffectModelToJson({ - effect: this, - externalMaterials, - externalTextures, - externalGeometries, - }) - }, - } -} - -/** - * Returns a compact representation of a ParticleEffectModel with default values removed. - */ -export function particleEffectModelToJson({ - effect, - externalMaterials, - externalTextures, - externalGeometries, -}: { - effect: ParticleEffectModel - externalMaterials: Record - externalTextures: Record - externalGeometries: Record -}): ParticleEffectModelJson { - const out: ParticleEffectModelJson = {} - const allMaterials = { - ...externalMaterials, - ...effect.materials, - } - const allGeometries = { - ...externalGeometries, - ...effect.geometries, - } - out.version = effect.version - if (effect.emitters.length) - out.emitters = effect.emitters.map((e) => - particleEmitterModelToJson(e, allMaterials, allGeometries), - ) - - // Build a texture UUID map from both external and bundled textures so - // material JSON can replace UUID references with texture keys. - const textureUuidMap = createTextureUuidMap({ - ...externalTextures, - ...effect.textures, - }) - - const materialEntries = Object.entries(effect.materials) - if (materialEntries.length) { - const materialsJson: Record = {} - for (const [id, mat] of materialEntries) { - materialsJson[id] = materialToJson(mat, textureUuidMap) - } - out.materials = materialsJson - } - - // Serialize bundled textures (do not include external textures) - const textureEntries = Object.entries(effect.textures) - if (textureEntries.length) { - const texturesJson: Record> = {} - for (const [id, tex] of textureEntries) { - texturesJson[id] = tex.toJSON() - } - out.textures = texturesJson - } - - // Serialize bundled geometries (do not include external geometries) - const geometryEntries = Object.entries(effect.geometries) - if (geometryEntries.length) { - const geometriesJson: Record = {} - for (const [id, geom] of geometryEntries) { - // Use three.js BufferGeometry.toJSON to serialize - geometriesJson[id] = geom.toJSON() - } - out.geometries = geometriesJson - } - - return out -} - -function createTextureUuidMap( - textures: Record, -): Record { - // Build a lookup from texture UUID to the provided key so we can map - // serialized texture references (uuids) back to keys used by MaterialLoader. - const uuidToKey: Record = {} - for (const [key, tex] of Object.entries(textures)) { - uuidToKey[tex.uuid] = key - } - return uuidToKey -} - -/** - * Returns a compact representation of a material with default values removed. - */ -function materialToJson( - material: Material, - textureUuidToKeyMap: Record, -): MaterialJSON { - // Use three.js Material.toJSON to serialize material - const matJson: MaterialJSON = material.toJSON() - delete matJson.textures - delete matJson.images - return replaceTextureUuids(matJson, textureUuidToKeyMap) -} - -/** - * Replace any texture UUID references with their corresponding keys - */ -function replaceTextureUuids( - val: any, - textureUuidToKeyMap: Record, -): any { - if (Array.isArray(val)) - return val.map((element) => - replaceTextureUuids(element, textureUuidToKeyMap), - ) - if (val && typeof val === 'object') { - const outObj: any = {} - for (const [k, v] of Object.entries(val)) { - outObj[k] = replaceTextureUuids(v, textureUuidToKeyMap) - } - return outObj } - if (typeof val === 'string' && textureUuidToKeyMap[val]) - return textureUuidToKeyMap[val] - return val } diff --git a/packages/three-particles/src/model/ParticleEmitterModel.ts b/packages/three-particles/src/model/ParticleEmitterModel.ts index 9e753af..eb9b24a 100644 --- a/packages/three-particles/src/model/ParticleEmitterModel.ts +++ b/packages/three-particles/src/model/ParticleEmitterModel.ts @@ -1,18 +1,20 @@ -import { EmitterDurationModel } from './ParticleEffectModel' +import { + EmitterDurationModel, + EmitterDurationModelJson, +} from './ParticleEffectModel' import { BufferGeometry, Material, MathUtils } from 'three' import { parseTimeline, timelineDefaults, TimelineModel, TimelineModelJson, - timelineModelToJson, } from './TimelineModel' -import { parseRange, rangeDefaults, rangeModelToJson } from './RangeModel' +import { parseRange, rangeDefaults } from './RangeModel' import cloneDeep from 'lodash/cloneDeep' -import { PartialDeep } from 'type-fest' +import { PartialDeep, ReadonlyDeep } from 'type-fest' import { isNonNil } from '../util/object' import { Maybe, MaybeArray } from '../util/type' -import { parseZone, Zone, zoneDefaults, zoneToJson } from './Zone' +import { parseZone, Zone, zoneDefaults, ZoneJson } from './Zone' /** * Data for a particle emitter. @@ -23,7 +25,7 @@ export interface ParticleEmitterModel { /** * The friendly name of the emitter. */ - name: string + name: string | null /** * True if the emitter should be used. @@ -71,14 +73,14 @@ export interface ParticleEmitterModel { propertyTimelines: TimelineModel[] /** - * The material(s) to use for this emitter. + * The Geometry to use for this emitter. */ - material: Material | Material[] | null + geometry: BufferGeometry | null /** - * The Geometry to use for this emitter. + * The material(s) to use for this emitter. */ - geometry: BufferGeometry | null + material: Material | Material[] | null } /** @@ -86,26 +88,30 @@ export interface ParticleEmitterModel { * parseEmitter */ export type ParticleEmitterModelJson = Omit< - PartialDeep, - | 'material' - | 'propertyTimelines' + Partial, + | 'duration' | 'emissionRate' | 'particleLifeExpectancy' + | 'spawn' + | 'propertyTimelines' | 'geometry' + | 'material' > & { + duration?: EmitterDurationModelJson emissionRate?: TimelineModelJson particleLifeExpectancy?: TimelineModelJson - material?: MaybeArray - geometry?: string + spawn?: ZoneJson propertyTimelines?: TimelineModelJson[] + geometry?: string | null + material?: MaybeArray | null } /** * Default ParticleEmitterModel values. */ -export const particleEmitterDefaults = { +export const particleEmitterModelDefaults = { uuid: '', - name: '', + name: null, enabled: true, count: 100, loops: true, @@ -151,9 +157,7 @@ export const particleEmitterDefaults = { spawn: zoneDefaults, rotateToOrientation: false, propertyTimelines: [], - material: null, - geometry: null, -} as const satisfies ParticleEmitterModel +} as const satisfies ParticleEmitterModelJson /** * Returns a new ParticleEmitterModel with defaults applied. @@ -163,7 +167,7 @@ export function parseEmitter({ materials, geometries, }: { - emitterJson: ParticleEmitterModelJson + emitterJson: ReadonlyDeep materials?: Record geometries?: Record }): ParticleEmitterModel { @@ -172,15 +176,16 @@ export function parseEmitter({ emitterJson.spawn ?? (cloneDeep(zoneDefaults) as Zone), ) const duration = parseEmitterDuration( - emitterJson.duration ?? cloneDeep(particleEmitterDefaults.duration), + emitterJson.duration ?? + cloneDeep(particleEmitterModelDefaults.duration), ) const emissionRate = parseTimeline( emitterJson.emissionRate ?? - cloneDeep(particleEmitterDefaults.emissionRate), + cloneDeep(particleEmitterModelDefaults.emissionRate), ) const particleLifeExpectancy = parseTimeline( emitterJson.particleLifeExpectancy ?? - cloneDeep(particleEmitterDefaults.particleLifeExpectancy), + cloneDeep(particleEmitterModelDefaults.particleLifeExpectancy), ) const propertyTimelines = (emitterJson.propertyTimelines ?? []) .filter(isNonNil) @@ -191,17 +196,17 @@ export function parseEmitter({ return { uuid: id, - name: emitterJson.name ?? particleEmitterDefaults.name, - enabled: emitterJson.enabled ?? particleEmitterDefaults.enabled, - loops: emitterJson.loops ?? particleEmitterDefaults.loops, + name: emitterJson.name ?? particleEmitterModelDefaults.name, + enabled: emitterJson.enabled ?? particleEmitterModelDefaults.enabled, + loops: emitterJson.loops ?? particleEmitterModelDefaults.loops, duration, - count: emitterJson.count ?? particleEmitterDefaults.count, + count: emitterJson.count ?? particleEmitterModelDefaults.count, emissionRate, particleLifeExpectancy, spawn, rotateToOrientation: emitterJson.rotateToOrientation ?? - particleEmitterDefaults.rotateToOrientation, + particleEmitterModelDefaults.rotateToOrientation, propertyTimelines, material, geometry, @@ -214,166 +219,50 @@ export function parseEmitterDuration( const d = durationJson ?? {} return { duration: parseRange( - d.duration ?? cloneDeep(particleEmitterDefaults.duration.duration), + d.duration ?? + cloneDeep(particleEmitterModelDefaults.duration.duration), ), delayBefore: parseRange( d.delayBefore ?? - cloneDeep(particleEmitterDefaults.duration.delayBefore), + cloneDeep(particleEmitterModelDefaults.duration.delayBefore), ), delayAfter: parseRange( d.delayAfter ?? - cloneDeep(particleEmitterDefaults.duration.delayAfter), + cloneDeep(particleEmitterModelDefaults.duration.delayAfter), ), } } -export function particleEmitterModelToJson( - emitter: ParticleEmitterModel, - materials: Record, - geometries: Record = {}, -): Partial { - const out: ParticleEmitterModelJson = { - uuid: emitter.uuid, - } - if (emitter.name !== particleEmitterDefaults.name) out.name = emitter.name - if (emitter.enabled !== particleEmitterDefaults.enabled) - out.enabled = emitter.enabled - if (emitter.loops !== particleEmitterDefaults.loops) - out.loops = emitter.loops - - const duration = durationToJson(emitter.duration) - if (Object.keys(duration).length) out.duration = duration - - if (emitter.count !== particleEmitterDefaults.count) - out.count = emitter.count - - const emissionRate = timelineModelToJson(emitter.emissionRate) - if (Object.keys(emissionRate).length) out.emissionRate = emissionRate - - const life = timelineModelToJson(emitter.particleLifeExpectancy) - if (Object.keys(life).length) out.particleLifeExpectancy = life - - const spawn = zoneToJson(emitter.spawn) - if (Object.keys(spawn).length) out.spawn = spawn - - if ( - emitter.rotateToOrientation !== - particleEmitterDefaults.rotateToOrientation - ) - out.rotateToOrientation = emitter.rotateToOrientation - - if (emitter.propertyTimelines.length) - out.propertyTimelines = emitter.propertyTimelines.map((t) => - timelineModelToJson(t), - ) - - if (emitter.material != null) { - const mat = toMaterialIds(emitter.material, materials) - if (mat != null && (!Array.isArray(mat) || mat.length > 0)) { - out.material = mat - } - } - - if (emitter.geometry != null) { - const geoId = toGeometryId(emitter.geometry, geometries) - if (geoId != null) out.geometry = geoId - } - - return out -} - -export function durationToJson( - duration: EmitterDurationModel, -): Partial { - const out: any = {} - const dur = rangeModelToJson(duration.duration) - if (Object.keys(dur).length) out.duration = dur - const before = rangeModelToJson(duration.delayBefore) - if (Object.keys(before).length) out.delayBefore = before - const after = rangeModelToJson(duration.delayAfter) - if (Object.keys(after).length) out.delayAfter = after - return out -} - /** * Maps material id(s) to their respective materials. * Keeps Material objects as is. */ export function toMaterials( - materialIds: Maybe>, + materialIds: Maybe, materials: Record, ): Material[] | Material | null { if (!materialIds) return null - if (Array.isArray(materialIds)) { + if (typeof materialIds === 'string') { + return materials[materialIds] ?? null + } else { return materialIds .map((materialId) => materials[materialId]) .filter(isNonNil) - } else { - return materials[materialIds] ?? null } } /** - * Reverse of toMaterials: maps Material object(s) to their id(s). - * Keeps string id(s) as-is. + * Converts an input identifier or geometry object into a BufferGeometry instance. */ -export function toMaterialIds( - mats: Maybe>, - materials: Record, -): string | string[] | null { - if (!mats) return null - if (Array.isArray(mats)) { - const out: string[] = [] - for (const m of mats) { - const id = toMaterialId(m, materials) - if (id != null) out.push(id) - } - return out - } else { - return toMaterialId(mats, materials) - } -} - -export function toMaterialId( - mat: Maybe, - materials: Record, -): string | null { - if (mat == null) return null - if (typeof mat === 'string') return mat - // find key whose value strictly equals the material - for (const [id, material] of Object.entries(materials)) { - if (material === mat) return id - } - console.warn('Missing material id for provided Material') - return null -} - -/** Geometry helpers **/ export function toGeometry( - geometryId: Maybe, + geometryId: Maybe, geometries: Record, ): BufferGeometry | null { if (!geometryId) return null - if (typeof geometryId === 'string') { - const geom = geometries[geometryId] - if (!geom) { - console.warn(`Missing geometry: ${geometryId}`) - return null - } - return geom - } - return geometryId -} - -export function toGeometryId( - geometry: Maybe, - geometries: Record, -): string | null { - if (geometry == null) return null - if (typeof geometry === 'string') return geometry - for (const [id, g] of Object.entries(geometries)) { - if (g === geometry) return id + const geom = geometries[geometryId] ?? null + if (!geom) { + console.warn(`Missing geometry: ${geometryId}`) + return null } - console.warn('Missing geometry id for provided BufferGeometry') - return null + return geom } diff --git a/packages/three-particles/src/model/RangeModel.ts b/packages/three-particles/src/model/RangeModel.ts index 8e6789a..e837d92 100644 --- a/packages/three-particles/src/model/RangeModel.ts +++ b/packages/three-particles/src/model/RangeModel.ts @@ -1,5 +1,5 @@ import * as easing from '../util/easing' -import type { Maybe } from '../util/type' +import { ReadonlyDeep } from 'type-fest' /** * Describes a number range with easing. @@ -24,10 +24,12 @@ export const rangeDefaults = { /** * Returns a new RangeModel with defaults applied. */ -export function parseRange(rangeJson: Maybe): RangeModel { - const min = rangeJson?.min ?? rangeDefaults.min - const max = rangeJson?.max ?? rangeJson?.min ?? rangeDefaults.max - const ease = rangeJson?.ease ?? rangeDefaults.ease +export function parseRange( + rangeJson: ReadonlyDeep, +): RangeModel { + const min = rangeJson.min ?? rangeDefaults.min + const max = rangeJson.max ?? rangeJson.min ?? rangeDefaults.max + const ease = rangeJson.ease ?? rangeDefaults.ease return { min, max, ease } } @@ -49,16 +51,3 @@ export function valueFromRange(range: RangeModel): number { const fn = easing.getEase(range.ease) return fn(Math.random()) * (range.max - range.min) + range.min } - -/** - * Returns a compact representation of a RangeModel with default values removed. - */ -export function rangeModelToJson(range: RangeModel): RangeModelJson { - const out: RangeModelJson = {} - if (range.min !== rangeDefaults.min) out.min = range.min - // Only include max if it differs from min and default - if (range.max !== range.min && range.max !== rangeDefaults.max) - out.max = range.max - if (range.ease !== rangeDefaults.ease) out.ease = range.ease - return out -} diff --git a/packages/three-particles/src/model/TimelineModel.ts b/packages/three-particles/src/model/TimelineModel.ts index fe19e8c..7e97358 100644 --- a/packages/three-particles/src/model/TimelineModel.ts +++ b/packages/three-particles/src/model/TimelineModel.ts @@ -1,6 +1,6 @@ -import { RangeModel, rangeModelToJson, parseRange } from './RangeModel' +import { parseRange, RangeModel, RangeModelJson } from './RangeModel' import cloneDeep from 'lodash/cloneDeep' -import type { PartialDeep } from 'type-fest' +import { ReadonlyDeep } from 'type-fest' /** * Describes a property timeline. @@ -42,7 +42,7 @@ export interface TimelineModel { */ export const timelineDefaults = { property: '', - timeline: new Float32Array(), + timeline: [], useEmitterDuration: false, relative: false, low: { @@ -55,28 +55,31 @@ export const timelineDefaults = { max: 1, ease: 'linear', }, -} as const satisfies TimelineModel - -export type TimelineModelJson = Omit, 'timeline'> & { - timeline?: Float32Array | number[] +} as const satisfies TimelineModelJson + +export type TimelineModelJson = Omit< + Partial, + 'timeline' | 'low' | 'high' +> & { + property?: string + timeline?: number[] + low?: RangeModelJson + high?: RangeModelJson } /** * Returns a new TimelineModel with defaults applied. */ -export function parseTimeline(timeline: TimelineModelJson): TimelineModel { +export function parseTimeline( + timeline: ReadonlyDeep, +): TimelineModel { const low = parseRange(timeline.low ?? cloneDeep(timelineDefaults.low)) const high = parseRange( timeline.high ?? timeline.low ?? cloneDeep(timelineDefaults.high), ) - const tl: Float32Array = Array.isArray(timeline.timeline) - ? new Float32Array(timeline.timeline) - : timeline.timeline instanceof Float32Array - ? timeline.timeline - : new Float32Array() return { - property: timeline.property ?? timelineDefaults.property, - timeline: tl, + property: timeline.property ?? '', + timeline: new Float32Array(timeline.timeline ?? []), useEmitterDuration: timeline.useEmitterDuration ?? timelineDefaults.useEmitterDuration, relative: timeline.relative ?? timelineDefaults.relative, @@ -84,31 +87,3 @@ export function parseTimeline(timeline: TimelineModelJson): TimelineModel { high, } } - -/** - * Returns a compact representation of a TimelineModel with default values removed. - */ -export function timelineModelToJson( - timeline: TimelineModel, -): TimelineModelJson { - const out: TimelineModelJson = {} - - if (timeline.property !== timelineDefaults.property) - out.property = timeline.property - - if (timeline.timeline.length > 0) - out.timeline = Array.from(timeline.timeline) - - if (timeline.useEmitterDuration) - out.useEmitterDuration = timeline.useEmitterDuration - - if (timeline.relative) out.relative = timeline.relative - - const low = rangeModelToJson(timeline.low) - if (Object.keys(low).length) out.low = low - - const high = rangeModelToJson(timeline.high) - if (Object.keys(high).length) out.high = high - - return out -} diff --git a/packages/three-particles/src/model/Zone.ts b/packages/three-particles/src/model/Zone.ts index bee49e1..361cc7f 100644 --- a/packages/three-particles/src/model/Zone.ts +++ b/packages/three-particles/src/model/Zone.ts @@ -17,6 +17,8 @@ export interface Zone { ease: EaseType } +export type ZoneJson = Partial + export const zoneDefaults = { type: 'point', x: 0, diff --git a/packages/three-particles/src/parseTextureJson.ts b/packages/three-particles/src/parseTextureJson.ts index 610b733..05667e5 100644 --- a/packages/three-particles/src/parseTextureJson.ts +++ b/packages/three-particles/src/parseTextureJson.ts @@ -1,6 +1,7 @@ -import { Texture, TextureLoader, TextureJSON } from 'three' +import { Texture, TextureJSON, TextureLoader } from 'three' import { Wrapping } from 'three/src/constants' import { cloneDeep } from 'lodash' +import { PartialDeep, ReadonlyDeep } from 'type-fest' /** * Parse a THREE.TextureJSON object by loading its image and applying fields. @@ -8,7 +9,7 @@ import { cloneDeep } from 'lodash' * consider integrating THREE.ObjectLoader in the future. */ export function parseTextureJson( - json: Partial, + json: ReadonlyDeep>, textureLoader: TextureLoader, ): Texture { const t = json.image ? textureLoader.load(json.image) : new Texture() @@ -28,17 +29,17 @@ export function parseTextureJson( t.wrapS = json.wrap[0] as Wrapping t.wrapT = json.wrap[1] as Wrapping } - if (json.mapping !== undefined) t.mapping = json.mapping + if (json.mapping != null) t.mapping = json.mapping // sampling / filtering - if (json.magFilter !== undefined) t.magFilter = json.magFilter - if (json.minFilter !== undefined) t.minFilter = json.minFilter - if (typeof json.anisotropy === 'number') t.anisotropy = json.anisotropy + if (json.magFilter != null) t.magFilter = json.magFilter + if (json.minFilter != null) t.minFilter = json.minFilter + if (json.anisotropy != null) t.anisotropy = json.anisotropy // format / type / color space - if (json.format !== undefined) t.format = json.format - if (json.type !== undefined) t.type = json.type - if (json.colorSpace !== undefined) t.colorSpace = json.colorSpace + if (json.format != null) t.format = json.format + if (json.type != null) t.type = json.type + if (json.colorSpace != null) t.colorSpace = json.colorSpace // mipmaps and flags if (typeof json.flipY === 'boolean') t.flipY = json.flipY diff --git a/packages/three-particles/test/model/ParticleEffectModel.test.ts b/packages/three-particles/test/model/ParticleEffectModel.test.ts index ce1e3e4..8e405cc 100644 --- a/packages/three-particles/test/model/ParticleEffectModel.test.ts +++ b/packages/three-particles/test/model/ParticleEffectModel.test.ts @@ -1,14 +1,4 @@ -import { - parseParticleEffect, - ParticleEffectModel, - ParticleEffectModelJson, - particleEffectModelToJson, -} from '../../src/model/ParticleEffectModel' -import { - particleEmitterModelToJson, - parseEmitter, -} from '../../src/model/ParticleEmitterModel' -import { PointsMaterial } from 'three' +import { parseParticleEffect } from '../../src/model/ParticleEffectModel' describe('ParticleEffectModel', () => { describe('parseParticleEffect', () => { @@ -19,7 +9,6 @@ describe('ParticleEffectModel', () => { bundledMaterials: {}, externalMaterials: {}, bundledTextures: {}, - externalTextures: {}, bundledGeometries: {}, externalGeometries: {}, }) @@ -33,88 +22,10 @@ describe('ParticleEffectModel', () => { bundledMaterials: {}, externalMaterials: {}, bundledTextures: {}, - externalTextures: {}, bundledGeometries: {}, externalGeometries: {}, }) expect(parsed.version).toBe('v1') }) }) - - describe('particleEffectModelToJson', () => { - it('should omit defaults', () => { - const parsed = parseParticleEffect({ - effectJson: {}, - bundledMaterials: {}, - externalMaterials: {}, - bundledTextures: {}, - externalTextures: {}, - bundledGeometries: {}, - externalGeometries: {}, - }) - const json = particleEffectModelToJson({ - effect: parsed, - externalMaterials: {}, - externalTextures: {}, - externalGeometries: {}, - }) - expect(json).toEqual({ version: '1.0' }) - }) - - it('should include non-default version, emitter json, and materials serialized via toJSON', () => { - const emitter = parseEmitter({ - emitterJson: { name: 'E', count: 2 }, - materials: {}, - }) - const mat = new PointsMaterial() - const effect: ParticleEffectModel = { - version: '2.0', - emitters: [emitter], - materials: { a: mat }, - textures: {}, - geometries: {}, - toJSON(): ParticleEffectModelJson { - return {} - }, - } - // Pass materials map so emitter/materials serialization can use it - const json = particleEffectModelToJson({ - effect: effect, - externalMaterials: { a: mat }, - externalTextures: {}, - externalGeometries: {}, - }) - expect(json.version).toBe('2.0') - expect(Array.isArray(json.emitters)).toBe(true) - expect(json.emitters!.length).toBe(1) - expect(json.emitters![0]).toEqual( - particleEmitterModelToJson(emitter, { a: mat }), - ) - expect(json.materials).toBeDefined() - expect(typeof json.materials!.a).toBe('object') - // Three.js should produce a type for material JSON. - // Depending on three.js version, this may be top-level or nested under materials[0]. - const matJson: any = json.materials?.a - const type = matJson?.type ?? matJson?.materials?.[0]?.type - expect(type).toBe('PointsMaterial') - }) - }) -}) - -// Added test to ensure toJSON works when spreading a model -describe('ParticleEffectModel spread toJSON', () => { - it('works when creating a new model using spread', () => { - const base = parseParticleEffect({ - effectJson: {}, - bundledMaterials: {}, - externalMaterials: {}, - bundledTextures: {}, - externalTextures: {}, - bundledGeometries: {}, - externalGeometries: {}, - }) - const spread: ParticleEffectModel = { ...base, version: '2.0' } - const json = spread.toJSON() - expect(json.version).toBe('2.0') - }) }) diff --git a/packages/three-particles/test/model/ParticleEmitterModel.test.ts b/packages/three-particles/test/model/ParticleEmitterModel.test.ts index c3607e4..162407d 100644 --- a/packages/three-particles/test/model/ParticleEmitterModel.test.ts +++ b/packages/three-particles/test/model/ParticleEmitterModel.test.ts @@ -1,8 +1,6 @@ -import { PointsMaterial } from 'three' import { parseEmitter, - particleEmitterModelToJson, - particleEmitterDefaults, + particleEmitterModelDefaults, } from '../../src/model/ParticleEmitterModel' describe('ParticleEmitterModel', () => { @@ -17,13 +15,12 @@ describe('ParticleEmitterModel', () => { }) it('should parse child objects', () => { - const emitter = { - count: 12, - emissionRate: {}, - propertyTimelines: [{}], - } const parsed = parseEmitter({ - emitterJson: emitter, + emitterJson: { + count: 12, + emissionRate: {}, + propertyTimelines: [{}], + }, materials: {}, geometries: {}, }) @@ -36,108 +33,6 @@ describe('ParticleEmitterModel', () => { expect(parsed.propertyTimelines[0].low.min).toBeDefined() }) }) - - describe('particleEmitterModelToJson', () => { - it('should omit top-level defaults', () => { - const emitter = parseEmitter({ - emitterJson: {}, - materials: {}, - geometries: {}, - }) - const json = particleEmitterModelToJson(emitter, {}) - expect(json.name).toBeUndefined() - expect(json.enabled).toBeUndefined() - expect(json.loops).toBeUndefined() - expect(json.count).toBeUndefined() - }) - - it('should include non-default fields', () => { - const emitter = parseEmitter({ - emitterJson: { - name: 'Test', - enabled: false, - loops: false, - count: 5, - spawn: { type: 'box', w: 1, h: 2, d: 3 }, - rotateToOrientation: true, - }, - materials: {}, - - geometries: {}, - }) - const json = particleEmitterModelToJson(emitter, {}) - expect(json.name).toBe('Test') - expect(json.enabled).toBe(false) - expect(json.loops).toBe(false) - expect(json.count).toBe(5) - expect(json.spawn).toBeDefined() - expect(json.rotateToOrientation).toBe(true) - }) - - it('should convert a single material on the model to its id using provided materials record', () => { - const matA = new PointsMaterial() - const matB = new PointsMaterial() - const materials = { a: matA, b: matB } - - // JSON uses ids only; materials are resolved when parsing. - const emitter = parseEmitter({ - emitterJson: { material: 'a' }, - materials, - geometries: {}, - }) - - const json = particleEmitterModelToJson(emitter, materials) - expect(json.material).toBe('a') - }) - - it('should convert array of materials on the model to array of ids', () => { - const matA = new PointsMaterial() - const matB = new PointsMaterial() - const materials = { a: matA, b: matB } - - // Parse from ids-only JSON, then ensure a Material array on the - // model is converted back to ids. - const emitter = parseEmitter({ - emitterJson: { material: ['a', 'b'] }, - materials, - geometries: {}, - }) - - // Simulate runtime modification where the emitter now holds - // actual Materials. - emitter.material = [matA, matB] - - const json = particleEmitterModelToJson(emitter, materials) - expect(Array.isArray(json.material)).toBe(true) - expect(json.material).toEqual(['a', 'b']) - }) - - describe('when no id is found for the provided Material', () => { - beforeEach(() => { - jest.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - afterEach(() => { - jest.restoreAllMocks() - }) - - it('should omit material without an id and warn', () => { - const mat = new PointsMaterial() - const emitter = parseEmitter({ - emitterJson: {}, - materials: {}, - geometries: {}, - }) - - // Assign a material that has no corresponding id in the map. - emitter.material = mat - - const json = particleEmitterModelToJson(emitter, {}) - expect(json.material).toBeUndefined() - expect(console.warn).toHaveBeenCalled() - }) - }) - }) }) describe('parseEmitter defaults deep clone', () => { @@ -153,15 +48,19 @@ describe('parseEmitter defaults deep clone', () => { geometries: {}, }) // Top-level objects should not be the same as defaults or each other - expect(a.emissionRate).not.toBe(particleEmitterDefaults.emissionRate) - expect(b.emissionRate).not.toBe(particleEmitterDefaults.emissionRate) + expect(a.emissionRate).not.toBe( + particleEmitterModelDefaults.emissionRate, + ) + expect(b.emissionRate).not.toBe( + particleEmitterModelDefaults.emissionRate, + ) expect(a.emissionRate).not.toBe(b.emissionRate) expect(a.particleLifeExpectancy).not.toBe( - particleEmitterDefaults.particleLifeExpectancy, + particleEmitterModelDefaults.particleLifeExpectancy, ) expect(b.particleLifeExpectancy).not.toBe( - particleEmitterDefaults.particleLifeExpectancy, + particleEmitterModelDefaults.particleLifeExpectancy, ) expect(a.particleLifeExpectancy).not.toBe(b.particleLifeExpectancy) @@ -186,8 +85,8 @@ describe('parseEmitter defaults deep clone', () => { geometries: {}, }) - expect(a.duration).not.toBe(particleEmitterDefaults.duration) - expect(b.duration).not.toBe(particleEmitterDefaults.duration) + expect(a.duration).not.toBe(particleEmitterModelDefaults.duration) + expect(b.duration).not.toBe(particleEmitterModelDefaults.duration) expect(a.duration).not.toBe(b.duration) expect(a.duration.duration).not.toBe(b.duration.duration) diff --git a/packages/three-particles/test/model/RangeModel.test.ts b/packages/three-particles/test/model/RangeModel.test.ts index 21c6b8a..4fe71a6 100644 --- a/packages/three-particles/test/model/RangeModel.test.ts +++ b/packages/three-particles/test/model/RangeModel.test.ts @@ -1,9 +1,9 @@ -import { rangeModelToJson, parseRange } from '../../src/model/RangeModel' +import { parseRange } from '../../src/model/RangeModel' describe('RangeModel', () => { describe('parseRange', () => { it('should set defaults', () => { - const r = parseRange(undefined) + const r = parseRange({}) expect(r.min).toBe(0) expect(r.max).toBe(0) expect(r.ease).toBe('linear') @@ -13,24 +13,4 @@ describe('RangeModel', () => { expect(r.max).toBe(3) }) }) - - describe('rangeModelToJson', () => { - it('should omit defaults', () => { - const r = parseRange({}) - const json = rangeModelToJson(r) - expect(json).toEqual({}) - }) - it('should include non-defaults', () => { - const r = parseRange({ min: 1, max: 2, ease: 'sineIn' }) - const json = rangeModelToJson(r) - expect(json.min).toBe(1) - expect(json.max).toBe(2) - expect(json.ease).toBe('sineIn') - }) - it('should omit max if equals min', () => { - const r = parseRange({ min: 5, max: 5 }) - const json = rangeModelToJson(r) - expect(json).toEqual({ min: 5 }) - }) - }) }) diff --git a/packages/three-particles/test/model/TimelineModel.test.ts b/packages/three-particles/test/model/TimelineModel.test.ts index 0e29da9..977864f 100644 --- a/packages/three-particles/test/model/TimelineModel.test.ts +++ b/packages/three-particles/test/model/TimelineModel.test.ts @@ -1,66 +1,35 @@ import { parseTimeline } from '../../src' -import { timelineModelToJson } from '../../src/model/TimelineModel' +import { timelineDefaults } from '../../src/model/TimelineModel' describe('TimelineModel', () => { describe('parseTimeline', () => { it('should set timeline to Float32Array', () => { - const timeline = { + const parsed = parseTimeline({ + property: '', timeline: [1, 2, 3], - } - const parsed = parseTimeline(timeline) + }) expect(parsed.timeline).toBeInstanceOf(Float32Array) expect(parsed.timeline).toEqual(new Float32Array([1, 2, 3])) }) it('should parse ranges', () => { - const timeline = { + const parsed = parseTimeline({ + property: '', low: { min: 3, }, - } - const parsed = parseTimeline(timeline) + }) expect(parsed.low.max).toEqual(3) // If high was omitted, use low expect(parsed.high.max).toEqual(3) }) }) - - describe('timelineModelToJson', () => { - it('should omit defaults', () => { - const timeline = parseTimeline({}) - const json = timelineModelToJson(timeline) - // Note: "high" min defaults to 1 in timelineDefaults, which differs from - // the generic rangeDefaults (min=0). The serializer uses range defaults, - // so it will include high.min = 1. This is expected. - expect(json).toEqual({ high: { min: 1 } }) - }) - - it('should include non-defaults and array timeline', () => { - const timeline = parseTimeline({ - property: 'size', - timeline: [0, 1, 1, 2], - useEmitterDuration: true, - relative: true, - low: { min: 1, max: 2 }, - high: { min: 2, max: 3 }, - }) - const json = timelineModelToJson(timeline) - expect(json.property).toBe('size') - expect(json.timeline).toEqual([0, 1, 1, 2]) - expect(json.useEmitterDuration).toBe(true) - expect(json.relative).toBe(true) - expect(json.low).toBeDefined() - expect(json.high).toBeDefined() - }) - }) }) -import { timelineDefaults } from '../../src/model/TimelineModel' - describe('parseTimeline defaults deep clone', () => { it('should deep clone low/high when using defaults', () => { - const a = parseTimeline({}) - const b = parseTimeline({}) + const a = parseTimeline({ property: '' }) + const b = parseTimeline({ property: '' }) expect(a.low).not.toBe(b.low) expect(a.high).not.toBe(b.high) // Should not be the same references as exported defaults diff --git a/packages/three-particles/test/state/ParticleState.test.ts b/packages/three-particles/test/state/ParticleState.test.ts index b0f8bbf..d5f53a3 100644 --- a/packages/three-particles/test/state/ParticleState.test.ts +++ b/packages/three-particles/test/state/ParticleState.test.ts @@ -19,7 +19,7 @@ function emitterWithTimelines( }[], rotateToOrientation = false, ) { - const json: ParticleEmitterModelJson = { + const json: Partial = { uuid: 'test', name: 'e', enabled: true, @@ -51,7 +51,7 @@ function emitterWithTimelines( rotateToOrientation, propertyTimelines: timelines.map((t) => ({ property: t.property, - timeline: new Float32Array(t.timeline), + timeline: t.timeline, useEmitterDuration: !!t.useEmitterDuration, })), material: null,