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/example/resources/diamond.png b/packages/example/resources/diamond.png deleted file mode 100644 index 226b4f2..0000000 Binary files a/packages/example/resources/diamond.png and /dev/null differ 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) => { 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", 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 6f0c7ff..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,8 +61,8 @@ export const particleEffectDefaults = { } as const export type ParticleEffectModelJson = Omit< - PartialDeep, - 'emitters' | 'materials' | 'geometries' + PartialDeep, + '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. @@ -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 { @@ -126,122 +124,11 @@ export function parseParticleEffect({ geometries: allGeometries, }), ) - return { version: effectJson.version ?? particleEffectDefaults.version, emitters, 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), - ) - - 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 - } - - // 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() as unknown as BufferGeometryJSON - } - if (Object.keys(geometriesJson).length) 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 66aa4c2..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 { rangeDefaults, rangeModelToJson, parseRange } 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 } from '../util/type' -import { parseZone, Zone, zoneDefaults, zoneToJson } from './Zone' +import { Maybe, MaybeArray } from '../util/type' +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,32 +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?: - | string - | string[] - | Material - | Material[] - | (string | Material)[] - | null - geometry?: string | BufferGeometry | null + 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, @@ -157,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. @@ -169,7 +167,7 @@ export function parseEmitter({ materials, geometries, }: { - emitterJson: ParticleEmitterModelJson + emitterJson: ReadonlyDeep materials?: Record geometries?: Record }): ParticleEmitterModel { @@ -178,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) @@ -197,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, @@ -220,185 +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< - 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) + if (typeof materialIds === 'string') { + return materials[materialIds] ?? null } else { - return toMaterial(materialIds, materials) + return materialIds + .map((materialId) => materials[materialId]) + .filter(isNonNil) } } /** - * Maps a material id to ots respective material. - * If the material id is a Material, returns as is. + * Converts an input identifier or geometry object into a BufferGeometry instance. */ -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, - 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 8ba1115..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() @@ -18,9 +19,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 @@ -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/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/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 41873f1..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,93 +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 material object to its id using provided materials record', () => { - const matA = new PointsMaterial() - const matB = new PointsMaterial() - const materials = { a: matA, b: matB } - const emitter = parseEmitter({ - emitterJson: { material: matA }, - materials, - geometries: {}, - }) - const json = particleEmitterModelToJson(emitter, materials) - expect(json.material).toBe('a') - }) - - it('should convert array of materials/ids to array of ids', () => { - const matA = new PointsMaterial() - const matB = new PointsMaterial() - const materials = { a: matA, b: matB } - const emitter = parseEmitter({ - emitterJson: { material: [matA, 'b'] }, - materials, - geometries: {}, - }) - 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 and warn', () => { - const mat = new PointsMaterial() - const emitter = parseEmitter({ - emitterJson: { material: mat }, - materials: {}, - geometries: {}, - }) - const json = particleEmitterModelToJson(emitter, {}) - expect(json.material).toBeUndefined() - expect(console.warn).toHaveBeenCalled() - }) - }) - }) }) describe('parseEmitter defaults deep clone', () => { @@ -138,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) @@ -171,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,