From 2780f8aebbaada03f39b1cf046e5218a8e107b50 Mon Sep 17 00:00:00 2001 From: L1Z0 <2grif484d@mozmail.com> Date: Tue, 29 Sep 2026 16:13:42 +0200 Subject: [PATCH] particle library --- AnimationDirector.js | 108 ++++++++++++++++++++++++++++++++++++++----- README.md | 2 +- 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/AnimationDirector.js b/AnimationDirector.js index 0f3f798..cb173bb 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -6162,7 +6162,7 @@ } function loadParticleLibrary(io = desktopFiles()) { - const ids = new Set(NON_PARTICLE_IDS), warnings = []; + const ids = new Set(NON_PARTICLE_IDS), warnings = [], definitions = new Map(); const selected = localStorage.getItem(PARTICLE_SOURCE_KEY) || localStorage.getItem(SOUND_SOURCE_KEY); const paths = uniqueTrimmedStrings([...savedPackFolders(), ...(selected ? [selected] : []), ...discoverPropSources(io).map((source) => source.file)]); @@ -6173,16 +6173,80 @@ for (const name of particleJsonPaths(source, io)) { const match = name.match(/^assets\/([^/]+)\/particles\/(.+)\.json$/); const id = match && `${match[1]}:${match[2]}`; - if (id && importResourceId(id) && ids.size < 10000) ids.add(id); + if (id && importResourceId(id) && ids.size < 10000) { + ids.add(id); + if (!definitions.has(id)) definitions.set(id, { file, name }); + } } } catch (error) { warnings.push(`${io.path.basename(file)}: ${error.message}`); } } - particleLibrary = { ids: [...ids].sort(), warnings }; + particleLibrary = { ids: [...ids].sort(), warnings, definitions, paths: paths.slice(0, 64), + thumbnails: new Map(), thumbnailBytes: 0 }; return particleLibrary; } - function applyLibraryParticle(id, insert = false) { + function particleThumbnailRows(ids, library = particleLibrary, io = null) { + const pending = new Map(), groups = new Map(); + if (!library?.definitions) return ids.map((id) => ({ id, thumbnail: '' })); + for (const id of ids) { + if (library.thumbnails.has(id)) continue; + library.thumbnails.set(id, ''); + const definition = library.definitions.get(id); + if (!definition || library.thumbnailBytes >= 8 * 1024 * 1024) continue; + if (!groups.has(definition.file)) groups.set(definition.file, []); + groups.get(definition.file).push({ id, name: definition.name }); + } + const readSprite = (source, id, name) => { + try { + const bytes = source.read(name); + if (!bytes) return false; + // Keep thumbnails bounded and use PNG data URLs rather than exposing local file paths. + if (bytes.length >= 24 && bytes.length <= 256 * 1024 + && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + && bytes.readUInt32BE(16) > 0 && bytes.readUInt32BE(16) <= 1024 + && bytes.readUInt32BE(20) > 0 && bytes.readUInt32BE(20) <= 1024 + && library.thumbnailBytes + bytes.length <= 8 * 1024 * 1024) { + library.thumbnails.set(id, `data:image/png;base64,${bytes.toString('base64')}`); + library.thumbnailBytes += bytes.length; + } + return true; + } catch (_) { return false; } + }; + // Batch the visible page by source so cross-pack sprites do not repeatedly reopen large JARs. + for (const [file, entries] of groups) { + try { + io ||= desktopFiles(); + const source = openPropSource(file, io); + for (const { id, name } of entries) { + try { + const json = propJson(source, name); + const sprite = Array.isArray(json?.textures) ? json.textures[0] : null; + if (typeof sprite !== 'string') continue; + const texture = propResourcePath(sprite, 'textures/particle', '.png'); + if (!readSprite(source, id, texture)) pending.set(id, texture); + } catch (_) { /* Missing or malformed assets do not prevent selecting a particle. */ } + } + } catch (_) { /* Unavailable sources retain a placeholder thumbnail. */ } + } + for (const file of library.paths) { + if (!pending.size) break; + try { + const source = openPropSource(file, io); + for (const [id, name] of pending) if (readSprite(source, id, name)) pending.delete(id); + } catch (_) { /* Try the remaining asset sources. */ } + } + return ids.map((id) => ({ id, thumbnail: library.thumbnails.get(id) || '' })); + } + + function availableParticleLocators() { + const locators = typeof Locator !== 'undefined' ? Locator.all + : typeof Outliner !== 'undefined' ? Outliner.elements?.filter((element) => element.type === 'locator') : []; + return uniqueTrimmedStrings((locators || []).map((locator) => locator.name)).sort(); + } + + function applyLibraryParticle(id, insert = false, locator = null) { if (!importResourceId(id)) throw new Error('Invalid particle ID.'); + if (locator !== null && !availableParticleLocators().includes(locator)) throw new Error('The selected locator is no longer available in this project.'); const animation = typeof Animation === 'undefined' ? null : Animation.selected; if (!animation) throw new Error('Select an animation first.'); const selected = (Timeline.selected || []).filter((frame) => frame.channel === 'particle' && frame.animator?.animation === animation); @@ -6192,6 +6256,7 @@ const point = frame.data_points[0]; if (point.effect !== id) point.file = ''; point.effect = id; + if (locator !== null) point.locator = locator; } Undo.finishEdit('Apply particle from library'); } else { @@ -6199,9 +6264,10 @@ const keyframes = effects.particle.filter((frame) => Math.abs(frame.time - Timeline.time) < 0.00001); Undo.initEdit({ keyframes }); let frame = keyframes[0]; - if (frame) frame.extend({ data_points: [...frame.data_points.map((point) => point.getUndoCopy()), { effect: id }] }); + const point = { effect: id, ...(locator !== null ? { locator } : {}) }; + if (frame) frame.extend({ data_points: [...frame.data_points.map((point) => point.getUndoCopy()), point] }); else { - frame = effects.createKeyframe({ data_points: [{ effect: id }] }, Timeline.time, 'particle', false, false); + frame = effects.createKeyframe({ data_points: [point] }, Timeline.time, 'particle', false, false); keyframes.push(frame); } effects.select(); frame.select(); @@ -6215,22 +6281,34 @@ try { loadParticleLibrary(); } catch (error) { particleLibrary = { ids: [...NON_PARTICLE_IDS].sort(), warnings: [error.message] }; } } + const locators = availableParticleLocators(); + const selectedFrames = (typeof Timeline === 'undefined' ? [] : Timeline.selected || []) + .filter((frame) => frame.channel === 'particle' && frame.animator?.animation === Animation.selected); + const currentLocators = uniqueTrimmedStrings(selectedFrames.map((frame) => frame.data_points[0]?.locator || '')); + const defaultLocator = selectedFrames.length && currentLocators.length === 1 && locators.includes(currentLocators[0]) + && selectedFrames.every((frame) => frame.data_points[0]?.locator === currentLocators[0]) + ? currentLocators[0] : selectedFrames.length ? '' : locators[0] || ''; particleLibraryDialog?.delete(); particleLibraryDialog = new Dialog({ id: 'animationdirector_particle_library', title: 'NoN Particle Library', width: 700, buttons: ['Close'], component: { data() { return { query: '', category: 'all', selected: '', page: 0, - ids: particleLibrary.ids, warnings: particleLibrary.warnings.join('\n'), error: '' }; }, + ids: particleLibrary.ids, locators, locator: defaultLocator, thumbnailRevision: 0, + warnings: particleLibrary.warnings.join('\n'), error: '' }; }, computed: { categories() { return [...new Set(this.ids.map((id) => id.split(':')[0]))].sort(); }, rows() { return this.ids.filter((id) => (this.category === 'all' || id.startsWith(`${this.category}:`)) && id.toLowerCase().includes(this.query.toLowerCase())); }, - visibleRows() { return this.rows.slice(this.page * 100, (this.page + 1) * 100); }, + visibleRows() { + // Reloading replaces the asset cache, even if the list of IDs stays unchanged. + void this.thumbnailRevision; + return particleThumbnailRows(this.rows.slice(this.page * 100, (this.page + 1) * 100)); + }, }, watch: { query() { this.page = 0; }, category() { this.page = 0; } }, methods: { reload() { try { loadParticleLibrary(); this.ids = particleLibrary.ids; this.warnings = particleLibrary.warnings.join('\n'); - this.page = 0; this.error = ''; } + this.page = 0; this.thumbnailRevision++; this.error = ''; } catch (error) { this.error = error.message; } }, folder() { @@ -6243,18 +6321,24 @@ if (files?.[0]?.path) { localStorage.setItem(PARTICLE_SOURCE_KEY, files[0].path); this.reload(); } }); }, - apply(insert) { try { applyLibraryParticle(this.selected, insert); this.error = ''; } catch (error) { this.error = error.message; } }, + apply(insert) { try { applyLibraryParticle(this.selected, insert, this.locator || null); this.error = ''; } catch (error) { this.error = error.message; } }, }, template: `
Includes NoN particles, remembered Pack Folders, and locally available Minecraft assets. Select a mod JAR to add its particles.
Set the spawn locator in the keyframe panel. This library assigns particle IDs only, without previews. Parameterized particles require a NoN addon resolver; their ID alone may not spawn them.
+Thumbnails show the first sprite, not particle motion or in-game tint. Parameterized particles require a NoN addon resolver; their ID alone may not spawn them.
{{error}}
{{warnings}}