From 2fa581a2de7efc47264a8be881fced8e6321283a Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 13:37:25 +0100 Subject: [PATCH 01/25] Make animation Save and Export produce identical library files --- src/animation_codec.ts | 90 +++++++++++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 27 deletions(-) diff --git a/src/animation_codec.ts b/src/animation_codec.ts index b4250d4..c77bd6d 100644 --- a/src/animation_codec.ts +++ b/src/animation_codec.ts @@ -55,6 +55,52 @@ function build_library(path: string | undefined, animations: VS_Animation[]): VS return library; } +/** Reads the library currently on disk at `path`, or null if absent/unreadable. */ +function read_library(path: string | undefined): VS_AnimationLibrary | null { + if (!path || !fs.existsSync(path)) return null; + try { + const parsed = autoParseJSON(fs.readFileSync(path, 'utf-8')) as VS_AnimationLibrary; + return parsed && Array.isArray(parsed.animations) ? parsed : null; + } catch (e) { + console.error('[VS Animation Codec] Failed to read existing library, overwriting:', e); + return null; + } +} + +/** + * Assembles the bytes to write for `path` from freshly compiled animations. + * + * Both the ANIMATIONS-panel Save and `Animation > Export Animations...` route through here so + * they produce identical output. When a library already exists at `path`, each compiled animation + * replaces the entry of the same name in place (keeping the on-disk ordering and the file's + * `code`/`name`), new ones are appended, and entries that are not loaded into the project are + * left untouched. `renamedFrom` maps a compiled animation's current name back to the name it + * still has on disk, so a rename updates the existing entry instead of adding a second one. + */ +function assemble_library( + path: string | undefined, + compiled: VS_Animation[], + renamedFrom?: Map, +): VS_AnimationLibrary { + const existing = read_library(path); + if (!existing) return build_library(path, compiled); + + if (path) libraryMeta.set(path, { code: existing.code, name: existing.name }); + + for (const vsAnim of compiled) { + const oldName = renamedFrom?.get(vsAnim.name) ?? vsAnim.name; + const idx = existing.animations.findIndex(a => a.name === oldName || a.name === vsAnim.name); + if (idx >= 0) { + existing.animations[idx] = vsAnim; + // Drop any later duplicate left over from a rename. + existing.animations = existing.animations.filter((a, i) => i === idx || a.name !== vsAnim.name); + } else { + existing.animations.push(vsAnim); + } + } + return existing; +} + /** Parses a VS animation library file and adds its animations (grouped under `file.path`). */ function load_file(file: AnimationCodecFile, animation_filter?: string[]): _Animation[] { const json = (file.json ?? autoParseJSON(file.content as string)) as VS_AnimationLibrary; @@ -70,11 +116,22 @@ function load_file(file: AnimationCodecFile, animation_filter?: string[]): _Anim return created; } -/** Compiles a set of animations into a full VS library object. */ +/** + * Compiles a set of animations into a full VS library object (`Animation > Export Animations...`). + * When every animation belongs to the same existing library file, the result is merged into that + * file's current contents so Export and the panel's Save produce byte-identical output. Mixing + * animations from different files has no single file to merge against, so it writes a fresh + * library containing exactly the selection. + */ function compile_file(animations: _Animation[]): VS_AnimationLibrary { const path = animations[0]?.path; + const sharedPath = path && animations.every(a => a.path === path) ? path : undefined; const compiled = compile_animation_library(animations).animations; - return build_library(path, compiled); + const renamedFrom = new Map(); + for (const a of animations) { + if (a.saved_name && a.saved_name !== a.name) renamedFrom.set(a.name, a.saved_name); + } + return assemble_library(sharedPath, compiled, renamedFrom); } /** Opens a dialog to import one or more VS animation library files. */ @@ -112,32 +169,11 @@ function write_animation_to_library(animation: _Animation): void { } const path = animation.path; - let existing: VS_AnimationLibrary | null = null; - if (fs.existsSync(path)) { - try { - existing = autoParseJSON(fs.readFileSync(path, 'utf-8')) as VS_AnimationLibrary; - } catch (e) { - console.error('[VS Animation Codec] Failed to read existing library, overwriting:', e); - existing = null; - } - } - - let library: VS_AnimationLibrary; - if (existing && Array.isArray(existing.animations)) { - library = existing; - libraryMeta.set(path, { code: existing.code, name: existing.name }); - const oldName = animation.saved_name ?? vsAnim.name; - const idx = library.animations.findIndex(a => a.name === oldName || a.name === vsAnim.name); - if (idx >= 0) { - library.animations[idx] = vsAnim; - // Drop any later duplicate left over from a rename. - library.animations = library.animations.filter((a, i) => i === idx || a.name !== vsAnim.name); - } else { - library.animations.push(vsAnim); - } - } else { - library = build_library(path, [vsAnim]); + const renamedFrom = new Map(); + if (animation.saved_name && animation.saved_name !== vsAnim.name) { + renamedFrom.set(vsAnim.name, animation.saved_name); } + const library = assemble_library(path, [vsAnim], renamedFrom); Blockbench.writeFile(path, { content: autoStringify(library) }, (real_path) => { animation.saved = true; From 92f479813868cf5bdff24345a25ce6b1ee5141b4 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 13:39:13 +0100 Subject: [PATCH 02/25] Preview VS particle keyframes in the Blockbench viewport --- src/animation_actions.ts | 21 ++++++++ src/animation_particles.ts | 107 +++++++++++++++++++++++++++++++++++++ src/import_animation.ts | 3 +- 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 src/animation_particles.ts diff --git a/src/animation_actions.ts b/src/animation_actions.ts index 8a2c9e3..f016299 100644 --- a/src/animation_actions.ts +++ b/src/animation_actions.ts @@ -2,6 +2,7 @@ import { createAction } from "./util/moddingTools"; import * as PACKAGE from "../package.json"; import { is_vs_project } from "./util"; import { clear_animations } from "./import_animation"; +import { relink_particle_previews } from "./animation_particles"; // Importing and exporting VS animation files is handled natively by the multi-file // animation workflow (format `animation_files` + the VS AnimationCodec): use the @@ -28,3 +29,23 @@ const clear_animations_action = createAction(`${PACKAGE.name}:clear_animations_v } }); MenuBar.addAction(clear_animations_action, 'edit'); + +// Particle keyframes only preview once their effect code has been resolved to a Snowstorm +// emitter file. Import does that automatically; this re-runs it for hand-authored keyframes +// and for effects added to the assets folder since the model was opened. +const reload_vfx_action = createAction(`${PACKAGE.name}:reload_vfx_previews`, { + name: 'Reload VFX Previews', + icon: 'auto_awesome', + condition() { + return is_vs_project(Project); + }, + click: function () { + const linked = relink_particle_previews(); + Blockbench.showQuickMessage( + linked === 0 + ? 'No particle effects could be resolved' + : `Linked ${linked} particle keyframe${linked === 1 ? '' : 's'}` + ); + } +}); +MenuBar.addAction(reload_vfx_action, 'animation'); diff --git a/src/animation_particles.ts b/src/animation_particles.ts new file mode 100644 index 0000000..700119c --- /dev/null +++ b/src/animation_particles.ts @@ -0,0 +1,107 @@ +/** + * Viewport preview for VS particle keyframes. + * + * Blockbench renders a particle keyframe through Wintersky, but only when the keyframe's data + * point carries a `file` pointing at a Snowstorm emitter JSON that has been registered with + * `Animator.loadParticleEmitter`. VS keyframes only store the effect's asset code, so nothing + * renders until we resolve that code to a file on disk and load it. + * + * Effect codes are VS asset locations (`[domain:]path`). GlintMod registers a `particles` asset + * category, so an effect lives at `//particles/.json`. + */ + +import { parse_model_location } from "./animation_library_paths"; + +// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- +const fs = requireNativeModule('fs'); + +/** Effect codes already resolved this session: code -> file path, or null when unresolvable. */ +const resolvedEffects = new Map(); + +/** Resets the cache so a newly added particle file is picked up without restarting Blockbench. */ +export function clear_particle_cache(): void { + resolvedEffects.clear(); +} + +/** `//particles/.json` for an effect code, using the project's asset root. */ +function particle_file_for(effectCode: string): string | null { + const modelPath = Project?.save_path || Project?.export_path; + if (!modelPath) return null; + const ctx = parse_model_location(modelPath); + if (!ctx) return null; + + const colon = effectCode.indexOf(':'); + const domain = colon >= 0 ? effectCode.slice(0, colon) : ctx.domain; + const path = colon >= 0 ? effectCode.slice(colon + 1) : effectCode; + if (!path) return null; + + return `${ctx.assetsRoot}/${domain}/particles/${path}.json`; +} + +/** + * Resolves an effect code to a loaded emitter file, returning the path Blockbench should use on + * the keyframe data point. Returns null when the effect has no file, which just means no preview. + */ +export function resolve_particle_effect(effectCode: string): string | null { + const code = effectCode.trim(); + if (!code) return null; + + const cached = resolvedEffects.get(code); + if (cached !== undefined) return cached; + + const file = particle_file_for(code); + if (!file || !fs.existsSync(file)) { + resolvedEffects.set(code, null); + return null; + } + + // Registering the same path twice is harmless; Blockbench replaces the config in place. + if (!Animator.particle_effects[file]) { + try { + Animator.loadParticleEmitter(file, fs.readFileSync(file, 'utf-8')); + } catch (e) { + console.warn(`[VS Particles] Could not load emitter "${file}":`, e); + resolvedEffects.set(code, null); + return null; + } + } + + resolvedEffects.set(code, file); + return file; +} + +/** Builds one Blockbench particle data point, with a preview file attached when we can find one. */ +export function particle_data_point(effect: string, locator?: string): Record { + const file = resolve_particle_effect(effect); + const dp: Record = { effect, locator: locator || '' }; + if (file) dp.file = file; + return dp; +} + +/** + * Re-resolves every particle keyframe in the project. Used by the "Reload VFX Previews" action so + * effects authored by hand (or added to the assets folder after import) start previewing without + * a reimport. Returns how many data points now have an emitter. + */ +export function relink_particle_previews(): number { + clear_particle_cache(); + let linked = 0; + + for (const animation of (Animation as unknown as typeof _Animation).all) { + const effects = (animation.animators as any)?.effects; + if (!effects?.particle) continue; + + for (const keyframe of effects.particle as _Keyframe[]) { + for (const dp of keyframe.data_points as any[]) { + const file = resolve_particle_effect(dp.effect || ''); + if (file) { + dp.file = file; + linked++; + } else { + delete dp.file; + } + } + } + } + return linked; +} diff --git a/src/import_animation.ts b/src/import_animation.ts index 17cdd06..29ea821 100644 --- a/src/import_animation.ts +++ b/src/import_animation.ts @@ -1,5 +1,6 @@ import * as util from "./util"; import { VS_Animation, VS_AnimationKey, VS_KeyFrameInterpolation } from "./vs_shape_def"; +import { particle_data_point } from "./animation_particles"; /** * Creates one Blockbench animation from a VS animation definition and returns it. @@ -47,7 +48,7 @@ export function create_animation(vsAnimation: VS_Animation, path?: string, saved effects.addKeyframe({ channel: 'particle', time: vsKeyframe.frame / FPS, - data_points: vsKeyframe.particles.map(p => ({ effect: p.effect, locator: p.atAttachmentPoint || '' })), + data_points: vsKeyframe.particles.map(p => particle_data_point(p.effect, p.atAttachmentPoint)), }); } From 6548d589e546b602dbc66c6554f28e48976efba9 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 13:46:05 +0100 Subject: [PATCH 03/25] Export and import sound keyframes on animations --- src/animation_sounds.ts | 75 +++++++++++++++++++++++++++++++++++++++++ src/export_animation.ts | 18 ++++++++-- src/import_animation.ts | 9 +++++ src/vs_shape_def.ts | 22 ++++++++++++ 4 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 src/animation_sounds.ts diff --git a/src/animation_sounds.ts b/src/animation_sounds.ts new file mode 100644 index 0000000..8d6a072 --- /dev/null +++ b/src/animation_sounds.ts @@ -0,0 +1,75 @@ +/** + * Sound keyframe support. + * + * VS has no sound field on shape keyframes: the engine's `AnimationSound` lives on + * `AnimationMetaData` in entity JSON. Rather than require an engine change, sounds ride in the + * shape's keyframes as `sounds[]` exactly like `particles[]` does, and GlintMod reads them back + * out of the raw shape JSON. + * + * Blockbench's sound keyframes store a free-text `effect` name plus a `file` path to the audio on + * disk (which it plays during timeline scrubbing). VS wants an asset location, so we convert + * between the two: `//sounds/.ogg` <-> `domain:path`. + */ + +import { parse_model_location } from "./animation_library_paths"; + +// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- +const fs = requireNativeModule('fs'); + +const SOUND_EXTENSIONS = ['.ogg', '.wav', '.mp3']; + +/** Strips a trailing audio extension. */ +function without_extension(path: string): string { + for (const ext of SOUND_EXTENSIONS) { + if (path.toLowerCase().endsWith(ext)) return path.slice(0, -ext.length); + } + return path; +} + +/** + * Converts a local audio file path to a VS asset location, when the file sits inside an + * `assets//sounds/` tree. Returns null for files kept outside the mod's assets. + */ +export function sound_location_for_file(filePath: string): string | null { + const p = filePath.replace(/\\/g, '/'); + const match = /\/assets\/([^/]+)\/sounds\/(.+)$/.exec(p); + if (!match) return null; + return `${match[1]}:${without_extension(match[2])}`; +} + +/** The VS asset location a Blockbench sound data point should export as. */ +export function sound_location_for_data_point(dp: KeyframeDataPointData): string | null { + const fromFile = dp.file ? sound_location_for_file(dp.file) : null; + if (fromFile) return fromFile; + // Hand-typed effect names are treated as asset locations so authors can reference sounds + // that live outside the project folder. + const effect = (dp.effect || '').trim(); + return effect || null; +} + +/** Finds the audio file for a VS sound location so Blockbench can play it while scrubbing. */ +function sound_file_for_location(location: string): string | null { + const modelPath = Project?.save_path || Project?.export_path; + if (!modelPath) return null; + const ctx = parse_model_location(modelPath); + if (!ctx) return null; + + const colon = location.indexOf(':'); + const domain = colon >= 0 ? location.slice(0, colon) : ctx.domain; + const path = colon >= 0 ? location.slice(colon + 1) : location; + if (!path) return null; + + for (const ext of SOUND_EXTENSIONS) { + const candidate = `${ctx.assetsRoot}/${domain}/sounds/${path}${ext}`; + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +/** Builds one Blockbench sound data point, attaching the audio file when it can be found. */ +export function sound_data_point(location: string): Record { + const dp: Record = { effect: location }; + const file = sound_file_for_location(location); + if (file) dp.file = file; + return dp; +} diff --git a/src/export_animation.ts b/src/export_animation.ts index 4176c25..d1b1b27 100644 --- a/src/export_animation.ts +++ b/src/export_animation.ts @@ -1,4 +1,5 @@ -import { VS_Animation, VS_AnimationKey, VS_AnimationLibrary, VS_AnimationParticle, VS_Keyframe, VS_KeyFrameInterpolation } from "./vs_shape_def"; +import { VS_Animation, VS_AnimationKey, VS_AnimationLibrary, VS_AnimationParticle, VS_AnimationSound, VS_Keyframe, VS_KeyFrameInterpolation } from "./vs_shape_def"; +import { sound_location_for_data_point } from "./animation_sounds"; import * as util from "./util"; import { is_backdrop_project } from "./util/misc"; @@ -292,6 +293,15 @@ export function compile_animation(animation: _Animation, catmullConverted?: stri (keyframes[frame].particles = keyframes[frame].particles || []).push(particle); }); } + if (kf.channel === 'sound') { + kf.data_points.forEach(dp => { + const location = sound_location_for_data_point(dp); + if (!location) return; + const frame = Math.round(kf.time * fps); + keyframes[frame] = keyframes[frame] || { frame, elements: {} }; + (keyframes[frame].sounds = keyframes[frame].sounds || []).push({ location }); + }); + } }); } }); @@ -308,6 +318,9 @@ export function compile_animation(animation: _Animation, catmullConverted?: stri if (keyframe.particles) { keyframe.particles = keyframe.particles.map(p => new oneLiner(p) as unknown as VS_AnimationParticle); } + if (keyframe.sounds) { + keyframe.sounds = keyframe.sounds.map(s => new oneLiner(s) as unknown as VS_AnimationSound); + } } // Use preserved VS values if available, otherwise compute defaults @@ -448,7 +461,8 @@ function stable_keyframe_content(keyframe: VS_Keyframe): string { return JSON.stringify({ elements: sort_nested_object(keyframe.elements), textures: keyframe.textures ? sort_nested_object(keyframe.textures) : undefined, - particles: keyframe.particles ? sort_nested_object(keyframe.particles) : undefined + particles: keyframe.particles ? sort_nested_object(keyframe.particles) : undefined, + sounds: keyframe.sounds ? sort_nested_object(keyframe.sounds) : undefined }); } diff --git a/src/import_animation.ts b/src/import_animation.ts index 29ea821..f7996fd 100644 --- a/src/import_animation.ts +++ b/src/import_animation.ts @@ -1,6 +1,7 @@ import * as util from "./util"; import { VS_Animation, VS_AnimationKey, VS_KeyFrameInterpolation } from "./vs_shape_def"; import { particle_data_point } from "./animation_particles"; +import { sound_data_point } from "./animation_sounds"; /** * Creates one Blockbench animation from a VS animation definition and returns it. @@ -52,6 +53,14 @@ export function create_animation(vsAnimation: VS_Animation, path?: string, saved }); } + if (vsKeyframe.sounds && vsKeyframe.sounds.length > 0) { + getEffectAnimator(animation).addKeyframe({ + channel: 'sound', + time: vsKeyframe.frame / FPS, + data_points: vsKeyframe.sounds.map(s => sound_data_point(s.location)), + }); + } + for (const boneName in vsKeyframe.elements) { const transform = vsKeyframe.elements[boneName]; const bone = Group.all.find(g => g.name === boneName); diff --git a/src/vs_shape_def.ts b/src/vs_shape_def.ts index 0be1dcc..fe6f0f6 100644 --- a/src/vs_shape_def.ts +++ b/src/vs_shape_def.ts @@ -105,6 +105,28 @@ export interface VS_Keyframe { elements: Record, textures?: Record, particles?: Array, + sounds?: Array, +} + +/** + * A sound triggered on a keyframe. Mirrors the engine's `AnimationSound`, minus `frame` (which + * the containing keyframe supplies). Read by GlintMod; the base game ignores it, because stock VS + * only takes animation sounds from `AnimationMetaData` in entity JSON. + * + * Blockbench can only author `location`. The remaining fields are for hand-written shapes and are + * NOT preserved if the animation is re-exported from Blockbench. + */ +export interface VS_AnimationSound { + /** Sound asset location, e.g. "game:creature/hyena/growl" */ + location: string, + /** Audible radius in blocks */ + range?: number, + volume?: number, + pitch?: number, + /** 0..1 probability the sound plays when the frame is reached */ + chance?: number, + /** Loop for as long as the animation runs, instead of firing once */ + looping?: boolean, } /** From 81c0bc4123942d39e27ec9758b83588bcab298cc Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 14:22:26 +0100 Subject: [PATCH 04/25] Exclude dist from typecheck and fix step parent frame narrowing --- src/attachments/post_import.ts | 12 ++++++++---- tsconfig.json | 3 ++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/attachments/post_import.ts b/src/attachments/post_import.ts index 0042a35..e493ed0 100644 --- a/src/attachments/post_import.ts +++ b/src/attachments/post_import.ts @@ -340,14 +340,18 @@ function placeStepParentWrappers(newElements: any[], newElementsSet: Set, l const worldRotation = getElementBindRotation(element); const liveSocketFrom = getSocketFrom(target); const liveSocketRotation = getElementBindRotation(target); + // Read into locals so the isFiniteVector3 guards narrow away the undefined, which they + // cannot do through a property access. + const storedFrom = element.vs_step_parent_origin; + const storedRotation = element.vs_step_parent_rotation; const hasStoredFrame = element.vs_has_step_parent_transform === true - && isFiniteVector3(element.vs_step_parent_origin) - && isFiniteVector3(element.vs_step_parent_rotation); + && isFiniteVector3(storedFrom) + && isFiniteVector3(storedRotation); const authoredSocketFrom = hasStoredFrame - ? [...element.vs_step_parent_origin] as Vector3Tuple + ? [...storedFrom] as Vector3Tuple : liveSocketFrom; const authoredSocketRotation = hasStoredFrame - ? [...element.vs_step_parent_rotation] as Vector3Tuple + ? [...storedRotation] as Vector3Tuple : liveSocketRotation; const localRotation = relativeEulerXYZ(worldRotation, authoredSocketRotation); const socketDelta: Vector3Tuple = [ diff --git a/tsconfig.json b/tsconfig.json index 1ff2f9a..db00c68 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "skipLibCheck": true, }, "exclude": [ - "node_modules" + "node_modules", + "dist" ] } \ No newline at end of file From cbeef56e23bf8c2ca27f628a21c4e4da4d96e102 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 14:33:37 +0100 Subject: [PATCH 05/25] Declare bbmodel import types and the requireNativeModule global --- src/attachments/importer.ts | 33 +++++++++++++++++++++++++++++++ src/types/blockbench-augment.d.ts | 8 ++++++++ 2 files changed, 41 insertions(+) diff --git a/src/attachments/importer.ts b/src/attachments/importer.ts index b1c6d38..098321d 100644 --- a/src/attachments/importer.ts +++ b/src/attachments/importer.ts @@ -5,6 +5,39 @@ import { handleVSTextures } from './texture_handler'; const DEBUG = false; +/** + * Minimal shapes for the parts of a .bbmodel this importer reads. Blockbench writes far more than + * this; everything here is deliberately loose because the file is user data that may come from an + * older Blockbench version, so each field is validated at the point of use rather than trusted. + */ + +/** A face's texture is written either as an index into `textures` or as a texture uuid. */ +type TextureRef = string | number; + +interface BBTextureData { + name?: string; + path?: string; + uuid?: string; + [key: string]: unknown; +} + +interface BBCubeElement { + uuid: string; + type: 'cube'; + [key: string]: unknown; +} + +/** Outliner entries are either a bare element uuid (Blockbench 4.x) or an inline group object. */ +type BBOutlinerItem = string | Record; + +interface BBModel { + elements?: unknown; + groups?: unknown; + outliner?: unknown; + textures?: unknown; + [key: string]: unknown; +} + function logDebug(message: string, ...args: any[]) { if (DEBUG) console.log(message, ...args); } diff --git a/src/types/blockbench-augment.d.ts b/src/types/blockbench-augment.d.ts index 63a81c8..2b316a3 100644 --- a/src/types/blockbench-augment.d.ts +++ b/src/types/blockbench-augment.d.ts @@ -53,6 +53,14 @@ declare global { interface ModelFormat { animation_codec?: AnimationCodec; } + + /** + * Blockbench's desktop-only escape hatch to Node's require. Missing from blockbench-types, + * so every call site otherwise needs its own @ts-expect-error. + */ + function requireNativeModule(name: 'fs'): typeof import('fs'); + function requireNativeModule(name: 'path'): typeof import('path'); + function requireNativeModule(name: string): any; } export {}; From e232700f5ec0f245d31c7570f4fc621ecfb408d8 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 14:33:37 +0100 Subject: [PATCH 06/25] Remove stale ts-expect-error directives --- src/actions.ts | 2 -- src/animation_codec.ts | 1 - src/animation_particles.ts | 1 - src/animation_sounds.ts | 1 - src/debug_actions.ts | 2 -- src/export.ts | 2 -- src/export_textures.ts | 2 -- src/format_definition.ts | 1 - src/import.ts | 1 - src/mods/boneAnimatorMod.ts | 1 - src/mods/nodePreviewControllerMod.ts | 11 ----------- src/panels/vs_face_panel.ts | 11 ----------- src/property.ts | 6 ------ src/util/misc.ts | 2 -- src/vs_plugin.ts | 2 -- 15 files changed, 46 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index f50a1b3..edb2dc0 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -6,9 +6,7 @@ import { is_backdrop_project } from "./util/misc"; import { codecVS } from "./codec"; import { ex } from "./export"; -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const fs = requireNativeModule('fs'); -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const path = requireNativeModule('path'); const export_action = createAction(`${PACKAGE.name}:export_vs`, { diff --git a/src/animation_codec.ts b/src/animation_codec.ts index c77bd6d..45b8915 100644 --- a/src/animation_codec.ts +++ b/src/animation_codec.ts @@ -22,7 +22,6 @@ import { create_animation } from "./import_animation"; import { VS_Animation, VS_AnimationLibrary } from "./vs_shape_def"; import { parse_model_location, basename_no_ext } from "./animation_library_paths"; -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const fs = requireNativeModule('fs'); // Remembers each loaded library file's optional `code`/`name` so re-saving preserves them diff --git a/src/animation_particles.ts b/src/animation_particles.ts index 700119c..1e28bab 100644 --- a/src/animation_particles.ts +++ b/src/animation_particles.ts @@ -12,7 +12,6 @@ import { parse_model_location } from "./animation_library_paths"; -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const fs = requireNativeModule('fs'); /** Effect codes already resolved this session: code -> file path, or null when unresolvable. */ diff --git a/src/animation_sounds.ts b/src/animation_sounds.ts index 8d6a072..a225f84 100644 --- a/src/animation_sounds.ts +++ b/src/animation_sounds.ts @@ -13,7 +13,6 @@ import { parse_model_location } from "./animation_library_paths"; -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const fs = requireNativeModule('fs'); const SOUND_EXTENSIONS = ['.ogg', '.wav', '.mp3']; diff --git a/src/debug_actions.ts b/src/debug_actions.ts index 835271c..4f5ac94 100644 --- a/src/debug_actions.ts +++ b/src/debug_actions.ts @@ -3,9 +3,7 @@ import * as PACKAGE from "../package.json"; import { is_vs_project } from "./util"; import JSON5 from "json5"; -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const path = requireNativeModule('path'); -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const fs = requireNativeModule('fs'); diff --git a/src/export.ts b/src/export.ts index a784322..2cadec7 100644 --- a/src/export.ts +++ b/src/export.ts @@ -5,9 +5,7 @@ import { VS_PROJECT_PROPS } from "./property"; import { export_textures, resolveTextureLocation } from "./export_textures"; import { path_to_reference } from "./animation_library_paths"; -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const fs = requireNativeModule('fs'); -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const path = requireNativeModule('path'); declare var Settings: any; diff --git a/src/export_textures.ts b/src/export_textures.ts index 95f1c04..b0675c6 100644 --- a/src/export_textures.ts +++ b/src/export_textures.ts @@ -1,9 +1,7 @@ import { collect_tree_data, flatten } from "./util/element_tree"; import { VS_Element } from "./vs_shape_def"; -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const fs = requireNativeModule('fs'); -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const path = requireNativeModule('path'); /** diff --git a/src/format_definition.ts b/src/format_definition.ts index 50a6fd3..37df159 100644 --- a/src/format_definition.ts +++ b/src/format_definition.ts @@ -49,7 +49,6 @@ export function create_format(): ModelFormat { java_cube_shading_properties: false, cullfaces: false, // Not sure if Vintage Story supports this render_sides: "double", - //@ts-expect-error: Missing in type --- IGNORE --- euler_order: "XYZ", animation_loop_wrapping: true, quaternion_interpolation: false, diff --git a/src/import.ts b/src/import.ts index a151ce1..1dbf6c3 100644 --- a/src/import.ts +++ b/src/import.ts @@ -7,7 +7,6 @@ import { load_back_drop_shape } from "./util/misc"; import { reference_to_candidate_paths } from "./animation_library_paths"; import { vsAnimationCodec } from "./animation_codec"; -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const fs = requireNativeModule('fs'); export function im(content: VS_Shape, _path: string, asBackdrop: boolean) { diff --git a/src/mods/boneAnimatorMod.ts b/src/mods/boneAnimatorMod.ts index b14fc1e..de93993 100644 --- a/src/mods/boneAnimatorMod.ts +++ b/src/mods/boneAnimatorMod.ts @@ -2,7 +2,6 @@ import { createBlockbenchMod } from "../util/moddingTools"; import * as PACKAGE from "../../package.json"; import { is_vs_project } from "../util"; -// @ts-expect-error: THREE is global in Blockbench declare const THREE: typeof import('three'); /** diff --git a/src/mods/nodePreviewControllerMod.ts b/src/mods/nodePreviewControllerMod.ts index ddea68a..885e870 100644 --- a/src/mods/nodePreviewControllerMod.ts +++ b/src/mods/nodePreviewControllerMod.ts @@ -44,10 +44,8 @@ createBlockbenchMod(`${PACKAGE.name}:node_preview_controller_mod`, * Does the same as the original method but skips parenting root groups to the internal root mesh (commented section) */ function updateStepChildTransform(controller: NodePreviewController, element: any, stepParent: Group | Cube | null) { - //@ts-expect-error: missing types const mesh = element.mesh; - //@ts-expect-error: missing types if (element.getTypeBehavior('movable')) { let position = [...element.origin] as Vector3Tuple; if (stepParent) { @@ -69,30 +67,21 @@ function updateStepChildTransform(controller: NodePreviewController, element: an ]; } } - //@ts-expect-error: missing types mesh.position.set(position[0], position[1], position[2]); } - //@ts-expect-error: missing types if (element.getTypeBehavior('rotatable')) { const rotation = element.vs_step_parent_local === true || !stepParent ? ([element.rotation?.[0] || 0, element.rotation?.[1] || 0, element.rotation?.[2] || 0] as [number, number, number]) : relativeEulerXYZ(getElementBindRotation(element), getElementBindRotation(stepParent)); - //@ts-expect-error: missing types mesh.rotation.x = Math.degToRad(rotation[0]); - //@ts-expect-error: missing types mesh.rotation.y = Math.degToRad(rotation[1]); - //@ts-expect-error: missing types mesh.rotation.z = Math.degToRad(rotation[2]); } - //@ts-expect-error: missing types if (element.getTypeBehavior('scalable')) { - //@ts-expect-error: missing types mesh.scale.x = element.scale[0] || 1e-7; - //@ts-expect-error: missing types mesh.scale.y = element.scale[1] || 1e-7; - //@ts-expect-error: missing types mesh.scale.z = element.scale[2] || 1e-7; } diff --git a/src/panels/vs_face_panel.ts b/src/panels/vs_face_panel.ts index f577817..8c34328 100644 --- a/src/panels/vs_face_panel.ts +++ b/src/panels/vs_face_panel.ts @@ -78,18 +78,14 @@ function computeWindData(cube: Cube, direction: string): [number, number, number for (let i = 0; i < 4; i++) { const pos = vertices[i]; - // @ts-expect-error: THREE is global in Blockbench const localPos = new THREE.Vector3( pos[0] - cube.origin[0], pos[1] - cube.origin[1], pos[2] - cube.origin[2] ); - // @ts-expect-error: mesh access if (cube.mesh) { - // @ts-expect-error: mesh access cube.mesh.updateMatrixWorld(); - // @ts-expect-error: mesh access const worldPos = cube.mesh.localToWorld(localPos); result[i] = Math.trunc(worldPos.y / 16); } else { @@ -104,11 +100,8 @@ let vertexDot: any = null; function createVertexDot() { if (vertexDot) return; - // @ts-expect-error: THREE is global in Blockbench const geo = new THREE.SphereGeometry(0.5, 8, 8); - // @ts-expect-error: THREE is global in Blockbench const mat = new THREE.MeshBasicMaterial({ color: 0xff4444, depthTest: false, transparent: true, opacity: 0.9 }); - // @ts-expect-error: THREE is global in Blockbench vertexDot = new THREE.Mesh(geo, mat); vertexDot.renderOrder = 999; vertexDot.visible = false; @@ -120,18 +113,14 @@ function showVertexDot(cube: Cube, direction: string, vertexIndex: number) { const pos = vertices[vertexIndex]; if (!pos) return; - // @ts-expect-error: THREE is global in Blockbench const localPos = new THREE.Vector3( pos[0] - cube.origin[0], pos[1] - cube.origin[1], pos[2] - cube.origin[2] ); - // @ts-expect-error: mesh access if (cube.mesh) { - // @ts-expect-error: mesh access cube.mesh.updateMatrixWorld(); - // @ts-expect-error: mesh access const worldPos = cube.mesh.localToWorld(localPos); vertexDot.position.copy(worldPos); } else { diff --git a/src/property.ts b/src/property.ts index 23472b2..7554022 100644 --- a/src/property.ts +++ b/src/property.ts @@ -381,17 +381,11 @@ export const VS_LOCATOR_PROPS = [ ]; export const VS_FACE_PROPS = [ - // @ts-expect-error: CubeFace is not in blockbench types for Property new Property(CubeFace, "number", "glow"), - // @ts-expect-error: CubeFace is not in blockbench types for Property new Property(CubeFace, "number", "reflectiveMode"), - // @ts-expect-error: CubeFace is not in blockbench types for Property new Property(CubeFace, "array", "windMode"), - // @ts-expect-error: CubeFace is not in blockbench types for Property new Property(CubeFace, "array", "windData"), - // @ts-expect-error: CubeFace is not in blockbench types for Property new Property(CubeFace, "boolean", "autoUv", { default: false }), - // @ts-expect-error: CubeFace is not in blockbench types for Property new Property(CubeFace, "boolean", "snapUv", { default: false }), ]; diff --git a/src/util/misc.ts b/src/util/misc.ts index f6846d3..3fd9055 100644 --- a/src/util/misc.ts +++ b/src/util/misc.ts @@ -1,9 +1,7 @@ import { im } from "../import"; import JSON5 from "json5"; -// @ts-expect-error: requireNativeModule is missing in blockbench types const path = requireNativeModule('path'); -// @ts-expect-error: requireNativeModule is missing in blockbench types const fs = requireNativeModule('fs'); export function load_back_drop_shape(backDropShape: string) { diff --git a/src/vs_plugin.ts b/src/vs_plugin.ts index 5ec0ab0..736054f 100644 --- a/src/vs_plugin.ts +++ b/src/vs_plugin.ts @@ -1,6 +1,4 @@ -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const fs = requireNativeModule('fs'); -// @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const path = requireNativeModule('path'); import { events } from "./util/events"; From 1ac5e219309952d3736972f9faf9eb5274d76c51 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 14:42:25 +0100 Subject: [PATCH 07/25] Author sound range, volume, pitch, chance and looping in the keyframe panel --- src/animation_sounds.ts | 75 ++++++++++++++++++++++++++++++++++++++--- src/export_animation.ts | 8 ++--- src/import_animation.ts | 2 +- 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/animation_sounds.ts b/src/animation_sounds.ts index a225f84..982832d 100644 --- a/src/animation_sounds.ts +++ b/src/animation_sounds.ts @@ -12,11 +12,64 @@ */ import { parse_model_location } from "./animation_library_paths"; +import { VS_AnimationSound } from "./vs_shape_def"; const fs = requireNativeModule('fs'); const SOUND_EXTENSIONS = ['.ogg', '.wav', '.mp3']; +/** + * Extra sound fields, registered on Blockbench's keyframe data point so they get real inputs in + * the keyframe panel. Blockbench renders whatever is registered on KeyframeDataPoint + * (`v-for="(property, key) in data_point.constructor.properties"`), honouring `condition`, so + * these only appear on sound keyframes. They round-trip through .bbmodel automatically because + * Keyframe.getUndoCopy() copies every registered property of each data point. + * + * Defaults mirror the engine's AnimationSound, and export omits any field still at its default. + */ +const SOUND_DEFAULTS = { vs_range: 32, vs_volume: 1, vs_pitch: 1, vs_chance: 1 } as const; + +function onSoundKeyframe(dataPoint: any): boolean { + return dataPoint?.keyframe?.channel === 'sound'; +} + +if (typeof KeyframeDataPoint !== 'undefined') { + new Property(KeyframeDataPoint, 'number', 'vs_range', { + label: 'Range', exposed: true, default: SOUND_DEFAULTS.vs_range, condition: onSoundKeyframe, + }); + new Property(KeyframeDataPoint, 'number', 'vs_volume', { + label: 'Volume', exposed: true, default: SOUND_DEFAULTS.vs_volume, condition: onSoundKeyframe, + }); + new Property(KeyframeDataPoint, 'number', 'vs_pitch', { + label: 'Pitch', exposed: true, default: SOUND_DEFAULTS.vs_pitch, condition: onSoundKeyframe, + }); + new Property(KeyframeDataPoint, 'number', 'vs_chance', { + label: 'Chance', exposed: true, default: SOUND_DEFAULTS.vs_chance, condition: onSoundKeyframe, + }); + new Property(KeyframeDataPoint, 'boolean', 'vs_looping', { + label: 'Looping', exposed: true, default: false, condition: onSoundKeyframe, + }); +} + +/** + * Reads the optional sound fields off a data point. The panel renders numbers as text inputs, so + * these come back as strings and have to be coerced before comparing against the defaults. + */ +function readSoundOptions(dp: KeyframeDataPointData): Partial { + const out: Partial = {}; + + for (const key of ['range', 'volume', 'pitch', 'chance'] as const) { + const raw = dp[`vs_${key}`]; + if (raw === undefined || raw === null || raw === '') continue; + const value = Number(raw); + if (!Number.isFinite(value) || value === SOUND_DEFAULTS[`vs_${key}`]) continue; + out[key] = value; + } + + if (dp.vs_looping === true) out.looping = true; + return out; +} + /** Strips a trailing audio extension. */ function without_extension(path: string): string { for (const ext of SOUND_EXTENSIONS) { @@ -37,7 +90,7 @@ export function sound_location_for_file(filePath: string): string | null { } /** The VS asset location a Blockbench sound data point should export as. */ -export function sound_location_for_data_point(dp: KeyframeDataPointData): string | null { +function sound_location_for_data_point(dp: KeyframeDataPointData): string | null { const fromFile = dp.file ? sound_location_for_file(dp.file) : null; if (fromFile) return fromFile; // Hand-typed effect names are treated as asset locations so authors can reference sounds @@ -46,6 +99,13 @@ export function sound_location_for_data_point(dp: KeyframeDataPointData): string return effect || null; } +/** Converts one Blockbench sound data point to a VS keyframe sound, or null if it has no target. */ +export function sound_from_data_point(dp: KeyframeDataPointData): VS_AnimationSound | null { + const location = sound_location_for_data_point(dp); + if (!location) return null; + return { location, ...readSoundOptions(dp) }; +} + /** Finds the audio file for a VS sound location so Blockbench can play it while scrubbing. */ function sound_file_for_location(location: string): string | null { const modelPath = Project?.save_path || Project?.export_path; @@ -66,9 +126,16 @@ function sound_file_for_location(location: string): string | null { } /** Builds one Blockbench sound data point, attaching the audio file when it can be found. */ -export function sound_data_point(location: string): Record { - const dp: Record = { effect: location }; - const file = sound_file_for_location(location); +export function sound_data_point(sound: VS_AnimationSound): Record { + const dp: Record = { effect: sound.location }; + const file = sound_file_for_location(sound.location); if (file) dp.file = file; + + if (sound.range !== undefined) dp.vs_range = sound.range; + if (sound.volume !== undefined) dp.vs_volume = sound.volume; + if (sound.pitch !== undefined) dp.vs_pitch = sound.pitch; + if (sound.chance !== undefined) dp.vs_chance = sound.chance; + if (sound.looping) dp.vs_looping = true; + return dp; } diff --git a/src/export_animation.ts b/src/export_animation.ts index d1b1b27..f6c4fe2 100644 --- a/src/export_animation.ts +++ b/src/export_animation.ts @@ -1,5 +1,5 @@ import { VS_Animation, VS_AnimationKey, VS_AnimationLibrary, VS_AnimationParticle, VS_AnimationSound, VS_Keyframe, VS_KeyFrameInterpolation } from "./vs_shape_def"; -import { sound_location_for_data_point } from "./animation_sounds"; +import { sound_from_data_point } from "./animation_sounds"; import * as util from "./util"; import { is_backdrop_project } from "./util/misc"; @@ -295,11 +295,11 @@ export function compile_animation(animation: _Animation, catmullConverted?: stri } if (kf.channel === 'sound') { kf.data_points.forEach(dp => { - const location = sound_location_for_data_point(dp); - if (!location) return; + const sound = sound_from_data_point(dp); + if (!sound) return; const frame = Math.round(kf.time * fps); keyframes[frame] = keyframes[frame] || { frame, elements: {} }; - (keyframes[frame].sounds = keyframes[frame].sounds || []).push({ location }); + (keyframes[frame].sounds = keyframes[frame].sounds || []).push(sound); }); } }); diff --git a/src/import_animation.ts b/src/import_animation.ts index f7996fd..0be24b6 100644 --- a/src/import_animation.ts +++ b/src/import_animation.ts @@ -57,7 +57,7 @@ export function create_animation(vsAnimation: VS_Animation, path?: string, saved getEffectAnimator(animation).addKeyframe({ channel: 'sound', time: vsKeyframe.frame / FPS, - data_points: vsKeyframe.sounds.map(s => sound_data_point(s.location)), + data_points: vsKeyframe.sounds.map(s => sound_data_point(s)), }); } From 963df741d54a2f33e4712d6c95db01dc2b3dfe6b Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 14:57:52 +0100 Subject: [PATCH 08/25] Fix undo amend flag, codec selection and Blockbench type gaps --- src/attachments/actions.ts | 2 +- src/attachments/codec.ts | 5 ++-- src/attachments/delete_section.ts | 4 ++-- src/attachments/discovery.ts | 2 +- src/attachments/export_attachment_bb.ts | 4 ++-- src/attachments/panel.ts | 9 +++++-- src/attachments/texture_handler.ts | 31 ++++++++++++------------- src/mods/boneAnimatorMod.ts | 1 - src/panels/vs_face_panel.ts | 6 ++++- src/types/property-augment.d.ts | 31 +++++++++++++++++++++++++ 10 files changed, 67 insertions(+), 28 deletions(-) diff --git a/src/attachments/actions.ts b/src/attachments/actions.ts index 9ca175f..ff4e234 100644 --- a/src/attachments/actions.ts +++ b/src/attachments/actions.ts @@ -51,7 +51,7 @@ function createImportAction(config: ImportActionConfig) { if (!files || !files.length) return; const fileName = files[0]?.name || 'attachment'; - Undo.initEdit({ outliner: true }, `Import attachment: ${fileName}`); + Undo.initEdit({ outliner: true }); const elementsBefore = new Set([...Group.all, ...Cube.all]); diff --git a/src/attachments/codec.ts b/src/attachments/codec.ts index 0af89f3..5544d59 100644 --- a/src/attachments/codec.ts +++ b/src/attachments/codec.ts @@ -1,7 +1,7 @@ import { composeEulerXYZ } from './attachment_transform'; import type { StepParentFrame, Vector3Tuple } from './attachment_transform'; -export function createExportCodec() { +export function createExportCodec(selection: any[] = []) { function getElementBindRotation(element: Group | Cube): Vector3Tuple { const chain: Vector3Tuple[] = []; let current: any = element; @@ -126,7 +126,8 @@ export function createExportCodec() { name: 'Blockbench Project Selection', extension: 'bbmodel', remember: true, - export(selection: any[]) { + export() { + if (selection.length === 0) return; Blockbench.export({ resource_id: 'model', type: this.name, diff --git a/src/attachments/delete_section.ts b/src/attachments/delete_section.ts index 30d974c..63a2de7 100644 --- a/src/attachments/delete_section.ts +++ b/src/attachments/delete_section.ts @@ -67,7 +67,7 @@ export function deleteSectionSafe(elements: any[]) { return; } - Undo.initEdit({ outliner: true }, `Delete (-) Root`); + Undo.initEdit({ outliner: true }); // Identify root attachment groups (first level with clothingSlot) that should be preserved const rootGroups = identifyRootAttachmentGroups(elements); @@ -110,7 +110,7 @@ export function deleteSection(elements: any[]) { } // Confirmation is handled in panel.ts confirmDelete method - Undo.initEdit({ outliner: true }, `Delete ${elements.length} attachment(s)`); + Undo.initEdit({ outliner: true }); // The 'elements' array from the panel is the definitive list of top-level attachments for this section. // Blockbench's .remove() method handles removing children automatically, regardless of their properties. diff --git a/src/attachments/discovery.ts b/src/attachments/discovery.ts index 9576ad5..dcce55b 100644 --- a/src/attachments/discovery.ts +++ b/src/attachments/discovery.ts @@ -89,5 +89,5 @@ export function getAttachments(): (Group | Cube)[] { export function isAttachment(node: any): boolean { if (!node) return false; - return (node instanceof Group || node instanceof Cube) && node.clothingSlot && node.clothingSlot.trim() !== ''; + return !!(node instanceof Group || node instanceof Cube) && !!node.clothingSlot && node.clothingSlot.trim() !== ''; } diff --git a/src/attachments/export_attachment_bb.ts b/src/attachments/export_attachment_bb.ts index 839b78c..017432d 100644 --- a/src/attachments/export_attachment_bb.ts +++ b/src/attachments/export_attachment_bb.ts @@ -13,8 +13,8 @@ export function exportAttachmentsBB(selection: Group[]) { return; } - const bb_codec = createExportCodec(); - bb_codec.export(selection); + const bb_codec = createExportCodec(selection); + bb_codec.export(); if (DEBUG) console.log("Exporting selected attachments to .bbmodel format..."); } diff --git a/src/attachments/panel.ts b/src/attachments/panel.ts index 3198cea..3d6771f 100644 --- a/src/attachments/panel.ts +++ b/src/attachments/panel.ts @@ -560,7 +560,7 @@ const vuePanel = { if (!elements || !Array.isArray(elements)) return; try { - Undo.initEdit({ outliner: true }, `Toggle visibility: ${elements.length} element(s)`); + Undo.initEdit({ outliner: true }); elements.forEach(element => { if (!element) return; @@ -660,6 +660,7 @@ const vuePanel = { */ export function createAttachmentsPanel(actions: any) { const toolbar = new Toolbar('attachments_toolbar', { + id: 'attachments_toolbar', children: [] }); @@ -672,13 +673,17 @@ export function createAttachmentsPanel(actions: any) { } const panel = new Panel('attachments_panel', { + id: 'attachments_panel', name: 'Attachments', icon: 'attach_file', + expand_button: false, + default_side: 'right', default_position: { slot: 'right_bar', float_position: [0, 0], float_size: [300, 400], - height: 400 + height: 400, + folded: false }, toolbars: [toolbar], component: vuePanel diff --git a/src/attachments/texture_handler.ts b/src/attachments/texture_handler.ts index 6083dfb..e434e21 100644 --- a/src/attachments/texture_handler.ts +++ b/src/attachments/texture_handler.ts @@ -90,7 +90,7 @@ function findExistingTextures(name: string, textureLocation: string): TextureMat continue; } - const pathFilename = extractFilename(texture.path)?.toLowerCase(); + const pathFilename = texture.path ? extractFilename(texture.path)?.toLowerCase() : undefined; if (pathFilename === locationFilename) { match.byFilename = texture; } @@ -155,21 +155,20 @@ function handleExistingTextureByName( // Always update UV size updateTextureUVSize(texture, name, content); - // Only attempt to load if not already loaded - if (!texture.loaded) { - // Try to find a valid path from other matches - if (match.byLocation?.path) { - texture.path = match.byLocation.path; - texture.load(); - } else if (match.byFilename?.path) { - texture.path = match.byFilename.path; + // Point the texture at the best path available and load it. This used to be guarded by + // `if (!texture.loaded)`, but Blockbench Textures have no `loaded` flag, so the guard was + // always true and the load happened regardless. + if (match.byLocation?.path) { + texture.path = match.byLocation.path; + texture.load(); + } else if (match.byFilename?.path) { + texture.path = match.byFilename.path; + texture.load(); + } else { + const texturePath = util.get_texture_location(null, textureLocation); + if (texturePath && texturePath.length > 0) { + texture.path = texturePath; texture.load(); - } else { - const texturePath = util.get_texture_location(null, textureLocation); - if (texturePath && texturePath.length > 0) { - texture.path = texturePath; - texture.load(); - } } } } @@ -189,7 +188,7 @@ function handleExistingTextureByLocation( updateTextureUVSize(texture, name, content); } else { // Different name, create new texture reference pointing to same location - createVSAttachmentTexture(name, texture.path, textureLocation, content); + createVSAttachmentTexture(name, texture.path ?? '', textureLocation, content); } } diff --git a/src/mods/boneAnimatorMod.ts b/src/mods/boneAnimatorMod.ts index de93993..5e82e67 100644 --- a/src/mods/boneAnimatorMod.ts +++ b/src/mods/boneAnimatorMod.ts @@ -50,7 +50,6 @@ createBlockbenchMod( THREE.MathUtils.degToRad(rotation[0]), THREE.MathUtils.degToRad(rotation[1]), THREE.MathUtils.degToRad(rotation[2]) - //@ts-expect-error: Missing in type --- IGNORE --- ), Format.euler_order); this.displayPosition(vec.toArray(), multiplier); } diff --git a/src/panels/vs_face_panel.ts b/src/panels/vs_face_panel.ts index 8c34328..ffd25cf 100644 --- a/src/panels/vs_face_panel.ts +++ b/src/panels/vs_face_panel.ts @@ -377,14 +377,18 @@ createBlockbenchMod( {}, () => { panel = new Panel('vs_face_properties', { + id: 'vs_face_properties', name: 'VS Face Properties', icon: 'grain', + expand_button: false, + default_side: 'right', condition: () => Format?.id === 'formatVS', default_position: { slot: 'right_bar', float_position: [0, 0], float_size: [300, 300], - height: 300 + height: 300, + folded: false }, component: vueComponent }); diff --git a/src/types/property-augment.d.ts b/src/types/property-augment.d.ts index 2d6d7e7..0d491ff 100644 --- a/src/types/property-augment.d.ts +++ b/src/types/property-augment.d.ts @@ -15,6 +15,37 @@ declare global { inputs?: Record; onChange?(this: any, value?: any): void; } + + interface Property { + /** + * Copies this property's value from an instance onto a plain object. blockbench-types + * types both sides as the property's own value type, but the target is a bag of fields. + */ + copy(instance: any, target: any): void; + } + + /** + * Format flags Blockbench supports but blockbench-types omits. Object literals are checked + * for excess properties, and TypeScript only reports the first unknown key, so a single + * missing declaration hides every later one in the same literal. + */ + interface FormatOptions { + /** Whether the format supports null objects (locators' animated cousins). */ + null_object?: boolean; + /** Rotation order the format's transforms are composed in. */ + euler_order?: string; + animation_loop_wrapping?: boolean; + quaternion_interpolation?: boolean; + per_animator_rotation_interpolation?: boolean; + } + + interface Locator { + /** + * VS stores a locator's position in `from`, mirroring its elements. Blockbench's own + * locators use `position`, which the export falls back to. + */ + from?: ArrayVector3; + } } export {}; From 4cd10916dd9cb933c320c6d92d3bd3e031319518 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 14:59:25 +0100 Subject: [PATCH 09/25] Re-resolve particle and sound file paths on project load --- src/animation_particles.ts | 19 ++++++++++++++++--- src/animation_sounds.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/animation_particles.ts b/src/animation_particles.ts index 1e28bab..2355081 100644 --- a/src/animation_particles.ts +++ b/src/animation_particles.ts @@ -11,6 +11,7 @@ */ import { parse_model_location } from "./animation_library_paths"; +import { is_vs_project } from "./util"; const fs = requireNativeModule('fs'); @@ -78,9 +79,21 @@ export function particle_data_point(effect: string, locator?: string): Record { + if (!is_vs_project(Project)) return; + relink_particle_previews(); +}); + +/** + * Re-resolves every particle keyframe in the project. Also exposed through the "Reload VFX + * Previews" action, for effects authored by hand or added to the assets folder after the model was + * opened. Returns how many data points now have an emitter. */ export function relink_particle_previews(): number { clear_particle_cache(); diff --git a/src/animation_sounds.ts b/src/animation_sounds.ts index 982832d..c8b8add 100644 --- a/src/animation_sounds.ts +++ b/src/animation_sounds.ts @@ -12,6 +12,7 @@ */ import { parse_model_location } from "./animation_library_paths"; +import { is_vs_project } from "./util"; import { VS_AnimationSound } from "./vs_shape_def"; const fs = requireNativeModule('fs'); @@ -125,6 +126,38 @@ function sound_file_for_location(location: string): string | null { return null; } +/** + * Re-resolves the audio file on every sound keyframe. Blockbench saves `file` into the .bbmodel as + * an absolute path, so a project opened elsewhere points at a file that does not exist. The path + * is derived from the sound's asset location, so it is rebuilt on load rather than trusted. + */ +export function relink_sound_files(): number { + let linked = 0; + + for (const animation of (Animation as unknown as typeof _Animation).all) { + const effects = (animation.animators as any)?.effects; + if (!effects?.sound) continue; + + for (const keyframe of effects.sound as _Keyframe[]) { + for (const dp of keyframe.data_points as any[]) { + const file = sound_file_for_location((dp.effect || '').trim()); + if (file) { + dp.file = file; + linked++; + } else { + delete dp.file; + } + } + } + } + return linked; +} + +Blockbench.on('load_project', () => { + if (!is_vs_project(Project)) return; + relink_sound_files(); +}); + /** Builds one Blockbench sound data point, attaching the audio file when it can be found. */ export function sound_data_point(sound: VS_AnimationSound): Record { const dp: Record = { effect: sound.location }; From b98166a2ae33a1e6a201ec7718416ee875663f5a Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 15:11:25 +0100 Subject: [PATCH 10/25] Prefer the sound effect code over the picked file when exporting --- src/animation_sounds.ts | 46 ++++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/src/animation_sounds.ts b/src/animation_sounds.ts index c8b8add..081a46e 100644 --- a/src/animation_sounds.ts +++ b/src/animation_sounds.ts @@ -90,14 +90,18 @@ export function sound_location_for_file(filePath: string): string | null { return `${match[1]}:${without_extension(match[2])}`; } -/** The VS asset location a Blockbench sound data point should export as. */ +/** + * The VS asset location a Blockbench sound data point should export as. + * + * The effect code wins over the picked file. The file is only a local preview, and a code can say + * things a single file cannot, most importantly the `*` wildcard VS uses to pick a random variant + * (bruister_peck_* covering bruister_peck_1/2/3). Deriving the location from the file would silently + * collapse that to whichever variant happened to be picked. + */ function sound_location_for_data_point(dp: KeyframeDataPointData): string | null { - const fromFile = dp.file ? sound_location_for_file(dp.file) : null; - if (fromFile) return fromFile; - // Hand-typed effect names are treated as asset locations so authors can reference sounds - // that live outside the project folder. const effect = (dp.effect || '').trim(); - return effect || null; + if (effect) return effect; + return dp.file ? sound_location_for_file(dp.file) : null; } /** Converts one Blockbench sound data point to a VS keyframe sound, or null if it has no target. */ @@ -119,10 +123,38 @@ function sound_file_for_location(location: string): string | null { const path = colon >= 0 ? location.slice(colon + 1) : location; if (!path) return null; + // A `*` marks a random-variant set (bruister_peck_* -> bruister_peck_1/2/3). Preview the + // first variant, since Blockbench can only play one file. + const concrete = path.split('*').join('1'); + const soundsRoot = `${ctx.assetsRoot}/${domain}/sounds`; + for (const ext of SOUND_EXTENSIONS) { - const candidate = `${ctx.assetsRoot}/${domain}/sounds/${path}${ext}`; + const candidate = `${soundsRoot}/${concrete}${ext}`; if (fs.existsSync(candidate)) return candidate; } + + // Codes are often short names rather than full paths (bruister_peck_* for + // entity/bruister/bruister_peck_1.ogg), so fall back to searching the sounds tree. + return find_sound_by_name(soundsRoot, concrete.split('/').pop() ?? concrete); +} + +/** Depth-first search for `` under the sounds folder. */ +function find_sound_by_name(dir: string, name: string, depth = 0): string | null { + if (depth > 6 || !fs.existsSync(dir)) return null; + + let entries: any[]; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return null; } + + for (const entry of entries) { + if (entry.isFile() && SOUND_EXTENSIONS.some(ext => entry.name.toLowerCase() === name.toLowerCase() + ext)) { + return `${dir}/${entry.name}`; + } + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const found = find_sound_by_name(`${dir}/${entry.name}`, name, depth + 1); + if (found) return found; + } return null; } From cc912f5e868107394791cba7f7770641468b5242 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 15:27:58 +0100 Subject: [PATCH 11/25] Namespace particle components so Blockbench can preview VS effects --- src/animation_particles.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/animation_particles.ts b/src/animation_particles.ts index 2355081..7cbea41 100644 --- a/src/animation_particles.ts +++ b/src/animation_particles.ts @@ -58,7 +58,7 @@ export function resolve_particle_effect(effectCode: string): string | null { // Registering the same path twice is harmless; Blockbench replaces the config in place. if (!Animator.particle_effects[file]) { try { - Animator.loadParticleEmitter(file, fs.readFileSync(file, 'utf-8')); + Animator.loadParticleEmitter(file, namespace_components(fs.readFileSync(file, 'utf-8'))); } catch (e) { console.warn(`[VS Particles] Could not load emitter "${file}":`, e); resolvedEffects.set(code, null); @@ -70,6 +70,28 @@ export function resolve_particle_effect(effectCode: string): string | null { return file; } +/** + * Adds the `minecraft:` namespace to component names that lack one. + * + * Wintersky, which drives Blockbench's particle preview, looks every component up as + * `components["minecraft:" + name]`. VS effects are written with bare names, so without this every + * lookup misses and the emitter is built with no rate, shape, lifetime or appearance: registered, + * linked, and completely inert. Only `components` is touched, since `curves` and `events` are + * author-named in Bedrock too. The file on disk is left alone. + */ +function namespace_components(content: string): string { + const json = JSON.parse(content); + const components = json?.particle_effect?.components; + if (!components) return content; + + const namespaced: Record = {}; + for (const [name, value] of Object.entries(components)) { + namespaced[name.includes(':') ? name : `minecraft:${name}`] = value; + } + json.particle_effect.components = namespaced; + return JSON.stringify(json); +} + /** Builds one Blockbench particle data point, with a preview file attached when we can find one. */ export function particle_data_point(effect: string, locator?: string): Record { const file = resolve_particle_effect(effect); From 6215c2a8d79c41727d16d83b5f3f934abca5376a Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 15:32:33 +0100 Subject: [PATCH 12/25] Use native path separators so particle textures resolve --- src/animation_particles.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/animation_particles.ts b/src/animation_particles.ts index 7cbea41..3b861c8 100644 --- a/src/animation_particles.ts +++ b/src/animation_particles.ts @@ -14,6 +14,7 @@ import { parse_model_location } from "./animation_library_paths"; import { is_vs_project } from "./util"; const fs = requireNativeModule('fs'); +const nodePath = requireNativeModule('path'); /** Effect codes already resolved this session: code -> file path, or null when unresolvable. */ const resolvedEffects = new Map(); @@ -23,7 +24,14 @@ export function clear_particle_cache(): void { resolvedEffects.clear(); } -/** `//particles/.json` for an effect code, using the project's asset root. */ +/** + * `//particles/.json` for an effect code, using the project's asset root. + * + * Returned with native separators. Blockbench finds an effect's texture by splitting this path on + * the platform separator, locating the "particles" segment and treating everything above it as the + * pack root. A forward-slash path on Windows splits into a single segment, so that search fails and + * every particle falls back to the placeholder texture. + */ function particle_file_for(effectCode: string): string | null { const modelPath = Project?.save_path || Project?.export_path; if (!modelPath) return null; @@ -35,7 +43,7 @@ function particle_file_for(effectCode: string): string | null { const path = colon >= 0 ? effectCode.slice(colon + 1) : effectCode; if (!path) return null; - return `${ctx.assetsRoot}/${domain}/particles/${path}.json`; + return nodePath.join(ctx.assetsRoot, domain, 'particles', `${path}.json`); } /** From 87031257da38bb3214f7ed2515adca4978d67add Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 15:36:28 +0100 Subject: [PATCH 13/25] Link effect files from the render path instead of on project load --- src/animation_particles.ts | 21 +++++++++++++++++++++ src/animation_sounds.ts | 14 ++++++++++++++ src/mods/effectAnimatorMod.ts | 33 +++++++++++++++++++++++++++++++++ src/vs_plugin.ts | 1 + 4 files changed, 69 insertions(+) create mode 100644 src/mods/effectAnimatorMod.ts diff --git a/src/animation_particles.ts b/src/animation_particles.ts index 3b861c8..f213734 100644 --- a/src/animation_particles.ts +++ b/src/animation_particles.ts @@ -120,6 +120,27 @@ Blockbench.on('load_project', () => { relink_particle_previews(); }); +/** + * Links any particle data point on this animator that has an effect code but no emitter yet. + * + * Called from the render path rather than on a project-load event, because `load_project` fires + * before the project is ready (see the 100ms deferral in attachments.ts). Being demand-driven + * means it cannot run too early. Already-linked points cost a property check, and the resolver + * caches, so repeat frames do no real work. + */ +export function ensure_particle_links(animator: any): void { + const keyframes = animator?.particle; + if (!keyframes?.length) return; + + for (const keyframe of keyframes as _Keyframe[]) { + for (const dp of keyframe.data_points as any[]) { + if (dp.file) continue; + const file = resolve_particle_effect(dp.effect || ''); + if (file) dp.file = file; + } + } +} + /** * Re-resolves every particle keyframe in the project. Also exposed through the "Reload VFX * Previews" action, for effects authored by hand or added to the assets folder after the model was diff --git a/src/animation_sounds.ts b/src/animation_sounds.ts index 081a46e..91aba33 100644 --- a/src/animation_sounds.ts +++ b/src/animation_sounds.ts @@ -158,6 +158,20 @@ function find_sound_by_name(dir: string, name: string, depth = 0): string | null return null; } +/** Links any sound data point on this animator that has a location but no audio file yet. */ +export function ensure_sound_links(animator: any): void { + const keyframes = animator?.sound; + if (!keyframes?.length) return; + + for (const keyframe of keyframes as _Keyframe[]) { + for (const dp of keyframe.data_points as any[]) { + if (dp.file) continue; + const file = sound_file_for_location((dp.effect || '').trim()); + if (file) dp.file = file; + } + } +} + /** * Re-resolves the audio file on every sound keyframe. Blockbench saves `file` into the .bbmodel as * an absolute path, so a project opened elsewhere points at a file that does not exist. The path diff --git a/src/mods/effectAnimatorMod.ts b/src/mods/effectAnimatorMod.ts new file mode 100644 index 0000000..8112928 --- /dev/null +++ b/src/mods/effectAnimatorMod.ts @@ -0,0 +1,33 @@ +import { createBlockbenchMod } from "../util/moddingTools"; +import * as PACKAGE from "../../package.json"; +import { is_vs_project } from "../util"; +import { ensure_particle_links } from "../animation_particles"; +import { ensure_sound_links } from "../animation_sounds"; + +/** + * Resolves effect files just before Blockbench draws them. + * + * VS keyframes only store an asset code; Blockbench needs a real path in `data_point.file` before + * it will spawn a particle emitter or play a sound. Doing that on `load_project` is unreliable + * because the event fires before the project is ready, which is why attachments.ts defers its own + * work by 100ms. Hooking the render path instead makes it demand-driven, so it cannot run too + * early and there is nothing for the user to trigger by hand. + */ +createBlockbenchMod(`${PACKAGE.name}:effect_animator_mod`, + { + original: Blockbench.EffectAnimator.prototype.displayFrame + }, + inject_context => { + Blockbench.EffectAnimator.prototype.displayFrame = function (this: EffectAnimator, in_loop?: boolean) { + if (is_vs_project(Project)) { + ensure_particle_links(this); + ensure_sound_links(this); + } + return inject_context.original.call(this, in_loop); + }; + return inject_context; + }, + extract_context => { + Blockbench.EffectAnimator.prototype.displayFrame = extract_context.original; + } +); diff --git a/src/vs_plugin.ts b/src/vs_plugin.ts index 736054f..0716291 100644 --- a/src/vs_plugin.ts +++ b/src/vs_plugin.ts @@ -18,6 +18,7 @@ import "./mods/formatMod"; import "./mods/settingsMod"; import "./mods/legacyFormatConverterMod"; import "./mods/nodePreviewControllerMod"; +import "./mods/effectAnimatorMod"; import "./mods/attachmentsMod"; // Panels (loaded after mods to avoid blocking critical format registration) From cecdec0c9af434a91029d36127831974ad7ef70a Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 15:40:00 +0100 Subject: [PATCH 14/25] Stop one-shot animations retriggering effects on their end rewind --- src/mods/effectAnimatorMod.ts | 39 +++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/mods/effectAnimatorMod.ts b/src/mods/effectAnimatorMod.ts index 8112928..b9c0416 100644 --- a/src/mods/effectAnimatorMod.ts +++ b/src/mods/effectAnimatorMod.ts @@ -13,17 +13,48 @@ import { ensure_sound_links } from "../animation_sounds"; * work by 100ms. Hooking the render path instead makes it demand-driven, so it cannot run too * early and there is nothing for the user to trigger by hand. */ +/** + * True when this render is the rewind Blockbench performs as a "once" animation finishes: the + * playhead has jumped back to the start from somewhere later in the timeline. Playing again from a + * paused start does not match, because the previous render left the playhead at 0. + */ +function is_end_of_single_shot(animator: any): boolean { + return animator.animation?.loop === 'once' + && animator.animation.time === 0 + && animator.last_displayed_time > 0; +} + createBlockbenchMod(`${PACKAGE.name}:effect_animator_mod`, { original: Blockbench.EffectAnimator.prototype.displayFrame }, inject_context => { Blockbench.EffectAnimator.prototype.displayFrame = function (this: EffectAnimator, in_loop?: boolean) { - if (is_vs_project(Project)) { - ensure_particle_links(this); - ensure_sound_links(this); + if (!is_vs_project(Project)) { + return inject_context.original.call(this, in_loop); + } + + ensure_particle_links(this); + ensure_sound_links(this); + + if (!is_end_of_single_shot(this)) { + return inject_context.original.call(this, in_loop); + } + + // Blockbench ends a "once" animation with setTime(0) -> preview() -> pause(), so the + // rewind re-renders frame 0 and fires anything keyed there a second time. Its own + // 400ms sound cooldown has expired by then on all but the shortest animations. Mute + // the effect channels just for this rewind render so the tail is silent. + const muted = (this as any).muted; + const previous = { sound: muted.sound, particle: muted.particle }; + muted.sound = true; + muted.particle = true; + try { + return inject_context.original.call(this, in_loop); + } finally { + muted.sound = previous.sound; + muted.particle = previous.particle; } - return inject_context.original.call(this, in_loop); }; return inject_context; }, From 43ea7f8d96f26ffd303c22cf42d75aeaffe4073f Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 15:45:58 +0100 Subject: [PATCH 15/25] Latch the one-shot rewind so the final preview pass stays muted --- src/mods/effectAnimatorMod.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/mods/effectAnimatorMod.ts b/src/mods/effectAnimatorMod.ts index b9c0416..66e515c 100644 --- a/src/mods/effectAnimatorMod.ts +++ b/src/mods/effectAnimatorMod.ts @@ -13,15 +13,27 @@ import { ensure_sound_links } from "../animation_sounds"; * work by 100ms. Hooking the render path instead makes it demand-driven, so it cannot run too * early and there is nothing for the user to trigger by hand. */ +/** Animators currently sitting on the rewind at the end of a "once" animation. */ +const rewound = new WeakSet(); + /** - * True when this render is the rewind Blockbench performs as a "once" animation finishes: the - * playhead has jumped back to the start from somewhere later in the timeline. Playing again from a - * paused start does not match, because the previous render left the playhead at 0. + * True while this animator is showing the rewind Blockbench performs as a "once" animation ends. + * + * Ending a one-shot runs `setTime(0)`, `preview()`, `pause()` and finally `preview(true)`, so the + * playhead lands on 0 and renders three times. Only the last of those passes `in_loop`, which is + * what Blockbench gates sound on, so it is the one that replays a frame-0 keyframe. Detecting the + * rewind by comparing against `last_displayed_time` alone does not survive that, because the first + * render resets it to 0. The state is latched instead, and cleared as soon as the playhead moves, + * so pressing play again still triggers the sound normally. */ function is_end_of_single_shot(animator: any): boolean { - return animator.animation?.loop === 'once' - && animator.animation.time === 0 - && animator.last_displayed_time > 0; + const animation = animator.animation; + if (!animation || animation.loop !== 'once' || animation.time > 0) { + rewound.delete(animator); + return false; + } + if (animator.last_displayed_time > 0) rewound.add(animator); + return rewound.has(animator); } createBlockbenchMod(`${PACKAGE.name}:effect_animator_mod`, From 7448fe69ab5c29f46facd4f97e9ac6410c8a7839 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 15:59:46 +0100 Subject: [PATCH 16/25] Qualify bare sound codes with the resolved domain and folder --- src/animation_sounds.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/animation_sounds.ts b/src/animation_sounds.ts index 91aba33..73999b2 100644 --- a/src/animation_sounds.ts +++ b/src/animation_sounds.ts @@ -100,8 +100,22 @@ export function sound_location_for_file(filePath: string): string | null { */ function sound_location_for_data_point(dp: KeyframeDataPointData): string | null { const effect = (dp.effect || '').trim(); - if (effect) return effect; - return dp.file ? sound_location_for_file(dp.file) : null; + if (!effect) return dp.file ? sound_location_for_file(dp.file) : null; + + // Already a full location, so take it as authored. + if (effect.includes(':') || effect.includes('/')) return effect; + + // A bare name like "bruister_peck_*" is not resolvable on its own: VS would read it as + // game:bruister_peck_*. Borrow the domain and folder from the file the preview resolved to, + // keeping the authored name so a trailing wildcard survives (VS expands those itself, see + // SoundEngine.EndsWithWildCard). + const fromFile = dp.file ? sound_location_for_file(dp.file) : null; + if (!fromFile) return effect; + + const colon = fromFile.indexOf(':'); + const domain = fromFile.slice(0, colon); + const folder = fromFile.slice(colon + 1).split('/').slice(0, -1).join('/'); + return folder ? `${domain}:${folder}/${effect}` : `${domain}:${effect}`; } /** Converts one Blockbench sound data point to a VS keyframe sound, or null if it has no target. */ From 7693fe920cd5f3b7dbe7b76e96a57b691b4d0705 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 13 Aug 2026 14:10:46 +0100 Subject: [PATCH 17/25] vfx playback pass --- src/animation_particles.ts | 97 +++++++++++++++++++++++++++++++++----- src/animation_sounds.ts | 6 +-- src/mods/settingsMod.ts | 2 - 3 files changed, 87 insertions(+), 18 deletions(-) diff --git a/src/animation_particles.ts b/src/animation_particles.ts index f213734..1ed0f58 100644 --- a/src/animation_particles.ts +++ b/src/animation_particles.ts @@ -16,12 +16,49 @@ import { is_vs_project } from "./util"; const fs = requireNativeModule('fs'); const nodePath = requireNativeModule('path'); +declare var WinterskyScene: any; + /** Effect codes already resolved this session: code -> file path, or null when unresolvable. */ const resolvedEffects = new Map(); +const namespacedEmitters = new Set(); + /** Resets the cache so a newly added particle file is picked up without restarting Blockbench. */ export function clear_particle_cache(): void { resolvedEffects.clear(); + namespacedEmitters.clear(); +} + +function clear_emitter_mirror(file: string): void { + const effect = Animator.particle_effects[file]; + if (!effect) return; + + const magnitude = Math.abs(WinterskyScene?.global_options?.scale ?? 1) || 1; + for (const key of Object.keys(effect.emitters)) { + const emitter = effect.emitters[key]; + for (const space of [emitter.local_space, emitter.global_space]) { + if (!space) continue; + const s = space.scale; + if (s.x < 0 || s.y < 0 || s.z < 0) s.set(magnitude, magnitude, magnitude); + } + } +} + +function ensure_emitter_namespaced(file: string): boolean { + if (namespacedEmitters.has(file)) { + clear_emitter_mirror(file); + return true; + } + + try { + Animator.loadParticleEmitter(file, namespace_components(fs.readFileSync(file, 'utf-8'))); + } catch (e) { + console.warn(`[VS Particles] Could not load emitter "${file}":`, e); + return false; + } + namespacedEmitters.add(file); + clear_emitter_mirror(file); + return true; } /** @@ -63,15 +100,9 @@ export function resolve_particle_effect(effectCode: string): string | null { return null; } - // Registering the same path twice is harmless; Blockbench replaces the config in place. - if (!Animator.particle_effects[file]) { - try { - Animator.loadParticleEmitter(file, namespace_components(fs.readFileSync(file, 'utf-8'))); - } catch (e) { - console.warn(`[VS Particles] Could not load emitter "${file}":`, e); - resolvedEffects.set(code, null); - return null; - } + if (!ensure_emitter_namespaced(file)) { + resolvedEffects.set(code, null); + return null; } resolvedEffects.set(code, file); @@ -96,10 +127,43 @@ function namespace_components(content: string): string { for (const [name, value] of Object.entries(components)) { namespaced[name.includes(':') ? name : `minecraft:${name}`] = value; } - json.particle_effect.components = namespaced; + json.particle_effect.components = mirror_motion(namespaced); return JSON.stringify(json); } +function negate(value: unknown): unknown { + if (typeof value === 'number') return -value; + if (typeof value === 'string' && value.trim()) return `-(${value})`; + return value; +} + +const MIRRORED_FIELDS: Record = { + 'minecraft:particle_motion_dynamic': ['linear_acceleration'], + 'minecraft:particle_motion_parametric': ['relative_position', 'direction'], + 'minecraft:particle_initial_speed': ['*'], + 'minecraft:emitter_shape_point': ['offset', 'direction'], + 'minecraft:emitter_shape_sphere': ['offset', 'direction'], + 'minecraft:emitter_shape_box': ['offset', 'direction'], + 'minecraft:emitter_shape_disc': ['offset', 'direction', 'plane_normal'], + 'minecraft:emitter_shape_entity_aabb': ['direction'], +}; + +function mirror_motion(components: Record): Record { + for (const [name, fields] of Object.entries(MIRRORED_FIELDS)) { + const component = components[name] as any; + if (!component) continue; + + if (fields[0] === '*') { + if (Array.isArray(component)) components[name] = component.map(negate); + continue; + } + for (const field of fields) { + if (Array.isArray(component[field])) component[field] = component[field].map(negate); + } + } + return components; +} + /** Builds one Blockbench particle data point, with a preview file attached when we can find one. */ export function particle_data_point(effect: string, locator?: string): Record { const file = resolve_particle_effect(effect); @@ -134,7 +198,10 @@ export function ensure_particle_links(animator: any): void { for (const keyframe of keyframes as _Keyframe[]) { for (const dp of keyframe.data_points as any[]) { - if (dp.file) continue; + if (dp.file) { + ensure_emitter_namespaced(dp.file); + continue; + } const file = resolve_particle_effect(dp.effect || ''); if (file) dp.file = file; } @@ -160,9 +227,13 @@ export function relink_particle_previews(): number { if (file) { dp.file = file; linked++; - } else { - delete dp.file; + continue; + } + if (dp.file && fs.existsSync(dp.file) && ensure_emitter_namespaced(dp.file)) { + linked++; + continue; } + delete dp.file; } } } diff --git a/src/animation_sounds.ts b/src/animation_sounds.ts index 73999b2..f998702 100644 --- a/src/animation_sounds.ts +++ b/src/animation_sounds.ts @@ -85,7 +85,7 @@ function without_extension(path: string): string { */ export function sound_location_for_file(filePath: string): string | null { const p = filePath.replace(/\\/g, '/'); - const match = /\/assets\/([^/]+)\/sounds\/(.+)$/.exec(p); + const match = /\/assets\/([^/]+)\/(sounds\/.+)$/.exec(p); if (!match) return null; return `${match[1]}:${without_extension(match[2])}`; } @@ -109,7 +109,7 @@ function sound_location_for_data_point(dp: KeyframeDataPointData): string | null // game:bruister_peck_*. Borrow the domain and folder from the file the preview resolved to, // keeping the authored name so a trailing wildcard survives (VS expands those itself, see // SoundEngine.EndsWithWildCard). - const fromFile = dp.file ? sound_location_for_file(dp.file) : null; + const fromFile = sound_location_for_file(dp.file || sound_file_for_location(effect) || ''); if (!fromFile) return effect; const colon = fromFile.indexOf(':'); @@ -134,7 +134,7 @@ function sound_file_for_location(location: string): string | null { const colon = location.indexOf(':'); const domain = colon >= 0 ? location.slice(0, colon) : ctx.domain; - const path = colon >= 0 ? location.slice(colon + 1) : location; + const path = (colon >= 0 ? location.slice(colon + 1) : location).replace(/^sounds\//, ''); if (!path) return null; // A `*` marks a random-variant set (bruister_peck_* -> bruister_peck_1/2/3). Preview the diff --git a/src/mods/settingsMod.ts b/src/mods/settingsMod.ts index 595489d..7879890 100644 --- a/src/mods/settingsMod.ts +++ b/src/mods/settingsMod.ts @@ -193,5 +193,3 @@ createBlockbenchMod( //context?.delete(); } ); - - From 7673a3b6551fde5a0105d931848ac9b1df1964e4 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 4 Oct 2026 13:13:16 +0100 Subject: [PATCH 18/25] polish pass/fix tests --- src/animation_codec.ts | 70 ++++---------- src/animation_particles.ts | 109 ++++------------------ src/animation_sounds.ts | 118 +++++------------------- src/attachments/codec.ts | 3 +- src/attachments/export_attachment_vs.ts | 4 + src/mods/effectAnimatorMod.ts | 24 ----- src/mods/settingsMod.ts | 2 + src/vs_shape_def.ts | 12 --- 8 files changed, 66 insertions(+), 276 deletions(-) diff --git a/src/animation_codec.ts b/src/animation_codec.ts index 45b8915..0a02659 100644 --- a/src/animation_codec.ts +++ b/src/animation_codec.ts @@ -54,7 +54,6 @@ function build_library(path: string | undefined, animations: VS_Animation[]): VS return library; } -/** Reads the library currently on disk at `path`, or null if absent/unreadable. */ function read_library(path: string | undefined): VS_AnimationLibrary | null { if (!path || !fs.existsSync(path)) return null; try { @@ -66,40 +65,6 @@ function read_library(path: string | undefined): VS_AnimationLibrary | null { } } -/** - * Assembles the bytes to write for `path` from freshly compiled animations. - * - * Both the ANIMATIONS-panel Save and `Animation > Export Animations...` route through here so - * they produce identical output. When a library already exists at `path`, each compiled animation - * replaces the entry of the same name in place (keeping the on-disk ordering and the file's - * `code`/`name`), new ones are appended, and entries that are not loaded into the project are - * left untouched. `renamedFrom` maps a compiled animation's current name back to the name it - * still has on disk, so a rename updates the existing entry instead of adding a second one. - */ -function assemble_library( - path: string | undefined, - compiled: VS_Animation[], - renamedFrom?: Map, -): VS_AnimationLibrary { - const existing = read_library(path); - if (!existing) return build_library(path, compiled); - - if (path) libraryMeta.set(path, { code: existing.code, name: existing.name }); - - for (const vsAnim of compiled) { - const oldName = renamedFrom?.get(vsAnim.name) ?? vsAnim.name; - const idx = existing.animations.findIndex(a => a.name === oldName || a.name === vsAnim.name); - if (idx >= 0) { - existing.animations[idx] = vsAnim; - // Drop any later duplicate left over from a rename. - existing.animations = existing.animations.filter((a, i) => i === idx || a.name !== vsAnim.name); - } else { - existing.animations.push(vsAnim); - } - } - return existing; -} - /** Parses a VS animation library file and adds its animations (grouped under `file.path`). */ function load_file(file: AnimationCodecFile, animation_filter?: string[]): _Animation[] { const json = (file.json ?? autoParseJSON(file.content as string)) as VS_AnimationLibrary; @@ -115,22 +80,31 @@ function load_file(file: AnimationCodecFile, animation_filter?: string[]): _Anim return created; } -/** - * Compiles a set of animations into a full VS library object (`Animation > Export Animations...`). - * When every animation belongs to the same existing library file, the result is merged into that - * file's current contents so Export and the panel's Save produce byte-identical output. Mixing - * animations from different files has no single file to merge against, so it writes a fresh - * library containing exactly the selection. - */ -function compile_file(animations: _Animation[]): VS_AnimationLibrary { +function compile_file( + animations: _Animation[], + compiled = compile_animation_library(animations).animations, +): VS_AnimationLibrary { const path = animations[0]?.path; const sharedPath = path && animations.every(a => a.path === path) ? path : undefined; - const compiled = compile_animation_library(animations).animations; + const library = read_library(sharedPath); + if (!library) return build_library(sharedPath, compiled); + + if (sharedPath) libraryMeta.set(sharedPath, { code: library.code, name: library.name }); const renamedFrom = new Map(); for (const a of animations) { if (a.saved_name && a.saved_name !== a.name) renamedFrom.set(a.name, a.saved_name); } - return assemble_library(sharedPath, compiled, renamedFrom); + for (const vsAnim of compiled) { + const oldName = renamedFrom.get(vsAnim.name) ?? vsAnim.name; + const idx = library.animations.findIndex(a => a.name === oldName || a.name === vsAnim.name); + if (idx >= 0) { + library.animations[idx] = vsAnim; + library.animations = library.animations.filter((a, i) => i === idx || a.name !== vsAnim.name); + } else { + library.animations.push(vsAnim); + } + } + return library; } /** Opens a dialog to import one or more VS animation library files. */ @@ -168,11 +142,7 @@ function write_animation_to_library(animation: _Animation): void { } const path = animation.path; - const renamedFrom = new Map(); - if (animation.saved_name && animation.saved_name !== vsAnim.name) { - renamedFrom.set(vsAnim.name, animation.saved_name); - } - const library = assemble_library(path, [vsAnim], renamedFrom); + const library = compile_file([animation], [vsAnim]); Blockbench.writeFile(path, { content: autoStringify(library) }, (real_path) => { animation.saved = true; diff --git a/src/animation_particles.ts b/src/animation_particles.ts index 1ed0f58..fffc30d 100644 --- a/src/animation_particles.ts +++ b/src/animation_particles.ts @@ -1,15 +1,3 @@ -/** - * Viewport preview for VS particle keyframes. - * - * Blockbench renders a particle keyframe through Wintersky, but only when the keyframe's data - * point carries a `file` pointing at a Snowstorm emitter JSON that has been registered with - * `Animator.loadParticleEmitter`. VS keyframes only store the effect's asset code, so nothing - * renders until we resolve that code to a file on disk and load it. - * - * Effect codes are VS asset locations (`[domain:]path`). GlintMod registers a `particles` asset - * category, so an effect lives at `//particles/.json`. - */ - import { parse_model_location } from "./animation_library_paths"; import { is_vs_project } from "./util"; @@ -18,12 +6,10 @@ const nodePath = requireNativeModule('path'); declare var WinterskyScene: any; -/** Effect codes already resolved this session: code -> file path, or null when unresolvable. */ const resolvedEffects = new Map(); const namespacedEmitters = new Set(); -/** Resets the cache so a newly added particle file is picked up without restarting Blockbench. */ export function clear_particle_cache(): void { resolvedEffects.clear(); namespacedEmitters.clear(); @@ -61,14 +47,6 @@ function ensure_emitter_namespaced(file: string): boolean { return true; } -/** - * `//particles/.json` for an effect code, using the project's asset root. - * - * Returned with native separators. Blockbench finds an effect's texture by splitting this path on - * the platform separator, locating the "particles" segment and treating everything above it as the - * pack root. A forward-slash path on Windows splits into a single segment, so that search fails and - * every particle falls back to the placeholder texture. - */ function particle_file_for(effectCode: string): string | null { const modelPath = Project?.save_path || Project?.export_path; if (!modelPath) return null; @@ -83,10 +61,6 @@ function particle_file_for(effectCode: string): string | null { return nodePath.join(ctx.assetsRoot, domain, 'particles', `${path}.json`); } -/** - * Resolves an effect code to a loaded emitter file, returning the path Blockbench should use on - * the keyframe data point. Returns null when the effect has no file, which just means no preview. - */ export function resolve_particle_effect(effectCode: string): string | null { const code = effectCode.trim(); if (!code) return null; @@ -95,29 +69,11 @@ export function resolve_particle_effect(effectCode: string): string | null { if (cached !== undefined) return cached; const file = particle_file_for(code); - if (!file || !fs.existsSync(file)) { - resolvedEffects.set(code, null); - return null; - } - - if (!ensure_emitter_namespaced(file)) { - resolvedEffects.set(code, null); - return null; - } - - resolvedEffects.set(code, file); - return file; + const resolved = file && fs.existsSync(file) && ensure_emitter_namespaced(file) ? file : null; + resolvedEffects.set(code, resolved); + return resolved; } -/** - * Adds the `minecraft:` namespace to component names that lack one. - * - * Wintersky, which drives Blockbench's particle preview, looks every component up as - * `components["minecraft:" + name]`. VS effects are written with bare names, so without this every - * lookup misses and the emitter is built with no rate, shape, lifetime or appearance: registered, - * linked, and completely inert. Only `components` is touched, since `curves` and `events` are - * author-named in Bedrock too. The file on disk is left alone. - */ function namespace_components(content: string): string { const json = JSON.parse(content); const components = json?.particle_effect?.components; @@ -164,7 +120,6 @@ function mirror_motion(components: Record): Record { const file = resolve_particle_effect(effect); const dp: Record = { effect, locator: locator || '' }; @@ -172,70 +127,38 @@ export function particle_data_point(effect: string, locator?: string): Record { if (!is_vs_project(Project)) return; relink_particle_previews(); }); -/** - * Links any particle data point on this animator that has an effect code but no emitter yet. - * - * Called from the render path rather than on a project-load event, because `load_project` fires - * before the project is ready (see the 100ms deferral in attachments.ts). Being demand-driven - * means it cannot run too early. Already-linked points cost a property check, and the resolver - * caches, so repeat frames do no real work. - */ -export function ensure_particle_links(animator: any): void { - const keyframes = animator?.particle; - if (!keyframes?.length) return; - - for (const keyframe of keyframes as _Keyframe[]) { +export function ensure_particle_links(animator: any, relink = false): number { + let linked = 0; + for (const keyframe of (animator?.particle || []) as _Keyframe[]) { for (const dp of keyframe.data_points as any[]) { - if (dp.file) { + if (dp.file && !relink) { ensure_emitter_namespaced(dp.file); continue; } const file = resolve_particle_effect(dp.effect || ''); - if (file) dp.file = file; + if (file) { + dp.file = file; + linked++; + } else if (relink) { + if (dp.file && fs.existsSync(dp.file) && ensure_emitter_namespaced(dp.file)) linked++; + else delete dp.file; + } } } + return linked; } -/** - * Re-resolves every particle keyframe in the project. Also exposed through the "Reload VFX - * Previews" action, for effects authored by hand or added to the assets folder after the model was - * opened. Returns how many data points now have an emitter. - */ export function relink_particle_previews(): number { clear_particle_cache(); let linked = 0; for (const animation of (Animation as unknown as typeof _Animation).all) { - const effects = (animation.animators as any)?.effects; - if (!effects?.particle) continue; - - for (const keyframe of effects.particle as _Keyframe[]) { - for (const dp of keyframe.data_points as any[]) { - const file = resolve_particle_effect(dp.effect || ''); - if (file) { - dp.file = file; - linked++; - continue; - } - if (dp.file && fs.existsSync(dp.file) && ensure_emitter_namespaced(dp.file)) { - linked++; - continue; - } - delete dp.file; - } - } + linked += ensure_particle_links((animation.animators as any)?.effects, true); } return linked; } diff --git a/src/animation_sounds.ts b/src/animation_sounds.ts index f998702..3d94b27 100644 --- a/src/animation_sounds.ts +++ b/src/animation_sounds.ts @@ -1,16 +1,3 @@ -/** - * Sound keyframe support. - * - * VS has no sound field on shape keyframes: the engine's `AnimationSound` lives on - * `AnimationMetaData` in entity JSON. Rather than require an engine change, sounds ride in the - * shape's keyframes as `sounds[]` exactly like `particles[]` does, and GlintMod reads them back - * out of the raw shape JSON. - * - * Blockbench's sound keyframes store a free-text `effect` name plus a `file` path to the audio on - * disk (which it plays during timeline scrubbing). VS wants an asset location, so we convert - * between the two: `//sounds/.ogg` <-> `domain:path`. - */ - import { parse_model_location } from "./animation_library_paths"; import { is_vs_project } from "./util"; import { VS_AnimationSound } from "./vs_shape_def"; @@ -19,15 +6,6 @@ const fs = requireNativeModule('fs'); const SOUND_EXTENSIONS = ['.ogg', '.wav', '.mp3']; -/** - * Extra sound fields, registered on Blockbench's keyframe data point so they get real inputs in - * the keyframe panel. Blockbench renders whatever is registered on KeyframeDataPoint - * (`v-for="(property, key) in data_point.constructor.properties"`), honouring `condition`, so - * these only appear on sound keyframes. They round-trip through .bbmodel automatically because - * Keyframe.getUndoCopy() copies every registered property of each data point. - * - * Defaults mirror the engine's AnimationSound, and export omits any field still at its default. - */ const SOUND_DEFAULTS = { vs_range: 32, vs_volume: 1, vs_pitch: 1, vs_chance: 1 } as const; function onSoundKeyframe(dataPoint: any): boolean { @@ -35,27 +13,18 @@ function onSoundKeyframe(dataPoint: any): boolean { } if (typeof KeyframeDataPoint !== 'undefined') { - new Property(KeyframeDataPoint, 'number', 'vs_range', { - label: 'Range', exposed: true, default: SOUND_DEFAULTS.vs_range, condition: onSoundKeyframe, - }); - new Property(KeyframeDataPoint, 'number', 'vs_volume', { - label: 'Volume', exposed: true, default: SOUND_DEFAULTS.vs_volume, condition: onSoundKeyframe, - }); - new Property(KeyframeDataPoint, 'number', 'vs_pitch', { - label: 'Pitch', exposed: true, default: SOUND_DEFAULTS.vs_pitch, condition: onSoundKeyframe, - }); - new Property(KeyframeDataPoint, 'number', 'vs_chance', { - label: 'Chance', exposed: true, default: SOUND_DEFAULTS.vs_chance, condition: onSoundKeyframe, - }); + for (const [key, value] of Object.entries(SOUND_DEFAULTS)) { + const name = key.slice(3); + new Property(KeyframeDataPoint, 'number', key, { + label: name[0].toUpperCase() + name.slice(1), + exposed: true, default: value, condition: onSoundKeyframe, + }); + } new Property(KeyframeDataPoint, 'boolean', 'vs_looping', { label: 'Looping', exposed: true, default: false, condition: onSoundKeyframe, }); } -/** - * Reads the optional sound fields off a data point. The panel renders numbers as text inputs, so - * these come back as strings and have to be coerced before comparing against the defaults. - */ function readSoundOptions(dp: KeyframeDataPointData): Partial { const out: Partial = {}; @@ -71,7 +40,6 @@ function readSoundOptions(dp: KeyframeDataPointData): Partial return out; } -/** Strips a trailing audio extension. */ function without_extension(path: string): string { for (const ext of SOUND_EXTENSIONS) { if (path.toLowerCase().endsWith(ext)) return path.slice(0, -ext.length); @@ -79,10 +47,6 @@ function without_extension(path: string): string { return path; } -/** - * Converts a local audio file path to a VS asset location, when the file sits inside an - * `assets//sounds/` tree. Returns null for files kept outside the mod's assets. - */ export function sound_location_for_file(filePath: string): string | null { const p = filePath.replace(/\\/g, '/'); const match = /\/assets\/([^/]+)\/(sounds\/.+)$/.exec(p); @@ -90,25 +54,12 @@ export function sound_location_for_file(filePath: string): string | null { return `${match[1]}:${without_extension(match[2])}`; } -/** - * The VS asset location a Blockbench sound data point should export as. - * - * The effect code wins over the picked file. The file is only a local preview, and a code can say - * things a single file cannot, most importantly the `*` wildcard VS uses to pick a random variant - * (bruister_peck_* covering bruister_peck_1/2/3). Deriving the location from the file would silently - * collapse that to whichever variant happened to be picked. - */ function sound_location_for_data_point(dp: KeyframeDataPointData): string | null { const effect = (dp.effect || '').trim(); if (!effect) return dp.file ? sound_location_for_file(dp.file) : null; - // Already a full location, so take it as authored. if (effect.includes(':') || effect.includes('/')) return effect; - // A bare name like "bruister_peck_*" is not resolvable on its own: VS would read it as - // game:bruister_peck_*. Borrow the domain and folder from the file the preview resolved to, - // keeping the authored name so a trailing wildcard survives (VS expands those itself, see - // SoundEngine.EndsWithWildCard). const fromFile = sound_location_for_file(dp.file || sound_file_for_location(effect) || ''); if (!fromFile) return effect; @@ -118,14 +69,12 @@ function sound_location_for_data_point(dp: KeyframeDataPointData): string | null return folder ? `${domain}:${folder}/${effect}` : `${domain}:${effect}`; } -/** Converts one Blockbench sound data point to a VS keyframe sound, or null if it has no target. */ export function sound_from_data_point(dp: KeyframeDataPointData): VS_AnimationSound | null { const location = sound_location_for_data_point(dp); if (!location) return null; return { location, ...readSoundOptions(dp) }; } -/** Finds the audio file for a VS sound location so Blockbench can play it while scrubbing. */ function sound_file_for_location(location: string): string | null { const modelPath = Project?.save_path || Project?.export_path; if (!modelPath) return null; @@ -137,8 +86,6 @@ function sound_file_for_location(location: string): string | null { const path = (colon >= 0 ? location.slice(colon + 1) : location).replace(/^sounds\//, ''); if (!path) return null; - // A `*` marks a random-variant set (bruister_peck_* -> bruister_peck_1/2/3). Preview the - // first variant, since Blockbench can only play one file. const concrete = path.split('*').join('1'); const soundsRoot = `${ctx.assetsRoot}/${domain}/sounds`; @@ -147,12 +94,9 @@ function sound_file_for_location(location: string): string | null { if (fs.existsSync(candidate)) return candidate; } - // Codes are often short names rather than full paths (bruister_peck_* for - // entity/bruister/bruister_peck_1.ogg), so fall back to searching the sounds tree. return find_sound_by_name(soundsRoot, concrete.split('/').pop() ?? concrete); } -/** Depth-first search for `` under the sounds folder. */ function find_sound_by_name(dir: string, name: string, depth = 0): string | null { if (depth > 6 || !fs.existsSync(dir)) return null; @@ -172,43 +116,27 @@ function find_sound_by_name(dir: string, name: string, depth = 0): string | null return null; } -/** Links any sound data point on this animator that has a location but no audio file yet. */ -export function ensure_sound_links(animator: any): void { - const keyframes = animator?.sound; - if (!keyframes?.length) return; - - for (const keyframe of keyframes as _Keyframe[]) { +export function ensure_sound_links(animator: any, relink = false): number { + let linked = 0; + for (const keyframe of (animator?.sound || []) as _Keyframe[]) { for (const dp of keyframe.data_points as any[]) { - if (dp.file) continue; + if (dp.file && !relink) continue; const file = sound_file_for_location((dp.effect || '').trim()); - if (file) dp.file = file; + if (file) { + dp.file = file; + linked++; + } else if (relink) { + delete dp.file; + } } } + return linked; } -/** - * Re-resolves the audio file on every sound keyframe. Blockbench saves `file` into the .bbmodel as - * an absolute path, so a project opened elsewhere points at a file that does not exist. The path - * is derived from the sound's asset location, so it is rebuilt on load rather than trusted. - */ export function relink_sound_files(): number { let linked = 0; - for (const animation of (Animation as unknown as typeof _Animation).all) { - const effects = (animation.animators as any)?.effects; - if (!effects?.sound) continue; - - for (const keyframe of effects.sound as _Keyframe[]) { - for (const dp of keyframe.data_points as any[]) { - const file = sound_file_for_location((dp.effect || '').trim()); - if (file) { - dp.file = file; - linked++; - } else { - delete dp.file; - } - } - } + linked += ensure_sound_links((animation.animators as any)?.effects, true); } return linked; } @@ -218,16 +146,14 @@ Blockbench.on('load_project', () => { relink_sound_files(); }); -/** Builds one Blockbench sound data point, attaching the audio file when it can be found. */ export function sound_data_point(sound: VS_AnimationSound): Record { const dp: Record = { effect: sound.location }; const file = sound_file_for_location(sound.location); if (file) dp.file = file; - if (sound.range !== undefined) dp.vs_range = sound.range; - if (sound.volume !== undefined) dp.vs_volume = sound.volume; - if (sound.pitch !== undefined) dp.vs_pitch = sound.pitch; - if (sound.chance !== undefined) dp.vs_chance = sound.chance; + for (const key of ['range', 'volume', 'pitch', 'chance'] as const) { + if (sound[key] !== undefined) dp[`vs_${key}`] = sound[key]; + } if (sound.looping) dp.vs_looping = true; return dp; diff --git a/src/attachments/codec.ts b/src/attachments/codec.ts index 5544d59..6d456fe 100644 --- a/src/attachments/codec.ts +++ b/src/attachments/codec.ts @@ -127,7 +127,7 @@ export function createExportCodec(selection: any[] = []) { extension: 'bbmodel', remember: true, export() { - if (selection.length === 0) return; + if (!Project || selection.length === 0) return; Blockbench.export({ resource_id: 'model', type: this.name, @@ -138,6 +138,7 @@ export function createExportCodec(selection: any[] = []) { }); }, compile(selection: any[], options?: any) { + if (!Project) throw new Error('Cannot export attachments without an open project.'); if (!options) options = {}; const model: any = { meta: { diff --git a/src/attachments/export_attachment_vs.ts b/src/attachments/export_attachment_vs.ts index 1435249..c4fffa7 100644 --- a/src/attachments/export_attachment_vs.ts +++ b/src/attachments/export_attachment_vs.ts @@ -489,6 +489,10 @@ function findTopmostAttachmentRoot(element: any, clothingSlot: string): Group | * @param {Array} selection - An array of selected attachment groups. */ export function exportAttachmentsVS(selection: Group[]) { + if (!Project) { + Blockbench.showQuickMessage("Please open a project before exporting attachments.", QUICK_MESSAGE_DURATION); + return; + } if (!selection || selection.length === 0) { Blockbench.showQuickMessage("Please select one or more attachments to export.", QUICK_MESSAGE_DURATION); return; diff --git a/src/mods/effectAnimatorMod.ts b/src/mods/effectAnimatorMod.ts index 66e515c..87a6079 100644 --- a/src/mods/effectAnimatorMod.ts +++ b/src/mods/effectAnimatorMod.ts @@ -4,28 +4,8 @@ import { is_vs_project } from "../util"; import { ensure_particle_links } from "../animation_particles"; import { ensure_sound_links } from "../animation_sounds"; -/** - * Resolves effect files just before Blockbench draws them. - * - * VS keyframes only store an asset code; Blockbench needs a real path in `data_point.file` before - * it will spawn a particle emitter or play a sound. Doing that on `load_project` is unreliable - * because the event fires before the project is ready, which is why attachments.ts defers its own - * work by 100ms. Hooking the render path instead makes it demand-driven, so it cannot run too - * early and there is nothing for the user to trigger by hand. - */ -/** Animators currently sitting on the rewind at the end of a "once" animation. */ const rewound = new WeakSet(); -/** - * True while this animator is showing the rewind Blockbench performs as a "once" animation ends. - * - * Ending a one-shot runs `setTime(0)`, `preview()`, `pause()` and finally `preview(true)`, so the - * playhead lands on 0 and renders three times. Only the last of those passes `in_loop`, which is - * what Blockbench gates sound on, so it is the one that replays a frame-0 keyframe. Detecting the - * rewind by comparing against `last_displayed_time` alone does not survive that, because the first - * render resets it to 0. The state is latched instead, and cleared as soon as the playhead moves, - * so pressing play again still triggers the sound normally. - */ function is_end_of_single_shot(animator: any): boolean { const animation = animator.animation; if (!animation || animation.loop !== 'once' || animation.time > 0) { @@ -53,10 +33,6 @@ createBlockbenchMod(`${PACKAGE.name}:effect_animator_mod`, return inject_context.original.call(this, in_loop); } - // Blockbench ends a "once" animation with setTime(0) -> preview() -> pause(), so the - // rewind re-renders frame 0 and fires anything keyed there a second time. Its own - // 400ms sound cooldown has expired by then on all but the shortest animations. Mute - // the effect channels just for this rewind render so the tail is silent. const muted = (this as any).muted; const previous = { sound: muted.sound, particle: muted.particle }; muted.sound = true; diff --git a/src/mods/settingsMod.ts b/src/mods/settingsMod.ts index 7879890..595489d 100644 --- a/src/mods/settingsMod.ts +++ b/src/mods/settingsMod.ts @@ -193,3 +193,5 @@ createBlockbenchMod( //context?.delete(); } ); + + diff --git a/src/vs_shape_def.ts b/src/vs_shape_def.ts index fe6f0f6..2187d24 100644 --- a/src/vs_shape_def.ts +++ b/src/vs_shape_def.ts @@ -108,24 +108,12 @@ export interface VS_Keyframe { sounds?: Array, } -/** - * A sound triggered on a keyframe. Mirrors the engine's `AnimationSound`, minus `frame` (which - * the containing keyframe supplies). Read by GlintMod; the base game ignores it, because stock VS - * only takes animation sounds from `AnimationMetaData` in entity JSON. - * - * Blockbench can only author `location`. The remaining fields are for hand-written shapes and are - * NOT preserved if the animation is re-exported from Blockbench. - */ export interface VS_AnimationSound { - /** Sound asset location, e.g. "game:creature/hyena/growl" */ location: string, - /** Audible radius in blocks */ range?: number, volume?: number, pitch?: number, - /** 0..1 probability the sound plays when the frame is reached */ chance?: number, - /** Loop for as long as the animation runs, instead of firing once */ looping?: boolean, } From c2bb3912b1f8d70dc76bffc9843c38e5e4736b0b Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 4 Oct 2026 13:30:28 +0100 Subject: [PATCH 19/25] missing function reload_animations --- src/animation_codec.ts | 53 +++++++++++++++++++------------ src/export.ts | 4 +-- src/export_animation.ts | 9 +++--- src/import_animation.ts | 26 ++++++++++++--- src/property.ts | 4 +++ src/types/blockbench-augment.d.ts | 2 +- 6 files changed, 66 insertions(+), 32 deletions(-) diff --git a/src/animation_codec.ts b/src/animation_codec.ts index 0a02659..b68c01d 100644 --- a/src/animation_codec.ts +++ b/src/animation_codec.ts @@ -21,6 +21,7 @@ import { compile_animation, compile_animation_library } from "./export_animation import { create_animation } from "./import_animation"; import { VS_Animation, VS_AnimationLibrary } from "./vs_shape_def"; import { parse_model_location, basename_no_ext } from "./animation_library_paths"; +import JSON5 from "json5"; const fs = requireNativeModule('fs'); @@ -56,20 +57,16 @@ function build_library(path: string | undefined, animations: VS_Animation[]): VS function read_library(path: string | undefined): VS_AnimationLibrary | null { if (!path || !fs.existsSync(path)) return null; - try { - const parsed = autoParseJSON(fs.readFileSync(path, 'utf-8')) as VS_AnimationLibrary; - return parsed && Array.isArray(parsed.animations) ? parsed : null; - } catch (e) { - console.error('[VS Animation Codec] Failed to read existing library, overwriting:', e); - return null; - } + const library = JSON5.parse(fs.readFileSync(path, 'utf-8')) as VS_AnimationLibrary; + if (!Array.isArray(library.animations)) throw new Error(`Invalid animation library: ${path}`); + return library; } /** Parses a VS animation library file and adds its animations (grouped under `file.path`). */ function load_file(file: AnimationCodecFile, animation_filter?: string[]): _Animation[] { - const json = (file.json ?? autoParseJSON(file.content as string)) as VS_AnimationLibrary; + const json = (file.json ?? JSON5.parse(file.content as string)) as VS_AnimationLibrary; const created: _Animation[] = []; - if (!json || !Array.isArray(json.animations)) return created; + if (!Array.isArray(json.animations)) throw new Error(`Invalid animation library: ${file.path}`); libraryMeta.set(file.path, { code: json.code, name: json.name }); @@ -124,7 +121,27 @@ function pick_file(): void { /** Loads a single picked file into the project (called by pickFile and the file menu). */ function import_file(file: AnimationCodecFile): _Animation[] { - return load_file(file); + Undo.initEdit({ animations: [], outliner: true, elements: Cube.all }); + const created = load_file(file); + Undo.finishEdit('Import animations', { animations: created }); + return created; +} + +function reload_animations(animations: _Animation[]): void { + if (!animations.length) return; + const path = animations[0].path; + const json = JSON5.parse(fs.readFileSync(path, 'utf-8')) as VS_AnimationLibrary; + if (!Array.isArray(json.animations)) throw new Error(`Invalid animation library: ${path}`); + const selected = animations.find(a => a.selected); + Undo.initEdit({ animations, outliner: true, elements: Cube.all }); + animations.forEach(a => a.remove(false, false)); + const created = load_file({ path, json }, animations.map(a => a.saved_name || a.name)); + for (const animation of created) { + const previous = animations.find(a => (a.saved_name || a.name) === animation.name)!; + (animation as any).vs_library_ref = (previous as any).vs_library_ref; + if (previous === selected) animation.select(); + } + Undo.finishEdit('Reload animations', { animations: created }); } /** @@ -166,6 +183,7 @@ function save_animation(animation: _Animation, save_as?: boolean): void { custom_writer: (_content, exportPath) => { if (!exportPath) return; animation.path = exportPath; + delete (animation as any).vs_library_ref; write_animation_to_library(animation); } }); @@ -204,7 +222,7 @@ function export_file(path: string, save_as?: boolean): void { startpath: filterPath || default_animations_dir(), custom_writer: (_content, exportPath) => { if (!exportPath) return; - animations.forEach(a => { a.path = exportPath; }); + animations.forEach(a => { a.path = exportPath; delete (a as any).vs_library_ref; }); fs.writeFileSync(exportPath, autoStringify(compile_file(animations))); animations.forEach(a => { a.saved = true; a.saved_name = a.name; }); }, @@ -214,15 +232,8 @@ function export_file(path: string, save_as?: boolean): void { /** Removes an animation's entry from its library file (when deleted from the project). */ function delete_animation_from_file(animation: _Animation): void { const path = animation.path; - if (!path || !fs.existsSync(path)) return; - let library: VS_AnimationLibrary | null = null; - try { - library = autoParseJSON(fs.readFileSync(path, 'utf-8')) as VS_AnimationLibrary; - } catch (e) { - console.error('[VS Animation Codec] Failed to read library for deletion:', e); - return; - } - if (!library || !Array.isArray(library.animations)) return; + const library = read_library(path); + if (!library) return; const target = animation.saved_name ?? animation.name; library.animations = library.animations.filter(a => a.name !== target); Blockbench.writeFile(path, { content: autoStringify(library) }); @@ -240,6 +251,8 @@ function create_vs_animation_codec(): AnimationCodec | undefined { pickFile: pick_file, importFile: import_file, loadFile: load_file, + reloadFile: path => reload_animations(all_animations().filter(a => a.path === path && a.saved)), + reloadAnimation: animation => reload_animations([animation]), compileAnimation: (animation) => compile_animation(animation), compileFile: compile_file, saveAnimation: save_animation, diff --git a/src/export.ts b/src/export.ts index 2cadec7..7cda02e 100644 --- a/src/export.ts +++ b/src/export.ts @@ -154,7 +154,7 @@ export function ex(options): VS_Shape { for (const animation of allAnimations) { if (!animation.path) continue; // @ts-expect-error: custom property for round-trip fidelity - const ref: string | null = animation.vs_library_ref ?? path_to_reference(animation.path); + const ref: string | null = animation.vs_library_ref || path_to_reference(animation.path); if (ref && !seenRefs.has(ref)) { seenRefs.add(ref); libraryRefs.push(ref); } } @@ -181,4 +181,4 @@ export function ex(options): VS_Shape { } return data; -} \ No newline at end of file +} diff --git a/src/export_animation.ts b/src/export_animation.ts index f6c4fe2..60fb3ca 100644 --- a/src/export_animation.ts +++ b/src/export_animation.ts @@ -214,11 +214,8 @@ export function compile_animation(animation: _Animation, catmullConverted?: stri }); (Object.keys(byChannel) as BBChannel[]).forEach(ch => byChannel[ch].sort((a, b) => a.time - b.time)); - // Unit scale is normally omitted, but a non-linear scale channel needs every one of - // its keyframes present in the JSON: import detects the channel by the stretch fields - // and sizes each bezier segment from the gaps between them. Dropping an all-1 keyframe - // would silently lengthen the neighbouring segment. - const scaleNeedsAllKeys = byChannel.scale.some(kf => mapInterpolation(kf.interpolation).mode !== null); + const scaleNeedsAllKeys = byChannel.scale.some(kf => mapInterpolation(kf.interpolation).mode !== null + || ['x', 'y', 'z'].some(axis => Number(kf.data_points[0][axis]) !== 1)); animator.keyframes.forEach(kf => { const { mode: vsInterp, isCatmull } = mapInterpolation(kf.interpolation); @@ -372,6 +369,8 @@ export function compile_animation(animation: _Animation, catmullConverted?: stri if (frame0) { const virtualFrame = JSON.parse(JSON.stringify(frame0)); virtualFrame.frame = lastFrame; + delete virtualFrame.sounds; + delete virtualFrame.particles; vsAnimation.keyframes.push(virtualFrame); vsAnimation.keyframes.sort((a, b) => a.frame - b.frame); } diff --git a/src/import_animation.ts b/src/import_animation.ts index 0be24b6..1add433 100644 --- a/src/import_animation.ts +++ b/src/import_animation.ts @@ -18,6 +18,7 @@ export function create_animation(vsAnimation: VS_Animation, path?: string, saved const animation = ((new Animation({ //@ts-expect-error: Blockbench overwrites libdom's Animation type with its own Animation Class, but TypeScript doesn't include a way to overwrite UMD global types. name: vsAnimation.name, + path: path || '', loop: isLooping ? 'loop' : (vsAnimation.onAnimationEnd === 'Hold' ? 'hold' : 'once'), length: animationLength, snapping: FPS @@ -61,9 +62,28 @@ export function create_animation(vsAnimation: VS_Animation, path?: string, saved }); } + if (vsKeyframe.textures) { + getEffectAnimator(animation).addKeyframe({ + channel: 'timeline', + time: vsKeyframe.frame / FPS, + data_points: [{ script: `"textures": ${JSON.stringify(vsKeyframe.textures)}` }], + }); + } + for (const boneName in vsKeyframe.elements) { const transform = vsKeyframe.elements[boneName]; - const bone = Group.all.find(g => g.name === boneName); + let bone = Group.all.find(g => g.name === boneName); + if (!bone) { + const cube = Cube.all.find(c => c.name === boneName); + if (cube) { + bone = new Group(cube.getSaveCopy()).sortInBefore(cube).init(); + cube.name = `${boneName}_geo`; + cube.rotation = [0, 0, 0]; + cube.stepParentName = ''; + cube.addTo(bone); + Canvas.updateAllPositions(); + } + } if (!bone) continue; const animator = animation.getBoneAnimator(bone); @@ -88,12 +108,10 @@ export function import_animations(animations: Array) { animations.forEach(vsAnimation => create_animation(vsAnimation)); }; -// Blockbench lazily creates the effect animator on first access, but only on builds where -// `animators` is the proxy. Fall back to constructing it so particle import cannot throw. function getEffectAnimator(animation: _Animation): any { const animators = animation.animators as any; if (animators.effects) return animators.effects; - animators.effects = new EffectAnimator(null, animation, 'Effects'); + animators.effects = new (EffectAnimator as any)(animation); return animators.effects; } diff --git a/src/property.ts b/src/property.ts index 7554022..b6ec8fb 100644 --- a/src/property.ts +++ b/src/property.ts @@ -1,5 +1,9 @@ import { VS_Direction, VS_EditorSettings, VS_Face, VS_ReflectiveMode } from "./vs_shape_def"; +for (const name of ['vs_code', 'vs_onActivityStopped', 'vs_onAnimationEnd', 'vs_library_ref']) { + new Property(Blockbench.Animation, 'string', name, { exposed: false }); +} + export const VS_PROJECT_PROPS = [ new Property(ModelProject, "string", "backDropShape", { exposed: false, }), new Property(ModelProject, "string", "collapsedPaths", { exposed: false, }), diff --git a/src/types/blockbench-augment.d.ts b/src/types/blockbench-augment.d.ts index 2b316a3..5dbfc28 100644 --- a/src/types/blockbench-augment.d.ts +++ b/src/types/blockbench-augment.d.ts @@ -20,7 +20,7 @@ declare global { pickFile?(): void; importFile?(file: AnimationCodecFile, auto_loaded?: boolean): _Animation[]; loadFile?(file: AnimationCodecFile, animation_filter?: string[]): _Animation[]; - reloadFile?(file: AnimationCodecFile): void; + reloadFile?(path: string): void; reloadAnimation?(animation: _Animation): void; // eslint-disable-next-line @typescript-eslint/no-explicit-any compileAnimation?(animation: _Animation): any; From 46760f8512dd68e25d561ca9e16d73961142a75a Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 4 Oct 2026 13:32:13 +0100 Subject: [PATCH 20/25] direct types --- src/attachments/panel.ts | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/attachments/panel.ts b/src/attachments/panel.ts index 3d6771f..0484799 100644 --- a/src/attachments/panel.ts +++ b/src/attachments/panel.ts @@ -2,12 +2,19 @@ import { exportAttachmentsVS } from './export_attachment_vs'; import { exportAttachmentsBB } from './export_attachment_bb'; import { deleteSection, deleteSectionSafe } from './delete_section'; import { findAttachments } from './discovery'; +import type { IAttachmentSection } from './discovery'; import { DISCOVERY_DEBOUNCE_MS, MIN_TOUCH_TARGET_SIZE, QUICK_MESSAGE_DURATION } from './constants'; import { getActiveSlotNames } from './presets'; import { getSlotInfo, getSlotCategory } from './slot_helpers'; const DEBUG = false; +interface AttachmentPanelState { + sections: IAttachmentSection[]; + openSections: string[]; + hoveredSection: string | null; +} + function logDebug(message: string, ...args: any[]) { if (DEBUG) console.log(message, ...args); } @@ -113,7 +120,7 @@ function updateOutlinerSelection(elements: any[]) { elements.forEach((element: any) => { Outliner.selected.safePush(element); }); - (updateSelection as any)(); + updateSelection(); } @@ -378,10 +385,10 @@ const vuePanel = { `, - data: () => ({ + data: (): AttachmentPanelState => ({ sections: [], openSections: [], - hoveredSection: null as string | null + hoveredSection: null }), methods: { /** @@ -512,19 +519,19 @@ const vuePanel = { /** * Updates the list of attachments by calling the discovery function. */ - updateAttachments() { - (this as any).sections = findAttachments(); + updateAttachments(this: AttachmentPanelState) { + this.sections = findAttachments(); }, /** * Toggles the visibility of a section in the panel. * @param {string} slot The slot name of the section to toggle. */ - toggleSection(slot: string) { - const index = (this as any).openSections.indexOf(slot); + toggleSection(this: AttachmentPanelState, slot: string) { + const index = this.openSections.indexOf(slot); if (index > -1) { - (this as any).openSections.splice(index, 1); + this.openSections.splice(index, 1); } else { - (this as any).openSections.push(slot); + this.openSections.push(slot); } }, /** @@ -532,8 +539,8 @@ const vuePanel = { * @param {string} slot The slot name of the section. * @returns {boolean} True if the section is open, false otherwise. */ - isSectionOpen(slot: string) { - return (this as any).openSections.includes(slot); + isSectionOpen(this: AttachmentPanelState, slot: string) { + return this.openSections.includes(slot); }, /** * Selects an element and all its children. From bf8a9fe11635c6e2c7bcf674ef1bf7126fa3ead5 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 4 Oct 2026 13:36:39 +0100 Subject: [PATCH 21/25] traversal helper --- src/attachments/panel.ts | 87 +++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 46 deletions(-) diff --git a/src/attachments/panel.ts b/src/attachments/panel.ts index 0484799..b999762 100644 --- a/src/attachments/panel.ts +++ b/src/attachments/panel.ts @@ -15,6 +15,19 @@ interface AttachmentPanelState { hoveredSection: string | null; } +type AttachmentPanelListener = [event: string, listener: () => void]; + +interface AttachmentPanelContext extends AttachmentPanelState { + updateAttachments(): void; + refresh?: () => void; + _bbListeners?: AttachmentPanelListener[]; +} + +type AttachmentOutlinerNode = OutlinerNode & { + visibility?: boolean; + toggleVisibility?: (visible: boolean) => void; +}; + function logDebug(message: string, ...args: any[]) { if (DEBUG) console.log(message, ...args); } @@ -75,6 +88,13 @@ function getAllChildElements(element: any) { return elements; } +function walkOutliner(node: AttachmentOutlinerNode, callback: (node: AttachmentOutlinerNode) => void): void { + callback(node); + if (node instanceof Group && Array.isArray(node.children)) { + node.children.forEach(child => walkOutliner(child, callback)); + } +} + /** * Calculates the depth of an element in the outliner hierarchy. * Depth is measured from the root (root = 0, first child = 1, etc.) @@ -466,18 +486,11 @@ const vuePanel = { */ exportBB(elements: any[]) { try { - (this as any).isExporting = true; exportAttachmentsBB(elements); - // Note: exportAttachmentsBB uses Blockbench.export which is async but doesn't return a promise - // The success message will be shown after the export dialog completes - setTimeout(() => { - (this as any).isExporting = false; - }, 100); } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e); Blockbench.showQuickMessage(`Export failed: ${errorMsg}`, QUICK_MESSAGE_DURATION); if (DEBUG) console.error('Export BB error:', e); - (this as any).isExporting = false; } }, /** @@ -486,18 +499,11 @@ const vuePanel = { */ exportVS(elements: any[]) { try { - (this as any).isExporting = true; exportAttachmentsVS(elements); - // Note: exportAttachmentsVS uses Blockbench.export which is async but doesn't return a promise - // The success message will be shown after the export dialog completes - setTimeout(() => { - (this as any).isExporting = false; - }, 100); } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e); Blockbench.showQuickMessage(`Export failed: ${errorMsg}`, QUICK_MESSAGE_DURATION); if (DEBUG) console.error('Export VS error:', e); - (this as any).isExporting = false; } }, /** @@ -563,7 +569,7 @@ const vuePanel = { * @param {Array} elements The elements to toggle visibility for. * @param {boolean} isVisible The desired visibility state. */ - toggleVisibility(elements: any[], isVisible: boolean) { + toggleVisibility(elements: OutlinerNode[], isVisible: boolean) { if (!elements || !Array.isArray(elements)) return; try { @@ -572,7 +578,7 @@ const vuePanel = { elements.forEach(element => { if (!element) return; try { - (this as any)._walk(element, (node: any) => { + walkOutliner(element, node => { if (!node) return; if (typeof node.toggleVisibility === 'function') { if (node.visibility !== isVisible) node.toggleVisibility(isVisible); @@ -593,17 +599,6 @@ const vuePanel = { Blockbench.showQuickMessage('Failed to toggle visibility', QUICK_MESSAGE_DURATION); } }, - /** - * Traverses a node and its children, applying a callback to each. - * @param {object} node The node to start traversal from. - * @param {function} callback The function to apply to each node. - */ - _walk(node: any, callback: (n: any) => void) { - callback(node); - if (node instanceof Group && Array.isArray(node.children)) { - node.children.forEach(child => (this as any)._walk(child, callback)); - } - }, /** * Selects all elements in a section. * @param {Array} elements The elements in the section. @@ -629,33 +624,33 @@ const vuePanel = { return element instanceof Group ? 'folder' : 'widgets'; } }, - mounted() { + mounted(this: AttachmentPanelContext) { // Debounce refresh calls to avoid unnecessary updates when multiple events fire - (this as any).refresh = debounce(() => (this as any).updateAttachments(), DISCOVERY_DEBOUNCE_MS); - (this as any).updateAttachments(); // Initial load without debounce + this.refresh = debounce(() => this.updateAttachments(), DISCOVERY_DEBOUNCE_MS); + this.updateAttachments(); // Initial load without debounce - (this as any)._bbListeners = [ - ['update_outliner', (this as any).refresh], - ['load_project', (this as any).refresh], - ['select_project', (this as any).refresh], - ['new_project', (this as any).refresh], - ['update_selection', (this as any).refresh], - ['undo', (this as any).refresh], - ['redo', (this as any).refresh], - ['attachments_changed', (this as any).refresh] + this._bbListeners = [ + ['update_outliner', this.refresh], + ['load_project', this.refresh], + ['select_project', this.refresh], + ['new_project', this.refresh], + ['update_selection', this.refresh], + ['undo', this.refresh], + ['redo', this.refresh], + ['attachments_changed', this.refresh] ]; - (this as any)._bbListeners.forEach(([evt, fn]: [string, any]) => Blockbench.on(evt, fn)); + this._bbListeners.forEach(([evt, fn]) => Blockbench.on(evt, fn)); }, - beforeUnmount() { - if ((this as any)._bbListeners) { - (this as any)._bbListeners.forEach(([evt, fn]: [string, any]) => Blockbench.removeListener(evt, fn)); + beforeUnmount(this: AttachmentPanelContext) { + if (this._bbListeners) { + this._bbListeners.forEach(([evt, fn]) => Blockbench.removeListener(evt, fn)); } }, // Vue 2 compatibility - beforeDestroy() { - if ((this as any)._bbListeners) { - (this as any)._bbListeners.forEach(([evt, fn]: [string, any]) => Blockbench.removeListener(evt, fn)); + beforeDestroy(this: AttachmentPanelContext) { + if (this._bbListeners) { + this._bbListeners.forEach(([evt, fn]) => Blockbench.removeListener(evt, fn)); } } }; From 79a176a21112d7accafeaa37b3f6ce1c95b9dbcf Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 4 Oct 2026 13:43:39 +0100 Subject: [PATCH 22/25] more cleanup --- src/attachments/export_attachment_vs.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/attachments/export_attachment_vs.ts b/src/attachments/export_attachment_vs.ts index c4fffa7..7c2e2eb 100644 --- a/src/attachments/export_attachment_vs.ts +++ b/src/attachments/export_attachment_vs.ts @@ -33,7 +33,7 @@ function getElementBindRotation(element: Group | Cube): Vector3Tuple { } function getSocketFrom(group: Group): Vector3Tuple { - const storedFrom = (group as any).vs_group_from; + const storedFrom = group.vs_group_from; if (isFiniteVector3(storedFrom)) return [...storedFrom]; const geoChild = group.children.find(child => child instanceof Cube && child.name === `${group.name}_geo` @@ -381,9 +381,9 @@ function process_attachment_group( // Filter children to ONLY include those with matching clothingSlot // Don't include groups with no slot, as they might be base model elements - const filteredChildren = (node.children || []).filter((child: any) => { + const filteredChildren = (node.children || []).filter(child => { if (!(child instanceof Group || child instanceof Cube)) return false; - const childSlot = (child as any).clothingSlot; + const childSlot = child.clothingSlot; // Empty means inherit from this attachment root. An explicit different // slot is a real boundary and must not be exported with this attachment. return !childSlot || childSlot.trim() === '' || childSlot.trim() === targetSlot.trim(); From 5a12c8531478139ae11e21369384113f64a2426a Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 4 Oct 2026 13:50:50 +0100 Subject: [PATCH 23/25] improve node here too --- src/attachments/export_attachment_vs.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/attachments/export_attachment_vs.ts b/src/attachments/export_attachment_vs.ts index 7c2e2eb..35f6d3e 100644 --- a/src/attachments/export_attachment_vs.ts +++ b/src/attachments/export_attachment_vs.ts @@ -416,10 +416,11 @@ function traverseAttachment( ) { for (const node of nodes) { if (!node.export) continue; + if (!(node instanceof Group || node instanceof Cube)) continue; // Filter: ONLY process nodes with matching clothingSlot // Don't include nodes with no slot, as they might be base model elements - const nodeSlot = (node as any).clothingSlot; + const nodeSlot = node.clothingSlot; const hasMatchingSlot = !nodeSlot || nodeSlot.trim() === '' || nodeSlot.trim() === targetSlot.trim(); // Skip nodes that don't have the matching slot From 250286c3482eeb2b9c86537a491000d6b585143b Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 4 Oct 2026 14:26:13 +0100 Subject: [PATCH 24/25] additional typing checks --- src/animation_actions.ts | 2 +- src/animation_codec.ts | 10 +- src/animation_particles.ts | 4 +- src/animation_sounds.ts | 4 +- src/attachments/export_attachment_vs.ts | 44 +++--- src/export.ts | 13 +- src/export_animation.ts | 47 +++--- src/export_model/group.ts | 12 +- src/export_model/locator.ts | 10 +- src/export_textures.ts | 55 ++++--- src/import.ts | 3 +- src/import_animation.ts | 44 +++--- src/import_model/locator.ts | 8 +- src/panels/vs_face_panel.ts | 192 ++++++++++++------------ src/property.ts | 2 +- src/types/blockbench-augment.d.ts | 9 ++ src/types/property-augment.d.ts | 1 + src/vs_shape_def.ts | 6 + 18 files changed, 244 insertions(+), 222 deletions(-) diff --git a/src/animation_actions.ts b/src/animation_actions.ts index f016299..178f59d 100644 --- a/src/animation_actions.ts +++ b/src/animation_actions.ts @@ -16,7 +16,7 @@ const clear_animations_action = createAction(`${PACKAGE.name}:clear_animations_v return is_vs_project(Project); }, click: function () { - const total = (Animation as unknown as typeof _Animation).all.length; + const total = Blockbench.Animation.all.length; if (total === 0) { Blockbench.showQuickMessage('No animations to clear'); return; diff --git a/src/animation_codec.ts b/src/animation_codec.ts index b68c01d..c7f3759 100644 --- a/src/animation_codec.ts +++ b/src/animation_codec.ts @@ -30,9 +30,9 @@ const fs = requireNativeModule('fs'); // them keeps library files diff-stable across a round trip). Keyed by absolute file path. const libraryMeta = new Map(); -/** All animations, with Blockbench's runtime `Animation` cast to the `_Animation` type. */ +/** All animations in the current project. */ function all_animations(): _Animation[] { - return (Animation as unknown as typeof _Animation).all; + return Blockbench.Animation.all; } /** Default directory the file dialogs open in: `//animations`. */ @@ -138,7 +138,7 @@ function reload_animations(animations: _Animation[]): void { const created = load_file({ path, json }, animations.map(a => a.saved_name || a.name)); for (const animation of created) { const previous = animations.find(a => (a.saved_name || a.name) === animation.name)!; - (animation as any).vs_library_ref = (previous as any).vs_library_ref; + animation.vs_library_ref = previous.vs_library_ref; if (previous === selected) animation.select(); } Undo.finishEdit('Reload animations', { animations: created }); @@ -183,7 +183,7 @@ function save_animation(animation: _Animation, save_as?: boolean): void { custom_writer: (_content, exportPath) => { if (!exportPath) return; animation.path = exportPath; - delete (animation as any).vs_library_ref; + delete animation.vs_library_ref; write_animation_to_library(animation); } }); @@ -222,7 +222,7 @@ function export_file(path: string, save_as?: boolean): void { startpath: filterPath || default_animations_dir(), custom_writer: (_content, exportPath) => { if (!exportPath) return; - animations.forEach(a => { a.path = exportPath; delete (a as any).vs_library_ref; }); + animations.forEach(a => { a.path = exportPath; delete a.vs_library_ref; }); fs.writeFileSync(exportPath, autoStringify(compile_file(animations))); animations.forEach(a => { a.saved = true; a.saved_name = a.name; }); }, diff --git a/src/animation_particles.ts b/src/animation_particles.ts index fffc30d..14a3f81 100644 --- a/src/animation_particles.ts +++ b/src/animation_particles.ts @@ -157,8 +157,8 @@ export function relink_particle_previews(): number { clear_particle_cache(); let linked = 0; - for (const animation of (Animation as unknown as typeof _Animation).all) { - linked += ensure_particle_links((animation.animators as any)?.effects, true); + for (const animation of Blockbench.Animation.all) { + linked += ensure_particle_links(animation.animators?.effects, true); } return linked; } diff --git a/src/animation_sounds.ts b/src/animation_sounds.ts index 3d94b27..6ec9ecf 100644 --- a/src/animation_sounds.ts +++ b/src/animation_sounds.ts @@ -135,8 +135,8 @@ export function ensure_sound_links(animator: any, relink = false): number { export function relink_sound_files(): number { let linked = 0; - for (const animation of (Animation as unknown as typeof _Animation).all) { - linked += ensure_sound_links((animation.animators as any)?.effects, true); + for (const animation of Blockbench.Animation.all) { + linked += ensure_sound_links(animation.animators?.effects, true); } return linked; } diff --git a/src/attachments/export_attachment_vs.ts b/src/attachments/export_attachment_vs.ts index 35f6d3e..b7f1a10 100644 --- a/src/attachments/export_attachment_vs.ts +++ b/src/attachments/export_attachment_vs.ts @@ -12,7 +12,7 @@ const fs = requireNativeModule('fs'); const DEBUG = false; // Enable debug to see what's being exported -function logDebug(message: string, ...args: any[]) { +function logDebug(message: string, ...args: unknown[]) { if (DEBUG) console.log(message, ...args); } @@ -24,7 +24,7 @@ function isFiniteVector3(value: unknown): value is Vector3Tuple { function getElementBindRotation(element: Group | Cube): Vector3Tuple { const chain: Vector3Tuple[] = []; - let current: any = element; + let current: OutlinerNode | OutlinerNode['parent'] = element; while (current instanceof Group || current instanceof Cube) { chain.unshift([...(current.rotation || [0, 0, 0])] as Vector3Tuple); current = current.parent; @@ -42,7 +42,7 @@ function getSocketFrom(group: Group): Vector3Tuple { } function isInAttachmentSubtree(candidate: Group, attachmentRoot: Group | Cube): boolean { - let current: any = candidate; + let current: OutlinerNode | OutlinerNode['parent'] = candidate; while (current && current instanceof Group) { if (current === attachmentRoot) return true; current = current.parent; @@ -438,14 +438,14 @@ function traverseAttachment( } else if (node instanceof Cube) { // For cubes, we need to apply stepParentName manually since process_cube doesn't accept it // Save the original stepParentName to restore later - const originalStepParent = (node as any).stepParentName; + const originalStepParent = node.stepParentName; if (stepParentToUse && (!originalStepParent || originalStepParent.trim() === '')) { - (node as any).stepParentName = stepParentToUse; + node.stepParentName = stepParentToUse; } process_cube(parent, node, accu, offset); // Restore original stepParentName if (stepParentToUse) { - (node as any).stepParentName = originalStepParent; + node.stepParentName = originalStepParent; } } // Locator nodes are handled as attachment points on their parent elements, so skip them here @@ -459,7 +459,7 @@ function traverseAttachment( * @param clothingSlot The clothing slot to look for * @returns The topmost group that should contain this attachment, or null */ -function findTopmostAttachmentRoot(element: any, clothingSlot: string): Group | null { +function findTopmostAttachmentRoot(element: Group | Cube, clothingSlot: string): Group | null { // Walk up the tree to find the highest group with the matching clothingSlot // Only return groups that have the matching clothingSlot (don't include groups with no slot) let current = element.parent; @@ -467,7 +467,7 @@ function findTopmostAttachmentRoot(element: any, clothingSlot: string): Group | while (current && current instanceof Group) { // Check if this group's clothingSlot matches - const groupSlot = (current as any).clothingSlot; + const groupSlot = current.clothingSlot; const hasMatchingSlot = groupSlot && groupSlot.trim() !== '' && groupSlot === clothingSlot; if (hasMatchingSlot) { @@ -487,9 +487,9 @@ function findTopmostAttachmentRoot(element: any, clothingSlot: string): Group | /** * Exports the selected attachments to the Vintage Story format. - * @param {Array} selection - An array of selected attachment groups. + * @param selection - The selected attachment groups and cubes. */ -export function exportAttachmentsVS(selection: Group[]) { +export function exportAttachmentsVS(selection: (Group | Cube)[]) { if (!Project) { Blockbench.showQuickMessage("Please open a project before exporting attachments.", QUICK_MESSAGE_DURATION); return; @@ -515,10 +515,10 @@ export function exportAttachmentsVS(selection: Group[]) { // First pass: find the actual root groups for all elements // This handles cases where clothingSlot is on cubes but not parent groups const rootGroupsSet = new Set(); - const processedElements = new Set(); + const processedElements = new Set(); for (const element of selection) { - const myClothingSlot = (element as any).clothingSlot; + const myClothingSlot = element.clothingSlot; if (!myClothingSlot || myClothingSlot.trim() === '') { continue; // Not an attachment } @@ -547,7 +547,7 @@ export function exportAttachmentsVS(selection: Group[]) { for (const element of selection) { if (processedElements.has(element)) continue; - const myClothingSlot = (element as any).clothingSlot; + const myClothingSlot = element.clothingSlot; if (!myClothingSlot || myClothingSlot.trim() === '') continue; if (element instanceof Cube) { @@ -559,7 +559,7 @@ export function exportAttachmentsVS(selection: Group[]) { // Convert to array and filter out groups with no clothingSlot // Only export groups that have a clothingSlot (don't export base model groups like "Root") const rootAttachments = Array.from(rootGroupsSet).filter(group => { - const groupSlot = (group as any).clothingSlot; + const groupSlot = group.clothingSlot; const hasSlot = groupSlot && groupSlot.trim() !== ''; if (!hasSlot) { logDebug(`[VS Attachment Export] Filtering out group "${group.name}" - no clothingSlot`); @@ -579,13 +579,13 @@ export function exportAttachmentsVS(selection: Group[]) { // If no stepParentName was determined, find the first parent with a different clothingSlot // This parent should be the base model group that the attachment attaches to if (!stepParentName || stepParentName.trim() === '') { - const groupClothingSlot = (group as any).clothingSlot; + const groupClothingSlot = group.clothingSlot; let currentParent = group.parent; // Walk up the parent chain to find the first parent with a different (or no) clothingSlot // This should be the base model group that the attachment attaches to while (!stepParentName && currentParent && currentParent instanceof Group) { - const parentClothingSlot = (currentParent as any).clothingSlot; + const parentClothingSlot = currentParent.clothingSlot; const parentHasDifferentSlot = !parentClothingSlot || parentClothingSlot.trim() === '' || parentClothingSlot !== groupClothingSlot; if (parentHasDifferentSlot) { @@ -607,7 +607,7 @@ export function exportAttachmentsVS(selection: Group[]) { if (stepParentName && stepParentName.trim() !== '') { // Only modify if we're setting a new value if (!originalStepParent || originalStepParent.trim() === '') { - (group as any).stepParentName = stepParentName; + group.stepParentName = stepParentName; modifiedGroups.push(group); logDebug(`[VS Attachment Export] Set stepParentName="${stepParentName}" on "${group.name}"`); } @@ -644,7 +644,7 @@ export function exportAttachmentsVS(selection: Group[]) { // Use filtered attachment traversal logic to process the attachment // We create a temporary array to hold the elements for this single attachment const attachmentElements: VS_Element[] = []; - const groupClothingSlot = (group as any).clothingSlot || ''; + const groupClothingSlot = group.clothingSlot || ''; traverseAttachment(null, [group], attachmentElements, offset, groupClothingSlot, stepParentName, rootTransform); // Add the processed elements to the main elements array @@ -659,7 +659,7 @@ export function exportAttachmentsVS(selection: Group[]) { // Group orphan cubes by clothingSlot to determine stepParentName for each group const cubesBySlot = new Map(); orphanCubes.forEach(cube => { - const slot = (cube as any).clothingSlot || ''; + const slot = cube.clothingSlot || ''; if (!cubesBySlot.has(slot)) { cubesBySlot.set(slot, []); } @@ -672,14 +672,14 @@ export function exportAttachmentsVS(selection: Group[]) { orphanCubes.forEach(cube => { // Determine stepParentName for this specific cube let stepParentName: string | null = cube.stepParentName?.trim() || null; - const cubeSlot = (cube as any).clothingSlot || ''; + const cubeSlot = cube.clothingSlot || ''; let currentParent = cube.parent; // Walk up the parent chain to find the first parent with a different (or no) clothingSlot // This should be the immediate base model parent for this cube. // An authored stepParentName always wins. while (!stepParentName && currentParent && currentParent instanceof Group) { - const parentClothingSlot = (currentParent as any).clothingSlot; + const parentClothingSlot = currentParent.clothingSlot; const parentHasDifferentSlot = !parentClothingSlot || parentClothingSlot.trim() === '' || parentClothingSlot !== cubeSlot; if (parentHasDifferentSlot) { @@ -739,7 +739,7 @@ export function exportAttachmentsVS(selection: Group[]) { } finally { // Clean up the temporarily added property to avoid side effects modifiedGroups.forEach(group => { - delete (group as any).stepParentName; + delete group.stepParentName; }); } diff --git a/src/export.ts b/src/export.ts index 7cda02e..2463b47 100644 --- a/src/export.ts +++ b/src/export.ts @@ -2,7 +2,7 @@ import {export_model} from "./export_model"; import { compile_animation_library } from "./export_animation"; import { VS_EditorSettings, VS_Shape } from "./vs_shape_def"; import { VS_PROJECT_PROPS } from "./property"; -import { export_textures, resolveTextureLocation } from "./export_textures"; +import { export_textures, resolveTextureLocation, warnTextureReadErrors } from "./export_textures"; import { path_to_reference } from "./animation_library_paths"; const fs = requireNativeModule('fs'); @@ -114,17 +114,19 @@ export function ex(options): VS_Shape { // Populate Textures const textures: Record = {}; + const unresolvedReadErrors = new Map(); for (const texture of Texture.all) { // Try using existing textureLocation first, then resolve from project path or texture source let location = texture.textureLocation || ""; + const readErrors = new Map(); if (!location || location === "") { // Try project save path first - location = resolveTextureLocation(Project.save_path, texture.name); + location = resolveTextureLocation(Project.save_path, texture.name, readErrors); // If no save path, try texture source path if ((!location || location === "") && texture.source) { - location = resolveTextureLocation(texture.source, texture.name); + location = resolveTextureLocation(texture.source, texture.name, readErrors); } } @@ -135,6 +137,7 @@ export function ex(options): VS_Shape { } textures[texture.name] = location || ""; + if (!location) readErrors.forEach((error, dir) => unresolvedReadErrors.set(dir, error)); } // Export model elements @@ -143,7 +146,7 @@ export function ex(options): VS_Shape { // Partition animations by file: path-less ones are inline (embedded in the shape), // while animations belonging to a library file are referenced via animationLibraries // (the library file itself is saved separately through the animation codec / panel). - const allAnimations = (Animation as unknown as typeof _Animation).all; + const allAnimations = Blockbench.Animation.all; const inlineAnimations = compile_animation_library(allAnimations.filter(a => !a.path)).animations; // Library refs are emitted even for backdrop projects. Backdrops suppress inline animation @@ -153,7 +156,6 @@ export function ex(options): VS_Shape { const seenRefs = new Set(); for (const animation of allAnimations) { if (!animation.path) continue; - // @ts-expect-error: custom property for round-trip fidelity const ref: string | null = animation.vs_library_ref || path_to_reference(animation.path); if (ref && !seenRefs.has(ref)) { seenRefs.add(ref); libraryRefs.push(ref); } } @@ -180,5 +182,6 @@ export function ex(options): VS_Shape { data.animationLibraries = libraryRefs; } + warnTextureReadErrors(unresolvedReadErrors); return data; } diff --git a/src/export_animation.ts b/src/export_animation.ts index 60fb3ca..a52e582 100644 --- a/src/export_animation.ts +++ b/src/export_animation.ts @@ -1,4 +1,4 @@ -import { VS_Animation, VS_AnimationKey, VS_AnimationLibrary, VS_AnimationParticle, VS_AnimationSound, VS_Keyframe, VS_KeyFrameInterpolation } from "./vs_shape_def"; +import { VS_Animation, VS_AnimationKey, VS_AnimationNumericField, VS_AnimationInterpolationField, VS_AnimationLibrary, VS_AnimationParticle, VS_Keyframe, VS_KeyFrameInterpolation } from "./vs_shape_def"; import { sound_from_data_point } from "./animation_sounds"; import * as util from "./util"; import { is_backdrop_project } from "./util/misc"; @@ -32,16 +32,16 @@ function applyBezierHandle( segmentFrames: number, defaultWidthFrames: number, fps: number, - tangentFields: [keyof VS_AnimationKey, keyof VS_AnimationKey, keyof VS_AnimationKey], - widthFields: [keyof VS_AnimationKey, keyof VS_AnimationKey, keyof VS_AnimationKey], + tangentFields: [VS_AnimationNumericField, VS_AnimationNumericField, VS_AnimationNumericField], + widthFields: [VS_AnimationNumericField, VS_AnimationNumericField, VS_AnimationNumericField], ) { if (!valueDeltas || !timeDeltas || segmentFrames <= 0) return; for (let i = 0; i < 3; i++) { const widthFrames = Number(timeDeltas[i]) * fps; if (widthFrames === 0) continue; // degenerate (zero-width) handle; nothing meaningful to store const tangent = Number(valueDeltas[i]) * segmentFrames / widthFrames; - if (tangent !== 0) (elem as any)[tangentFields[i]] = tangent; - if (Math.abs(widthFrames - defaultWidthFrames) > 1e-9) (elem as any)[widthFields[i]] = widthFrames; + if (tangent !== 0) elem[tangentFields[i]] = tangent; + if (Math.abs(widthFrames - defaultWidthFrames) > 1e-9) elem[widthFields[i]] = widthFrames; } } @@ -94,25 +94,25 @@ function applyCatmullRomToKey( fps: number, ) { const fields = CHANNEL_FIELDS[channel]; - (elem as any)[fields.interp] = 'Bezier'; + elem[fields.interp] = 'Bezier'; const tx = computeCatmullRomTangents(channelKfs, idx, 'x', fps); const ty = computeCatmullRomTangents(channelKfs, idx, 'y', fps); const tz = computeCatmullRomTangents(channelKfs, idx, 'z', fps); - if (tx.out !== 0) (elem as any)[fields.tangentOutX] = tx.out; - if (ty.out !== 0) (elem as any)[fields.tangentOutY] = ty.out; - if (tz.out !== 0) (elem as any)[fields.tangentOutZ] = tz.out; - if (tx.in !== 0) (elem as any)[fields.tangentInX] = tx.in; - if (ty.in !== 0) (elem as any)[fields.tangentInY] = ty.in; - if (tz.in !== 0) (elem as any)[fields.tangentInZ] = tz.in; + if (tx.out !== 0) elem[fields.tangentOutX] = tx.out; + if (ty.out !== 0) elem[fields.tangentOutY] = ty.out; + if (tz.out !== 0) elem[fields.tangentOutZ] = tz.out; + if (tx.in !== 0) elem[fields.tangentInX] = tx.in; + if (ty.in !== 0) elem[fields.tangentInY] = ty.in; + if (tz.in !== 0) elem[fields.tangentInZ] = tz.in; } interface ChannelFieldNames { - interp: keyof VS_AnimationKey; - tangentInX: keyof VS_AnimationKey; tangentInY: keyof VS_AnimationKey; tangentInZ: keyof VS_AnimationKey; - tangentOutX: keyof VS_AnimationKey; tangentOutY: keyof VS_AnimationKey; tangentOutZ: keyof VS_AnimationKey; - tangentInWidthX: keyof VS_AnimationKey; tangentInWidthY: keyof VS_AnimationKey; tangentInWidthZ: keyof VS_AnimationKey; - tangentOutWidthX: keyof VS_AnimationKey; tangentOutWidthY: keyof VS_AnimationKey; tangentOutWidthZ: keyof VS_AnimationKey; + interp: VS_AnimationInterpolationField; + tangentInX: VS_AnimationNumericField; tangentInY: VS_AnimationNumericField; tangentInZ: VS_AnimationNumericField; + tangentOutX: VS_AnimationNumericField; tangentOutY: VS_AnimationNumericField; tangentOutZ: VS_AnimationNumericField; + tangentInWidthX: VS_AnimationNumericField; tangentInWidthY: VS_AnimationNumericField; tangentInWidthZ: VS_AnimationNumericField; + tangentOutWidthX: VS_AnimationNumericField; tangentOutWidthY: VS_AnimationNumericField; tangentOutWidthZ: VS_AnimationNumericField; } const CHANNEL_FIELDS: Record = { @@ -149,7 +149,7 @@ function applyInterpolationToKey( fps: number, ) { const fields = CHANNEL_FIELDS[channel]; - (elem as any)[fields.interp] = interp; + elem[fields.interp] = interp; if (interp !== 'Bezier') return; @@ -313,31 +313,28 @@ export function compile_animation(animation: _Animation, catmullConverted?: stri } keyframe.elements = wrapped_elements; if (keyframe.particles) { - keyframe.particles = keyframe.particles.map(p => new oneLiner(p) as unknown as VS_AnimationParticle); + keyframe.particles = keyframe.particles.map(p => new oneLiner(p)); } if (keyframe.sounds) { - keyframe.sounds = keyframe.sounds.map(s => new oneLiner(s) as unknown as VS_AnimationSound); + keyframe.sounds = keyframe.sounds.map(s => new oneLiner(s)); } } // Use preserved VS values if available, otherwise compute defaults - // @ts-expect-error: custom property from import const storedCode = animation.vs_code; - // @ts-expect-error: custom property from import const storedOnActivityStopped = animation.vs_onActivityStopped; - // @ts-expect-error: custom property from import const storedOnAnimationEnd = animation.vs_onAnimationEnd; // Map the Blockbench loop mode to onAnimationEnd. A stored value from import is only kept // while it still agrees with the current loop mode (it can be a superset, e.g. EaseOut maps // to 'once'); once the user changes the loop mode in Blockbench, the loop mode wins. - const loopToEnd: Record = { + const loopToEnd: Record<_Animation['loop'], VS_Animation['onAnimationEnd'][]> = { loop: ["Repeat"], hold: ["Hold"], once: ["Stop", "EaseOut"], }; const validEnds = loopToEnd[animation.loop] ?? loopToEnd.once; - const onAnimationEnd = validEnds.includes(storedOnAnimationEnd) ? storedOnAnimationEnd : validEnds[0]; + const onAnimationEnd = storedOnAnimationEnd && validEnds.includes(storedOnAnimationEnd) ? storedOnAnimationEnd : validEnds[0]; const vsAnimation : VS_Animation = { name: animation.name, diff --git a/src/export_model/group.ts b/src/export_model/group.ts index c32ad5e..dbadb14 100644 --- a/src/export_model/group.ts +++ b/src/export_model/group.ts @@ -21,7 +21,7 @@ function get_parent_pos(parent: Group | null, parent_from_override?: [number, nu } if (!parent) return [0, 0, 0]; // Use stored VS from if available (set during import), otherwise fall back to origin - return (parent as any).vs_group_from ?? parent.origin; + return parent.vs_group_from ?? parent.origin; } /** @@ -29,7 +29,7 @@ function get_parent_pos(parent: Group | null, parent_from_override?: [number, nu * Uses vs_group_from if set during import, otherwise falls back to origin. */ function get_group_vs_from(node: Group): [number, number, number] { - return (node as any).vs_group_from ?? [...node.origin] as [number, number, number]; + return node.vs_group_from ?? [...node.origin]; } /** @@ -37,7 +37,7 @@ function get_group_vs_from(node: Group): [number, number, number] { * Uses vs_group_to if set during import, otherwise falls back to origin. */ function get_group_vs_to(node: Group): [number, number, number] { - return (node as any).vs_group_to ?? [...node.origin] as [number, number, number]; + return node.vs_group_to ?? [...node.origin]; } /** @@ -81,11 +81,11 @@ export function process_group( from: from, to: to, rotationOrigin: rotationOrigin, - ...((node as any).vs_uv ? { uv: (node as any).vs_uv } : undefined), + ...(node.vs_uv ? { uv: node.vs_uv } : undefined), ...(converted_rotation[0] !== 0 && { rotationX: converted_rotation[0] }), ...(converted_rotation[1] !== 0 && { rotationY: converted_rotation[1] }), ...(converted_rotation[2] !== 0 && { rotationZ: converted_rotation[2] }), - ...((node as any).vs_zero_size_faces ? { faces: (node as any).vs_zero_size_faces } : undefined), + ...(node.vs_zero_size_faces ? { faces: node.vs_zero_size_faces } : undefined), children: [] }; @@ -193,7 +193,7 @@ export function process_collapsed_group( from: from, to: to, rotationOrigin: rotationOrigin, - ...((geoChild as any).vs_uv ? { uv: (geoChild as any).vs_uv } : ((geoChild.uv_offset[0] !== 0 || geoChild.uv_offset[1] !== 0) && { uv: geoChild.uv_offset })), + ...(geoChild.vs_uv ? { uv: geoChild.vs_uv } : ((geoChild.uv_offset[0] !== 0 || geoChild.uv_offset[1] !== 0) && { uv: geoChild.uv_offset })), ...(converted_rotation[0] !== 0 && { rotationX: converted_rotation[0] }), ...(converted_rotation[1] !== 0 && { rotationY: converted_rotation[1] }), ...(converted_rotation[2] !== 0 && { rotationZ: converted_rotation[2] }), diff --git a/src/export_model/locator.ts b/src/export_model/locator.ts index 8479c72..33a3c74 100644 --- a/src/export_model/locator.ts +++ b/src/export_model/locator.ts @@ -19,23 +19,23 @@ export function process_locators( // Use vs_group_from for consistency with group/cube export parent reference const parent_pos: [number, number, number] = parent - ? ((parent as any).vs_group_from ?? parent.origin) + ? (parent.vs_group_from ?? parent.origin) : [0, 0, 0]; for (const locator of locators) { if (!locator.export) continue; // Locator position is stored in .from (not .position) - const locator_pos: [number, number, number] = (locator.from ?? (locator as any).position ?? [0, 0, 0]) as [number, number, number]; + const locator_pos: [number, number, number] = locator.from ?? locator.position ?? [0, 0, 0]; // Calculate position relative to parent const relative_pos = util.vector_sub(locator_pos, parent_pos); // Read rotation from registered properties (persisted in .bbmodel) const rotation: [number, number, number] = [ - (locator as any).rotationX || 0, - (locator as any).rotationY || 0, - (locator as any).rotationZ || 0, + locator.rotationX || 0, + locator.rotationY || 0, + locator.rotationZ || 0, ]; const attachmentPoint: VS_AttachmentPoint = { diff --git a/src/export_textures.ts b/src/export_textures.ts index b0675c6..3da74ad 100644 --- a/src/export_textures.ts +++ b/src/export_textures.ts @@ -1,5 +1,6 @@ import { collect_tree_data, flatten } from "./util/element_tree"; import { VS_Element } from "./vs_shape_def"; +import type { Dirent } from "fs"; const fs = requireNativeModule('fs'); const path = requireNativeModule('path'); @@ -13,6 +14,7 @@ export function export_textures(elements: VS_Element[]): Record // Populate Textures const used_texture_refs = get_used_texture_names(elements); const textures: Record = {}; + const unresolvedReadErrors = new Map(); for (const texture of Texture.all) { // Skip unused textures if(!used_texture_refs.has(texture.name)) { @@ -20,19 +22,28 @@ export function export_textures(elements: VS_Element[]): Record } // Try using existing textureLocation first, then resolve from project path or texture source let location = texture.textureLocation; + const readErrors = new Map(); if (!location) { // Try project save path first - location = resolveTextureLocation(Project!.save_path, texture.name); + location = resolveTextureLocation(Project!.save_path, texture.name, readErrors); // If no save path, try texture source path if (!location && texture.source) { - location = resolveTextureLocation(texture.source, texture.name); + location = resolveTextureLocation(texture.source, texture.name, readErrors); } } textures[texture.name] = location; + if (!location) readErrors.forEach((error, dir) => unresolvedReadErrors.set(dir, error)); } + warnTextureReadErrors(unresolvedReadErrors); return textures; } +export function warnTextureReadErrors(readErrors: Map): void { + if (readErrors.size === 0) return; + console.error('[VS Export] Unresolved texture search; unreadable folders:', readErrors); + Blockbench.showQuickMessage('Some texture paths are unresolved and folders could not be searched. Check the exported texture references.', 5000); +} + /** * Get all texture names used in the element tree. * @param elements The element tree root to search for texture references. @@ -67,7 +78,7 @@ function texture_name_extractor(element: VS_Element): Set { * @param textureName - The name of the texture (e.g., "fern.png") * @returns The VS-style texture path (e.g., "blocks/fern") or empty string if not found */ -export function resolveTextureLocation(projectPath: string | undefined, textureName: string): string { +export function resolveTextureLocation(projectPath: string | undefined, textureName: string, readErrors = new Map()): string { if (!projectPath || !textureName) { return ""; } @@ -96,13 +107,8 @@ export function resolveTextureLocation(projectPath: string | undefined, textureN return ""; } - // Check if textures folder exists - if (!fs.existsSync(texturesPath)) { - return ""; - } - // Search recursively for the texture file - const textureFile = findTextureFile(texturesPath, textureName); + const textureFile = findTextureFile(texturesPath, textureName, readErrors); if (!textureFile) return ""; // Build VS-style relative path (relative to textures folder, without extension) @@ -115,23 +121,26 @@ export function resolveTextureLocation(projectPath: string | undefined, textureN /** * Recursively searches for a texture file in a directory. */ -function findTextureFile(dir: string, textureName: string): string | null { +function findTextureFile(dir: string, textureName: string, readErrors: Map): string | null { + let entries: Dirent[]; try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (error: unknown) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { + readErrors.set(dir, error); + } + return null; + } - if (entry.isDirectory()) { - const found = findTextureFile(fullPath, textureName); - if (found) return found; - } else if (entry.isFile() && entry.name === textureName) { - return fullPath; - } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const found = findTextureFile(fullPath, textureName, readErrors); + if (found) return found; + } else if (entry.isFile() && entry.name === textureName) { + return fullPath; } - } catch (e) { - // Directory read error, skip } return null; -} \ No newline at end of file +} diff --git a/src/import.ts b/src/import.ts index 1dbf6c3..8964260 100644 --- a/src/import.ts +++ b/src/import.ts @@ -92,8 +92,7 @@ function load_animation_libraries(refs: string[], modelPath: string) { } const created = vsAnimationCodec.loadFile({ path: filePath, content: fileContent }); for (const anim of created) { - // @ts-expect-error: custom property for round-trip fidelity anim.vs_library_ref = ref; } } -} \ No newline at end of file +} diff --git a/src/import_animation.ts b/src/import_animation.ts index 1add433..d47a5ff 100644 --- a/src/import_animation.ts +++ b/src/import_animation.ts @@ -1,5 +1,5 @@ import * as util from "./util"; -import { VS_Animation, VS_AnimationKey, VS_KeyFrameInterpolation } from "./vs_shape_def"; +import { VS_Animation, VS_AnimationKey, VS_AnimationNumericField, VS_AnimationInterpolationField, VS_KeyFrameInterpolation } from "./vs_shape_def"; import { particle_data_point } from "./animation_particles"; import { sound_data_point } from "./animation_sounds"; @@ -15,14 +15,13 @@ export function create_animation(vsAnimation: VS_Animation, path?: string, saved const animationLength = vsAnimation.quantityframes / FPS; const isLooping = vsAnimation.onAnimationEnd === 'Repeat'; - const animation = ((new Animation({ - //@ts-expect-error: Blockbench overwrites libdom's Animation type with its own Animation Class, but TypeScript doesn't include a way to overwrite UMD global types. + const animation = new Blockbench.Animation({ name: vsAnimation.name, path: path || '', loop: isLooping ? 'loop' : (vsAnimation.onAnimationEnd === 'Hold' ? 'hold' : 'once'), length: animationLength, snapping: FPS - }) as unknown) as _Animation).add(); + }).add(); // Associate with a library file when loaded from one (drives panel grouping + saving). if (path) animation.path = path; @@ -32,11 +31,8 @@ export function create_animation(vsAnimation: VS_Animation, path?: string, saved } // Preserve VS-specific animation properties for round-trip fidelity - // @ts-expect-error: custom property for round-trip animation.vs_code = vsAnimation.code; - // @ts-expect-error: custom property for round-trip animation.vs_onActivityStopped = vsAnimation.onActivityStopped; - // @ts-expect-error: custom property for round-trip animation.vs_onAnimationEnd = vsAnimation.onAnimationEnd; // Per-bone, per-channel sorted frame lists so bezier handle widths can be placed against the @@ -118,12 +114,12 @@ function getEffectAnimator(animation: _Animation): any { type BBImportChannel = 'rotation' | 'position' | 'scale'; interface ImportChannelConfig { - interp: keyof VS_AnimationKey; - value: [keyof VS_AnimationKey, keyof VS_AnimationKey, keyof VS_AnimationKey]; - tangentIn: [keyof VS_AnimationKey, keyof VS_AnimationKey, keyof VS_AnimationKey]; - tangentOut: [keyof VS_AnimationKey, keyof VS_AnimationKey, keyof VS_AnimationKey]; - widthIn: [keyof VS_AnimationKey, keyof VS_AnimationKey, keyof VS_AnimationKey]; - widthOut: [keyof VS_AnimationKey, keyof VS_AnimationKey, keyof VS_AnimationKey]; + interp: VS_AnimationInterpolationField; + value: [VS_AnimationNumericField, VS_AnimationNumericField, VS_AnimationNumericField]; + tangentIn: [VS_AnimationNumericField, VS_AnimationNumericField, VS_AnimationNumericField]; + tangentOut: [VS_AnimationNumericField, VS_AnimationNumericField, VS_AnimationNumericField]; + widthIn: [VS_AnimationNumericField, VS_AnimationNumericField, VS_AnimationNumericField]; + widthOut: [VS_AnimationNumericField, VS_AnimationNumericField, VS_AnimationNumericField]; default: number; } @@ -169,7 +165,7 @@ function buildChannelFrames(vsAnimation: VS_Animation): ChannelFrameMap { const transform = kf.elements[boneName]; const entry = map[boneName] || (map[boneName] = { rotation: [], position: [], scale: [] }); (Object.keys(IMPORT_CHANNELS) as BBImportChannel[]).forEach(channel => { - if (IMPORT_CHANNELS[channel].value.some(k => (transform as any)[k] != null)) { + if (IMPORT_CHANNELS[channel].value.some(k => transform[k] != null)) { entry[channel].push(kf.frame); } }); @@ -192,15 +188,15 @@ function buildChannelKeyframeOptions( fps: number, ): KeyframeOptions | null { const cfg = IMPORT_CHANNELS[channel]; - if (!cfg.value.some(k => (transform as any)[k] != null)) return null; + if (!cfg.value.some(k => transform[k] != null)) return null; const value = { - x: (transform as any)[cfg.value[0]] ?? cfg.default, - y: (transform as any)[cfg.value[1]] ?? cfg.default, - z: (transform as any)[cfg.value[2]] ?? cfg.default, + x: transform[cfg.value[0]] ?? cfg.default, + y: transform[cfg.value[1]] ?? cfg.default, + z: transform[cfg.value[2]] ?? cfg.default, }; - const bbInterp = mapInterpolation((transform as any)[cfg.interp]); + const bbInterp = mapInterpolation(transform[cfg.interp]); const opts: KeyframeOptions = { interpolation: bbInterp, time: frame / fps, @@ -238,8 +234,8 @@ function segmentDurations(frames: number[], frame: number): { outDur: number, in // null when there's no adjacent segment (a boundary handle), leaving Blockbench's own default there. function reconstructHandle( transform: VS_AnimationKey, - tangentFields: [keyof VS_AnimationKey, keyof VS_AnimationKey, keyof VS_AnimationKey], - widthFields: [keyof VS_AnimationKey, keyof VS_AnimationKey, keyof VS_AnimationKey], + tangentFields: [VS_AnimationNumericField, VS_AnimationNumericField, VS_AnimationNumericField], + widthFields: [VS_AnimationNumericField, VS_AnimationNumericField, VS_AnimationNumericField], segmentFrames: number, sign: 1 | -1, fps: number, @@ -249,9 +245,9 @@ function reconstructHandle( const value: [number, number, number] = [0, 0, 0]; const time: [number, number, number] = [0, 0, 0]; for (let i = 0; i < 3; i++) { - const wRaw = (transform as any)[widthFields[i]]; + const wRaw = transform[widthFields[i]]; const widthFrames = wRaw != null ? Number(wRaw) : defaultWidth; - const tRaw = (transform as any)[tangentFields[i]]; + const tRaw = transform[tangentFields[i]]; const tangent = tRaw != null ? Number(tRaw) : 0; time[i] = widthFrames / fps; value[i] = widthFrames !== 0 ? tangent * widthFrames / segmentFrames : 0; @@ -270,7 +266,7 @@ function mapInterpolation(interp: VS_KeyFrameInterpolation | undefined): 'linear * Animations created in this call go onto the undo stack so the user can revert. */ export function clear_animations(): number { - const all = (Animation as unknown as typeof _Animation).all.slice(); + const all = Blockbench.Animation.all.slice(); all.forEach(a => a.remove(true)); return all.length; } diff --git a/src/import_model/locator.ts b/src/import_model/locator.ts index 954dd41..597d207 100644 --- a/src/import_model/locator.ts +++ b/src/import_model/locator.ts @@ -24,7 +24,7 @@ export function process_attachment_points( // which is stored as vs_group_from in absolute BB space (not rotationOrigin/origin) const absolute_pos = util.vector_add( [posX, posY, posZ], - (parent as any).vs_group_from ?? parent.origin + parent.vs_group_from ?? parent.origin ); const locator = new Locator({ @@ -33,9 +33,9 @@ export function process_attachment_points( }); // Set rotation via registered properties (persists in .bbmodel) - (locator as any).rotationX = parseFloat(ap.rotationX) || 0; - (locator as any).rotationY = parseFloat(ap.rotationY) || 0; - (locator as any).rotationZ = parseFloat(ap.rotationZ) || 0; + locator.rotationX = parseFloat(ap.rotationX) || 0; + locator.rotationY = parseFloat(ap.rotationY) || 0; + locator.rotationZ = parseFloat(ap.rotationZ) || 0; if (asBackdrop) { locator.locked = true; diff --git a/src/panels/vs_face_panel.ts b/src/panels/vs_face_panel.ts index ffd25cf..5663987 100644 --- a/src/panels/vs_face_panel.ts +++ b/src/panels/vs_face_panel.ts @@ -96,19 +96,20 @@ function computeWindData(cube: Cube, direction: string): [number, number, number } // Vertex highlight dot — shared THREE.js objects -let vertexDot: any = null; +let vertexDot: THREE.Mesh | null = null; function createVertexDot() { - if (vertexDot) return; + if (vertexDot) return vertexDot; const geo = new THREE.SphereGeometry(0.5, 8, 8); const mat = new THREE.MeshBasicMaterial({ color: 0xff4444, depthTest: false, transparent: true, opacity: 0.9 }); vertexDot = new THREE.Mesh(geo, mat); vertexDot.renderOrder = 999; vertexDot.visible = false; + return vertexDot; } function showVertexDot(cube: Cube, direction: string, vertexIndex: number) { - createVertexDot(); + const dot = createVertexDot(); const vertices = getFaceVertices(cube, direction); const pos = vertices[vertexIndex]; if (!pos) return; @@ -122,15 +123,15 @@ function showVertexDot(cube: Cube, direction: string, vertexIndex: number) { if (cube.mesh) { cube.mesh.updateMatrixWorld(); const worldPos = cube.mesh.localToWorld(localPos); - vertexDot.position.copy(worldPos); + dot.position.copy(worldPos); } else { - vertexDot.position.set(pos[0], pos[1], pos[2]); + dot.position.set(pos[0], pos[1], pos[2]); } - vertexDot.visible = true; + dot.visible = true; - if (!vertexDot.parent) { - Canvas.scene.add(vertexDot); + if (!dot.parent) { + Canvas.scene.add(dot); } } @@ -149,6 +150,24 @@ function removeVertexDot() { } } +function createFacePanelData() { + return { + selectedCube: null as Cube | null, + selectedFaceName: 'north' as CubeFaceDirection, + applyToAll: false, + glow: 0, + reflectiveMode: '0', + windMode: [-1, -1, -1, -1] as number[], + windData: [0, 0, 0, 0] as number[], + windModeOptions: WIND_MODE_OPTIONS, + reflectiveModeOptions: REFLECTIVE_MODE_OPTIONS, + faceDirections: FACE_DIRECTIONS, + faceLabels: FACE_LABELS, + faceColors: FACE_COLORS, + _listeners: [] as Array<() => void>, + }; +} + const vueComponent = { template: `
@@ -195,58 +214,39 @@ const vueComponent = {
`, - data() { - return { - selectedCube: null as Cube | null, - selectedFaceName: 'north' as string, - applyToAll: false, - glow: 0, - reflectiveMode: '0', - windMode: [-1, -1, -1, -1] as number[], - windData: [0, 0, 0, 0] as number[], - windModeOptions: WIND_MODE_OPTIONS, - reflectiveModeOptions: REFLECTIVE_MODE_OPTIONS, - faceDirections: FACE_DIRECTIONS, - faceLabels: FACE_LABELS, - faceColors: FACE_COLORS, - _listeners: [] as Array<() => void>, - }; - }, + data: createFacePanelData, computed: { - windDataDisplay(): string { - return (this as any).windData.join(', '); + windDataDisplay(this: FacePanelContext): string { + return this.windData.join(', '); } }, methods: { - getSelectedFace(): any | null { - const self = this as any; - if (!self.selectedCube || !self.selectedFaceName) return null; - return self.selectedCube.faces[self.selectedFaceName]; + getSelectedFace(this: FacePanelContext): CubeFace | null { + if (!this.selectedCube || !this.selectedFaceName) return null; + return this.selectedCube.faces[this.selectedFaceName]; }, - getTargetFaces(): Array<{ face: any, direction: string }> { - const self = this as any; - if (!self.selectedCube) return []; - if (self.applyToAll) { + getTargetFaces(this: FacePanelContext): Array<{ face: CubeFace, direction: CubeFaceDirection }> { + const cube = this.selectedCube; + if (!cube) return []; + if (this.applyToAll) { return FACE_DIRECTIONS - .filter(d => self.selectedCube.faces[d]) - .map(d => ({ face: self.selectedCube.faces[d], direction: d })); + .filter(d => cube.faces[d]) + .map(d => ({ face: cube.faces[d], direction: d })); } - const face = self.getSelectedFace(); + const face = this.getSelectedFace(); if (!face) return []; - return [{ face, direction: self.selectedFaceName }]; + return [{ face, direction: this.selectedFaceName }]; }, - selectFace(direction: string) { - const self = this as any; - self.selectedFaceName = direction; - self.loadFromFace(); + selectFace(this: FacePanelContext, direction: CubeFaceDirection) { + this.selectedFaceName = direction; + this.loadFromFace(); }, - highlightVertex(index: number) { - const self = this as any; - if (self.selectedCube && self.selectedFaceName) { - showVertexDot(self.selectedCube, self.selectedFaceName, index); + highlightVertex(this: FacePanelContext, index: number) { + if (this.selectedCube && this.selectedFaceName) { + showVertexDot(this.selectedCube, this.selectedFaceName, index); } }, @@ -254,42 +254,41 @@ const vueComponent = { hideVertexDot(); }, - setGlow(value: string) { + setGlow(this: FacePanelContext, value: string) { const num = Math.max(0, Math.min(255, parseInt(value) || 0)); - (this as any).glow = num; - for (const { face } of (this as any).getTargetFaces()) { + this.glow = num; + for (const { face } of this.getTargetFaces()) { face.glow = num; } }, - setReflectiveMode(value: string) { + setReflectiveMode(this: FacePanelContext, value: string) { const num = parseInt(value) || 0; - (this as any).reflectiveMode = String(num); - for (const { face } of (this as any).getTargetFaces()) { + this.reflectiveMode = String(num); + for (const { face } of this.getTargetFaces()) { face.reflectiveMode = num; } }, - getWindModeComponent(index: number): string { - return String((this as any).windMode[index] ?? -1); + getWindModeComponent(this: FacePanelContext, index: number): string { + return String(this.windMode[index] ?? -1); }, - setWindModeComponent(index: number, value: string) { - const self = this as any; + setWindModeComponent(this: FacePanelContext, index: number, value: string) { const num = parseInt(value); - self.windMode[index] = num; + this.windMode[index] = num; // Force reactivity - self.windMode = [...self.windMode]; + this.windMode = [...this.windMode]; - for (const { face, direction } of self.getTargetFaces()) { + for (const { face, direction } of this.getTargetFaces()) { if (!face.windMode) { face.windMode = [-1, -1, -1, -1]; } face.windMode[index] = num; // Auto-compute wind data when wind mode changes (matching VSMC behavior) - if (self.selectedCube) { - const autoData = computeWindData(self.selectedCube, direction); + if (this.selectedCube) { + const autoData = computeWindData(this.selectedCube, direction); if (autoData[0] !== 0 || autoData[1] !== 0 || autoData[2] !== 0 || autoData[3] !== 0) { face.windData = autoData; } else { @@ -305,71 +304,74 @@ const vueComponent = { } // Update wind data display - self.updateWindDataDisplay(); + this.updateWindDataDisplay(); }, - updateWindDataDisplay() { - const self = this as any; - const face = self.getSelectedFace(); + updateWindDataDisplay(this: FacePanelContext) { + const face = this.getSelectedFace(); if (face && face.windData) { - self.windData = [...face.windData]; - } else if (self.selectedCube && self.selectedFaceName) { - self.windData = computeWindData(self.selectedCube, self.selectedFaceName); + this.windData = [...face.windData]; + } else if (this.selectedCube && this.selectedFaceName) { + this.windData = computeWindData(this.selectedCube, this.selectedFaceName); } else { - self.windData = [0, 0, 0, 0]; + this.windData = [0, 0, 0, 0]; } }, - loadFromFace() { - const self = this as any; - const face = self.getSelectedFace(); + loadFromFace(this: FacePanelContext) { + const face = this.getSelectedFace(); if (!face) { - self.glow = 0; - self.reflectiveMode = '0'; - self.windMode = [-1, -1, -1, -1]; - self.windData = [0, 0, 0, 0]; + this.glow = 0; + this.reflectiveMode = '0'; + this.windMode = [-1, -1, -1, -1]; + this.windData = [0, 0, 0, 0]; return; } - self.glow = face.glow || 0; - self.reflectiveMode = String(face.reflectiveMode || 0); - self.windMode = face.windMode ? [...face.windMode] : [-1, -1, -1, -1]; - self.updateWindDataDisplay(); + this.glow = face.glow || 0; + this.reflectiveMode = String(face.reflectiveMode || 0); + this.windMode = face.windMode ? [...face.windMode] : [-1, -1, -1, -1]; + this.updateWindDataDisplay(); }, - updateSelection() { - const self = this as any; + updateSelection(this: FacePanelContext) { hideVertexDot(); const selected = Cube.selected; if (!selected || selected.length === 0) { - self.selectedCube = null; - self.selectedFaceName = 'north'; + this.selectedCube = null; + this.selectedFaceName = 'north'; return; } - self.selectedCube = selected[0]; - self.loadFromFace(); + this.selectedCube = selected[0]; + this.loadFromFace(); }, }, - mounted() { - const self = this as any; - self.updateSelection(); + mounted(this: FacePanelContext) { + this.updateSelection(); - const onSelectionUpdate = () => self.updateSelection(); + const onSelectionUpdate = () => this.updateSelection(); Blockbench.on('update_selection' as EventName, onSelectionUpdate); - self._listeners = [ + this._listeners = [ () => Blockbench.removeListener('update_selection' as EventName, onSelectionUpdate), ]; }, - beforeDestroy() { - const self = this as any; + beforeDestroy(this: FacePanelContext) { hideVertexDot(); - for (const unsub of self._listeners) { + for (const unsub of this._listeners) { unsub(); } } }; +interface FacePanelContext extends ReturnType { + getSelectedFace(): CubeFace | null; + getTargetFaces(): Array<{ face: CubeFace, direction: CubeFaceDirection }>; + loadFromFace(): void; + updateWindDataDisplay(): void; + updateSelection(): void; +} + let panel: Panel | null = null; createBlockbenchMod( diff --git a/src/property.ts b/src/property.ts index b6ec8fb..1f5963c 100644 --- a/src/property.ts +++ b/src/property.ts @@ -398,7 +398,7 @@ export const VS_FACE_PROPS = [ */ declare global { interface Face { - glow: boolean; + glow?: number; reflectiveMode?: VS_ReflectiveMode; windMode?: [number, number, number, number]; windData?: [number, number, number, number]; diff --git a/src/types/blockbench-augment.d.ts b/src/types/blockbench-augment.d.ts index 5dbfc28..5b7efe7 100644 --- a/src/types/blockbench-augment.d.ts +++ b/src/types/blockbench-augment.d.ts @@ -6,7 +6,16 @@ * https://github.com/JannisX11/blockbench/blob/master/js/animations/animation_codec.ts */ +import type { VS_Animation } from '../vs_shape_def'; + declare global { + interface _Animation { + vs_code?: string; + vs_onActivityStopped?: VS_Animation['onActivityStopped'] | ''; + vs_onAnimationEnd?: VS_Animation['onAnimationEnd'] | ''; + vs_library_ref?: string; + } + /** The file-like object handed to `AnimationCodec.loadFile`. */ interface AnimationCodecFile { path: string; diff --git a/src/types/property-augment.d.ts b/src/types/property-augment.d.ts index 0d491ff..ce10b6b 100644 --- a/src/types/property-augment.d.ts +++ b/src/types/property-augment.d.ts @@ -40,6 +40,7 @@ declare global { } interface Locator { + position?: ArrayVector3; /** * VS stores a locator's position in `from`, mirroring its elements. Blockbench's own * locators use `position`, which the export falls back to. diff --git a/src/vs_shape_def.ts b/src/vs_shape_def.ts index 2187d24..aa3ff4a 100644 --- a/src/vs_shape_def.ts +++ b/src/vs_shape_def.ts @@ -208,6 +208,12 @@ export interface VS_AnimationKey { stretchTangentOutWidthZ?: number, } +export type VS_AnimationNumericField = { + [K in keyof VS_AnimationKey]-?: VS_AnimationKey[K] extends number | undefined ? K : never; +}[keyof VS_AnimationKey]; + +export type VS_AnimationInterpolationField = 'positionInterp' | 'rotationInterp' | 'scaleInterp'; + /** * In VS shape files, attachment point numeric values are stored as strings. * Other numeric values in VS_Element (like from, to, rotation) are stored as actual numbers. From 0446ad7eccd3ea7d58ac88345ccda925312be0d0 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 4 Oct 2026 14:37:56 +0100 Subject: [PATCH 25/25] another typing error cleanup pass --- src/actions.ts | 3 +- src/attachments.ts | 42 +++++++++++++++++---------- src/attachments/actions.ts | 3 +- src/attachments/dialogs.ts | 5 +--- src/attachments/discovery.ts | 2 -- src/attachments/importer.ts | 50 ++++++++++++++++----------------- src/attachments/panel.ts | 19 ++----------- src/attachments/post_import.ts | 18 ++---------- src/debug_actions.ts | 2 -- src/export.ts | 2 +- src/export_model/group.ts | 2 -- src/import_model.ts | 2 +- src/mods/settingsMod.ts | 43 ++++------------------------ src/property.ts | 2 +- src/types/property-augment.d.ts | 4 +++ src/vs_plugin.ts | 5 +--- 16 files changed, 72 insertions(+), 132 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index edb2dc0..94f2124 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -3,7 +3,6 @@ import * as PACKAGE from "../package.json"; import { is_vs_project } from "./util"; import { im } from "./import"; import { is_backdrop_project } from "./util/misc"; -import { codecVS } from "./codec"; import { ex } from "./export"; const fs = requireNativeModule('fs'); @@ -84,4 +83,4 @@ const import_backdrop_action = createAction(`${PACKAGE.name}:import_backdrop_act }); } }); -MenuBar.addAction(import_backdrop_action, 'file.import'); \ No newline at end of file +MenuBar.addAction(import_backdrop_action, 'file.import'); diff --git a/src/attachments.ts b/src/attachments.ts index aefdbc4..1141a41 100644 --- a/src/attachments.ts +++ b/src/attachments.ts @@ -1,8 +1,6 @@ -import { findAttachments, isAttachment, getAttachments } from "./attachments/discovery"; +import { findAttachments, isAttachment } from "./attachments/discovery"; import { createAttachmentsPanel } from "./attachments/panel"; import { createActions } from "./attachments/actions"; -declare var Deletable: any; -declare var Panel: any; declare var Mode: any; declare var Blockbench: any; @@ -64,27 +62,41 @@ function init() { function cleanup() { console.log("Attachments module cleaning up..."); - - deletables.forEach((item) => { - try { - item.delete(); - } catch(e) { - console.log(e); - } + const failures: { resource: Deletable | string; error: unknown }[] = []; + + deletables = deletables.filter(item => { + try { + item.delete(); + return false; + } catch (error) { + failures.push({ resource: item, error }); + return true; + } }); - deletables = []; - eventListeners.forEach(({ event, listener }) => { - Blockbench.removeListener(event, listener); - console.log(`Removed ${event} listener`); + eventListeners = eventListeners.filter(({ event, listener }) => { + try { + Blockbench.removeListener(event, listener); + console.log(`Removed ${event} listener`); + return false; + } catch (error) { + failures.push({ resource: event, error }); + return true; + } }); - eventListeners = []; if ((window as any).debugFindAttachments) { delete (window as any).debugFindAttachments; } + if (failures.length) { + console.error("Attachments module cleanup incomplete:", failures); + Blockbench.showQuickMessage('Could not fully unload attachments. Restart Blockbench to finish cleanup.', 5000); + return false; + } + console.log("Attachments module cleanup complete"); + return true; } export const attachments = { diff --git a/src/attachments/actions.ts b/src/attachments/actions.ts index ff4e234..603374c 100644 --- a/src/attachments/actions.ts +++ b/src/attachments/actions.ts @@ -50,7 +50,6 @@ function createImportAction(config: ImportActionConfig) { }, function(files) { if (!files || !files.length) return; - const fileName = files[0]?.name || 'attachment'; Undo.initEdit({ outliner: true }); const elementsBefore = new Set([...Group.all, ...Cube.all]); @@ -144,4 +143,4 @@ export function createActions() { importBB: importBBAction, importVS: importVSAction }; -} \ No newline at end of file +} diff --git a/src/attachments/dialogs.ts b/src/attachments/dialogs.ts index 106239a..d4f39f8 100644 --- a/src/attachments/dialogs.ts +++ b/src/attachments/dialogs.ts @@ -1,9 +1,6 @@ import { getActiveSlotNames } from './presets'; -import { suggestSlotFromName, getSlotInfo } from './slot_helpers'; -import { QUICK_MESSAGE_DURATION } from './constants'; - -const DEBUG = false; +import { suggestSlotFromName } from './slot_helpers'; // Store last used slot per file pattern const slotMemory = new Map(); diff --git a/src/attachments/discovery.ts b/src/attachments/discovery.ts index dcce55b..4cc4c5b 100644 --- a/src/attachments/discovery.ts +++ b/src/attachments/discovery.ts @@ -1,5 +1,3 @@ -import { getActiveSlotNames } from './presets'; - export interface IAttachmentSection { slot: string; elements: (Group | Cube)[]; diff --git a/src/attachments/importer.ts b/src/attachments/importer.ts index 098321d..3ef583f 100644 --- a/src/attachments/importer.ts +++ b/src/attachments/importer.ts @@ -38,7 +38,7 @@ interface BBModel { [key: string]: unknown; } -function logDebug(message: string, ...args: any[]) { +function logDebug(message: string, ...args: unknown[]) { if (DEBUG) console.log(message, ...args); } @@ -64,15 +64,15 @@ function buildUuidMap(items: unknown): Map { - const map = new Map(); +function buildTextureMap(model: BBModel): Map { + const map = new Map(); for (const [oldIndex, texData] of asArray(model.textures).entries()) { const texName = typeof texData.name === 'string' ? texData.name : undefined; const texPath = typeof texData.path === 'string' ? texData.path : undefined; const existing = Texture.all.find( - (t: any) => (texName && t.name === texName) || (texPath && t.path && t.path === texPath) + t => (texName && t.name === texName) || (texPath && t.path && t.path === texPath) ); let texture = existing; @@ -80,9 +80,9 @@ function buildTextureMap(model: BBModel): Map { texture = new Texture(texData).add(); // Preserve textureLocation if it exists in the imported data - if (typeof (texData as any).textureLocation === 'string') { - (texture as any).textureLocation = (texData as any).textureLocation; - logDebug(`[Import BB] Set textureLocation: ${(texData as any).textureLocation}`); + if (typeof texData.textureLocation === 'string') { + texture.textureLocation = texData.textureLocation; + logDebug(`[Import BB] Set textureLocation: ${texData.textureLocation}`); } // Load texture from embedded base64 or from disk path (when available) @@ -101,9 +101,9 @@ function buildTextureMap(model: BBModel): Map { if (typeof texData.uv_height === 'number') texture.uv_height = texData.uv_height; // Update textureLocation if it exists in the imported data and isn't already set - if (typeof (texData as any).textureLocation === 'string' && !(texture as any).textureLocation) { - (texture as any).textureLocation = (texData as any).textureLocation; - logDebug(`[Import BB] Updated textureLocation: ${(texData as any).textureLocation}`); + if (typeof texData.textureLocation === 'string' && !texture.textureLocation) { + texture.textureLocation = texData.textureLocation; + logDebug(`[Import BB] Updated textureLocation: ${texData.textureLocation}`); } logDebug( @@ -118,7 +118,7 @@ function buildTextureMap(model: BBModel): Map { return map; } -function remapCubeFaceTextures(cubeProps: Record, textureMap: Map) { +function remapCubeFaceTextures(cubeProps: Record, textureMap: Map) { const faces = cubeProps?.faces; if (!faces || !isRecord(faces)) return; @@ -143,8 +143,8 @@ function normalizePaletteSlot(value: unknown): number { function createCubeFromElementData( elemData: BBCubeElement, - parentGroup: any, - textureMap: Map, + parentGroup: Group | null, + textureMap: Map, createdGroups: Set, inheritedPaletteSlot = 0, ) { @@ -170,7 +170,7 @@ function createCubeFromElementData( const cubeClothingSlot = cubeProps.clothingSlot; if (cubeClothingSlot && typeof cubeClothingSlot === 'string' && cubeClothingSlot.trim() !== '') { // Propagate clothingSlot up the hierarchy, but stop at boundaries - let currentGroup: any = parentGroup; + let currentGroup: OutlinerNode['parent'] | null = parentGroup; while (currentGroup && currentGroup instanceof Group && createdGroups.has(currentGroup)) { const existingSlot = currentGroup.clothingSlot; // If this group already has a clothingSlot, stop propagation (we've hit a boundary) @@ -191,7 +191,7 @@ function createCubeFromElementData( logDebug(`[Import BB] Created cube: ${cube.name ?? '(unnamed)'}`); } -function findExistingGroupByName(parentGroup: any, groupName: string): any | null { +function findExistingGroupByName(parentGroup: Group | null, groupName: string): Group | null { if (!groupName) return null; const searchRoot = parentGroup ? parentGroup.children || [] : Outliner.root; @@ -206,10 +206,10 @@ function findExistingGroupByName(parentGroup: any, groupName: string): any | nul function getOrCreateGroup( groupSeed: Record, - parentGroup: any, + parentGroup: Group | null, createdGroups: Set, inheritedPaletteSlot = 0, -): { group: any; childPaletteSlot: number } { +): { group: Group; childPaletteSlot: number } { const groupName = typeof groupSeed.name === 'string' ? groupSeed.name : ''; const hasExternalStepParent = typeof groupSeed.stepParentName === 'string' && groupSeed.stepParentName.trim() !== ''; const ownPaletteSlot = normalizePaletteSlot(groupSeed.paletteSlot); @@ -252,7 +252,7 @@ function isCubeElement(value: unknown): value is BBCubeElement { * @param filePath The path to the file being imported, used for clothing slot inference. */ export function mergeVSAttachment(content: VS_Shape, filePath?: string) { - const elementsBefore = new Set([...Group.all, ...Cube.all]); + const elementsBefore = new Set([...Group.all, ...Cube.all]); handleVSTextures(content); // Attachment roots are already expressed in their step parent's local space. // Applying the normal block-model [-8, 0, -8] offset corrupts that transform. @@ -286,11 +286,11 @@ export function mergeBBModel(content: unknown, _filePath: string) { const elementByUuid = buildUuidMap>(model.elements); const groupByUuid = buildUuidMap>(model.groups); const createdGroups = new Set(); - const processChildren = (children: BBOutlinerItem[], parent: any, inheritedPaletteSlot = 0) => { + const processChildren = (children: BBOutlinerItem[], parent: Group | null, inheritedPaletteSlot = 0) => { for (const child of children) processOutlinerItem(child, parent, inheritedPaletteSlot); }; - const tryCreateCubeByUuid = (uuid: UUID, parent: any, inheritedPaletteSlot = 0): boolean => { + const tryCreateCubeByUuid = (uuid: UUID, parent: Group | null, inheritedPaletteSlot = 0): boolean => { const elemData = elementByUuid.get(uuid); if (!isCubeElement(elemData)) return false; createCubeFromElementData(elemData, parent, textureMap, createdGroups, inheritedPaletteSlot); @@ -300,7 +300,7 @@ export function mergeBBModel(content: unknown, _filePath: string) { const tryProcessGroupByUuid = ( uuid: UUID, node: Record, - parent: any, + parent: Group | null, inheritedPaletteSlot = 0, ): boolean => { const groupData = groupByUuid.get(uuid); @@ -315,18 +315,18 @@ export function mergeBBModel(content: unknown, _filePath: string) { return true; }; - const processUuidStringItem = (uuid: UUID, parent: any, inheritedPaletteSlot = 0) => { + const processUuidStringItem = (uuid: UUID, parent: Group | null, inheritedPaletteSlot = 0) => { // Blockbench 4.x: element references are UUID strings if (tryCreateCubeByUuid(uuid, parent, inheritedPaletteSlot)) return; }; - const processInlineGroupNode = (node: Record, parent: any, inheritedPaletteSlot = 0) => { + const processInlineGroupNode = (node: Record, parent: Group | null, inheritedPaletteSlot = 0) => { const { group: targetGroup, childPaletteSlot } = getOrCreateGroup(node, parent, createdGroups, inheritedPaletteSlot); const children = asArray(node.children); processChildren(children, targetGroup, childPaletteSlot); }; - const processObjectNode = (node: Record, parent: any, inheritedPaletteSlot = 0) => { + const processObjectNode = (node: Record, parent: Group | null, inheritedPaletteSlot = 0) => { const uuid = typeof node.uuid === 'string' ? node.uuid : undefined; // Blockbench 5.x+: outliner node references separate `elements` / `groups` lists by UUID. @@ -339,7 +339,7 @@ export function mergeBBModel(content: unknown, _filePath: string) { processInlineGroupNode(node, parent, inheritedPaletteSlot); }; - const processOutlinerItem = (item: BBOutlinerItem, parent: any, inheritedPaletteSlot = 0) => { + const processOutlinerItem = (item: BBOutlinerItem, parent: Group | null, inheritedPaletteSlot = 0) => { if (typeof item === 'string') return processUuidStringItem(item, parent, inheritedPaletteSlot); if (!isRecord(item)) return; return processObjectNode(item, parent, inheritedPaletteSlot); diff --git a/src/attachments/panel.ts b/src/attachments/panel.ts index b999762..8f1e7c8 100644 --- a/src/attachments/panel.ts +++ b/src/attachments/panel.ts @@ -3,9 +3,8 @@ import { exportAttachmentsBB } from './export_attachment_bb'; import { deleteSection, deleteSectionSafe } from './delete_section'; import { findAttachments } from './discovery'; import type { IAttachmentSection } from './discovery'; -import { DISCOVERY_DEBOUNCE_MS, MIN_TOUCH_TARGET_SIZE, QUICK_MESSAGE_DURATION } from './constants'; -import { getActiveSlotNames } from './presets'; -import { getSlotInfo, getSlotCategory } from './slot_helpers'; +import { DISCOVERY_DEBOUNCE_MS, QUICK_MESSAGE_DURATION } from './constants'; +import { getSlotInfo } from './slot_helpers'; const DEBUG = false; @@ -28,10 +27,6 @@ type AttachmentOutlinerNode = OutlinerNode & { toggleVisibility?: (visible: boolean) => void; }; -function logDebug(message: string, ...args: any[]) { - if (DEBUG) console.log(message, ...args); -} - // Track recently imported elements (within last 5 minutes) const RECENT_IMPORT_THRESHOLD = 5 * 60 * 1000; // 5 minutes in ms const recentImports = new Map(); @@ -121,16 +116,6 @@ function getMinDepth(elements: any[]): number { return Math.min(...elements.map(el => getElementDepth(el))); } -/** - * Calculates the maximum depth among all elements in a section. - * @param elements Array of elements in the section. - * @returns The maximum depth found. - */ -function getMaxDepth(elements: any[]): number { - if (elements.length === 0) return 0; - return Math.max(...elements.map(el => getElementDepth(el))); -} - /** * Updates the outliner selection with the provided elements. * @param {Array} elements The elements to select. diff --git a/src/attachments/post_import.ts b/src/attachments/post_import.ts index e493ed0..d06979c 100644 --- a/src/attachments/post_import.ts +++ b/src/attachments/post_import.ts @@ -8,20 +8,6 @@ import type { Vector3Tuple } from './attachment_transform'; const DEBUG = false; -function logDebug(message: string, ...args: any[]) { - if (DEBUG) console.log(message, ...args); -} - -function getGroupPath(group: Group): string { - const parts: string[] = []; - let current: any = group; - while (current && current instanceof Group) { - parts.unshift(current.name || 'Unnamed'); - current = current.parent; - } - return parts.join(' > ') || group.name || 'Unknown'; -} - /** * Finds a matching group in the existing model based on clothing slot and optional name. */ @@ -378,7 +364,7 @@ function placeStepParentWrappers(newElements: any[], newElementsSet: Set, l /** * Merges any duplicate groups in the model. */ -function mergeDuplicateGroups(newElementsSet: Set, logPrefix: string) { +function mergeDuplicateGroups(newElementsSet: Set) { const toDelete: Group[] = []; collectGroupsDepthFirst(Outliner.root).forEach(group => { if (isWithinStepParentAttachment(group, newElementsSet)) return; @@ -444,7 +430,7 @@ export async function processImportedAttachments(elementsBefore: Set, fileP placeStepParentWrappers(newElements, newElementsSet, logPrefix); // 7. Cleanup Duplicates - mergeDuplicateGroups(newElementsSet, logPrefix); + mergeDuplicateGroups(newElementsSet); Undo.finishEdit(`Import attachment: ${filePath.split(/[/\\]/).pop()}`); Canvas.updateAll(); diff --git a/src/debug_actions.ts b/src/debug_actions.ts index 4f5ac94..8c8edbd 100644 --- a/src/debug_actions.ts +++ b/src/debug_actions.ts @@ -191,7 +191,6 @@ const roundTripDiffAction = createAction(`${PACKAGE.name}:roundTripDiff`, { const test_folder = form_result.select_folder; const test_files: string[] = fs.readdirSync(test_folder, { encoding: "utf-8" }); let totalFiles = 0; - let filesWithDiffs = 0; let totalDiffs = 0; for (const test_file of test_files) { @@ -237,7 +236,6 @@ const roundTripDiffAction = createAction(`${PACKAGE.name}:roundTripDiff`, { const diffs = deepCompare(originalForCompare, reexportForCompare, '$'); if (diffs.length > 0) { - filesWithDiffs++; totalDiffs += diffs.length; console.group(`%c DIFFS in ${test_file} (${diffs.length} differences)`, 'color: orange; font-weight: bold'); for (const diff of diffs.slice(0, 50)) { diff --git a/src/export.ts b/src/export.ts index 2463b47..ec94dfc 100644 --- a/src/export.ts +++ b/src/export.ts @@ -2,7 +2,7 @@ import {export_model} from "./export_model"; import { compile_animation_library } from "./export_animation"; import { VS_EditorSettings, VS_Shape } from "./vs_shape_def"; import { VS_PROJECT_PROPS } from "./property"; -import { export_textures, resolveTextureLocation, warnTextureReadErrors } from "./export_textures"; +import { resolveTextureLocation, warnTextureReadErrors } from "./export_textures"; import { path_to_reference } from "./animation_library_paths"; const fs = requireNativeModule('fs'); diff --git a/src/export_model/group.ts b/src/export_model/group.ts index dbadb14..c31eae3 100644 --- a/src/export_model/group.ts +++ b/src/export_model/group.ts @@ -74,7 +74,6 @@ export function process_group( rotationOrigin = util.vector_add(rotationOrigin, offset); } - const hasRotation = converted_rotation[0] !== 0 || converted_rotation[1] !== 0 || converted_rotation[2] !== 0; const vsElement: VS_Element = { name: node.name, @@ -186,7 +185,6 @@ export function process_collapsed_group( // Process faces from the _geo cube const reduced_faces = process_faces(geoChild.faces); - const hasRotation = converted_rotation[0] !== 0 || converted_rotation[1] !== 0 || converted_rotation[2] !== 0; const vsElement: VS_Element = { name: node.name, diff --git a/src/import_model.ts b/src/import_model.ts index 31107b9..bcb1558 100644 --- a/src/import_model.ts +++ b/src/import_model.ts @@ -1,4 +1,4 @@ -import { VS_Element, VS_Shape } from "./vs_shape_def"; +import { VS_Shape } from "./vs_shape_def"; import {traverse} from "./import_model/traverse"; import { expand_complex_elements} from "./transform"; diff --git a/src/mods/settingsMod.ts b/src/mods/settingsMod.ts index 595489d..88a9f67 100644 --- a/src/mods/settingsMod.ts +++ b/src/mods/settingsMod.ts @@ -7,34 +7,6 @@ declare var Settings: any; declare var Dialog: any; declare var Interface: any; -/* - * Making a custom settinsg category errors out when loading the plugin upon the start of Blockbench - * (works fine when the plugin is loaded after Blockbench has alrady started). - * Probably an issue where Blockbench loads the plugin when the Settings dialog isn't fully initialized yet... =/ - */ -// -// createBlockbenchMod( -// `${PACKAGE.name}:vs_settings_category_mod`, -// {}, -// _context => { -// //@ts-expect-error: addCategory is not available in blockbench types yet -// Settings.addCategory("vintage_story", {name: "Vintage Story"}); -// }, -// _context => { -// removeSettingsCategory("vintage_story"); -// } - -// ); - -function removeSettingsCategory(id: string) { - if(Settings.dialog[id]){ - delete Settings.structure[id]; - delete Settings.dialog.sidebar.pages[id]; - Settings.dialog.sidebar.build(); - } -} - - createBlockbenchMod( `${PACKAGE.name}:vs_gamepath_settings_mod`, {}, @@ -66,8 +38,7 @@ createBlockbenchMod( }); return setting; }, - context => { - //context?.delete(); + () => { } ); @@ -101,8 +72,7 @@ createBlockbenchMod( return presetSetting; }, - context => { - //context?.delete(); + () => { } ); @@ -153,8 +123,7 @@ createBlockbenchMod( return customSlotsSetting; }, - context => { - //context?.delete(); + () => { } ); @@ -171,8 +140,7 @@ createBlockbenchMod( }); return setting; }, - context => { - //context?.delete(); + () => { } ); @@ -189,8 +157,7 @@ createBlockbenchMod( }); return setting; }, - context => { - //context?.delete(); + () => { } ); diff --git a/src/property.ts b/src/property.ts index 1f5963c..8975900 100644 --- a/src/property.ts +++ b/src/property.ts @@ -1,4 +1,4 @@ -import { VS_Direction, VS_EditorSettings, VS_Face, VS_ReflectiveMode } from "./vs_shape_def"; +import { VS_Direction, VS_Face, VS_ReflectiveMode } from "./vs_shape_def"; for (const name of ['vs_code', 'vs_onActivityStopped', 'vs_onAnimationEnd', 'vs_library_ref']) { new Property(Blockbench.Animation, 'string', name, { exposed: false }); diff --git a/src/types/property-augment.d.ts b/src/types/property-augment.d.ts index ce10b6b..7bdfb1b 100644 --- a/src/types/property-augment.d.ts +++ b/src/types/property-augment.d.ts @@ -39,6 +39,10 @@ declare global { per_animator_rotation_interpolation?: boolean; } + interface OutlinerNode { + addTo(target?: OutlinerNode | null): this; + } + interface Locator { position?: ArrayVector3; /** diff --git a/src/vs_plugin.ts b/src/vs_plugin.ts index 0716291..d150bd5 100644 --- a/src/vs_plugin.ts +++ b/src/vs_plugin.ts @@ -1,6 +1,3 @@ -const fs = requireNativeModule('fs'); -const path = requireNativeModule('path'); - import { events } from "./util/events"; import PACKAGE from "../package.json"; @@ -48,4 +45,4 @@ BBPlugin.register(PACKAGE.name, { onuninstall() { events.UNINSTALL.dispatch(); }, -}); \ No newline at end of file +});