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.

-
{{id}}
+
+ + Particle sprite + ? + {{row.id}} +
{{rows.length}} results
+
-

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}}

`, }, diff --git a/README.md b/README.md index 5b1a4c2..3551403 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Blockbench tools for creating multi-actor animations and animation definitions f ## Preview Tools - **NoN Sound Library** auditions cues and assigns local sound previews to imported/created sound keyframes. These preview files are not exported as animation cues. -- **NoN Particle Library** appears on particle keyframes and in the animation tools menu. Search/filter NoN particles and IDs discovered from remembered pack folders, local Minecraft assets, or a selected JAR/ZIP/assets folder. Apply to selected particle keyframes or insert at the playhead, then set the locator in the keyframe panel. There are no particle previews yet. Asset discovery is not a runtime registry check; parameterized particles need a NoN addon resolver unless NoN already handles them (such as its liquid particles). +- **NoN Particle Library** appears on particle keyframes and in the animation tools menu. Search/filter NoN particles and IDs discovered from remembered pack folders, local Minecraft assets, or a selected JAR/ZIP/assets folder. Choose an available project locator, then apply to selected particle keyframes or insert at the playhead. List thumbnails show the first available sprite without motion or in-game tint; particles without a resolvable sprite show a placeholder. Asset discovery is not a runtime registry check; parameterized particles need a NoN addon resolver unless NoN already handles them (such as its liquid particles). - **Autoplay Stages** advances non-looping clips to the next physical stage of the same animation ID. It takes priority over Blockbench's global loop toggle without changing that preference. Looping clips and custom timeline ranges remain unchanged. - The timeline pose buttons copy the preceding stage's final pose to the current start, or copy keyed channels from the current start to its end. - Mixed texture-size UV normalization is applied only to GeckoLib projects. Other formats retain their native UV sizing.