From b9bad131ab9a97fe47777e6fa0dd10faa023e94c Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 12:14:12 +0100 Subject: [PATCH 01/13] Refresh package-lock after dependency install --- package-lock.json | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed76322..8d6c33a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "vs_plugin", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vs_plugin", - "version": "0.11.0", + "version": "0.12.0", "dependencies": { - "ajv": "^8.17.1" + "ajv": "^8.17.1", + "json5": "^2.2.3" }, "devDependencies": { + "@types/json5": "^0.0.30", "@types/node": "^24.9.1", "blockbench-types": "https://github.com/SnaveSutit/blockbench-types.git", "esbuild": "0.27.0", @@ -992,6 +994,13 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/json5": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.30.tgz", + "integrity": "sha512-sqm9g7mHlPY/43fcSNrCYfOeX9zkTTK+euO5E6+CVijSMm5tTjkVdwdqRkY3ljjIAf8679vps5jKUoJBCLsMDA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -1006,7 +1015,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "dev": true, - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1110,7 +1118,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz", "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -1330,7 +1337,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1363,7 +1369,6 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -1955,7 +1960,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2755,7 +2759,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -2796,6 +2799,18 @@ "dev": true, "optional": true }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -3137,7 +3152,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4151,7 +4165,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4277,6 +4290,7 @@ "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz", "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", "dev": true, + "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", @@ -4300,6 +4314,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -4316,6 +4331,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, + "peer": true, "engines": { "node": ">=4.0" } @@ -4325,6 +4341,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, + "peer": true, "bin": { "semver": "bin/semver.js" }, From ea4c423a82d852872dbe77c9852644b45b28d62d Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 12:18:39 +0100 Subject: [PATCH 02/13] Extract per-animation compile and create functions --- src/export_animation.ts | 248 +++++++++++++++++++++------------------- src/import_animation.ts | 98 ++++++++-------- 2 files changed, 180 insertions(+), 166 deletions(-) diff --git a/src/export_animation.ts b/src/export_animation.ts index 1c896fc..76ed5ff 100644 --- a/src/export_animation.ts +++ b/src/export_animation.ts @@ -7,151 +7,159 @@ import { is_backdrop_project } from "./util/misc"; * @returns An array of VS animations. */ export function export_animations(): Array { - const animations: Array = []; - // Don't export any animations if project contains backdrops - if(is_backdrop_project()) { + if (is_backdrop_project()) { return []; } - // Track animations with non-linear interpolations for warning - const animationsWithNonLinearInterpolation: string[] = []; + // Track animations with non-linear interpolations for a single aggregated warning + const nonLinear: string[] = []; + const animations = (Animation as unknown as typeof _Animation).all + .map(animation => compile_animation(animation, nonLinear)) + .filter((a): a is VS_Animation => a !== null); - (Animation as unknown as typeof _Animation).all.forEach(animation => { - const keyframes: Record = {}; - const fps = util.fps; - const baseFrameCount = get_base_frame_quantity(animation); - const animators = Object.values(animation.animators || {}); - let hasNonLinearInterpolation = false; + // Warn user if any animations use non-linear interpolations + if (nonLinear.length > 0) { + display_interpolation_warning(nonLinear); + } - animators.forEach(animator => { - if (animator.type === 'bone' && animator.keyframes && animator.keyframes.length > 0) { - // Skip NullObject animators (IK controllers) — they don't exist as VS elements - if (typeof NullObject !== 'undefined' && NullObject.all?.some((n: any) => n.uuid === animator.uuid)) { - return; - } - const bone_name = animator.name; + return animations; +} - animator.keyframes.forEach(kf => { - // Check if keyframe uses non-linear interpolation - // VS only supports linear interpolation, so warn if other types are used - if (kf.interpolation && kf.interpolation !== 'linear') { - hasNonLinearInterpolation = true; - } +/** + * Converts a single Blockbench animation to the Vintage Story animation format. + * Returns null when the animation has no exportable keyframes (or for backdrop projects). + * When `nonLinear` is supplied, the animation's name is appended to it if any of its keyframes + * use a non-linear interpolation, so the caller can show one aggregated warning instead of a + * popup per animation. + */ +export function compile_animation(animation: _Animation, nonLinear?: string[]): VS_Animation | null { + // Don't export animations for backdrop projects. + if (is_backdrop_project()) return null; - const frame = Math.round(kf.time * fps); - keyframes[frame] = keyframes[frame] || { frame, elements: {} }; - keyframes[frame].elements[bone_name] = keyframes[frame].elements[bone_name] || {}; + const keyframes: Record = {}; + const fps = util.fps; + const baseFrameCount = get_base_frame_quantity(animation); + const animators = Object.values(animation.animators || {}); + let hasNonLinearInterpolation = false; - const dataPoint = kf.data_points[0]; - switch (kf.channel) { - case 'rotation': - const rot = [dataPoint.x, dataPoint.y, dataPoint.z]; - keyframes[frame].elements[bone_name].rotationX = Number(rot[0]); - keyframes[frame].elements[bone_name].rotationY = Number(rot[1]); - keyframes[frame].elements[bone_name].rotationZ = Number(rot[2]); - break; - case 'position': - keyframes[frame].elements[bone_name].offsetX = Number(dataPoint.x); - keyframes[frame].elements[bone_name].offsetY = Number(dataPoint.y); - keyframes[frame].elements[bone_name].offsetZ = Number(dataPoint.z); - break; - case 'scale': - if (dataPoint.x !== 1) keyframes[frame].elements[bone_name].stretchX = Number(dataPoint.x); - if (dataPoint.y !== 1) keyframes[frame].elements[bone_name].stretchY = Number(dataPoint.y); - if (dataPoint.z !== 1) keyframes[frame].elements[bone_name].stretchZ = Number(dataPoint.z); - break; - } - }); + animators.forEach(animator => { + if (animator.type === 'bone' && animator.keyframes && animator.keyframes.length > 0) { + // Skip NullObject animators (IK controllers) — they don't exist as VS elements + if (typeof NullObject !== 'undefined' && NullObject.all?.some((n: any) => n.uuid === animator.uuid)) { + return; } + const bone_name = animator.name; + + animator.keyframes.forEach(kf => { + // Check if keyframe uses non-linear interpolation + // VS only supports linear interpolation, so warn if other types are used + if (kf.interpolation && kf.interpolation !== 'linear') { + hasNonLinearInterpolation = true; + } - // Process effect animators for texture swap keyframes - if (animator.type === 'effect' && animator.keyframes && animator.keyframes.length > 0) { - animator.keyframes.forEach(kf => { - if (kf.channel === 'timeline') { - const script = kf.data_points[0]?.script; - if (script) { - const textures = parseTextureSwapScript(script); - if (textures) { - const frame = Math.round(kf.time * fps); - keyframes[frame] = keyframes[frame] || { frame, elements: {} }; - keyframes[frame].textures = textures; - } + const frame = Math.round(kf.time * fps); + keyframes[frame] = keyframes[frame] || { frame, elements: {} }; + keyframes[frame].elements[bone_name] = keyframes[frame].elements[bone_name] || {}; + + const dataPoint = kf.data_points[0]; + switch (kf.channel) { + case 'rotation': + const rot = [dataPoint.x, dataPoint.y, dataPoint.z]; + keyframes[frame].elements[bone_name].rotationX = Number(rot[0]); + keyframes[frame].elements[bone_name].rotationY = Number(rot[1]); + keyframes[frame].elements[bone_name].rotationZ = Number(rot[2]); + break; + case 'position': + keyframes[frame].elements[bone_name].offsetX = Number(dataPoint.x); + keyframes[frame].elements[bone_name].offsetY = Number(dataPoint.y); + keyframes[frame].elements[bone_name].offsetZ = Number(dataPoint.z); + break; + case 'scale': + if (dataPoint.x !== 1) keyframes[frame].elements[bone_name].stretchX = Number(dataPoint.x); + if (dataPoint.y !== 1) keyframes[frame].elements[bone_name].stretchY = Number(dataPoint.y); + if (dataPoint.z !== 1) keyframes[frame].elements[bone_name].stretchZ = Number(dataPoint.z); + break; + } + }); + } + + // Process effect animators for texture swap keyframes + if (animator.type === 'effect' && animator.keyframes && animator.keyframes.length > 0) { + animator.keyframes.forEach(kf => { + if (kf.channel === 'timeline') { + const script = kf.data_points[0]?.script; + if (script) { + const textures = parseTextureSwapScript(script); + if (textures) { + const frame = Math.round(kf.time * fps); + keyframes[frame] = keyframes[frame] || { frame, elements: {} }; + keyframes[frame].textures = textures; } } - }); - } - }); + } + }); + } + }); - normalize_terminal_keyframe(keyframes, baseFrameCount); + normalize_terminal_keyframe(keyframes, baseFrameCount); - // Wraps all animation elements into oneLiner wrappers (after all animators are processed) - for(const keyframe of Object.values(keyframes)) { - const wrapped_elements = {}; - for (const [element, content] of Object.entries(keyframe.elements)) { - wrapped_elements[element] = new oneLiner(content); - } - keyframe.elements = wrapped_elements; + // Wraps all animation elements into oneLiner wrappers (after all animators are processed) + for(const keyframe of Object.values(keyframes)) { + const wrapped_elements = {}; + for (const [element, content] of Object.entries(keyframe.elements)) { + wrapped_elements[element] = new oneLiner(content); } + keyframe.elements = wrapped_elements; + } - // 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; + // 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; - const vsAnimation : VS_Animation = { - name: animation.name, - code: storedCode || animation.name.toLowerCase().replace(/ /g, ''), - quantityframes: get_frame_quantity(animation, keyframes), - onActivityStopped: storedOnActivityStopped || "EaseOut", - onAnimationEnd: storedOnAnimationEnd || (animation.loop === 'loop' ? "Repeat" : "Hold"), - keyframes: Object.values(keyframes).sort((a, b) => a.frame - b.frame) - }; - - if (vsAnimation.quantityframes === 0 && vsAnimation.keyframes.length > 0) { - const frame0 = vsAnimation.keyframes.find(kf => kf.frame === 0); - if (frame0) { - const frame1 = JSON.parse(JSON.stringify(frame0)); - frame1.frame = 1; - vsAnimation.keyframes.push(frame1); - vsAnimation.quantityframes = 1; - } - } + const vsAnimation : VS_Animation = { + name: animation.name, + code: storedCode || animation.name.toLowerCase().replace(/ /g, ''), + quantityframes: get_frame_quantity(animation, keyframes), + onActivityStopped: storedOnActivityStopped || "EaseOut", + onAnimationEnd: storedOnAnimationEnd || (animation.loop === 'loop' ? "Repeat" : "Hold"), + keyframes: Object.values(keyframes).sort((a, b) => a.frame - b.frame) + }; - // For looping animations, insert a virtual end frame copying frame 0 - // so VS can interpolate back to the start. QuantityFrames is a frame count, - // so the last valid frame is quantityframes - 1. - if (vsAnimation.onAnimationEnd === "Repeat" && vsAnimation.quantityframes > 0) { - const lastFrame = vsAnimation.quantityframes - 1; - const hasLastFrame = vsAnimation.keyframes.some(kf => kf.frame === lastFrame); - if (!hasLastFrame) { - const frame0 = vsAnimation.keyframes.find(kf => kf.frame === 0); - if (frame0) { - const virtualFrame = JSON.parse(JSON.stringify(frame0)); - virtualFrame.frame = lastFrame; - vsAnimation.keyframes.push(virtualFrame); - vsAnimation.keyframes.sort((a, b) => a.frame - b.frame); - } - } + if (vsAnimation.quantityframes === 0 && vsAnimation.keyframes.length > 0) { + const frame0 = vsAnimation.keyframes.find(kf => kf.frame === 0); + if (frame0) { + const frame1 = JSON.parse(JSON.stringify(frame0)); + frame1.frame = 1; + vsAnimation.keyframes.push(frame1); + vsAnimation.quantityframes = 1; } + } - if (vsAnimation.keyframes.length > 0) { - animations.push(vsAnimation); - if (hasNonLinearInterpolation) { - animationsWithNonLinearInterpolation.push(animation.name); + // For looping animations, insert a virtual end frame copying frame 0 + // so VS can interpolate back to the start. QuantityFrames is a frame count, + // so the last valid frame is quantityframes - 1. + if (vsAnimation.onAnimationEnd === "Repeat" && vsAnimation.quantityframes > 0) { + const lastFrame = vsAnimation.quantityframes - 1; + const hasLastFrame = vsAnimation.keyframes.some(kf => kf.frame === lastFrame); + if (!hasLastFrame) { + const frame0 = vsAnimation.keyframes.find(kf => kf.frame === 0); + if (frame0) { + const virtualFrame = JSON.parse(JSON.stringify(frame0)); + virtualFrame.frame = lastFrame; + vsAnimation.keyframes.push(virtualFrame); + vsAnimation.keyframes.sort((a, b) => a.frame - b.frame); } } - }); - - // Warn user if any animations use non-linear interpolations - if (animationsWithNonLinearInterpolation.length > 0) { - display_interpolation_warning(animationsWithNonLinearInterpolation); } - return animations; + if (vsAnimation.keyframes.length === 0) return null; + if (hasNonLinearInterpolation && nonLinear) nonLinear.push(animation.name); + return vsAnimation; } /** diff --git a/src/import_animation.ts b/src/import_animation.ts index ce96355..b0733ed 100644 --- a/src/import_animation.ts +++ b/src/import_animation.ts @@ -2,57 +2,63 @@ import * as util from "./util"; import { VS_Animation } from "./vs_shape_def"; /** - * Imports animations from the Vintage Story format into Blockbench. - * @param {Array} animations The array of animation data from the VS model file. + * Creates one Blockbench animation from a VS animation definition and returns it. */ -export function import_animations(animations: Array) { +export function create_animation(vsAnimation: VS_Animation): _Animation { const FPS = util.fps; const interpolationMode = "linear"; + 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. + name: vsAnimation.name, + loop: isLooping ? 'loop' : 'once', + length: animationLength, + snapping: FPS + }) as unknown) as _Animation).add(); + + // 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; + + vsAnimation.keyframes.forEach(vsKeyframe => { + const time = vsKeyframe.frame / FPS; + for (const boneName in vsKeyframe.elements) { + const transform = vsKeyframe.elements[boneName]; + const bone = Group.all.find(g => g.name === boneName); + + if (bone) { + const animator = animation.getBoneAnimator(bone); + + if (transform.rotationX != null || transform.rotationY != null || transform.rotationZ != null) { + const rotation = [transform.rotationX || 0, transform.rotationY || 0, transform.rotationZ || 0]; + animator.addKeyframe({ interpolation: interpolationMode, time, channel: 'rotation', data_points: [{ x: rotation[0], y: rotation[1], z: rotation[2] }] }); + } - animations.forEach(vsAnimation => { - 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. - name: vsAnimation.name, - loop: isLooping ? 'loop' : 'once', - length: animationLength, - snapping: FPS - }) as unknown) as _Animation).add(); - - // 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; - - vsAnimation.keyframes.forEach(vsKeyframe => { - const time = vsKeyframe.frame / FPS; - for (const boneName in vsKeyframe.elements) { - const transform = vsKeyframe.elements[boneName]; - const bone = Group.all.find(g => g.name === boneName); - - if (bone) { - const animator = animation.getBoneAnimator(bone); - - if (transform.rotationX != null || transform.rotationY != null || transform.rotationZ != null) { - const rotation = [transform.rotationX || 0, transform.rotationY || 0, transform.rotationZ || 0]; - animator.addKeyframe({ interpolation: interpolationMode, time, channel: 'rotation', data_points: [{ x: rotation[0], y: rotation[1], z: rotation[2] }] }); - } - - if (transform.offsetX != null || transform.offsetY != null || transform.offsetZ != null) { - const position = [transform.offsetX || 0, transform.offsetY || 0, transform.offsetZ || 0]; - animator.addKeyframe({ interpolation: interpolationMode, time, channel: 'position', data_points: [{ x: position[0] || 0, y: position[1] || 0, z: position[2] || 0 }] }); - } - - if (transform.stretchX != null || transform.stretchY != null || transform.stretchZ != null) { - animator.addKeyframe({ interpolation: interpolationMode, time, channel: 'scale', data_points: [{ x: transform.stretchX ?? 1, y: transform.stretchY ?? 1, z: transform.stretchZ ?? 1 }] }); - } + if (transform.offsetX != null || transform.offsetY != null || transform.offsetZ != null) { + const position = [transform.offsetX || 0, transform.offsetY || 0, transform.offsetZ || 0]; + animator.addKeyframe({ interpolation: interpolationMode, time, channel: 'position', data_points: [{ x: position[0] || 0, y: position[1] || 0, z: position[2] || 0 }] }); + } + + if (transform.stretchX != null || transform.stretchY != null || transform.stretchZ != null) { + animator.addKeyframe({ interpolation: interpolationMode, time, channel: 'scale', data_points: [{ x: transform.stretchX ?? 1, y: transform.stretchY ?? 1, z: transform.stretchZ ?? 1 }] }); } } - }); + } }); + + return animation; +} + +/** + * Imports animations from the Vintage Story format into Blockbench. + * @param {Array} animations The array of animation data from the VS model file. + */ +export function import_animations(animations: Array) { + animations.forEach(vsAnimation => create_animation(vsAnimation)); }; From 07d07759cd1cc12e16f6ec5aa168ca3d17d311ea Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 12:22:32 +0100 Subject: [PATCH 03/13] Round-trip bezier and step keyframe interpolation with handle widths --- src/export_animation.ts | 278 ++++++++++++++++++++++++++++++++++------ src/import_animation.ts | 182 +++++++++++++++++++++++--- src/vs_shape_def.ts | 60 +++++++++ 3 files changed, 461 insertions(+), 59 deletions(-) diff --git a/src/export_animation.ts b/src/export_animation.ts index 76ed5ff..f4df26e 100644 --- a/src/export_animation.ts +++ b/src/export_animation.ts @@ -1,7 +1,179 @@ -import { VS_Animation, VS_Keyframe } from "./vs_shape_def"; +import { VS_Animation, VS_AnimationKey, VS_Keyframe, VS_KeyFrameInterpolation } from "./vs_shape_def"; import * as util from "./util"; import { is_backdrop_project } from "./util/misc"; +type BBChannel = 'position' | 'rotation' | 'scale'; + +// Maps a Blockbench keyframe interpolation mode to the engine's mode. Linear returns null +// (the default; field omitted from JSON). The engine has no native Catmull-Rom mode, so +// catmullrom converts to Bezier with computed tangents (see computeCatmullRomTangents). +// That conversion is exact for uniformly spaced keyframes and an approximation otherwise. +function mapInterpolation(bbMode: string | undefined): { mode: VS_KeyFrameInterpolation | null, isCatmull: boolean } { + if (!bbMode || bbMode === 'linear') return { mode: null, isCatmull: false }; + if (bbMode === 'bezier') return { mode: 'Bezier', isCatmull: false }; + if (bbMode === 'step') return { mode: 'Step', isCatmull: false }; + if (bbMode === 'catmullrom') return { mode: 'Bezier', isCatmull: true }; + return { mode: null, isCatmull: false }; +} + +// Converts one Blockbench bezier handle into the engine's (tangent slope + handle width) pair. +// Blockbench stores a handle as a value delta (bezier_*_value) plus a time delta in seconds +// (bezier_*_time); the engine wants a slope in value-per-segment-t plus the handle's horizontal +// extent in frames: +// widthFrames = timeDelta * fps +// tangent = valueDelta * segmentFrames / widthFrames +// Width is emitted only when it differs from the engine's +/-(segmentFrames/3) default, so +// default-shaped handles stay free of extra fields and re-export byte-identically. +function applyBezierHandle( + elem: VS_AnimationKey, + valueDeltas: ArrayVector3 | undefined, + timeDeltas: ArrayVector3 | undefined, + 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], +) { + 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; + } +} + +// Computes a Catmull-Rom keyframe's tangent in value-per-segment-t units for both segment +// sides (out into the next segment, in from the prev). The slope at frame_i is the symmetric +// finite difference `(P_{i+1} - P_{i-1}) / (frame_{i+1} - frame_{i-1})`; scaling it by each +// adjacent segment's frame duration gives the per-segment-t tangent the engine expects. +// At the boundaries (no prev, no next, single keyframe) it falls back to a one-sided difference. +function computeCatmullRomTangents( + channelKfs: _Keyframe[], + idx: number, + axis: 'x' | 'y' | 'z', + fps: number, +): { out: number, in: number } { + const cur = channelKfs[idx]; + const prev = idx > 0 ? channelKfs[idx - 1] : null; + const next = idx < channelKfs.length - 1 ? channelKfs[idx + 1] : null; + + const curFrame = Math.round(cur.time * fps); + const curVal = Number(cur.data_points[0][axis]); + + let slopePerFrame = 0; + if (prev && next) { + const prevFrame = Math.round(prev.time * fps); + const nextFrame = Math.round(next.time * fps); + const span = nextFrame - prevFrame; + if (span > 0) { + slopePerFrame = (Number(next.data_points[0][axis]) - Number(prev.data_points[0][axis])) / span; + } + } else if (next) { + const nextFrame = Math.round(next.time * fps); + const span = nextFrame - curFrame; + if (span > 0) slopePerFrame = (Number(next.data_points[0][axis]) - curVal) / span; + } else if (prev) { + const prevFrame = Math.round(prev.time * fps); + const span = curFrame - prevFrame; + if (span > 0) slopePerFrame = (curVal - Number(prev.data_points[0][axis])) / span; + } + + const outDur = next ? Math.max(0, Math.round(next.time * fps) - curFrame) : 0; + const inDur = prev ? Math.max(0, curFrame - Math.round(prev.time * fps)) : 0; + return { out: slopePerFrame * outDur, in: slopePerFrame * inDur }; +} + +function applyCatmullRomToKey( + elem: VS_AnimationKey, + channel: BBChannel, + channelKfs: _Keyframe[], + idx: number, + fps: number, +) { + const fields = CHANNEL_FIELDS[channel]; + (elem as any)[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; +} + +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; +} + +const CHANNEL_FIELDS: Record = { + position: { + interp: 'positionInterp', + tangentInX: 'offsetTangentInX', tangentInY: 'offsetTangentInY', tangentInZ: 'offsetTangentInZ', + tangentOutX: 'offsetTangentOutX', tangentOutY: 'offsetTangentOutY', tangentOutZ: 'offsetTangentOutZ', + tangentInWidthX: 'offsetTangentInWidthX', tangentInWidthY: 'offsetTangentInWidthY', tangentInWidthZ: 'offsetTangentInWidthZ', + tangentOutWidthX: 'offsetTangentOutWidthX', tangentOutWidthY: 'offsetTangentOutWidthY', tangentOutWidthZ: 'offsetTangentOutWidthZ', + }, + rotation: { + interp: 'rotationInterp', + tangentInX: 'rotationTangentInX', tangentInY: 'rotationTangentInY', tangentInZ: 'rotationTangentInZ', + tangentOutX: 'rotationTangentOutX', tangentOutY: 'rotationTangentOutY', tangentOutZ: 'rotationTangentOutZ', + tangentInWidthX: 'rotationTangentInWidthX', tangentInWidthY: 'rotationTangentInWidthY', tangentInWidthZ: 'rotationTangentInWidthZ', + tangentOutWidthX: 'rotationTangentOutWidthX', tangentOutWidthY: 'rotationTangentOutWidthY', tangentOutWidthZ: 'rotationTangentOutWidthZ', + }, + scale: { + interp: 'scaleInterp', + tangentInX: 'stretchTangentInX', tangentInY: 'stretchTangentInY', tangentInZ: 'stretchTangentInZ', + tangentOutX: 'stretchTangentOutX', tangentOutY: 'stretchTangentOutY', tangentOutZ: 'stretchTangentOutZ', + tangentInWidthX: 'stretchTangentInWidthX', tangentInWidthY: 'stretchTangentInWidthY', tangentInWidthZ: 'stretchTangentInWidthZ', + tangentOutWidthX: 'stretchTangentOutWidthX', tangentOutWidthY: 'stretchTangentOutWidthY', tangentOutWidthZ: 'stretchTangentOutWidthZ', + }, +}; + +function applyInterpolationToKey( + elem: VS_AnimationKey, + channel: BBChannel, + kf: _Keyframe, + interp: VS_KeyFrameInterpolation, + channelKfs: _Keyframe[] | null, + idx: number, + fps: number, +) { + const fields = CHANNEL_FIELDS[channel]; + (elem as any)[fields.interp] = interp; + + if (interp !== 'Bezier') return; + + // Handle widths are placed relative to the adjacent segment, so we need the neighbouring + // keyframes IN THIS CHANNEL (matching the engine's per-channel keyframe walk). + const curFrame = Math.round(kf.time * fps); + const prev = channelKfs && idx > 0 ? channelKfs[idx - 1] : null; + const next = channelKfs && idx >= 0 && idx < channelKfs.length - 1 ? channelKfs[idx + 1] : null; + const outDur = next ? Math.max(0, Math.round(next.time * fps) - curFrame) : 0; + const inDur = prev ? Math.max(0, curFrame - Math.round(prev.time * fps)) : 0; + + // The OUT handle shapes the segment to the next keyframe; the IN handle the segment from the + // previous one. A handle with no adjacent segment affects no curve, so it's skipped. + if (outDur > 0) { + applyBezierHandle(elem, kf.bezier_right_value, kf.bezier_right_time, outDur, outDur / 3, fps, + [fields.tangentOutX, fields.tangentOutY, fields.tangentOutZ], + [fields.tangentOutWidthX, fields.tangentOutWidthY, fields.tangentOutWidthZ]); + } + if (inDur > 0) { + applyBezierHandle(elem, kf.bezier_left_value, kf.bezier_left_time, inDur, -inDur / 3, fps, + [fields.tangentInX, fields.tangentInY, fields.tangentInZ], + [fields.tangentInWidthX, fields.tangentInWidthY, fields.tangentInWidthZ]); + } +} + /** * Exports Blockbench animations to the Vintage Story animation format. * @returns An array of VS animations. @@ -12,15 +184,13 @@ export function export_animations(): Array { return []; } - // Track animations with non-linear interpolations for a single aggregated warning - const nonLinear: string[] = []; + const catmullConverted: string[] = []; const animations = (Animation as unknown as typeof _Animation).all - .map(animation => compile_animation(animation, nonLinear)) + .map(animation => compile_animation(animation, catmullConverted)) .filter((a): a is VS_Animation => a !== null); - // Warn user if any animations use non-linear interpolations - if (nonLinear.length > 0) { - display_interpolation_warning(nonLinear); + if (catmullConverted.length > 0) { + display_catmull_conversion_notice(catmullConverted); } return animations; @@ -29,11 +199,11 @@ export function export_animations(): Array { /** * Converts a single Blockbench animation to the Vintage Story animation format. * Returns null when the animation has no exportable keyframes (or for backdrop projects). - * When `nonLinear` is supplied, the animation's name is appended to it if any of its keyframes - * use a non-linear interpolation, so the caller can show one aggregated warning instead of a - * popup per animation. + * When `catmullConverted` is supplied, the animation's name is appended to it if any of its + * keyframes were converted from catmull-rom to bezier, so the caller can show one aggregated + * notice instead of a popup per animation. */ -export function compile_animation(animation: _Animation, nonLinear?: string[]): VS_Animation | null { +export function compile_animation(animation: _Animation, catmullConverted?: string[]): VS_Animation | null { // Don't export animations for backdrop projects. if (is_backdrop_project()) return null; @@ -41,7 +211,7 @@ export function compile_animation(animation: _Animation, nonLinear?: string[]): const fps = util.fps; const baseFrameCount = get_base_frame_quantity(animation); const animators = Object.values(animation.animators || {}); - let hasNonLinearInterpolation = false; + let hadCatmullConversion = false; animators.forEach(animator => { if (animator.type === 'bone' && animator.keyframes && animator.keyframes.length > 0) { @@ -51,34 +221,64 @@ export function compile_animation(animation: _Animation, nonLinear?: string[]): } const bone_name = animator.name; + // Catmull-Rom -> Bezier conversion needs the prev/next keyframes in the SAME + // channel (matching the engine's per-channel keyframe walk). Pre-sort once. + const byChannel: Record = { position: [], rotation: [], scale: [] }; animator.keyframes.forEach(kf => { - // Check if keyframe uses non-linear interpolation - // VS only supports linear interpolation, so warn if other types are used - if (kf.interpolation && kf.interpolation !== 'linear') { - hasNonLinearInterpolation = true; + if (kf.channel === 'position' || kf.channel === 'rotation' || kf.channel === 'scale') { + byChannel[kf.channel].push(kf); } + }); + (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); + + animator.keyframes.forEach(kf => { + const { mode: vsInterp, isCatmull } = mapInterpolation(kf.interpolation); + if (isCatmull) hadCatmullConversion = true; const frame = Math.round(kf.time * fps); keyframes[frame] = keyframes[frame] || { frame, elements: {} }; keyframes[frame].elements[bone_name] = keyframes[frame].elements[bone_name] || {}; + const elem = keyframes[frame].elements[bone_name]; const dataPoint = kf.data_points[0]; + const value = { x: Number(dataPoint.x), y: Number(dataPoint.y), z: Number(dataPoint.z) }; + const channel = kf.channel as BBChannel; + const channelKfs = channel in byChannel ? byChannel[channel] : null; + + const applyInterp = () => { + if (!vsInterp) return; + const idx = channelKfs ? channelKfs.indexOf(kf) : -1; + if (isCatmull && channelKfs) { + applyCatmullRomToKey(elem, channel, channelKfs, idx, fps); + } else { + applyInterpolationToKey(elem, channel, kf, vsInterp, channelKfs, idx, fps); + } + }; + switch (kf.channel) { case 'rotation': - const rot = [dataPoint.x, dataPoint.y, dataPoint.z]; - keyframes[frame].elements[bone_name].rotationX = Number(rot[0]); - keyframes[frame].elements[bone_name].rotationY = Number(rot[1]); - keyframes[frame].elements[bone_name].rotationZ = Number(rot[2]); + elem.rotationX = value.x; + elem.rotationY = value.y; + elem.rotationZ = value.z; + applyInterp(); break; case 'position': - keyframes[frame].elements[bone_name].offsetX = Number(dataPoint.x); - keyframes[frame].elements[bone_name].offsetY = Number(dataPoint.y); - keyframes[frame].elements[bone_name].offsetZ = Number(dataPoint.z); + elem.offsetX = value.x; + elem.offsetY = value.y; + elem.offsetZ = value.z; + applyInterp(); break; case 'scale': - if (dataPoint.x !== 1) keyframes[frame].elements[bone_name].stretchX = Number(dataPoint.x); - if (dataPoint.y !== 1) keyframes[frame].elements[bone_name].stretchY = Number(dataPoint.y); - if (dataPoint.z !== 1) keyframes[frame].elements[bone_name].stretchZ = Number(dataPoint.z); + if (scaleNeedsAllKeys || value.x !== 1) elem.stretchX = value.x; + if (scaleNeedsAllKeys || value.y !== 1) elem.stretchY = value.y; + if (scaleNeedsAllKeys || value.z !== 1) elem.stretchZ = value.z; + applyInterp(); break; } }); @@ -158,7 +358,7 @@ export function compile_animation(animation: _Animation, nonLinear?: string[]): } if (vsAnimation.keyframes.length === 0) return null; - if (hasNonLinearInterpolation && nonLinear) nonLinear.push(animation.name); + if (hadCatmullConversion && catmullConverted) catmullConverted.push(animation.name); return vsAnimation; } @@ -266,25 +466,25 @@ function parseTextureSwapScript(script: string): Record | null { function display_animation_length_warning(animation_name: string) { Blockbench.showMessageBox({ title: 'Animation Length Warning', - message: + message: `The animation "${animation_name}" has keyframes on or past the last frame. ` + `This may not animate correctly in Vintage Story. ` + `Consider moving the keyframes away from the last frame if you experience issues.` }); } -function display_interpolation_warning(animation_names: string[]) { - const animationList = animation_names.length === 1 - ? `"${animation_names[0]}"` +function display_catmull_conversion_notice(animation_names: string[]) { + const animationList = animation_names.length === 1 + ? `"${animation_names[0]}"` : animation_names.map(name => `"${name}"`).join(', '); - + Blockbench.showMessageBox({ - title: 'Interpolation Warning', - message: - `The following animation(s) use non-linear interpolation: ${animationList}\n\n` + - `Vintage Story only supports linear interpolation between keyframes. ` + - `All keyframes will be exported with linear interpolation, which may change ` + - `the animation's appearance. Consider adding more keyframes to approximate ` + - `the desired easing curves, or use linear interpolation in Blockbench.` + title: 'Catmull-Rom Converted to Bezier', + message: + `The following animation(s) had catmull-rom keyframes: ${animationList}\n\n` + + `Vintage Story has no native catmull-rom mode, so these keyframes were exported ` + + `as bezier with tangents computed from neighbouring keyframes. The curve is ` + + `preserved (exact for uniform keyframe spacing, close otherwise). No action needed.` }); } + diff --git a/src/import_animation.ts b/src/import_animation.ts index b0733ed..8c06d27 100644 --- a/src/import_animation.ts +++ b/src/import_animation.ts @@ -1,12 +1,11 @@ import * as util from "./util"; -import { VS_Animation } from "./vs_shape_def"; +import { VS_Animation, VS_AnimationKey, VS_KeyFrameInterpolation } from "./vs_shape_def"; /** * Creates one Blockbench animation from a VS animation definition and returns it. */ export function create_animation(vsAnimation: VS_Animation): _Animation { const FPS = util.fps; - const interpolationMode = "linear"; const animationLength = vsAnimation.quantityframes / FPS; const isLooping = vsAnimation.onAnimationEnd === 'Repeat'; @@ -26,29 +25,22 @@ export function create_animation(vsAnimation: VS_Animation): _Animation { // @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 + // correct segment (mirrors the engine's per-channel keyframe walk). + const channelFrames = buildChannelFrames(vsAnimation); + vsAnimation.keyframes.forEach(vsKeyframe => { - const time = vsKeyframe.frame / FPS; for (const boneName in vsKeyframe.elements) { const transform = vsKeyframe.elements[boneName]; const bone = Group.all.find(g => g.name === boneName); + if (!bone) continue; - if (bone) { - const animator = animation.getBoneAnimator(bone); - - if (transform.rotationX != null || transform.rotationY != null || transform.rotationZ != null) { - const rotation = [transform.rotationX || 0, transform.rotationY || 0, transform.rotationZ || 0]; - animator.addKeyframe({ interpolation: interpolationMode, time, channel: 'rotation', data_points: [{ x: rotation[0], y: rotation[1], z: rotation[2] }] }); - } - - if (transform.offsetX != null || transform.offsetY != null || transform.offsetZ != null) { - const position = [transform.offsetX || 0, transform.offsetY || 0, transform.offsetZ || 0]; - animator.addKeyframe({ interpolation: interpolationMode, time, channel: 'position', data_points: [{ x: position[0] || 0, y: position[1] || 0, z: position[2] || 0 }] }); - } - - if (transform.stretchX != null || transform.stretchY != null || transform.stretchZ != null) { - animator.addKeyframe({ interpolation: interpolationMode, time, channel: 'scale', data_points: [{ x: transform.stretchX ?? 1, y: transform.stretchY ?? 1, z: transform.stretchZ ?? 1 }] }); - } - } + const animator = animation.getBoneAnimator(bone); + const frames = channelFrames[boneName]; + (Object.keys(IMPORT_CHANNELS) as BBImportChannel[]).forEach(channel => { + const opts = buildChannelKeyframeOptions(transform, channel, vsKeyframe.frame, frames[channel], FPS); + if (opts) animator.addKeyframe(opts); + }); } }); @@ -62,3 +54,153 @@ export function create_animation(vsAnimation: VS_Animation): _Animation { export function import_animations(animations: Array) { animations.forEach(vsAnimation => create_animation(vsAnimation)); }; + +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]; + default: number; +} + +const IMPORT_CHANNELS: Record = { + rotation: { + interp: 'rotationInterp', + value: ['rotationX', 'rotationY', 'rotationZ'], + tangentIn: ['rotationTangentInX', 'rotationTangentInY', 'rotationTangentInZ'], + tangentOut: ['rotationTangentOutX', 'rotationTangentOutY', 'rotationTangentOutZ'], + widthIn: ['rotationTangentInWidthX', 'rotationTangentInWidthY', 'rotationTangentInWidthZ'], + widthOut: ['rotationTangentOutWidthX', 'rotationTangentOutWidthY', 'rotationTangentOutWidthZ'], + default: 0, + }, + position: { + interp: 'positionInterp', + value: ['offsetX', 'offsetY', 'offsetZ'], + tangentIn: ['offsetTangentInX', 'offsetTangentInY', 'offsetTangentInZ'], + tangentOut: ['offsetTangentOutX', 'offsetTangentOutY', 'offsetTangentOutZ'], + widthIn: ['offsetTangentInWidthX', 'offsetTangentInWidthY', 'offsetTangentInWidthZ'], + widthOut: ['offsetTangentOutWidthX', 'offsetTangentOutWidthY', 'offsetTangentOutWidthZ'], + default: 0, + }, + scale: { + interp: 'scaleInterp', + value: ['stretchX', 'stretchY', 'stretchZ'], + tangentIn: ['stretchTangentInX', 'stretchTangentInY', 'stretchTangentInZ'], + tangentOut: ['stretchTangentOutX', 'stretchTangentOutY', 'stretchTangentOutZ'], + widthIn: ['stretchTangentInWidthX', 'stretchTangentInWidthY', 'stretchTangentInWidthZ'], + widthOut: ['stretchTangentOutWidthX', 'stretchTangentOutWidthY', 'stretchTangentOutWidthZ'], + default: 1, + }, +}; + +type ChannelFrameMap = Record>; + +// Collects, per bone and per channel, the sorted frames that actually set that channel, matching +// the per-channel grouping the engine walks. Used to size each bezier segment for handle-width +// recovery. +function buildChannelFrames(vsAnimation: VS_Animation): ChannelFrameMap { + const map: ChannelFrameMap = {}; + vsAnimation.keyframes.forEach(kf => { + for (const boneName in kf.elements) { + 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)) { + entry[channel].push(kf.frame); + } + }); + } + }); + for (const boneName in map) { + (Object.keys(map[boneName]) as BBImportChannel[]).forEach(channel => map[boneName][channel].sort((a, b) => a - b)); + } + return map; +} + +// Builds the Blockbench keyframe options for one channel of one VS keyframe element, restoring the +// interpolation mode and (for bezier) the exact handle value+time deltas. Returns null when the +// channel isn't present on this element. +function buildChannelKeyframeOptions( + transform: VS_AnimationKey, + channel: BBImportChannel, + frame: number, + channelFrames: number[], + fps: number, +): KeyframeOptions | null { + const cfg = IMPORT_CHANNELS[channel]; + if (!cfg.value.some(k => (transform as any)[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, + }; + + const bbInterp = mapInterpolation((transform as any)[cfg.interp]); + const opts: KeyframeOptions = { + interpolation: bbInterp, + time: frame / fps, + channel, + data_points: [{ x: value.x, y: value.y, z: value.z }], + }; + + if (bbInterp === 'bezier') { + const { outDur, inDur } = segmentDurations(channelFrames, frame); + const right = reconstructHandle(transform, cfg.tangentOut, cfg.widthOut, outDur, +1, fps); + const left = reconstructHandle(transform, cfg.tangentIn, cfg.widthIn, inDur, -1, fps); + if (right) { opts.bezier_right_value = right.value; opts.bezier_right_time = right.time; } + if (left) { opts.bezier_left_value = left.value; opts.bezier_left_time = left.time; } + } + + return opts; +} + +// Lengths (in frames) of the segments adjacent to `frame` within a single channel's keyframe list. +function segmentDurations(frames: number[], frame: number): { outDur: number, inDur: number } { + const i = frames.indexOf(frame); + if (i === -1) return { outDur: 0, inDur: 0 }; + const prev = i > 0 ? frames[i - 1] : null; + const next = i < frames.length - 1 ? frames[i + 1] : null; + return { + outDur: next != null ? next - frame : 0, + inDur: prev != null ? frame - prev : 0, + }; +} + +// Inverts the export: from the engine tangent slope (value-per-segment-t) plus the handle width (in +// frames) recover Blockbench's value delta and time delta (seconds): time = widthFrames/fps, +// value = tangent * widthFrames / segmentFrames. A missing width falls back to the engine's +// +/-(segmentFrames/3) default so the rebuilt BB curve matches the engine's render exactly. Returns +// 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], + segmentFrames: number, + sign: 1 | -1, + fps: number, +): { value: ArrayVector3, time: ArrayVector3 } | null { + if (segmentFrames <= 0) return null; + const defaultWidth = sign * segmentFrames / 3; + 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 widthFrames = wRaw != null ? Number(wRaw) : defaultWidth; + const tRaw = (transform as any)[tangentFields[i]]; + const tangent = tRaw != null ? Number(tRaw) : 0; + time[i] = widthFrames / fps; + value[i] = widthFrames !== 0 ? tangent * widthFrames / segmentFrames : 0; + } + return { value, time }; +} + +function mapInterpolation(interp: VS_KeyFrameInterpolation | undefined): 'linear' | 'bezier' | 'step' { + if (interp === 'Bezier') return 'bezier'; + if (interp === 'Step') return 'step'; + return 'linear'; +} diff --git a/src/vs_shape_def.ts b/src/vs_shape_def.ts index 3a158cb..adee8a2 100644 --- a/src/vs_shape_def.ts +++ b/src/vs_shape_def.ts @@ -92,6 +92,14 @@ export interface VS_Keyframe { textures?: Record, } +/** + * Per-channel interpolation mode for the OUTGOING segment from this keyframe to + * the next. Linear is the default and matches the engine's legacy behaviour. + * Bezier is a cubic bezier defined by a tangent slope plus a handle width per axis; + * Step holds the previous value until the next keyframe. + */ +export type VS_KeyFrameInterpolation = "Linear" | "Bezier" | "Step" + /** * Json Attributes also include * OriginX, OriginY, OriginZ @@ -110,6 +118,58 @@ export interface VS_AnimationKey { stretchX?: number, stretchY?: number, stretchZ?: number, + + positionInterp?: VS_KeyFrameInterpolation, + rotationInterp?: VS_KeyFrameInterpolation, + scaleInterp?: VS_KeyFrameInterpolation, + + // Cubic bezier handle tangent slopes in value-per-segment-t units. Non-null only when + // the corresponding channel uses bezier interpolation. + offsetTangentInX?: number, + offsetTangentInY?: number, + offsetTangentInZ?: number, + offsetTangentOutX?: number, + offsetTangentOutY?: number, + offsetTangentOutZ?: number, + + rotationTangentInX?: number, + rotationTangentInY?: number, + rotationTangentInZ?: number, + rotationTangentOutX?: number, + rotationTangentOutY?: number, + rotationTangentOutZ?: number, + + stretchTangentInX?: number, + stretchTangentInY?: number, + stretchTangentInZ?: number, + stretchTangentOutX?: number, + stretchTangentOutY?: number, + stretchTangentOutZ?: number, + + // Horizontal extent (in frames) of each bezier handle, mirroring Blockbench's + // bezier_*_time. Together with the tangent slope this pins the cubic control point so + // non-1/3 handle widths survive. Out-widths >= 0, in-widths <= 0. Omitted when equal to + // the +/-1/3-of-segment default (the engine then falls back to the glTF Hermite curve). + offsetTangentInWidthX?: number, + offsetTangentInWidthY?: number, + offsetTangentInWidthZ?: number, + offsetTangentOutWidthX?: number, + offsetTangentOutWidthY?: number, + offsetTangentOutWidthZ?: number, + + rotationTangentInWidthX?: number, + rotationTangentInWidthY?: number, + rotationTangentInWidthZ?: number, + rotationTangentOutWidthX?: number, + rotationTangentOutWidthY?: number, + rotationTangentOutWidthZ?: number, + + stretchTangentInWidthX?: number, + stretchTangentInWidthY?: number, + stretchTangentInWidthZ?: number, + stretchTangentOutWidthX?: number, + stretchTangentOutWidthY?: number, + stretchTangentOutWidthZ?: number, } /** From 9cc33220af10571b93ced8e0e92b4e5fe2d35ebc Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 12:23:37 +0100 Subject: [PATCH 04/13] Round-trip particle keyframes on the animation effect channel --- src/export_animation.ts | 22 +++++++++++++++++++--- src/import_animation.ts | 19 +++++++++++++++++++ src/vs_shape_def.ts | 12 ++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/export_animation.ts b/src/export_animation.ts index f4df26e..8b62d3d 100644 --- a/src/export_animation.ts +++ b/src/export_animation.ts @@ -1,4 +1,4 @@ -import { VS_Animation, VS_AnimationKey, VS_Keyframe, VS_KeyFrameInterpolation } from "./vs_shape_def"; +import { VS_Animation, VS_AnimationKey, VS_AnimationParticle, VS_Keyframe, VS_KeyFrameInterpolation } from "./vs_shape_def"; import * as util from "./util"; import { is_backdrop_project } from "./util/misc"; @@ -284,7 +284,7 @@ export function compile_animation(animation: _Animation, catmullConverted?: stri }); } - // Process effect animators for texture swap keyframes + // Process effect animators for texture swap and particle keyframes if (animator.type === 'effect' && animator.keyframes && animator.keyframes.length > 0) { animator.keyframes.forEach(kf => { if (kf.channel === 'timeline') { @@ -298,6 +298,18 @@ export function compile_animation(animation: _Animation, catmullConverted?: stri } } } + if (kf.channel === 'particle') { + kf.data_points.forEach(dp => { + const effect = (dp.effect || '').trim(); + if (!effect) return; + const frame = Math.round(kf.time * fps); + keyframes[frame] = keyframes[frame] || { frame, elements: {} }; + const particle: VS_AnimationParticle = { effect }; + const locator = (dp.locator || '').trim(); + if (locator) particle.atAttachmentPoint = locator; + (keyframes[frame].particles = keyframes[frame].particles || []).push(particle); + }); + } }); } }); @@ -311,6 +323,9 @@ export function compile_animation(animation: _Animation, catmullConverted?: stri wrapped_elements[element] = new oneLiner(content); } keyframe.elements = wrapped_elements; + if (keyframe.particles) { + keyframe.particles = keyframe.particles.map(p => new oneLiner(p) as unknown as VS_AnimationParticle); + } } // Use preserved VS values if available, otherwise compute defaults @@ -421,7 +436,8 @@ function keyframe_contents_match(a: VS_Keyframe, b: VS_Keyframe): boolean { 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 + textures: keyframe.textures ? sort_nested_object(keyframe.textures) : undefined, + particles: keyframe.particles ? sort_nested_object(keyframe.particles) : undefined }); } diff --git a/src/import_animation.ts b/src/import_animation.ts index 8c06d27..e9f96ed 100644 --- a/src/import_animation.ts +++ b/src/import_animation.ts @@ -30,6 +30,16 @@ export function create_animation(vsAnimation: VS_Animation): _Animation { const channelFrames = buildChannelFrames(vsAnimation); vsAnimation.keyframes.forEach(vsKeyframe => { + // Particle triggers live on the effect animator (Blockbench's Effects > Particle channel) + if (vsKeyframe.particles && vsKeyframe.particles.length > 0) { + const effects = getEffectAnimator(animation); + effects.addKeyframe({ + channel: 'particle', + time: vsKeyframe.frame / FPS, + data_points: vsKeyframe.particles.map(p => ({ effect: p.effect, locator: p.atAttachmentPoint || '' })), + }); + } + for (const boneName in vsKeyframe.elements) { const transform = vsKeyframe.elements[boneName]; const bone = Group.all.find(g => g.name === boneName); @@ -55,6 +65,15 @@ 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'); + return animators.effects; +} + type BBImportChannel = 'rotation' | 'position' | 'scale'; interface ImportChannelConfig { diff --git a/src/vs_shape_def.ts b/src/vs_shape_def.ts index adee8a2..ef2f71d 100644 --- a/src/vs_shape_def.ts +++ b/src/vs_shape_def.ts @@ -90,6 +90,18 @@ export interface VS_Keyframe { frame: number, elements: Record, textures?: Record, + particles?: Array, +} + +/** + * A one-shot particle effect trigger on a keyframe. Read by GlintMod's snowstorm + * particle system (SnowstormAnimationParticles); the base game ignores it. + */ +export interface VS_AnimationParticle { + /** Snowstorm particle effect identifier, e.g. "bruister_feathers" */ + effect: string, + /** Attachment point (Blockbench locator) the effect spawns at; entity center when omitted */ + atAttachmentPoint?: string, } /** From 9a891b6f236a84dde222d8610aacb3870ed27eab Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 12:24:20 +0100 Subject: [PATCH 05/13] Map animation loop mode to onAnimationEnd in both directions --- src/export_animation.ts | 13 ++++++++++++- src/import_animation.ts | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/export_animation.ts b/src/export_animation.ts index 8b62d3d..262aee7 100644 --- a/src/export_animation.ts +++ b/src/export_animation.ts @@ -336,12 +336,23 @@ export function compile_animation(animation: _Animation, catmullConverted?: stri // @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 = { + loop: ["Repeat"], + hold: ["Hold"], + once: ["Stop", "EaseOut"], + }; + const validEnds = loopToEnd[animation.loop] ?? loopToEnd.once; + const onAnimationEnd = validEnds.includes(storedOnAnimationEnd) ? storedOnAnimationEnd : validEnds[0]; + const vsAnimation : VS_Animation = { name: animation.name, code: storedCode || animation.name.toLowerCase().replace(/ /g, ''), quantityframes: get_frame_quantity(animation, keyframes), onActivityStopped: storedOnActivityStopped || "EaseOut", - onAnimationEnd: storedOnAnimationEnd || (animation.loop === 'loop' ? "Repeat" : "Hold"), + onAnimationEnd, keyframes: Object.values(keyframes).sort((a, b) => a.frame - b.frame) }; diff --git a/src/import_animation.ts b/src/import_animation.ts index e9f96ed..e65a5aa 100644 --- a/src/import_animation.ts +++ b/src/import_animation.ts @@ -12,7 +12,7 @@ export function create_animation(vsAnimation: VS_Animation): _Animation { 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, - loop: isLooping ? 'loop' : 'once', + loop: isLooping ? 'loop' : (vsAnimation.onAnimationEnd === 'Hold' ? 'hold' : 'once'), length: animationLength, snapping: FPS }) as unknown) as _Animation).add(); From 09671be33c71adc9c16c1b01586374c320da134e Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 12:25:39 +0100 Subject: [PATCH 06/13] Add multi-file animation library support via AnimationCodec --- src/animation_codec.ts | 246 ++++++++++++++++++++++++++++++ src/animation_library_paths.ts | 102 +++++++++++++ src/export.ts | 28 +++- src/export_animation.ts | 38 ++--- src/format_definition.ts | 9 +- src/import.ts | 42 ++++- src/import_animation.ts | 17 ++- src/types/blockbench-augment.d.ts | 58 +++++++ src/vs_shape_def.ts | 14 ++ 9 files changed, 527 insertions(+), 27 deletions(-) create mode 100644 src/animation_codec.ts create mode 100644 src/animation_library_paths.ts create mode 100644 src/types/blockbench-augment.d.ts diff --git a/src/animation_codec.ts b/src/animation_codec.ts new file mode 100644 index 0000000..b4250d4 --- /dev/null +++ b/src/animation_codec.ts @@ -0,0 +1,246 @@ +/** + * Vintage Story animation codec. Registers with Blockbench's pluggable `AnimationCodec` + * system so the native multi-file animation workflow (group-by-file in the ANIMATIONS + * panel, Load Animation File / Save / Save All) reads and writes VS animation *library* + * files instead of the default Bedrock format. + * + * The format mirrors the engine's `AnimationLibrary` class: `{ code?, name?, animations[] }` + * where each entry is a `VS_Animation`. Library files live at + * `assets//animations/.json` and are referenced from a shape's + * `animationLibraries[]`. See `./animation_library_paths.ts` and the engine's + * `Shape.ResolveAnimationLibraries()`. + * + * Modelled on Blockbench's Bedrock codec. The panel gates "Load Animation File" on + * `getCodec()?.pickFile` and "Save All Animations" calls `codec.exportFile(path)`, so a + * codec must provide pickFile / importFile / exportFile (the base class supplies no + * defaults; it just does `Object.assign(this, options)`): + * https://github.com/JannisX11/blockbench/blob/master/js/formats/bedrock/bedrock_animation.js + */ + +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"; + +// @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 +// (the engine keys animations by their own `code`, so these are cosmetic, but preserving +// 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. */ +function all_animations(): _Animation[] { + return (Animation as unknown as typeof _Animation).all; +} + +/** Default directory the file dialogs open in: `//animations`. */ +function default_animations_dir(): string | undefined { + const modelPath = Project?.save_path || Project?.export_path; + if (!modelPath) return undefined; + const ctx = parse_model_location(modelPath); + return ctx ? `${ctx.assetsRoot}/${ctx.domain}/animations` : undefined; +} + +/** Builds a library wrapper for a file, using remembered or filename-derived code/name. */ +function build_library(path: string | undefined, animations: VS_Animation[]): VS_AnimationLibrary { + const meta = path ? libraryMeta.get(path) : undefined; + const stem = path ? basename_no_ext(path) : undefined; + const library: VS_AnimationLibrary = { animations }; + const code = meta?.code ?? stem; + const name = meta?.name ?? stem; + if (code) library.code = code; + if (name) library.name = name; + 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 created: _Animation[] = []; + if (!json || !Array.isArray(json.animations)) return created; + + libraryMeta.set(file.path, { code: json.code, name: json.name }); + + for (const vsAnim of json.animations) { + if (animation_filter && !animation_filter.includes(vsAnim.name)) continue; + created.push(create_animation(vsAnim, file.path, vsAnim.name)); + } + return created; +} + +/** Compiles a set of animations into a full VS library object. */ +function compile_file(animations: _Animation[]): VS_AnimationLibrary { + const path = animations[0]?.path; + const compiled = compile_animation_library(animations).animations; + return build_library(path, compiled); +} + +/** Opens a dialog to import one or more VS animation library files. */ +function pick_file(): void { + Blockbench.import({ + resource_id: 'vs_animation', + type: 'Vintage Story Animation', + extensions: ['json'], + multiple: true, + startpath: default_animations_dir(), + }, (files) => { + for (const file of files) { + import_file(file); + } + }); +} + +/** 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); +} + +/** + * Writes one animation into its library file (assumes `animation.path` is set), merging it + * with the file's other animations and preserving their order. + */ +function write_animation_to_library(animation: _Animation): void { + const vsAnim = compile_animation(animation); + if (!vsAnim) { + Blockbench.showMessageBox({ + title: 'Nothing to save', + message: `Animation "${animation.name}" has no keyframes to export.` + }); + return; + } + 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]); + } + + Blockbench.writeFile(path, { content: autoStringify(library) }, (real_path) => { + animation.saved = true; + animation.saved_name = animation.name; + animation.path = real_path; + // Without this a silently skipped save (e.g. clicking a greyed-out button) is + // indistinguishable from a successful one. + Blockbench.showQuickMessage(`Saved "${animation.name}" to ${basename_no_ext(real_path)}.json`, 2000); + }); +} + +/** Saves a single animation (per-animation "Save"); prompts for a file when it has none. */ +function save_animation(animation: _Animation, save_as?: boolean): void { + if (!animation.path || save_as) { + Blockbench.export({ + resource_id: 'vs_animation', + type: 'Vintage Story Animation', + extensions: ['json'], + name: animation.saved_name || animation.name, + startpath: animation.path || default_animations_dir(), + custom_writer: (_content, exportPath) => { + if (!exportPath) return; + animation.path = exportPath; + write_animation_to_library(animation); + } + }); + return; + } + write_animation_to_library(animation); +} + +/** + * Saves every animation that belongs to `path` to that file ("Save All" calls this once per + * distinct path). Path-less animations are inline (saved inside the shape by the model codec + * on project save/export), so the empty-path group is intentionally skipped here. + */ +function export_file(path: string, save_as?: boolean): void { + const filterPath = path || ''; + const animations = all_animations().filter(a => (a.path || '') === filterPath); + if (animations.length === 0) return; + + if (!save_as && filterPath && fs.existsSync(filterPath)) { + // Recompile and merge EVERY loaded animation, not just the unsaved ones. "Export + // Animations..." recompiles all of them, so skipping saved siblings here would leave + // their on-disk bytes untouched and make the two paths diverge. The merge still + // preserves animations on disk that aren't loaded into the project. + animations.forEach(a => a.save()); + return; + } + + // The "Unsaved" group (empty path), a brand-new file, or Save As: prompt for a destination, + // then write the whole group there and associate the animations with it. This is how the + // group's save button turns inline animations into a library file group. + Blockbench.export({ + resource_id: 'vs_animation', + type: 'Vintage Story Animation', + extensions: ['json'], + name: (filterPath && basename_no_ext(filterPath)) || 'animations', + startpath: filterPath || default_animations_dir(), + custom_writer: (_content, exportPath) => { + if (!exportPath) return; + animations.forEach(a => { a.path = exportPath; }); + fs.writeFileSync(exportPath, autoStringify(compile_file(animations))); + animations.forEach(a => { a.saved = true; a.saved_name = a.name; }); + }, + }); +} + +/** 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 target = animation.saved_name ?? animation.name; + library.animations = library.animations.filter(a => a.name !== target); + Blockbench.writeFile(path, { content: autoStringify(library) }); +} + +function create_vs_animation_codec(): AnimationCodec | undefined { + if (typeof AnimationCodec === 'undefined') { + console.error('[VS Plugin] This Blockbench version does not expose AnimationCodec; ' + + 'multi-file VS animation support is disabled. Update to Blockbench 5.x.'); + return undefined; + } + + return new AnimationCodec('vintagestory', { + multiple_per_file: true, + pickFile: pick_file, + importFile: import_file, + loadFile: load_file, + compileAnimation: (animation) => compile_animation(animation), + compileFile: compile_file, + saveAnimation: save_animation, + exportFile: export_file, + deleteAnimationFromFile: delete_animation_from_file, + }); +} + +export const vsAnimationCodec = create_vs_animation_codec(); diff --git a/src/animation_library_paths.ts b/src/animation_library_paths.ts new file mode 100644 index 0000000..031e769 --- /dev/null +++ b/src/animation_library_paths.ts @@ -0,0 +1,102 @@ +/** + * Helpers that map between Vintage Story animation-library *references* (the strings + * stored in a shape's `animationLibraries[]`) and on-disk file paths. + * + * Mirrors the engine's resolution in `Shape.ResolveAnimationLibraries()`: + * reference `"[domain:]sub/path"` <-> `assets//animations/sub/path.json` + * (an unqualified reference defaults to the "game" domain in the engine; for authoring + * we also try the shape's own domain so same-mod libraries resolve). + */ + +/** Normalises Windows backslashes to forward slashes (accepted by Node + Blockbench). */ +function normalize(p: string): string { + return p.replace(/\\/g, "/"); +} + +/** Splits a reference into its optional domain and sub-path, e.g. `game:humanoid/walk`. */ +export function split_reference(ref: string): { domain?: string, subpath: string } { + const trimmed = ref.trim(); + const idx = trimmed.indexOf(":"); + if (idx >= 0) { + return { domain: trimmed.slice(0, idx), subpath: trimmed.slice(idx + 1) }; + } + return { domain: undefined, subpath: trimmed }; +} + +/** + * Locates the `assets//` root from a model file path. + * `.../assets/game/shapes/entity/foo.json` -> `{ assetsRoot: ".../assets", domain: "game" }`. + * Returns null when the path is not inside an `assets//` tree. + */ +export function parse_model_location(modelPath: string): { assetsRoot: string, domain: string } | null { + const p = normalize(modelPath); + const marker = "/assets/"; + const i = p.indexOf(marker); + if (i < 0) return null; + const after = p.slice(i + marker.length); // "/shapes/..." + const slash = after.indexOf("/"); + if (slash <= 0) return null; + return { assetsRoot: p.slice(0, i) + "/assets", domain: after.slice(0, slash) }; +} + +/** + * Produces the candidate absolute file paths a reference could resolve to, in priority + * order. An explicit-domain reference yields a single candidate; an unqualified one is + * tried against the model's own domain first, then "game". The caller picks the first + * that exists on disk (matching the engine's "warn and skip if not found" behaviour). + */ +export function reference_to_candidate_paths(ref: string, modelPath: string): string[] { + const ctx = parse_model_location(modelPath); + if (!ctx) return []; + const { domain, subpath } = split_reference(ref); + if (!subpath) return []; + + const domains: string[] = []; + if (domain) { + domains.push(domain); + } else { + domains.push(ctx.domain); + if (ctx.domain !== "game") domains.push("game"); + } + + const seen = new Set(); + const paths: string[] = []; + for (const d of domains) { + const candidate = `${ctx.assetsRoot}/${d}/animations/${subpath}.json`; + if (!seen.has(candidate)) { seen.add(candidate); paths.push(candidate); } + } + return paths; +} + +/** + * Derives the `animationLibraries` reference for a library file path. Inverse of + * {@link reference_to_candidate_paths}. + * `.../assets/game/animations/humanoid/walk.json` -> `"game:humanoid/walk"`. + * Falls back to the bare sub-path when no `assets//` segment is present, and + * returns null when the path is not under an `animations/` folder. + */ +export function path_to_reference(filePath: string): string | null { + const p = normalize(filePath); + const marker = "/animations/"; + const i = p.indexOf(marker); + if (i < 0) return null; + + const subpath = p.slice(i + marker.length).replace(/\.json$/i, ""); + if (!subpath) return null; + + const before = p.slice(0, i); // ".../assets/" + const am = "/assets/"; + const ai = before.lastIndexOf(am); + if (ai >= 0) { + const domain = before.slice(ai + am.length).split("/")[0]; + if (domain) return `${domain}:${subpath}`; + } + return subpath; +} + +/** Library file base name without extension, used as a default library code/name. */ +export function basename_no_ext(filePath: string): string { + const p = normalize(filePath); + const base = p.slice(p.lastIndexOf("/") + 1); + return base.replace(/\.json$/i, ""); +} diff --git a/src/export.ts b/src/export.ts index bcff549..a784322 100644 --- a/src/export.ts +++ b/src/export.ts @@ -1,8 +1,9 @@ import {export_model} from "./export_model"; -import {export_animations} from "./export_animation"; +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 { path_to_reference } from "./animation_library_paths"; // @ts-expect-error: requireNativeModule is missing in blockbench types --- IGNORE --- const fs = requireNativeModule('fs'); @@ -141,8 +142,23 @@ export function ex(options): VS_Shape { // Export model elements const elements = export_model(); - // Export animations - const animations = export_animations(); + // 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 inlineAnimations = compile_animation_library(allAnimations.filter(a => !a.path)).animations; + + // Library refs are emitted even for backdrop projects. Backdrops suppress inline animation + // data, but the ref list points at files the user never edited here, so dropping it would + // silently unlink those libraries from the shape. + const libraryRefs: string[] = []; + 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); } + } // Populate Editor Info const editor: VS_EditorSettings = {}; @@ -159,8 +175,12 @@ export function ex(options): VS_Shape { textureSizes: textureSizes, textures: textures, elements: elements, - animations: animations, + animations: inlineAnimations, }; + if (libraryRefs.length > 0) { + data.animationLibraries = libraryRefs; + } + return data; } \ No newline at end of file diff --git a/src/export_animation.ts b/src/export_animation.ts index 262aee7..5ee0bf7 100644 --- a/src/export_animation.ts +++ b/src/export_animation.ts @@ -1,4 +1,4 @@ -import { VS_Animation, VS_AnimationKey, VS_AnimationParticle, VS_Keyframe, VS_KeyFrameInterpolation } from "./vs_shape_def"; +import { VS_Animation, VS_AnimationKey, VS_AnimationLibrary, VS_AnimationParticle, VS_Keyframe, VS_KeyFrameInterpolation } from "./vs_shape_def"; import * as util from "./util"; import { is_backdrop_project } from "./util/misc"; @@ -178,24 +178,6 @@ function applyInterpolationToKey( * Exports Blockbench animations to the Vintage Story animation format. * @returns An array of VS animations. */ -export function export_animations(): Array { - // Don't export any animations if project contains backdrops - if (is_backdrop_project()) { - return []; - } - - const catmullConverted: string[] = []; - const animations = (Animation as unknown as typeof _Animation).all - .map(animation => compile_animation(animation, catmullConverted)) - .filter((a): a is VS_Animation => a !== null); - - if (catmullConverted.length > 0) { - display_catmull_conversion_notice(catmullConverted); - } - - return animations; -} - /** * Converts a single Blockbench animation to the Vintage Story animation format. * Returns null when the animation has no exportable keyframes (or for backdrop projects). @@ -388,6 +370,24 @@ export function compile_animation(animation: _Animation, catmullConverted?: stri return vsAnimation; } +/** + * Compiles the given Blockbench animations into a VS animation library structure + * (`{ code?, name?, animations[] }`), the on-disk format for a standalone animation + * library file consumed by the engine's `Shape.AnimationLibraries` references. + */ +export function compile_animation_library(animations: _Animation[], code?: string, name?: string): VS_AnimationLibrary { + const catmullConverted: string[] = []; + const compiled = animations + .map(a => compile_animation(a, catmullConverted)) + .filter((a): a is VS_Animation => a !== null); + if (catmullConverted.length > 0) display_catmull_conversion_notice(catmullConverted); + + const library: VS_AnimationLibrary = { animations: compiled }; + if (code) library.code = code; + if (name) library.name = name; + return library; +} + /** * Returns the length of an animation in frames so it is compatible with VS. Displays a warning to the user if necessary. * @param animation The BB animation object. diff --git a/src/format_definition.ts b/src/format_definition.ts index 239a358..9b06155 100644 --- a/src/format_definition.ts +++ b/src/format_definition.ts @@ -1,4 +1,5 @@ import { codecVS } from "./codec"; +import { vsAnimationCodec } from "./animation_codec"; export function create_format(): ModelFormat { const format = new ModelFormat("formatVS", { @@ -32,7 +33,9 @@ export function create_format(): ModelFormat { select_texture_for_particles: false, texture_mcmeta: false, bone_binding_expression: false, // Revisit for animation - animation_files: false, + // Enables Blockbench's multi-file animation workflow: per-animation `path`/`saved_name`, + // grouping by file in the ANIMATIONS panel, and per-file Save / Save All / Import. + animation_files: true, texture_folder: true, image_editor: false, // Setting this to true removes the object outliner?!?! edit_mode: true, @@ -52,5 +55,9 @@ export function create_format(): ModelFormat { per_animator_rotation_interpolation: false, }); codecVS.format = format; + // Route animation save/load/compile through the VS library codec (AnimationCodec.getCodec + // checks Format.animation_codec first), so files use the VS format, not Bedrock's. + // vsAnimationCodec is undefined on Blockbench builds without the AnimationCodec API. + if (vsAnimationCodec) format.animation_codec = vsAnimationCodec; return format; }; \ No newline at end of file diff --git a/src/import.ts b/src/import.ts index c8e3e6b..a151ce1 100644 --- a/src/import.ts +++ b/src/import.ts @@ -4,6 +4,11 @@ import { import_animations } from "./import_animation"; import { VS_Shape } from "./vs_shape_def"; import { VS_PROJECT_PROPS } from "./property"; 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) { @@ -53,8 +58,43 @@ export function im(content: VS_Shape, _path: string, asBackdrop: boolean) { // Build the model structure using the dedicated module import_model(content, asBackdrop, _path); - // Import animations using the dedicated module + // Import inline (shape-embedded) animations. if (content.animations) { import_animations(content.animations); } + + // Load referenced external animation libraries so they appear grouped by file. + if (content.animationLibraries && content.animationLibraries.length > 0) { + load_animation_libraries(content.animationLibraries, _path); + } +} + +/** + * Resolves each `animationLibraries` reference to a file under `assets//animations/`, + * loads it through the VS animation codec (so its animations group under that file in the + * panel), and remembers the original reference string for a diff-stable round trip. + * Mirrors the engine's `Shape.ResolveAnimationLibraries()`. Warns and skips missing files. + */ +function load_animation_libraries(refs: string[], modelPath: string) { + if (!vsAnimationCodec) return; + for (const ref of refs) { + const candidates = reference_to_candidate_paths(ref, modelPath); + const filePath = candidates.find(p => fs.existsSync(p)); + if (!filePath) { + console.warn(`[VS Import] Animation library "${ref}" not found. Looked in: ${candidates.join(', ') || '(model is not inside an assets/ tree)'}`); + continue; + } + let fileContent: string; + try { + fileContent = fs.readFileSync(filePath, 'utf-8'); + } catch (e) { + console.error(`[VS Import] Failed to read animation library ${filePath}:`, e); + continue; + } + 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 e65a5aa..c29e7bf 100644 --- a/src/import_animation.ts +++ b/src/import_animation.ts @@ -3,8 +3,12 @@ import { VS_Animation, VS_AnimationKey, VS_KeyFrameInterpolation } from "./vs_sh /** * Creates one Blockbench animation from a VS animation definition and returns it. + * When `path` is given the animation is associated with that library file (so the + * ANIMATIONS panel groups it under the file and saves route back to it); `saved_name` + * records the name it currently has on disk. Path-less animations are "inline" and + * round-trip into the shape's own `animations[]` on export. */ -export function create_animation(vsAnimation: VS_Animation): _Animation { +export function create_animation(vsAnimation: VS_Animation, path?: string, saved_name?: string): _Animation { const FPS = util.fps; const animationLength = vsAnimation.quantityframes / FPS; const isLooping = vsAnimation.onAnimationEnd === 'Repeat'; @@ -17,6 +21,13 @@ export function create_animation(vsAnimation: VS_Animation): _Animation { snapping: FPS }) as unknown) as _Animation).add(); + // Associate with a library file when loaded from one (drives panel grouping + saving). + if (path) animation.path = path; + if (saved_name) { + animation.saved_name = saved_name; + animation.saved = true; + } + // Preserve VS-specific animation properties for round-trip fidelity // @ts-expect-error: custom property for round-trip animation.vs_code = vsAnimation.code; @@ -58,7 +69,9 @@ export function create_animation(vsAnimation: VS_Animation): _Animation { } /** - * Imports animations from the Vintage Story format into Blockbench. + * Imports inline (shape-embedded) animations from the Vintage Story format into Blockbench. + * These are not tied to a library file, so they round-trip back into the shape's own + * `animations[]` on export. * @param {Array} animations The array of animation data from the VS model file. */ export function import_animations(animations: Array) { diff --git a/src/types/blockbench-augment.d.ts b/src/types/blockbench-augment.d.ts new file mode 100644 index 0000000..63a81c8 --- /dev/null +++ b/src/types/blockbench-augment.d.ts @@ -0,0 +1,58 @@ +/** + * Ambient augmentations for Blockbench globals that the installed `blockbench-types` + * (SnaveSutit fork) predates. Blockbench 5 exposes a pluggable `AnimationCodec` + * (js/animations/animation_codec.ts, attached to `window`) and reads + * `Format.animation_codec` first in `AnimationCodec.getCodec()`. See + * https://github.com/JannisX11/blockbench/blob/master/js/animations/animation_codec.ts + */ + +declare global { + /** The file-like object handed to `AnimationCodec.loadFile`. */ + interface AnimationCodecFile { + path: string; + content?: string | ArrayBuffer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + json?: any; + } + + interface AnimationCodecOptions { + multiple_per_file?: boolean; + pickFile?(): void; + importFile?(file: AnimationCodecFile, auto_loaded?: boolean): _Animation[]; + loadFile?(file: AnimationCodecFile, animation_filter?: string[]): _Animation[]; + reloadFile?(file: AnimationCodecFile): void; + reloadAnimation?(animation: _Animation): void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + compileAnimation?(animation: _Animation): any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + compileFile?(animations: _Animation[]): any; + saveAnimation?(animation: _Animation, save_as?: boolean): void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + exportFile?(...args: any[]): void; + deleteAnimationFromFile?(animation: _Animation): void; + } + + class AnimationCodec { + constructor(id: string, options: AnimationCodecOptions); + id: string; + multiple_per_file: boolean; + loadFile(file: AnimationCodecFile, animation_filter?: string[]): _Animation[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + compileAnimation(animation: _Animation): any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + compileFile(animations: _Animation[]): any; + saveAnimation(animation: _Animation, save_as?: boolean): void; + static codecs: Record; + static getCodec(animation?: AnimationItem): AnimationCodec; + } + + interface FormatOptions { + animation_codec?: AnimationCodec; + } + + interface ModelFormat { + animation_codec?: AnimationCodec; + } +} + +export {}; diff --git a/src/vs_shape_def.ts b/src/vs_shape_def.ts index ef2f71d..0be1dcc 100644 --- a/src/vs_shape_def.ts +++ b/src/vs_shape_def.ts @@ -11,6 +11,19 @@ export interface VS_Shape { textures: Record, elements: Array, animations?: Array, + animationLibraries?: Array, +} + +/** + * Standalone animation library file format. Mirrors the C# AnimationLibrary class. + * Library files live under `assets//animations/.json` and are referenced + * from a Shape's `animationLibraries` property. The host shape merges library animations + * into its own `Animations[]` at load time, keyed by `Animation.code`. + */ +export interface VS_AnimationLibrary { + code?: string, + name?: string, + animations: Array, } export interface VS_EditorSettings { @@ -37,6 +50,7 @@ export interface VS_Element { climateColorMap?: string, gradientShade?: boolean, renderPass?: number, + paletteSlot?: number, seasonColorMap?: string, shade?: boolean, uv?: [number,number], From e58e21c4e9ec66cd490105155fe157734590d493 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 12:25:54 +0100 Subject: [PATCH 07/13] Add Clear All Animations action for VS projects --- src/animation_actions.ts | 30 ++++++++++++++++++++++++++++++ src/import_animation.ts | 10 ++++++++++ src/vs_plugin.ts | 1 + 3 files changed, 41 insertions(+) create mode 100644 src/animation_actions.ts diff --git a/src/animation_actions.ts b/src/animation_actions.ts new file mode 100644 index 0000000..8a2c9e3 --- /dev/null +++ b/src/animation_actions.ts @@ -0,0 +1,30 @@ +import { createAction } from "./util/moddingTools"; +import * as PACKAGE from "../package.json"; +import { is_vs_project } from "./util"; +import { clear_animations } from "./import_animation"; + +// Importing and exporting VS animation files is handled natively by the multi-file +// animation workflow (format `animation_files` + the VS AnimationCodec): use the +// ANIMATIONS panel's Import Animations / Save / Save All. Only the bulk-clear helper +// remains as a convenience action here. + +const clear_animations_action = createAction(`${PACKAGE.name}:clear_animations_vs`, { + name: 'Clear All Animations', + icon: 'delete_sweep', + condition() { + return is_vs_project(Project); + }, + click: function () { + const total = (Animation as unknown as typeof _Animation).all.length; + if (total === 0) { + Blockbench.showQuickMessage('No animations to clear'); + return; + } + if (!confirm(`Delete all ${total} animation${total === 1 ? '' : 's'} from this project?\n\nThis can be undone with Ctrl+Z.`)) { + return; + } + const removed = clear_animations(); + Blockbench.showQuickMessage(`Cleared ${removed} animation${removed === 1 ? '' : 's'}`); + } +}); +MenuBar.addAction(clear_animations_action, 'edit'); diff --git a/src/import_animation.ts b/src/import_animation.ts index c29e7bf..17cdd06 100644 --- a/src/import_animation.ts +++ b/src/import_animation.ts @@ -236,3 +236,13 @@ function mapInterpolation(interp: VS_KeyFrameInterpolation | undefined): 'linear if (interp === 'Step') return 'step'; return 'linear'; } + +/** + * Removes every animation from the current project. Returns the count removed. + * 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(); + all.forEach(a => a.remove(true)); + return all.length; +} diff --git a/src/vs_plugin.ts b/src/vs_plugin.ts index c9ddf60..aa9df72 100644 --- a/src/vs_plugin.ts +++ b/src/vs_plugin.ts @@ -9,6 +9,7 @@ import PACKAGE from "../package.json"; // Actions import "./debug_actions"; import "./actions"; +import "./animation_actions"; // Properties import "./property"; From f6dbc6d4ba178e2da29fbb3522aa331394d85888 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 12:26:11 +0100 Subject: [PATCH 08/13] Disable animation controllers for the VS format --- src/format_definition.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/format_definition.ts b/src/format_definition.ts index 9b06155..50a6fd3 100644 --- a/src/format_definition.ts +++ b/src/format_definition.ts @@ -43,7 +43,8 @@ export function create_format(): ModelFormat { display_mode: false, // Only some Minecraft Skin stuff it seems animation_mode: true, pose_mode: false, - animation_controllers: true, + animation_controllers: false, // VS has no animation-controller concept + box_uv_float_size: false, java_cube_shading_properties: false, cullfaces: false, // Not sure if Vintage Story supports this From 61b6bc49497b000c5d11d7b0a259cf1516d63603 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 12:28:25 +0100 Subject: [PATCH 09/13] Register VS round-trip metadata as real Blockbench properties --- src/import_model/cube/factory.ts | 4 +- src/import_model/group.ts | 7 +--- src/property.ts | 65 +++++++++++++++++++++++++++++++- src/types/property-augment.d.ts | 20 ++++++++++ 4 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 src/types/property-augment.d.ts diff --git a/src/import_model/cube/factory.ts b/src/import_model/cube/factory.ts index 4d026e6..6e84a2f 100644 --- a/src/import_model/cube/factory.ts +++ b/src/import_model/cube/factory.ts @@ -22,12 +22,10 @@ export function create_cube(object_space_pos: [number,number,number], vsElement: }; const cube = new Cube(cube_options); - // @ts-expect-error: custom property for round-trip fidelity cube.vs_has_rotation_origin = vsElement.rotationOrigin !== undefined; // Preserve element-level uv offset (Blockbench may not preserve uv_offset in per-face UV mode) if (vsElement.uv) { - // @ts-expect-error: custom property for round-trip fidelity cube.vs_uv = vsElement.uv; } @@ -40,4 +38,4 @@ export function create_cube(object_space_pos: [number,number,number], vsElement: } return cube; -} \ No newline at end of file +} diff --git a/src/import_model/group.ts b/src/import_model/group.ts index 93aadc6..06aad0f 100644 --- a/src/import_model/group.ts +++ b/src/import_model/group.ts @@ -27,22 +27,17 @@ export function process_group(parent: Group | null, object_space_pos: [number,nu // Store VS from/to in absolute BB coordinates for export round-trip. // Groups only have `origin` (= rotationOrigin), but VS elements can have // from/to that differ from rotationOrigin. - // @ts-expect-error: custom property for round-trip fidelity group.vs_group_from = absolute_from; - // @ts-expect-error: custom property for round-trip fidelity group.vs_group_to = absolute_to; - // @ts-expect-error: custom property for round-trip fidelity group.vs_has_rotation_origin = vsElement.rotationOrigin !== undefined; // Preserve faces on zero-size elements (from==to but with non-empty faces) if (vsElement.faces && Object.keys(vsElement.faces).length > 0 && util.vector_equals(vsElement.from, vsElement.to)) { - // @ts-expect-error: custom property for round-trip fidelity group.vs_zero_size_faces = vsElement.faces; } // Preserve element-level uv offset (Blockbench may not preserve uv_offset in per-face UV mode) if (vsElement.uv) { - // @ts-expect-error: custom property for round-trip fidelity group.vs_uv = vsElement.uv; } @@ -88,4 +83,4 @@ export function process_group(parent: Group | null, object_space_pos: [number,nu } return group; -} \ No newline at end of file +} diff --git a/src/property.ts b/src/property.ts index 01591a1..d058f63 100644 --- a/src/property.ts +++ b/src/property.ts @@ -1,4 +1,4 @@ -import { VS_EditorSettings, VS_ReflectiveMode } from "./vs_shape_def"; +import { VS_Direction, VS_EditorSettings, VS_Face, VS_ReflectiveMode } from "./vs_shape_def"; export const VS_PROJECT_PROPS = [ new Property(ModelProject, "string", "backDropShape", { exposed: false, }), @@ -9,6 +9,49 @@ export const VS_PROJECT_PROPS = [ new Property(ModelProject, "boolean", "vsFormatConverted", { exposed: false, }), ]; +type InternalPropertyType = 'vector' | 'vector2' | 'object' | 'boolean'; + +function isFiniteVector(value: unknown, length: 2 | 3): boolean { + return Array.isArray(value) + && value.length === length + && value.every(component => typeof component === 'number' && Number.isFinite(component)); +} + +function isNonEmptyRecord(value: unknown): boolean { + return value !== null + && typeof value === 'object' + && !Array.isArray(value) + && Object.keys(value).length > 0; +} + +function registerOptionalInternalProperty( + targetClass: any, + type: InternalPropertyType, + name: string, + validate: (value: unknown, container?: any) => boolean, +) { + return new Property(targetClass, type, name, { + exposed: false, + condition(instance: any) { + return validate(instance?.[name], instance); + }, + reset(instance: any) { + delete instance[name]; + }, + merge(instance: any, data: any) { + const value = data?.[name]; + if (!validate(value, data)) return; + if (Array.isArray(value)) { + instance[name] = value.slice(); + } else if (value !== null && typeof value === 'object') { + instance[name] = structuredClone(value); + } else { + instance[name] = value; + } + }, + }); +} + export const VS_GROUP_PROPS = [ new Property(Group, "string", "stepParentName", { default: '', @@ -73,6 +116,14 @@ new Property(Group, "string", "clothingSlot", { new Property(Group, "boolean", "backdrop"); +// Internal VS round-trip values. These stay out of VS_GROUP_PROPS so they are +// saved in .bbmodel projects without being emitted as Vintage Story JSON keys. +registerOptionalInternalProperty(Group, 'vector', 'vs_group_from', value => isFiniteVector(value, 3)); +registerOptionalInternalProperty(Group, 'vector', 'vs_group_to', value => isFiniteVector(value, 3)); +registerOptionalInternalProperty(Group, 'boolean', 'vs_has_rotation_origin', value => value === true); +registerOptionalInternalProperty(Group, 'object', 'vs_zero_size_faces', isNonEmptyRecord); +registerOptionalInternalProperty(Group, 'vector2', 'vs_uv', value => isFiniteVector(value, 2)); + export const VS_CUBE_PROPS = [ new Property(Cube, "string", "stepParentName", { default: '', @@ -242,6 +293,9 @@ new Property(Cube, "string", "clothingSlot", { new Property(Cube, "boolean", "backdrop"); +registerOptionalInternalProperty(Cube, 'boolean', 'vs_has_rotation_origin', value => value === true); +registerOptionalInternalProperty(Cube, 'vector2', 'vs_uv', value => isFiniteVector(value, 2)); + export const VS_TEXTURE_PROPS = [ new Property(Texture, "string", "textureLocation", { default: '', @@ -309,6 +363,11 @@ declare global { stepParentName?: string; clothingSlot?: string; backdrop?: boolean; + vs_group_from?: [number, number, number]; + vs_group_to?: [number, number, number]; + vs_has_rotation_origin?: boolean; + vs_zero_size_faces?: Partial>; + vs_uv?: [number, number]; } interface Cube { @@ -323,6 +382,8 @@ declare global { disableRandomDrawOffset?: boolean; unwrapRotation?: number; backdrop?: boolean; + vs_has_rotation_origin?: boolean; + vs_uv?: [number, number]; } interface Locator { @@ -330,4 +391,4 @@ declare global { rotationY?: number; rotationZ?: number; } -} \ No newline at end of file +} diff --git a/src/types/property-augment.d.ts b/src/types/property-augment.d.ts new file mode 100644 index 0000000..2d6d7e7 --- /dev/null +++ b/src/types/property-augment.d.ts @@ -0,0 +1,20 @@ +/** + * blockbench-types omits `PropertyOptions.inputs` (which Blockbench uses to render a property + * as a field in the element panel) and `PropertyOptions.onChange`. Declared here so the VS + * property definitions typecheck. + */ + +interface PropertyPanelInput { + label?: string; + type?: string; + options?: Record | (() => Record); +} + +declare global { + interface PropertyOptions { + inputs?: Record; + onChange?(this: any, value?: any): void; + } +} + +export {}; From 908418598580a68f3191cfd3ac0ca72aa1b20a43 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 12:30:37 +0100 Subject: [PATCH 10/13] Add palette slot property with model and attachment export --- src/attachments/export_attachment_vs.ts | 4 ++- src/export_model/cube.ts | 5 +++- src/export_model/group.ts | 20 ++++++++++++-- src/import_model/group.ts | 1 + src/property.ts | 35 +++++++++++++++++++++++++ src/transform.ts | 3 ++- src/vs_shape_def.ts | 1 + 7 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/attachments/export_attachment_vs.ts b/src/attachments/export_attachment_vs.ts index 79a68a8..5b44f22 100644 --- a/src/attachments/export_attachment_vs.ts +++ b/src/attachments/export_attachment_vs.ts @@ -250,7 +250,9 @@ function process_attachment_group( // Skip properties with default/empty values if (value !== undefined && value !== null && value !== '' && value !== false) { - vsElement[prop_name] = value; + const exportedValue = prop.type === 'number' ? Number(value) : value; + if (prop_name === 'paletteSlot' && exportedValue === 0) continue; + vsElement[prop_name] = exportedValue; } } diff --git a/src/export_model/cube.ts b/src/export_model/cube.ts index ecacd11..57a81ad 100644 --- a/src/export_model/cube.ts +++ b/src/export_model/cube.ts @@ -42,8 +42,11 @@ export function process_cube(parent: Group | null, node: Cube, accu: Array groupProp.name === prop_name)) continue; const value = node[prop_name]; if (prop_name === 'shade') { if (value !== undefined && value !== true) { @@ -115,6 +118,7 @@ export function process_group( if (prop_name === 'renderPass' && numValue === -1) continue; if (prop_name === 'unwrapMode' && numValue === 0) continue; if (prop_name === 'unwrapRotation' && numValue === 0) continue; + if (prop_name === 'paletteSlot' && numValue === 0) continue; vsElement[prop_name] = numValue; } } @@ -202,13 +206,22 @@ export function process_collapsed_group( const prop_name = prop.name; const value = node[prop_name]; if (value !== undefined && value !== null && value !== '' && value !== false) { - vsElement[prop_name] = value; + const exportedValue = prop.type === 'number' ? Number(value) : value; + if (prop_name === 'paletteSlot' && exportedValue === 0) continue; + vsElement[prop_name] = exportedValue; } } + const groupPaletteSlot = Number(node.paletteSlot) || 0; + const geometryPaletteSlot = Number(geoChild.paletteSlot) || 0; + if (groupPaletteSlot === 0 && geometryPaletteSlot !== 0) { + vsElement.paletteSlot = geometryPaletteSlot; + } + // Copy VS_CUBE_PROPS from the _geo cube (e.g. shade, climateColorMap, unwrapMode) for (const prop of VS_CUBE_PROPS) { const prop_name = prop.name; + if (VS_GROUP_PROPS.some(groupProp => groupProp.name === prop_name)) continue; const value = geoChild[prop_name]; if (prop_name === 'shade') { if (value !== true) { @@ -228,6 +241,9 @@ export function process_collapsed_group( if (prop_name === 'unwrapRotation' && numValue === 0) { continue; } + if (prop_name === 'paletteSlot' && numValue === 0) { + continue; + } vsElement[prop_name] = numValue; } } diff --git a/src/import_model/group.ts b/src/import_model/group.ts index 06aad0f..2d17c44 100644 --- a/src/import_model/group.ts +++ b/src/import_model/group.ts @@ -57,6 +57,7 @@ export function process_group(parent: Group | null, object_space_pos: [number,nu // (e.g. elements with from≠to but empty faces, like "eyesroot") for (const prop of VS_CUBE_PROPS) { const prop_name = prop.name; + if (VS_GROUP_PROPS.some(groupProp => groupProp.name === prop_name)) continue; if (prop_name === 'shade') continue; if (vsElement[prop_name] !== undefined) { group[prop_name] = vsElement[prop_name]; diff --git a/src/property.ts b/src/property.ts index d058f63..336359a 100644 --- a/src/property.ts +++ b/src/property.ts @@ -52,6 +52,37 @@ function registerOptionalInternalProperty( }); } +const PALETTE_SLOT_OPTIONS = { + '0': '0 - Inherit / Default', + '1': '1', + '2': '2', + '3': '3', + '4': '4', + '5': '5', + '6': '6', + '7': '7', +}; + +function registerPaletteSlotProperty(targetClass: any) { + const property = new Property(targetClass, 'number', 'paletteSlot', { + default: 0, + label: 'Palette Slot', + exposed: true, + options: PALETTE_SLOT_OPTIONS, + inputs: { + element_panel: { + input: { + label: 'Palette Slot', + type: 'select', + options: PALETTE_SLOT_OPTIONS, + }, + }, + }, + }); + + return property; +} + export const VS_GROUP_PROPS = [ new Property(Group, "string", "stepParentName", { default: '', @@ -70,6 +101,7 @@ export const VS_GROUP_PROPS = [ Canvas.updateAllPositions(); }, }), + registerPaletteSlotProperty(Group), ]; // Blockbench-only properties (not exported to VS JSON format) @@ -142,6 +174,7 @@ export const VS_CUBE_PROPS = [ Canvas.updateAllPositions(); }, }), + registerPaletteSlotProperty(Cube), new Property(Cube, "boolean", "shade", { default: true, label: "Shade", @@ -361,6 +394,7 @@ declare global { interface Group { stepParentName?: string; + paletteSlot?: number; clothingSlot?: string; backdrop?: boolean; vs_group_from?: [number, number, number]; @@ -372,6 +406,7 @@ declare global { interface Cube { stepParentName?: string; + paletteSlot?: number; clothingSlot?: string; climateColorMap?: string; gradientShade?: boolean; diff --git a/src/transform.ts b/src/transform.ts index 02f9cbb..9cee0bc 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -68,6 +68,7 @@ export function expand_complex_element(complex: VS_Element): VS_Element { rotationZ: 0, to: vector_sub(complex.to, complex.from), stepParentName: undefined, + paletteSlot: undefined, attachmentpoints: undefined, children: [], name: `${complex.name}_geo` @@ -104,4 +105,4 @@ export function has_animation(element: VS_Element, animations: VS_Animation[]): return Object.keys(kf.elements).includes(element.name); }); }); -} \ No newline at end of file +} diff --git a/src/vs_shape_def.ts b/src/vs_shape_def.ts index 3a158cb..2a94717 100644 --- a/src/vs_shape_def.ts +++ b/src/vs_shape_def.ts @@ -37,6 +37,7 @@ export interface VS_Element { climateColorMap?: string, gradientShade?: boolean, renderPass?: number, + paletteSlot?: number, seasonColorMap?: string, shade?: boolean, uv?: [number,number], From fb81cfae76504a28926a03e86a9a8457ee0253f1 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 12:32:22 +0100 Subject: [PATCH 11/13] Export and import attachments in step-parent socket space --- src/attachments/attachment_transform.ts | 135 ++++++++++++++ src/attachments/codec.ts | 121 +++++++++++-- src/attachments/discovery.ts | 25 ++- src/attachments/export_attachment_vs.ts | 229 ++++++++++++++++++++---- src/attachments/importer.ts | 108 +++++++---- src/attachments/post_import.ts | 204 ++++++++++++++++++--- src/import_model.ts | 12 +- src/property.ts | 37 ++++ 8 files changed, 755 insertions(+), 116 deletions(-) create mode 100644 src/attachments/attachment_transform.ts diff --git a/src/attachments/attachment_transform.ts b/src/attachments/attachment_transform.ts new file mode 100644 index 0000000..d630698 --- /dev/null +++ b/src/attachments/attachment_transform.ts @@ -0,0 +1,135 @@ +export type Vector3Tuple = [number, number, number]; + +export interface AttachmentElementFrame { + from: Vector3Tuple; + to: Vector3Tuple; + rotationOrigin: Vector3Tuple; + rotation: Vector3Tuple; +} + +export interface StepParentFrame { + from: Vector3Tuple; + rotation: Vector3Tuple; +} + +type QuaternionTuple = [number, number, number, number]; + +const DEG_TO_RAD = Math.PI / 180; +const RAD_TO_DEG = 180 / Math.PI; +const CLEAN_EPSILON = 1e-7; + +function cleanNumber(value: number): number { + if (Math.abs(value) < CLEAN_EPSILON) return 0; + const rounded = Math.round(value * 1e6) / 1e6; + return Object.is(rounded, -0) ? 0 : rounded; +} + +function eulerXYZToQuaternion(rotation: Vector3Tuple): QuaternionTuple { + const x = rotation[0] * DEG_TO_RAD / 2; + const y = rotation[1] * DEG_TO_RAD / 2; + const z = rotation[2] * DEG_TO_RAD / 2; + const c1 = Math.cos(x), c2 = Math.cos(y), c3 = Math.cos(z); + const s1 = Math.sin(x), s2 = Math.sin(y), s3 = Math.sin(z); + + return [ + s1 * c2 * c3 + c1 * s2 * s3, + c1 * s2 * c3 - s1 * c2 * s3, + c1 * c2 * s3 + s1 * s2 * c3, + c1 * c2 * c3 - s1 * s2 * s3, + ]; +} + +function multiplyQuaternion(a: QuaternionTuple, b: QuaternionTuple): QuaternionTuple { + const [ax, ay, az, aw] = a; + const [bx, by, bz, bw] = b; + return [ + ax * bw + aw * bx + ay * bz - az * by, + ay * bw + aw * by + az * bx - ax * bz, + az * bw + aw * bz + ax * by - ay * bx, + aw * bw - ax * bx - ay * by - az * bz, + ]; +} + +function invertUnitQuaternion(q: QuaternionTuple): QuaternionTuple { + return [-q[0], -q[1], -q[2], q[3]]; +} + +function quaternionToEulerXYZ(q: QuaternionTuple): Vector3Tuple { + const [x, y, z, w] = q; + const x2 = x + x, y2 = y + y, z2 = z + z; + const xx = x * x2, xy = x * y2, xz = x * z2; + const yy = y * y2, yz = y * z2, zz = z * z2; + const wx = w * x2, wy = w * y2, wz = w * z2; + const m11 = 1 - (yy + zz); + const m12 = xy - wz; + const m13 = xz + wy; + const m22 = 1 - (xx + zz); + const m23 = yz - wx; + const m32 = yz + wx; + const m33 = 1 - (xx + yy); + const clampedM13 = Math.max(-1, Math.min(1, m13)); + + let resultX: number; + let resultZ: number; + const resultY = Math.asin(clampedM13); + if (Math.abs(clampedM13) < 0.9999999) { + resultX = Math.atan2(-m23, m33); + resultZ = Math.atan2(-m12, m11); + } else { + resultX = Math.atan2(m32, m22); + resultZ = 0; + } + + return [ + cleanNumber(resultX * RAD_TO_DEG), + cleanNumber(resultY * RAD_TO_DEG), + cleanNumber(resultZ * RAD_TO_DEG), + ]; +} + +/** Composes a root-to-leaf chain of XYZ Euler rotations. */ +export function composeEulerXYZ(rotations: Vector3Tuple[]): Vector3Tuple { + let result: QuaternionTuple = [0, 0, 0, 1]; + for (const rotation of rotations) { + result = multiplyQuaternion(result, eulerXYZToQuaternion(rotation)); + } + return quaternionToEulerXYZ(result); +} + +/** Converts a model-space XYZ rotation into a parent's local rotation. */ +export function relativeEulerXYZ( + rotation: Vector3Tuple, + parentRotation: Vector3Tuple +): Vector3Tuple { + const inverseParentRotation = invertUnitQuaternion(eulerXYZToQuaternion(parentRotation)); + return quaternionToEulerXYZ(multiplyQuaternion( + inverseParentRotation, + eulerXYZToQuaternion(rotation) + )); +} + +function subtractVector(a: Vector3Tuple, b: Vector3Tuple): Vector3Tuple { + return [ + cleanNumber(a[0] - b[0]), + cleanNumber(a[1] - b[1]), + cleanNumber(a[2] - b[2]), + ]; +} + +/** + * Converts a model-space attachment root transform into the local space of a + * VS step-parent socket. Vintage Story uses XYZ Euler rotations for this format. + */ +export function rebaseAttachmentRoot( + root: AttachmentElementFrame, + socket: StepParentFrame +): AttachmentElementFrame { + // VS serializes a child's coordinates relative to its parent's `from` by + // subtraction. The inherited rotation is handled separately below. + return { + from: subtractVector(root.from, socket.from), + to: subtractVector(root.to, socket.from), + rotationOrigin: subtractVector(root.rotationOrigin, socket.from), + rotation: relativeEulerXYZ(root.rotation, socket.rotation), + }; +} diff --git a/src/attachments/codec.ts b/src/attachments/codec.ts index f7b3cf6..0af89f3 100644 --- a/src/attachments/codec.ts +++ b/src/attachments/codec.ts @@ -1,30 +1,71 @@ +import { composeEulerXYZ } from './attachment_transform'; +import type { StepParentFrame, Vector3Tuple } from './attachment_transform'; + export function createExportCodec() { + function getElementBindRotation(element: Group | Cube): Vector3Tuple { + const chain: Vector3Tuple[] = []; + let current: any = element; + while (current instanceof Group || current instanceof Cube) { + chain.unshift([...(current.rotation || [0, 0, 0])] as Vector3Tuple); + current = current.parent; + } + return composeEulerXYZ(chain); + } + + function getSocketFrom(group: Group): Vector3Tuple { + const storedFrom = (group as any).vs_group_from; + if (Array.isArray(storedFrom) && storedFrom.length === 3) { + return [...storedFrom] as Vector3Tuple; + } + const geoChild = group.children.find(child => + child instanceof Cube && child.name === `${group.name}_geo` + ) as Cube | undefined; + return [...(geoChild?.from || group.origin)] as Vector3Tuple; + } + /** - * Compiles groups for the outliner, preserving clothingSlot only on root groups. + * Compiles groups for the outliner while preserving attachment metadata. * Also sets stepParentName on root groups so they can be correctly placed in hierarchy on import. * @param rootGroups The root groups being exported (these should keep clothingSlot) * @param undo Whether this is for undo purposes * @param rootGroupUuids Set of UUIDs for root groups that should keep clothingSlot * @param parentNameMap Map of root group UUIDs to their original parent group names */ - function compileGroupsFrom(rootGroups: any[], undo: boolean, rootGroupUuids: Set, parentNameMap: Map): any[] { + function compileGroupsFrom( + rootGroups: any[], + undo: boolean, + rootGroupUuids: Set, + parentNameMap: Map, + stepParentTransformMap: Map, + rootBindRotationMap: Map + ): any[] { const result: any[] = []; function iterate(array: any[], save_array: any[], isRoot: boolean = false) { for (const element of array) { if (element.type === 'group') { const obj = element.compile(undo); - // Only preserve clothingSlot on root attachment groups - // Clear it from nested groups to prevent duplicate attachment detection on import - if (!rootGroupUuids.has(element.uuid)) { - delete obj.clothingSlot; - } // Set stepParentName on root groups so they can be placed correctly on import // This tells the importer where this group should go in the target model's hierarchy if (isRoot && rootGroupUuids.has(element.uuid)) { const parentName = parentNameMap.get(element.uuid); - if (parentName) { + if (!obj.stepParentName && parentName) { obj.stepParentName = parentName; } + + const transform = stepParentTransformMap.get(element.uuid); + if (transform && !obj.vs_step_parent_local) { + obj.vs_has_step_parent_transform = true; + obj.vs_step_parent_origin = [...transform.from]; + obj.vs_step_parent_rotation = [...transform.rotation]; + } + + // The selection is detached from its outliner parent in + // the saved file, so its root rotation must be promoted + // from socket-local to model-space as well. + const rootBindRotation = rootBindRotationMap.get(element.uuid); + if (rootBindRotation && !obj.vs_step_parent_local) { + obj.rotation = [...rootBindRotation]; + } } if (element.children.length > 0) { iterate(element.children, obj.children, false); @@ -114,14 +155,44 @@ export function createExportCodec() { // Track root group UUIDs - these are the groups that should keep their clothingSlot // Also track their parent names for correct hierarchy placement on import const rootGroupUuids = new Set(); + const rootElementUuids = new Set(); const parentNameMap = new Map(); + const stepParentTransformMap = new Map(); + const rootBindRotationMap = new Map(); selection.forEach(el => { - if (el instanceof Group) { - rootGroupUuids.add(el.uuid); + if (el instanceof Group || el instanceof Cube) { + rootElementUuids.add(el.uuid); + if (el instanceof Group) rootGroupUuids.add(el.uuid); // Store the parent group name so the importer knows where to place this group if (el.parent && el.parent instanceof Group) { parentNameMap.set(el.uuid, el.parent.name); } + + const stepParentName = el.stepParentName?.trim() || parentNameMap.get(el.uuid); + if (stepParentName && !el.vs_step_parent_local) { + rootBindRotationMap.set(el.uuid, getElementBindRotation(el)); + const candidates = Group.all.filter((candidate: Group) => { + if (candidate === el || candidate.name !== stepParentName) return false; + let current: any = candidate; + while (current && current instanceof Group) { + if (current === el) return false; + current = current.parent; + } + return true; + }); + const attachmentSlot = el.clothingSlot?.trim(); + const target = candidates.find((candidate: Group) => { + const candidateSlot = candidate.clothingSlot?.trim(); + return !candidateSlot || candidateSlot !== attachmentSlot; + }) || candidates[0]; + + if (target) { + stepParentTransformMap.set(el.uuid, { + from: getSocketFrom(target), + rotation: getElementBindRotation(target), + }); + } + } } }); @@ -129,19 +200,41 @@ export function createExportCodec() { const allElements = collectAllElements(selection); // Collect all cubes from the selection - // Strip clothingSlot from cubes to prevent them from being detected as individual attachments on import const cubes: any[] = []; allElements.forEach(el => { if (el instanceof Cube) { cubes.push(el); const saveCopy = el.getSaveCopy(); - // Remove clothingSlot from cubes - they inherit from their parent group - delete saveCopy.clothingSlot; + if (rootElementUuids.has(el.uuid)) { + const parentName = parentNameMap.get(el.uuid); + if (!saveCopy.stepParentName && parentName) { + saveCopy.stepParentName = parentName; + } + + const transform = stepParentTransformMap.get(el.uuid); + if (transform && !saveCopy.vs_step_parent_local) { + saveCopy.vs_has_step_parent_transform = true; + saveCopy.vs_step_parent_origin = [...transform.from]; + saveCopy.vs_step_parent_rotation = [...transform.rotation]; + } + + const rootBindRotation = rootBindRotationMap.get(el.uuid); + if (rootBindRotation && !saveCopy.vs_step_parent_local) { + saveCopy.rotation = [...rootBindRotation]; + } + } model.elements.push(saveCopy); } }); - model.outliner = compileGroupsFrom(selection, true, rootGroupUuids, parentNameMap); + model.outliner = compileGroupsFrom( + selection, + true, + rootGroupUuids, + parentNameMap, + stepParentTransformMap, + rootBindRotationMap + ); // Only export textures that are actually used by the selected elements const usedTextureUuids = collectUsedTextureUuids(cubes); diff --git a/src/attachments/discovery.ts b/src/attachments/discovery.ts index 57e17b4..9576ad5 100644 --- a/src/attachments/discovery.ts +++ b/src/attachments/discovery.ts @@ -50,21 +50,20 @@ export function findAttachments(): IAttachmentSection[] { function walk(node: any) { if ((node instanceof Group || node instanceof Cube) && node.clothingSlot && node.clothingSlot.trim() !== '') { - if (node instanceof Group) { - const parent = node.parent; - if (parent && parent instanceof Group) { - const parentSlot = (parent as any).clothingSlot; - if (parentSlot && parentSlot.trim() === node.clothingSlot.trim()) { - if (Array.isArray(node.children)) { - node.children.forEach(walk); - } - return; - } + const slot = node.clothingSlot.trim(); + let ancestor = node.parent; + let inheritsFromAncestor = false; + while (ancestor && ancestor instanceof Group) { + const ancestorSlot = ancestor.clothingSlot?.trim(); + if (ancestorSlot === slot) { + inheritsFromAncestor = true; + break; } + ancestor = ancestor.parent; } - if (!isStructuralParentOnly(node)) { - const bucket = getOrCreateBucket(node.clothingSlot); + if (!inheritsFromAncestor && !isStructuralParentOnly(node)) { + const bucket = getOrCreateBucket(slot); bucket.elements.push(node); } } @@ -91,4 +90,4 @@ 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() !== ''; -} \ No newline at end of file +} diff --git a/src/attachments/export_attachment_vs.ts b/src/attachments/export_attachment_vs.ts index 5b44f22..1435249 100644 --- a/src/attachments/export_attachment_vs.ts +++ b/src/attachments/export_attachment_vs.ts @@ -1,12 +1,12 @@ import { getActiveSlotNames } from './presets'; -import { traverse } from '../export_model/traverse'; import * as props from "../property"; import * as util from '../util'; import { VS_Element } from '../vs_shape_def'; import { QUICK_MESSAGE_DURATION } from './constants'; -import { process_group } from '../export_model/group'; import { process_cube } from '../export_model/cube'; import { visit_tree } from '../util/element_tree'; +import { composeEulerXYZ, rebaseAttachmentRoot } from './attachment_transform'; +import type { AttachmentElementFrame, StepParentFrame, Vector3Tuple } from './attachment_transform'; const fs = requireNativeModule('fs'); @@ -16,6 +16,88 @@ function logDebug(message: string, ...args: any[]) { if (DEBUG) console.log(message, ...args); } +function isFiniteVector3(value: unknown): value is Vector3Tuple { + return Array.isArray(value) + && value.length === 3 + && value.every(component => typeof component === 'number' && Number.isFinite(component)); +} + +function getElementBindRotation(element: Group | Cube): Vector3Tuple { + const chain: Vector3Tuple[] = []; + let current: any = element; + while (current instanceof Group || current instanceof Cube) { + chain.unshift([...(current.rotation || [0, 0, 0])] as Vector3Tuple); + current = current.parent; + } + return composeEulerXYZ(chain); +} + +function getSocketFrom(group: Group): Vector3Tuple { + const storedFrom = (group as any).vs_group_from; + if (isFiniteVector3(storedFrom)) return [...storedFrom]; + const geoChild = group.children.find(child => + child instanceof Cube && child.name === `${group.name}_geo` + ) as Cube | undefined; + return [...(geoChild?.from || group.origin)] as Vector3Tuple; +} + +function isInAttachmentSubtree(candidate: Group, attachmentRoot: Group | Cube): boolean { + let current: any = candidate; + while (current && current instanceof Group) { + if (current === attachmentRoot) return true; + current = current.parent; + } + return false; +} + +function resolveStepParentFrame(element: Group | Cube, stepParentName: string): StepParentFrame | null { + const attachmentSlot = element.clothingSlot?.trim(); + const candidates = Group.all.filter((candidate: Group) => { + if (candidate === element || candidate.name !== stepParentName) return false; + if (isInAttachmentSubtree(candidate, element)) return false; + return true; + }); + const target = candidates.find((candidate: Group) => + !candidate.stepParentName?.trim() + && (!candidate.clothingSlot?.trim() || candidate.clothingSlot?.trim() !== attachmentSlot) + ) || candidates.find((candidate: Group) => + !candidate.clothingSlot?.trim() || candidate.clothingSlot?.trim() !== attachmentSlot + ) || candidates[0]; + if (target) { + // A live socket represents the hierarchy that the exported attachment + // will actually run under. Stored metadata is the standalone fallback. + return { + from: getSocketFrom(target), + rotation: getElementBindRotation(target), + }; + } + + if ( + element.vs_has_step_parent_transform + && isFiniteVector3(element.vs_step_parent_origin) + && isFiniteVector3(element.vs_step_parent_rotation) + ) { + return { + from: [...element.vs_step_parent_origin], + rotation: [...element.vs_step_parent_rotation], + }; + } + + return null; +} + +function getGroupFrom(group: Group): Vector3Tuple { + return isFiniteVector3(group.vs_group_from) + ? [...group.vs_group_from] + : [...group.origin] as Vector3Tuple; +} + +function getGroupTo(group: Group): Vector3Tuple { + return isFiniteVector3(group.vs_group_to) + ? [...group.vs_group_to] + : [...group.origin] as Vector3Tuple; +} + /** * Collects texture names that are actually used by the given elements. * @param elements The exported VS elements to scan for texture references. @@ -214,19 +296,24 @@ function process_attachment_group( accu: Array, offset: [number, number, number], targetSlot: string, - stepParentName?: string | null + stepParentName?: string | null, + rootTransform?: AttachmentElementFrame ) { if (node.backdrop) { return; } - const parent_pos: [number, number, number] = parent ? parent.origin : [0, 0, 0]; - const converted_rotation = node.rotation; + const parent_pos: [number, number, number] = parent ? getGroupFrom(parent) : [0, 0, 0]; + const converted_rotation = parent === null && rootTransform ? rootTransform.rotation : node.rotation; - let from = util.vector_sub(node.origin, parent_pos); - let to = util.vector_sub(node.origin, parent_pos); + let from = util.vector_sub(getGroupFrom(node), parent_pos); + let to = util.vector_sub(getGroupTo(node), parent_pos); let rotationOrigin = util.vector_sub(node.origin, parent_pos); - if (parent === null) { + if (parent === null && rootTransform) { + from = [...rootTransform.from]; + to = [...rootTransform.to]; + rotationOrigin = [...rootTransform.rotationOrigin]; + } else if (parent === null) { from = util.vector_add(from, offset); to = util.vector_add(to, offset); rotationOrigin = util.vector_add(rotationOrigin, offset); @@ -237,9 +324,11 @@ function process_attachment_group( from: from, to: to, rotationOrigin: rotationOrigin, + ...(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.vs_zero_size_faces ? { faces: node.vs_zero_size_faces } : undefined), children: [] }; @@ -256,6 +345,28 @@ function process_attachment_group( } } + // Group-like VS elements may carry cube properties (for example shade, + // renderPass, or unwrapMode). Preserve the same payload as normal export. + for (const prop of props.VS_CUBE_PROPS) { + const prop_name = prop.name; + if (props.VS_GROUP_PROPS.some(groupProp => groupProp.name === prop_name)) continue; + const value = node[prop_name]; + if (prop_name === 'shade') { + if (value !== undefined && value !== true) { + vsElement[prop_name] = value; + } + continue; + } + if (value !== undefined && value !== null && value !== '' && value !== false) { + const exportedValue = prop.type === 'number' ? Number(value) : value; + if (prop_name === 'renderPass' && exportedValue === -1) continue; + if (prop_name === 'unwrapMode' && exportedValue === 0) continue; + if (prop_name === 'unwrapRotation' && exportedValue === 0) continue; + if (prop_name === 'paletteSlot' && exportedValue === 0) continue; + vsElement[prop_name] = exportedValue; + } + } + // Process child locators as attachment points const locators = node.children.filter(child => child instanceof Locator) as Array; if (locators.length > 0) { @@ -273,15 +384,15 @@ function process_attachment_group( const filteredChildren = (node.children || []).filter((child: any) => { if (!(child instanceof Group || child instanceof Cube)) return false; const childSlot = (child as any).clothingSlot; - // Only include children with the matching slot - exclude everything else - const childHasMatchingSlot = childSlot && childSlot.trim() !== '' && childSlot.trim() === targetSlot.trim(); - return childHasMatchingSlot; + // 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(); }); logDebug(`[VS Attachment Export] Filtered children of "${node.name}": ${filteredChildren.length} of ${node.children.length} match slot "${targetSlot}"`); // Recursively process filtered children (don't pass stepParentName - it's only for top-level elements) - traverseAttachment(node, filteredChildren, vsElement.children!, offset, targetSlot, undefined); + traverseAttachment(node, filteredChildren, vsElement.children!, offset, targetSlot, undefined, undefined); } /** @@ -294,14 +405,22 @@ function process_attachment_group( * @param targetSlot The clothingSlot to filter by - only children with this slot or no slot are processed * @param stepParentName The step parent name to apply to top-level elements (null if not top-level) */ -function traverseAttachment(parent: Group | null, nodes: Array, accu: Array, offset: [number, number, number], targetSlot: string, stepParentName?: string | null) { +function traverseAttachment( + parent: Group | null, + nodes: Array, + accu: Array, + offset: [number, number, number], + targetSlot: string, + stepParentName?: string | null, + rootTransform?: AttachmentElementFrame +) { for (const node of nodes) { if (!node.export) 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 hasMatchingSlot = nodeSlot && nodeSlot.trim() !== '' && nodeSlot.trim() === targetSlot.trim(); + const hasMatchingSlot = !nodeSlot || nodeSlot.trim() === '' || nodeSlot.trim() === targetSlot.trim(); // Skip nodes that don't have the matching slot if (!hasMatchingSlot) { @@ -314,7 +433,7 @@ function traverseAttachment(parent: Group | null, nodes: Array, ac const stepParentToUse = isTopLevel ? stepParentName : undefined; if (node instanceof Group) { - process_attachment_group(parent, node, accu, offset, targetSlot, stepParentToUse); + process_attachment_group(parent, node, accu, offset, targetSlot, stepParentToUse, rootTransform); } 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 @@ -445,6 +564,7 @@ export function exportAttachmentsVS(selection: Group[]) { logDebug(`[VS Attachment Export] Filtered ${selection.length} elements to ${rootAttachments.length} root groups and ${orphanCubes.length} orphan cubes`); + let warnedMissingSocketTransform = false; try { // Process each root attachment group with its own relative offset rootAttachments.forEach(group => { @@ -459,7 +579,7 @@ export function exportAttachmentsVS(selection: Group[]) { // 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 (currentParent && currentParent instanceof Group) { + while (!stepParentName && currentParent && currentParent instanceof Group) { const parentClothingSlot = (currentParent as any).clothingSlot; const parentHasDifferentSlot = !parentClothingSlot || parentClothingSlot.trim() === '' || parentClothingSlot !== groupClothingSlot; @@ -490,24 +610,37 @@ export function exportAttachmentsVS(selection: Group[]) { if (DEBUG) console.warn(`Could not determine a step-parent for attachment: ${group.name}`); } - // Find the parent group to make the attachment's position relative - const parentGroup = stepParentName ? Group.all.find((g: any) => g.name === stepParentName) : null; - - // Default offset, same as in the main model exporter let offset: [number, number, number] = [0, 0, 0]; - - if (parentGroup) { - // Adjust the offset by subtracting the parent's absolute position. - // This makes the attachment's root position relative to its parent. - offset = util.vector_sub(offset, parentGroup.origin); - logDebug(`[VS Attachment Export] Calculated offset for "${group.name}" relative to "${stepParentName}": [${offset.join(', ')}]`); + let rootTransform: AttachmentElementFrame | undefined; + + if (!group.vs_step_parent_local && stepParentName) { + const socketFrame = resolveStepParentFrame(group, stepParentName); + if (socketFrame) { + rootTransform = rebaseAttachmentRoot({ + from: getGroupFrom(group), + to: getGroupTo(group), + rotationOrigin: [...group.origin] as Vector3Tuple, + rotation: getElementBindRotation(group), + }, socketFrame); + logDebug(`[VS Attachment Export] Rebased "${group.name}" against "${stepParentName}": position [${rootTransform.rotationOrigin.join(', ')}], rotation [${rootTransform.rotation.join(', ')}]`); + } else { + // Backward-compatible translation fallback for old standalone + // source files. Rotation cannot be inferred without the actual + // socket frame, so retain the authored rotation and warn. + offset = util.vector_sub(offset, group.origin); + console.warn(`[VS Attachment Export] No distinct socket or stored socket transform found for "${group.name}" -> "${stepParentName}". Preserving legacy rotation.`); + if (!warnedMissingSocketTransform) { + warnedMissingSocketTransform = true; + Blockbench.showQuickMessage('Some legacy attachments have no stored socket transform; rotation was preserved as authored.', 5000); + } + } } // 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 || ''; - traverseAttachment(null, [group], attachmentElements, offset, groupClothingSlot, stepParentName); + traverseAttachment(null, [group], attachmentElements, offset, groupClothingSlot, stepParentName, rootTransform); // Add the processed elements to the main elements array data.elements.push(...attachmentElements); @@ -533,13 +666,14 @@ export function exportAttachmentsVS(selection: Group[]) { const orphanElements: VS_Element[] = []; orphanCubes.forEach(cube => { // Determine stepParentName for this specific cube - let stepParentName: string | null = null; + let stepParentName: string | null = cube.stepParentName?.trim() || null; const cubeSlot = (cube as any).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 - while (currentParent && currentParent instanceof Group) { + // 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 parentHasDifferentSlot = !parentClothingSlot || parentClothingSlot.trim() === '' || parentClothingSlot !== cubeSlot; @@ -559,15 +693,40 @@ export function exportAttachmentsVS(selection: Group[]) { } // Temporarily set stepParentName on the cube - const originalStepParent = (cube as any).stepParentName; + const originalStepParent = cube.stepParentName; if (stepParentName && (!originalStepParent || originalStepParent.trim() === '')) { - (cube as any).stepParentName = stepParentName; + cube.stepParentName = stepParentName; + } + const cubeElements: VS_Element[] = []; + process_cube(null, cube, cubeElements, [0, 0, 0]); + + const exportedCube = cubeElements[0]; + if (exportedCube && cube.vs_step_parent_local !== true && stepParentName) { + const socketFrame = resolveStepParentFrame(cube, stepParentName); + if (socketFrame) { + const rootTransform = rebaseAttachmentRoot({ + from: [...cube.from] as Vector3Tuple, + to: [...cube.to] as Vector3Tuple, + rotationOrigin: [...cube.origin] as Vector3Tuple, + rotation: getElementBindRotation(cube), + }, socketFrame); + exportedCube.from = rootTransform.from; + exportedCube.to = rootTransform.to; + exportedCube.rotationOrigin = rootTransform.rotationOrigin; + delete exportedCube.rotationX; + delete exportedCube.rotationY; + delete exportedCube.rotationZ; + if (rootTransform.rotation[0] !== 0) exportedCube.rotationX = rootTransform.rotation[0]; + if (rootTransform.rotation[1] !== 0) exportedCube.rotationY = rootTransform.rotation[1]; + if (rootTransform.rotation[2] !== 0) exportedCube.rotationZ = rootTransform.rotation[2]; + } else { + console.warn(`[VS Attachment Export] No distinct socket or stored socket transform found for cube "${cube.name}" -> "${stepParentName}"; preserving its model-space transform.`); + } } - // Process the cube - process_cube(null, cube, orphanElements, [0, 0, 0]); + orphanElements.push(...cubeElements); // Restore original stepParentName if (stepParentName) { - (cube as any).stepParentName = originalStepParent; + cube.stepParentName = originalStepParent; } }); data.elements.push(...orphanElements); diff --git a/src/attachments/importer.ts b/src/attachments/importer.ts index 141e3dd..b1c6d38 100644 --- a/src/attachments/importer.ts +++ b/src/attachments/importer.ts @@ -103,22 +103,42 @@ function remapCubeFaceTextures(cubeProps: Record, textureMap: Map) { +function normalizePaletteSlot(value: unknown): number { + const slot = Number(value); + return Number.isFinite(slot) && slot !== 0 ? slot : 0; +} + +function createCubeFromElementData( + elemData: BBCubeElement, + parentGroup: any, + textureMap: Map, + createdGroups: Set, + inheritedPaletteSlot = 0, +) { const cubeProps: Record = { ...elemData }; delete cubeProps.uuid; + // A source attachment can put paletteSlot on a structural group that is + // merged into an existing base-model bone. Keep that inheritance on the + // newly imported geometry instead of tinting the reused base bone. + if (normalizePaletteSlot(cubeProps.paletteSlot) === 0 && inheritedPaletteSlot !== 0) { + cubeProps.paletteSlot = inheritedPaletteSlot; + } + remapCubeFaceTextures(cubeProps, textureMap); // Handle legacy bbmodel files where clothingSlot is on cubes instead of groups. // If the cube has a clothingSlot, propagate it up the hierarchy to parent groups. // Stop when we reach a parent that already has a clothingSlot (this is a boundary - either // an existing attachment group or a base model group that has been marked). - // Then clear clothingSlot from the cube since cubes should inherit from their parent group. + // Keep the cube value as well: the attachment exporter treats an explicit + // different slot as a boundary, and the discovery panel de-duplicates + // descendants that inherit the same slot. const cubeClothingSlot = cubeProps.clothingSlot; if (cubeClothingSlot && typeof cubeClothingSlot === 'string' && cubeClothingSlot.trim() !== '') { // Propagate clothingSlot up the hierarchy, but stop at boundaries let currentGroup: any = parentGroup; - while (currentGroup && currentGroup instanceof Group) { + 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) if (existingSlot && existingSlot.trim() !== '') { @@ -131,9 +151,6 @@ function createCubeFromElementData(elemData: BBCubeElement, parentGroup: any, te // Move up to the parent group currentGroup = currentGroup.parent; } - // Clear clothingSlot from the cube to prevent it from being detected as a separate attachment - delete cubeProps.clothingSlot; - logDebug(`[Import BB] Cleared clothingSlot from cube: ${cubeProps.name ?? '(unnamed)'}`); } const cube = new Cube(cubeProps); @@ -154,24 +171,38 @@ function findExistingGroupByName(parentGroup: any, groupName: string): any | nul return null; } -function getOrCreateGroup(groupSeed: Record, parentGroup: any): any { +function getOrCreateGroup( + groupSeed: Record, + parentGroup: any, + createdGroups: Set, + inheritedPaletteSlot = 0, +): { group: any; childPaletteSlot: number } { const groupName = typeof groupSeed.name === 'string' ? groupSeed.name : ''; + const hasExternalStepParent = typeof groupSeed.stepParentName === 'string' && groupSeed.stepParentName.trim() !== ''; + const ownPaletteSlot = normalizePaletteSlot(groupSeed.paletteSlot); + const childPaletteSlot = ownPaletteSlot || inheritedPaletteSlot; - const existing = findExistingGroupByName(parentGroup, groupName); + // An explicit step-parent group is an attachment wrapper, not a duplicate + // of the base socket with the same name. + const existing = hasExternalStepParent ? null : findExistingGroupByName(parentGroup, groupName); if (existing) { logDebug(`[Import BB] Merging into existing group: ${groupName}`); - return existing; + return { group: existing, childPaletteSlot }; } const groupProps: Record = { ...groupSeed }; delete groupProps.uuid; delete groupProps.children; + if (ownPaletteSlot === 0 && inheritedPaletteSlot !== 0) { + groupProps.paletteSlot = inheritedPaletteSlot; + } const group = new Group(groupProps); group.addTo(parentGroup).init(); + createdGroups.add(group); logDebug(`[Import BB] Created new group: ${groupName || '(unnamed group)'}`); - return group; + return { group, childPaletteSlot }; } function isCubeElement(value: unknown): value is BBCubeElement { @@ -188,8 +219,19 @@ 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]); handleVSTextures(content); - import_model(content, false, filePath); + // Attachment roots are already expressed in their step parent's local space. + // Applying the normal block-model [-8, 0, -8] offset corrupts that transform. + import_model(content, false, filePath, { rootOffset: [0, 0, 0] }); + + for (const element of [...Group.all, ...Cube.all]) { + if (elementsBefore.has(element)) continue; + const parentIsNew = element.parent instanceof Group && !elementsBefore.has(element.parent); + if (!parentIsNew && element.stepParentName?.trim()) { + element.vs_step_parent_local = true; + } + } } /** @@ -210,58 +252,64 @@ export function mergeBBModel(content: unknown, _filePath: string) { const textureMap = buildTextureMap(model); const elementByUuid = buildUuidMap>(model.elements); const groupByUuid = buildUuidMap>(model.groups); - const processChildren = (children: BBOutlinerItem[], parent: any) => { - for (const child of children) processOutlinerItem(child, parent); + const createdGroups = new Set(); + const processChildren = (children: BBOutlinerItem[], parent: any, inheritedPaletteSlot = 0) => { + for (const child of children) processOutlinerItem(child, parent, inheritedPaletteSlot); }; - const tryCreateCubeByUuid = (uuid: UUID, parent: any): boolean => { + const tryCreateCubeByUuid = (uuid: UUID, parent: any, inheritedPaletteSlot = 0): boolean => { const elemData = elementByUuid.get(uuid); if (!isCubeElement(elemData)) return false; - createCubeFromElementData(elemData, parent, textureMap); + createCubeFromElementData(elemData, parent, textureMap, createdGroups, inheritedPaletteSlot); return true; }; - const tryProcessGroupByUuid = (uuid: UUID, node: Record, parent: any): boolean => { + const tryProcessGroupByUuid = ( + uuid: UUID, + node: Record, + parent: any, + inheritedPaletteSlot = 0, + ): boolean => { const groupData = groupByUuid.get(uuid); if (!groupData) return false; // Merge groupMap data with outliner hints (e.g., children structure) const seed = { ...groupData, ...node }; - const targetGroup = getOrCreateGroup(seed, parent); + const { group: targetGroup, childPaletteSlot } = getOrCreateGroup(seed, parent, createdGroups, inheritedPaletteSlot); const children = asArray(node.children); - processChildren(children, targetGroup); + processChildren(children, targetGroup, childPaletteSlot); return true; }; - const processUuidStringItem = (uuid: UUID, parent: any) => { + const processUuidStringItem = (uuid: UUID, parent: any, inheritedPaletteSlot = 0) => { // Blockbench 4.x: element references are UUID strings - if (tryCreateCubeByUuid(uuid, parent)) return; + if (tryCreateCubeByUuid(uuid, parent, inheritedPaletteSlot)) return; }; - const processInlineGroupNode = (node: Record, parent: any) => { - const targetGroup = getOrCreateGroup(node, parent); + const processInlineGroupNode = (node: Record, parent: any, inheritedPaletteSlot = 0) => { + const { group: targetGroup, childPaletteSlot } = getOrCreateGroup(node, parent, createdGroups, inheritedPaletteSlot); const children = asArray(node.children); - processChildren(children, targetGroup); + processChildren(children, targetGroup, childPaletteSlot); }; - const processObjectNode = (node: Record, parent: any) => { + const processObjectNode = (node: Record, parent: any, inheritedPaletteSlot = 0) => { const uuid = typeof node.uuid === 'string' ? node.uuid : undefined; // Blockbench 5.x+: outliner node references separate `elements` / `groups` lists by UUID. if (uuid) { - if (tryCreateCubeByUuid(uuid, parent)) return; - if (tryProcessGroupByUuid(uuid, node, parent)) return; + if (tryCreateCubeByUuid(uuid, parent, inheritedPaletteSlot)) return; + if (tryProcessGroupByUuid(uuid, node, parent, inheritedPaletteSlot)) return; } // Blockbench 4.x: the node itself is an inline group object - processInlineGroupNode(node, parent); + processInlineGroupNode(node, parent, inheritedPaletteSlot); }; - const processOutlinerItem = (item: BBOutlinerItem, parent: any) => { - if (typeof item === 'string') return processUuidStringItem(item, parent); + const processOutlinerItem = (item: BBOutlinerItem, parent: any, inheritedPaletteSlot = 0) => { + if (typeof item === 'string') return processUuidStringItem(item, parent, inheritedPaletteSlot); if (!isRecord(item)) return; - return processObjectNode(item, parent); + return processObjectNode(item, parent, inheritedPaletteSlot); }; for (const item of asArray(model.outliner)) { diff --git a/src/attachments/post_import.ts b/src/attachments/post_import.ts index 025afac..0042a35 100644 --- a/src/attachments/post_import.ts +++ b/src/attachments/post_import.ts @@ -3,6 +3,8 @@ import { showClothingSlotDialog } from './dialogs'; import { findAllGroupsByName, findGroupByName, stripNumericSuffix, collectGroupsDepthFirst, isDescendantOf } from '../util/outliner'; import { QUICK_MESSAGE_DURATION } from './constants'; import { markAsRecentlyImported } from './panel'; +import { composeEulerXYZ, relativeEulerXYZ } from './attachment_transform'; +import type { Vector3Tuple } from './attachment_transform'; const DEBUG = false; @@ -82,11 +84,72 @@ function applyClothingSlot(newElements: any[], slot: string, logPrefix: string) }); } +/** + * Returns true when an imported node belongs to a wrapper that is intentionally + * kept external and attached through VS step-parenting. Collapsing one of these + * wrappers loses its local translation/rotation. + */ +function isWithinStepParentAttachment(element: any, newElementsSet: Set): boolean { + let current = element; + while (current && newElementsSet.has(current)) { + if (current.stepParentName?.trim()) return true; + current = current.parent instanceof Group ? current.parent : null; + } + return false; +} + +function mergePaletteSlot(source: any, target: any, logPrefix: string) { + const sourceSlot = Number(source?.paletteSlot) || 0; + const targetSlot = Number(target?.paletteSlot) || 0; + if (sourceSlot === 0) return; + if (targetSlot === 0) { + target.paletteSlot = sourceSlot; + } else if (targetSlot !== sourceSlot) { + console.warn(`[${logPrefix}] Keeping paletteSlot ${targetSlot} on "${target.name}"; imported "${source.name}" requested ${sourceSlot}.`); + } +} + +function preserveInheritedPaletteOnChildren(source: any, children: any[]) { + const sourceSlot = Number(source?.paletteSlot) || 0; + if (sourceSlot === 0) return; + children.forEach(child => { + if ((child instanceof Group || child instanceof Cube) && (Number(child.paletteSlot) || 0) === 0) { + child.paletteSlot = sourceSlot; + } + }); +} + +function materializePaletteInheritance(newElements: any[], newElementsSet: Set) { + const roots = newElements.filter(element => + (element instanceof Group || element instanceof Cube) + && (!element.parent || !newElementsSet.has(element.parent)) + ); + + const walk = (element: any, inheritedSlot: number) => { + if (!(element instanceof Group || element instanceof Cube) || !newElementsSet.has(element)) return; + const ownSlot = Number(element.paletteSlot) || 0; + const effectiveSlot = ownSlot || inheritedSlot; + if (ownSlot === 0 && inheritedSlot !== 0) { + element.paletteSlot = inheritedSlot; + } + // Only groups have children; cubes are leaves. + if (element instanceof Group) { + element.children.forEach(child => walk(child, effectiveSlot)); + } + }; + + roots.forEach(root => walk(root, 0)); +} + /** * Matches new groups to existing ones based on clothing slot. */ function smartMatchGroups(newElements: any[], newElementsSet: Set, existingElements: Set, logPrefix: string): number { - const topLevelGroups = newElements.filter(e => e instanceof Group && (!e.parent || !newElementsSet.has(e.parent))) as Group[]; + const topLevelGroups = newElements.filter(e => + e instanceof Group + && (!e.parent || !newElementsSet.has(e.parent)) + && !isWithinStepParentAttachment(e, newElementsSet) + ) as Group[]; let matchCount = 0; if (DEBUG) console.log(`[${logPrefix}] Smart match: checking ${topLevelGroups.length} groups.`); @@ -99,6 +162,7 @@ function smartMatchGroups(newElements: any[], newElementsSet: Set, existing if (match) { if (DEBUG) console.log(`[${logPrefix}] Matched "${newGroup.name}" -> "${match.name}" (slot: ${slot})`); + mergePaletteSlot(newGroup, match, logPrefix); [...newGroup.children].forEach(child => child.addTo(match)); matchCount++; @@ -120,6 +184,7 @@ function mergeHierarchicalGroups(newElements: any[], newElementsSet: Set, l for (let i = allNewGroups.length - 1; i >= 0; i--) { const newGroup = allNewGroups[i]; if (!newElementsSet.has(newGroup)) continue; + if (isWithinStepParentAttachment(newGroup, newElementsSet)) continue; const matches = findAllGroupsByName(newGroup.name, Outliner.root).filter(g => !newElementsSet.has(g)); @@ -127,11 +192,13 @@ function mergeHierarchicalGroups(newElements: any[], newElementsSet: Set, l const target = matches[0]; // Take first match if (DEBUG) console.log(`[${logPrefix}] Merging "${newGroup.name}" into "${target.name}"`); - [...newGroup.children].forEach(child => child.addTo(target)); - - if (newGroup.clothingSlot && !target.clothingSlot) { - target.clothingSlot = newGroup.clothingSlot; - } + const movedChildren = [...newGroup.children]; + // This is a structural hierarchy match, so the existing target is + // a base socket rather than the attachment root. Keep attachment + // discovery and palette inheritance on the moved subtree instead + // of marking/tinting the base socket itself. + preserveInheritedPaletteOnChildren(newGroup, movedChildren); + movedChildren.forEach(child => child.addTo(target)); if (newGroup.children.length === 0) { newGroup.remove(); @@ -149,7 +216,7 @@ function mergeGroupsByCommonSlot(newElements: any[], newElementsSet: Set, l const bySlot = new Map(); newElements.forEach(e => { - if (e instanceof Group && newElementsSet.has(e) && e.clothingSlot) { + if (e instanceof Group && newElementsSet.has(e) && e.clothingSlot && !isWithinStepParentAttachment(e, newElementsSet)) { const slot = e.clothingSlot.trim(); if (!bySlot.has(slot)) bySlot.set(slot, []); bySlot.get(slot)!.push(e); @@ -179,6 +246,7 @@ function mergeGroupsByCommonSlot(newElements: any[], newElementsSet: Set, l if (!newElementsSet.has(g)) return; if (DEBUG) console.log(`[${logPrefix}] Merging "${g.name}" into "${finalTarget.name}" (slot: ${slot})`); + mergePaletteSlot(g, finalTarget, logPrefix); [...g.children].forEach(c => c.addTo(finalTarget)); if (g.children.length === 0) { @@ -190,36 +258,126 @@ function mergeGroupsByCommonSlot(newElements: any[], newElementsSet: Set, l }); } +function getElementBindRotation(element: Group | Cube): Vector3Tuple { + const chain: Vector3Tuple[] = []; + let current: any = element; + while (current instanceof Group || current instanceof Cube) { + chain.unshift([...(current.rotation || [0, 0, 0])] as Vector3Tuple); + current = current.parent; + } + return composeEulerXYZ(chain); +} + +function isFiniteVector3(value: unknown): value is Vector3Tuple { + return Array.isArray(value) + && value.length === 3 + && value.every(component => typeof component === 'number' && Number.isFinite(component)); +} + +function getSocketFrom(group: Group): Vector3Tuple { + if (isFiniteVector3(group.vs_group_from)) return [...group.vs_group_from]; + const geoChild = group.children.find(child => + child instanceof Cube && child.name === `${group.name}_geo` + ) as Cube | undefined; + return [...(geoChild?.from || group.origin)] as Vector3Tuple; +} + +function translateVector(vector: number[] | undefined, delta: Vector3Tuple) { + if (!Array.isArray(vector) || vector.length < 3) return; + vector[0] += delta[0]; + vector[1] += delta[1]; + vector[2] += delta[2]; +} + +function translateAttachmentSubtree(element: any, delta: Vector3Tuple) { + if (delta[0] === 0 && delta[1] === 0 && delta[2] === 0) return; + + if (element instanceof Group) { + translateVector(element.origin, delta); + translateVector(element.vs_group_from, delta); + translateVector(element.vs_group_to, delta); + } else if (element instanceof Cube) { + translateVector(element.from, delta); + translateVector(element.to, delta); + translateVector(element.origin, delta); + } else { + // Locators and other positioned child nodes use `from` in Blockbench. + translateVector(element?.from, delta); + } + + element.children?.forEach((child: any) => translateAttachmentSubtree(child, delta)); +} + /** - * Reparents elements based on their `stepParentName` property. + * Keeps VS-imported local wrappers external, while placing absolute-space BB + * wrappers beneath their real socket and converting their world rotation to a + * socket-local rotation. */ -function reparentByStepParent(newElements: any[], newElementsSet: Set, logPrefix: string) { - if (DEBUG) console.log(`[${logPrefix}] reparenting by stepParent.`); +function placeStepParentWrappers(newElements: any[], newElementsSet: Set, logPrefix: string) { + if (DEBUG) console.log(`[${logPrefix}] placing step-parent wrappers.`); newElements.forEach(element => { const stepParent = element.stepParentName?.trim(); if (!stepParent) return; + if (!(element instanceof Group || element instanceof Cube)) return; + + if (element.vs_step_parent_local === true) { + if (DEBUG) console.log(`[${logPrefix}] Kept local wrapper "${element.name}" external to "${stepParent}".`); + return; + } - const matches = findAllGroupsByName(stepParent, Outliner.root); - let parent = matches.find(g => !newElementsSet.has(g)); + const target = findAllGroupsByName(stepParent, Outliner.root).find(group => + !newElementsSet.has(group) + && group !== element + && !isDescendantOf(group, element) + ); - if (!parent && matches.length === 0) { - parent = new Group({ name: stepParent }).addTo().init(); - if (DEBUG) console.log(`[${logPrefix}] Created stepParent group: "${stepParent}"`); + if (!target) { + console.warn(`[${logPrefix}] Could not find external step parent "${stepParent}" for "${element.name}"; keeping its model-space transform.`); + return; } - if (parent && parent !== element && !isDescendantOf(parent, element)) { - element.addTo(parent); + const worldRotation = getElementBindRotation(element); + const liveSocketFrom = getSocketFrom(target); + const liveSocketRotation = getElementBindRotation(target); + const hasStoredFrame = element.vs_has_step_parent_transform === true + && isFiniteVector3(element.vs_step_parent_origin) + && isFiniteVector3(element.vs_step_parent_rotation); + const authoredSocketFrom = hasStoredFrame + ? [...element.vs_step_parent_origin] as Vector3Tuple + : liveSocketFrom; + const authoredSocketRotation = hasStoredFrame + ? [...element.vs_step_parent_rotation] as Vector3Tuple + : liveSocketRotation; + const localRotation = relativeEulerXYZ(worldRotation, authoredSocketRotation); + const socketDelta: Vector3Tuple = [ + liveSocketFrom[0] - authoredSocketFrom[0], + liveSocketFrom[1] - authoredSocketFrom[1], + liveSocketFrom[2] - authoredSocketFrom[2], + ]; + translateAttachmentSubtree(element, socketDelta); + element.rotation[0] = localRotation[0]; + element.rotation[1] = localRotation[1]; + element.rotation[2] = localRotation[2]; + + element.vs_has_step_parent_transform = true; + element.vs_step_parent_origin = [...liveSocketFrom]; + element.vs_step_parent_rotation = [...liveSocketRotation]; + + if (element.parent !== target) { + element.addTo(target); } + if (DEBUG) console.log(`[${logPrefix}] Placed "${element.name}" under "${target.name}" with local rotation [${localRotation.join(', ')}].`); }); } /** * Merges any duplicate groups in the model. */ -function mergeDuplicateGroups(logPrefix: string) { +function mergeDuplicateGroups(newElementsSet: Set, logPrefix: string) { const toDelete: Group[] = []; collectGroupsDepthFirst(Outliner.root).forEach(group => { + if (isWithinStepParentAttachment(group, newElementsSet)) return; const name = group.name || ''; const base = stripNumericSuffix(name); @@ -261,6 +419,10 @@ export async function processImportedAttachments(elementsBefore: Set, fileP // 2. Apply slot to new elements applyClothingSlot(newElements, masterSlot, logPrefix); + // Structural wrapper groups can be merged away below. Materialize palette + // inheritance first so an ancestor-only paletteSlot follows the imported + // attachment geometry rather than disappearing with the wrapper. + materializePaletteInheritance(newElements, newElementsSet); // 3. Smart Match: New groups -> Old groups with same slot const matchCount = smartMatchGroups(newElements, newElementsSet, elementsBefore, logPrefix); @@ -274,11 +436,11 @@ export async function processImportedAttachments(elementsBefore: Set, fileP // 5. Common Slot Match: "HairOuter" -> "Hair" if same slot mergeGroupsByCommonSlot(newElements, newElementsSet, logPrefix); - // 6. Step Parent Reparenting - reparentByStepParent(newElements, newElementsSet, logPrefix); + // 6. Step Parent Placement + placeStepParentWrappers(newElements, newElementsSet, logPrefix); // 7. Cleanup Duplicates - mergeDuplicateGroups(logPrefix); + mergeDuplicateGroups(newElementsSet, logPrefix); Undo.finishEdit(`Import attachment: ${filePath.split(/[/\\]/).pop()}`); Canvas.updateAll(); diff --git a/src/import_model.ts b/src/import_model.ts index 1b71c72..31107b9 100644 --- a/src/import_model.ts +++ b/src/import_model.ts @@ -5,19 +5,25 @@ import { expand_complex_elements} from "./transform"; declare var Settings: any; +export interface ImportModelOptions { + /** Overrides the normal model-centering offset for this import. */ + rootOffset?: [number, number, number]; +} + /** * Recursively traverses the Vintage Story element tree and creates Blockbench groups and cubes. * @param shape The VS_Shape object which elements should be imported * @param asBackdrop Whether to import the model as a backdrop. * @param filePath The path to the file being imported (for clothing slot inference) */ -export function import_model(shape: VS_Shape, asBackdrop: boolean, filePath?: string) { +export function import_model(shape: VS_Shape, asBackdrop: boolean, filePath?: string, options: ImportModelOptions = {}) { const expanded = expand_complex_elements(shape); // Reverse the [8, 0, 8] offset that export applies to root elements const applyOffset = Settings.get("vs_apply_model_offset") ?? true; - const offset: [number,number,number] = applyOffset ? [-8, 0, -8] : [0, 0, 0]; + const configuredOffset: [number,number,number] = applyOffset ? [-8, 0, -8] : [0, 0, 0]; + const offset = options.rootOffset ?? configuredOffset; traverse(null, offset, expanded.elements, asBackdrop, filePath); -} \ No newline at end of file +} diff --git a/src/property.ts b/src/property.ts index 336359a..23472b2 100644 --- a/src/property.ts +++ b/src/property.ts @@ -52,6 +52,30 @@ function registerOptionalInternalProperty( }); } +function registerStepParentTransformProperties(targetClass: any) { + registerOptionalInternalProperty( + targetClass, + 'boolean', + 'vs_step_parent_local', + value => value === true + ); + registerOptionalInternalProperty( + targetClass, + 'boolean', + 'vs_has_step_parent_transform', + value => value === true + ); + + for (const name of ['vs_step_parent_origin', 'vs_step_parent_rotation']) { + registerOptionalInternalProperty( + targetClass, + 'vector', + name, + (value, container) => container?.vs_has_step_parent_transform === true && isFiniteVector(value, 3) + ); + } +} + const PALETTE_SLOT_OPTIONS = { '0': '0 - Inherit / Default', '1': '1', @@ -148,6 +172,10 @@ new Property(Group, "string", "clothingSlot", { new Property(Group, "boolean", "backdrop"); +// Blockbench-only attachment round-trip metadata. The boolean flags gate the +// vector values so their default [0, 0, 0] cannot be mistaken for authored data. +registerStepParentTransformProperties(Group); + // Internal VS round-trip values. These stay out of VS_GROUP_PROPS so they are // saved in .bbmodel projects without being emitted as Vintage Story JSON keys. registerOptionalInternalProperty(Group, 'vector', 'vs_group_from', value => isFiniteVector(value, 3)); @@ -325,6 +353,7 @@ new Property(Cube, "string", "clothingSlot", { }); new Property(Cube, "boolean", "backdrop"); +registerStepParentTransformProperties(Cube); registerOptionalInternalProperty(Cube, 'boolean', 'vs_has_rotation_origin', value => value === true); registerOptionalInternalProperty(Cube, 'vector2', 'vs_uv', value => isFiniteVector(value, 2)); @@ -397,6 +426,10 @@ declare global { paletteSlot?: number; clothingSlot?: string; backdrop?: boolean; + vs_step_parent_local?: boolean; + vs_has_step_parent_transform?: boolean; + vs_step_parent_origin?: [number, number, number]; + vs_step_parent_rotation?: [number, number, number]; vs_group_from?: [number, number, number]; vs_group_to?: [number, number, number]; vs_has_rotation_origin?: boolean; @@ -417,6 +450,10 @@ declare global { disableRandomDrawOffset?: boolean; unwrapRotation?: number; backdrop?: boolean; + vs_step_parent_local?: boolean; + vs_has_step_parent_transform?: boolean; + vs_step_parent_origin?: [number, number, number]; + vs_step_parent_rotation?: [number, number, number]; vs_has_rotation_origin?: boolean; vs_uv?: [number, number]; } From 44e2ae106496fb8b2bc5430196ea7908b01f8343 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 12:32:31 +0100 Subject: [PATCH 12/13] Fix viewport preview for step-parented attachments --- src/mods/nodePreviewControllerMod.ts | 134 +++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 19 deletions(-) diff --git a/src/mods/nodePreviewControllerMod.ts b/src/mods/nodePreviewControllerMod.ts index 7bf0191..5a62cfa 100644 --- a/src/mods/nodePreviewControllerMod.ts +++ b/src/mods/nodePreviewControllerMod.ts @@ -3,6 +3,8 @@ import * as PACKAGE from "../../package.json"; import { is_vs_project } from "../util"; import { getAnimatedConstraintsForBone } from "./ik/constraints"; import { isIKAnimationContextActive } from "./ik/utils"; +import { composeEulerXYZ, relativeEulerXYZ } from "../attachments/attachment_transform"; +import type { Vector3Tuple } from "../attachments/attachment_transform"; createBlockbenchMod(`${PACKAGE.name}:node_preview_controller_mod`, @@ -13,7 +15,22 @@ createBlockbenchMod(`${PACKAGE.name}:node_preview_controller_mod`, Blockbench.NodePreviewController.prototype.updateTransform = function (this: NodePreviewController, node: OutlinerNode) { if (is_vs_project(Project)) { if ((node instanceof Group || node instanceof Cube)&& node.stepParentName && node.stepParentName !== "") { - return updateStepChildTransform(this, node); + const stepParent = resolveStepParentTarget(node); + const usesPhysicalParent = node.vs_step_parent_local !== true + && stepParent instanceof Group + && node.parent === stepParent; + + // Absolute-space BB attachments can use Blockbench's normal + // transform once they have been placed under the real socket. + // Standalone legacy sources should likewise retain their + // authored hierarchy when no socket is present. + if (usesPhysicalParent || (node.vs_step_parent_local !== true && !stepParent)) { + const result = inject_context.original.call(this, node); + applyConstrainedGroupRotation(node); + return result; + } + + return updateStepChildTransform(this, node, stepParent); } const result = inject_context.original.call(this, node); @@ -32,26 +49,48 @@ 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) { +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) { + if (element.vs_step_parent_local === true) { + // VS children are relative to parent.from, while the preview + // mesh is rooted at parent.rotationOrigin/origin. + const parentFrom = getStepParentFrom(stepParent); + position = [ + element.origin[0] + parentFrom[0] - stepParent.origin[0], + element.origin[1] + parentFrom[1] - stepParent.origin[1], + element.origin[2] + parentFrom[2] - stepParent.origin[2], + ]; + } else { + // Absolute BB coordinates become local to the target mesh pivot. + position = [ + element.origin[0] - stepParent.origin[0], + element.origin[1] - stepParent.origin[1], + element.origin[2] - stepParent.origin[2], + ]; + } + } //@ts-expect-error: missing types - if (element.getTypeBehavior('movable')) { - //@ts-expect-error: missing types - mesh.position.set(element.origin[0], element.origin[1], element.origin[2]); - } + 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 + ? getPreviewRotation(element) + : relativeEulerXYZ(getElementBindRotation(element), getElementBindRotation(stepParent)); //@ts-expect-error: missing types - if (element.getTypeBehavior('rotatable')) { - const rotation = getPreviewRotation(element); - //@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]); - } + 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')) { @@ -81,10 +120,9 @@ function updateStepChildTransform(controller: NodePreviewController, element: an // } if((element instanceof Cube || element instanceof Group) && element.stepParentName && element.stepParentName != "") { - const step_parent = Cube.all.find(c => c.name === `${element.stepParentName}_geo`); - // Guard against self-parenting (would cause THREE.js "can't add as child of itself" error) - if(step_parent && step_parent !== element && step_parent.mesh !== element.mesh) { - step_parent.mesh.add(element.mesh); + // Guard against self-parenting and attachment-child cycles. + if(stepParent && stepParent !== element && stepParent.mesh !== element.mesh) { + stepParent.mesh.add(element.mesh); } else { Project!.model_3d.add(mesh); } @@ -95,6 +133,64 @@ function updateStepChildTransform(controller: NodePreviewController, element: an controller.dispatchEvent('update_transform', { element }); } +function isInElementSubtree(candidate: Group | Cube, element: Group | Cube): boolean { + let current: any = candidate; + while (current instanceof Group || current instanceof Cube) { + if (current === element) return true; + current = current.parent; + } + return false; +} + +function resolveStepParentTarget(element: Group | Cube): Group | Cube | null { + const name = element.stepParentName?.trim(); + if (!name) return null; + const attachmentSlot = element.clothingSlot?.trim(); + + const groups = Group.all.filter((candidate: Group) => + candidate.name === name + && candidate !== element + && !isInElementSubtree(candidate, element) + ); + const preferredGroup = groups.find((candidate: Group) => + !candidate.stepParentName?.trim() + && (!candidate.clothingSlot?.trim() || candidate.clothingSlot?.trim() !== attachmentSlot) + ) || groups.find((candidate: Group) => + !candidate.clothingSlot?.trim() || candidate.clothingSlot?.trim() !== attachmentSlot + ) || groups[0]; + if (preferredGroup) return preferredGroup; + + const cubes = Cube.all.filter((candidate: Cube) => + candidate.name === `${name}_geo` + && candidate !== element + && !isInElementSubtree(candidate, element) + ); + return cubes.find((candidate: Cube) => + !candidate.clothingSlot?.trim() || candidate.clothingSlot?.trim() !== attachmentSlot + ) || cubes[0] || null; +} + +function getStepParentFrom(stepParent: Group | Cube): Vector3Tuple { + if (stepParent instanceof Cube) return [...stepParent.from] as Vector3Tuple; + if (Array.isArray(stepParent.vs_group_from) && stepParent.vs_group_from.length === 3) { + return [...stepParent.vs_group_from] as Vector3Tuple; + } + const geoChild = stepParent.children.find(child => + child instanceof Cube && child.name === `${stepParent.name}_geo` + ) as Cube | undefined; + return [...(geoChild?.from || stepParent.origin)] as Vector3Tuple; +} + +function getElementBindRotation(element: Group | Cube): Vector3Tuple { + const chain: Vector3Tuple[] = []; + let current: any = element; + while (current instanceof Group || current instanceof Cube) { + chain.unshift([...(current.rotation || [0, 0, 0])] as Vector3Tuple); + current = current.parent; + } + return composeEulerXYZ(chain); +} + function getPreviewRotation(element: any): [number, number, number] { const rotation: [number, number, number] = [ element.rotation?.[0] || 0, From e63a8f2d0d335b9a11cb85c2750bafbb18e62f74 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 12:35:50 +0100 Subject: [PATCH 13/13] Remove IK exploration mod and unpick its call sites --- src/export_animation.ts | 2 +- src/mods/boneAnimatorMod.ts | 208 +---------- src/mods/ik/baking.ts | 403 -------------------- src/mods/ik/chain_utils.ts | 105 ------ src/mods/ik/constraint_visualizer.ts | 309 ---------------- src/mods/ik/constraints.ts | 525 --------------------------- src/mods/ik/index.ts | 77 ---- src/mods/ik/interactive.ts | 306 ---------------- src/mods/ik/math.ts | 102 ------ src/mods/ik/properties.ts | 366 ------------------- src/mods/ik/solver.ts | 155 -------- src/mods/ik/types.ts | 51 --- src/mods/ik/utils.ts | 106 ------ src/mods/nodePreviewControllerMod.ts | 71 +--- src/vs_plugin.ts | 1 - 15 files changed, 8 insertions(+), 2779 deletions(-) delete mode 100644 src/mods/ik/baking.ts delete mode 100644 src/mods/ik/chain_utils.ts delete mode 100644 src/mods/ik/constraint_visualizer.ts delete mode 100644 src/mods/ik/constraints.ts delete mode 100644 src/mods/ik/index.ts delete mode 100644 src/mods/ik/interactive.ts delete mode 100644 src/mods/ik/math.ts delete mode 100644 src/mods/ik/properties.ts delete mode 100644 src/mods/ik/solver.ts delete mode 100644 src/mods/ik/types.ts delete mode 100644 src/mods/ik/utils.ts diff --git a/src/export_animation.ts b/src/export_animation.ts index 1c896fc..2503dab 100644 --- a/src/export_animation.ts +++ b/src/export_animation.ts @@ -26,7 +26,7 @@ export function export_animations(): Array { animators.forEach(animator => { if (animator.type === 'bone' && animator.keyframes && animator.keyframes.length > 0) { - // Skip NullObject animators (IK controllers) — they don't exist as VS elements + // Skip NullObject animators; they have no corresponding VS element if (typeof NullObject !== 'undefined' && NullObject.all?.some((n: any) => n.uuid === animator.uuid)) { return; } diff --git a/src/mods/boneAnimatorMod.ts b/src/mods/boneAnimatorMod.ts index 8d14148..b14fc1e 100644 --- a/src/mods/boneAnimatorMod.ts +++ b/src/mods/boneAnimatorMod.ts @@ -1,175 +1,10 @@ import { createBlockbenchMod } from "../util/moddingTools"; import * as PACKAGE from "../../package.json"; import { is_vs_project } from "../util"; -import { - getAnimatedConstraintsForBone, - getConstraintCacheEntries, - getIKConstraintData, - applyRotationConstraints, - isIKControllerAnimated -} from "./ik/constraints"; -import { findAllIKControllers, getIKChain } from "./ik/chain_utils"; -import { refreshIKPreview, isIKAnimationContextActive } from "./ik/utils"; -import { getIKWeightAtTime } from "./ik/baking"; -import { BoneConstraint } from "./ik/types"; // @ts-expect-error: THREE is global in Blockbench declare const THREE: typeof import('three'); -const DEG2RAD = Math.PI / 180; -const ROTATION_EPSILON = 0.0001; - -/** - * Map of patched bone names to their cleanup functions. - * Used to restore original updateMatrixWorld when constraints change. - */ -const patchedBones = new Map void>(); - -/** - * Patches updateMatrixWorld on constrained bone meshes so that - * IK constraints are enforced during the render pass — AFTER - * Blockbench's IK solver has set the bone rotations. - */ -export function syncConstraintEnforcement(): void { - // Clean up old patches - for (const cleanup of patchedBones.values()) cleanup(); - patchedBones.clear(); - - const entries = getConstraintCacheEntries(); - if (!entries) return; - - for (const [boneName] of entries) { - const bone = Group.all.find((g: any) => g.name === boneName); - if (!bone?.mesh) continue; - - const originalFn = bone.mesh.updateMatrixWorld; - - bone.mesh.updateMatrixWorld = function (this: any, force: boolean) { - if (!isIKAnimationContextActive()) { - return originalFn.call(this, force); - } - - const constraint = getAnimatedConstraintsForBone(boneName, getActiveAnimation()); - if (!constraint) { - return originalFn.call(this, force); - } - - // Clamp rotation before matrix is recomputed - const euler = this.rotation; - if (constraint.allowedAxes) { - if (!constraint.allowedAxes.x) euler.x = 0; - if (!constraint.allowedAxes.y) euler.y = 0; - if (!constraint.allowedAxes.z) euler.z = 0; - } - if (constraint.rotationLimits) { - if (constraint.rotationLimits.x) { - euler.x = Math.max(constraint.rotationLimits.x.min * DEG2RAD, - Math.min(constraint.rotationLimits.x.max * DEG2RAD, euler.x)); - } - if (constraint.rotationLimits.y) { - euler.y = Math.max(constraint.rotationLimits.y.min * DEG2RAD, - Math.min(constraint.rotationLimits.y.max * DEG2RAD, euler.y)); - } - if (constraint.rotationLimits.z) { - euler.z = Math.max(constraint.rotationLimits.z.min * DEG2RAD, - Math.min(constraint.rotationLimits.z.max * DEG2RAD, euler.z)); - } - } - originalFn.call(this, force); - }; - - patchedBones.set(boneName, () => { - if (bone.mesh) { - bone.mesh.updateMatrixWorld = originalFn; - } - }); - } -} - -/** - * Removes all constraint enforcement patches. - */ -export function clearConstraintEnforcement(): void { - for (const cleanup of patchedBones.values()) cleanup(); - patchedBones.clear(); -} - -function getActiveAnimation(): any | null { - try { - return Animation?.selected ?? Animator?.selected ?? null; - } catch { - return null; - } -} - -function enforceControllerAnimationConstraints(controller: any, animation: any | null): any[] { - if (!controller?.ik_target) { - return []; - } - - const chain = getIKChain(controller); - if (chain.length === 0) { - return []; - } - - if (!isIKControllerAnimated(controller, animation)) { - return []; - } - - if (animation) { - const weight = getIKWeightAtTime(controller, animation, animation.time); - if (weight <= 0) { - return []; - } - } - - const constraintData = getIKConstraintData(controller); - const changedBones: any[] = []; - - for (const bone of chain) { - const currentRotation: [number, number, number] = [ - bone.rotation?.[0] || 0, - bone.rotation?.[1] || 0, - bone.rotation?.[2] || 0 - ]; - const constrainedRotation = applyRotationConstraints(bone, currentRotation, constraintData); - - const changed = - Math.abs(constrainedRotation[0] - currentRotation[0]) > ROTATION_EPSILON || - Math.abs(constrainedRotation[1] - currentRotation[1]) > ROTATION_EPSILON || - Math.abs(constrainedRotation[2] - currentRotation[2]) > ROTATION_EPSILON; - - if (!changed) { - continue; - } - - bone.rotation[0] = constrainedRotation[0]; - bone.rotation[1] = constrainedRotation[1]; - bone.rotation[2] = constrainedRotation[2]; - changedBones.push(bone); - } - - return changedBones; -} - -function enforceAnimatedIKConstraints(): void { - if (!is_vs_project(Project) || !isIKAnimationContextActive()) { - return; - } - - const animation = getActiveAnimation(); - const changedBones = new Set(); - - for (const { controller } of findAllIKControllers()) { - const changed = enforceControllerAnimationConstraints(controller, animation); - changed.forEach((bone: any) => changedBones.add(bone)); - } - - if (changedBones.size > 0) { - refreshIKPreview(Array.from(changedBones)); - } -} - /** * Patches the BoneAnimator to flip rotation and position application when a VS file is loaded */ @@ -177,8 +12,7 @@ createBlockbenchMod( `${PACKAGE.name}:bone_animator_mod`, { original: Blockbench.BoneAnimator.prototype.displayFrame, - additional_function: Blockbench.BoneAnimator.prototype.flippedDisplayPosition, - originalPreview: Animator.preview + additional_function: Blockbench.BoneAnimator.prototype.flippedDisplayPosition }, context => { // Inject code here @@ -192,33 +26,6 @@ createBlockbenchMod( const rotation = this.interpolate('rotation'); const position = this.interpolate('position'); - // Apply IK constraints to interpolated rotation during playback - // (for bones with keyframes — IK-driven bones are handled by updateMatrixWorld patches) - if (rotation) { - const group = this.group; - if (group) { - const constraint = getAnimatedConstraintsForBone(group.name, getActiveAnimation()); - if (constraint) { - if (constraint.allowedAxes) { - if (!constraint.allowedAxes.x) rotation[0] = 0; - if (!constraint.allowedAxes.y) rotation[1] = 0; - if (!constraint.allowedAxes.z) rotation[2] = 0; - } - if (constraint.rotationLimits) { - if (constraint.rotationLimits.x) { - rotation[0] = Math.max(constraint.rotationLimits.x.min, Math.min(constraint.rotationLimits.x.max, rotation[0])); - } - if (constraint.rotationLimits.y) { - rotation[1] = Math.max(constraint.rotationLimits.y.min, Math.min(constraint.rotationLimits.y.max, rotation[1])); - } - if (constraint.rotationLimits.z) { - rotation[2] = Math.max(constraint.rotationLimits.z.min, Math.min(constraint.rotationLimits.z.max, rotation[2])); - } - } - } - } - } - //@ts-expect-error: Copied from blockbench itself, so it should work :P if (!this.muted.rotation) this.displayRotation(rotation, multiplier); @@ -233,17 +40,6 @@ createBlockbenchMod( return context.original.call(this, multiplier); }; - Animator.preview = function (this: any, in_loop?: boolean) { - const result = context.originalPreview.call(this, in_loop); - - if (is_vs_project(Project) && isIKAnimationContextActive()) { - enforceAnimatedIKConstraints(); - } - - return result; - }; - - Blockbench.BoneAnimator.prototype.flippedDisplayPosition = function (this: BoneAnimator, position, rotation, multiplier) { if (!rotation) { this.displayPosition(position, multiplier); @@ -264,8 +60,6 @@ createBlockbenchMod( return context; }, context => { - clearConstraintEnforcement(); - Animator.preview = context.originalPreview; Blockbench.BoneAnimator.prototype.flippedDisplayPosition = context.additional_function; Blockbench.BoneAnimator.prototype.displayFrame = context.original; } diff --git a/src/mods/ik/baking.ts b/src/mods/ik/baking.ts deleted file mode 100644 index 4298efe..0000000 --- a/src/mods/ik/baking.ts +++ /dev/null @@ -1,403 +0,0 @@ - -import { findAllIKControllers } from "./chain_utils"; -import { getIKConstraintData, setIKConstraintData, applyRotationConstraints, getOrientationInfluence } from "./constraints"; -import { IKConstraintData } from "./types"; -import { setForcedIKAnimationContext } from "./utils"; - -// Blockbench global types -declare var Blockbench: any; -declare var Project: any; -declare var Animation: any; -declare var _Animation: any; -declare var Animator: any; -declare var Format: any; -declare var Group: any; - -// Constants -const VS_DEFAULT_FPS = 20; -const KEYFRAME_TIME_TOLERANCE = 0.01; -const POSITION_THRESHOLD = 0.001; - -/** - * Gets the animatable IK weight for a controller at a specific time. - * Interpolates between keyframes if weight is animated. - * - * @param controller - The IK controller - * @param animation - The animation to check - * @param time - The time to evaluate - * @returns Weight value between 0-1 (0 = disabled, 1 = fully enabled) - */ -export function getIKWeightAtTime(controller: any, animation: any, time: number): number { - const constraintData = getIKConstraintData(controller); - const baseWeight = constraintData.weight ?? 1.0; - - try { - const animator = animation.getBoneAnimator(controller); - if (animator) { - - const weightKeyframes = animator.keyframes.filter((kf: any) => - kf.channel === 'ik_weight' - ); - - if (weightKeyframes.length > 0) { - - weightKeyframes.sort((a: any, b: any) => a.time - b.time); - - let beforeKf: any = null; - let afterKf: any = null; - - for (const kf of weightKeyframes) { - if (kf.time <= time) { - beforeKf = kf; - } else if (!afterKf) { - afterKf = kf; - break; - } - } - - if (beforeKf && afterKf) { - - const t = (time - beforeKf.time) / (afterKf.time - beforeKf.time); - const beforeWeight = beforeKf.data_points?.[0]?.x ?? baseWeight; - const afterWeight = afterKf.data_points?.[0]?.x ?? baseWeight; - return Math.max(0, Math.min(1, beforeWeight + (afterWeight - beforeWeight) * t)); - } else if (beforeKf) { - - return Math.max(0, Math.min(1, beforeKf.data_points?.[0]?.x ?? baseWeight)); - } else if (afterKf) { - - return Math.max(0, Math.min(1, afterKf.data_points?.[0]?.x ?? baseWeight)); - } - } - } - } catch (e) { - - console.warn('Error getting animatable IK weight:', e); - } - - return baseWeight; -} - -/** - * Gets the animatable IK lock state for a controller at a specific time. - * - * @param controller - The IK controller - * @param animation - The animation to check - * @param time - The time to evaluate - * @returns True if the controller position should be locked at this time - */ -export function getIKLockAtTime(controller: any, animation: any, time: number): boolean { - const constraintData = getIKConstraintData(controller); - const baseLock = constraintData.lockPosition ?? false; - - try { - const animator = animation.getBoneAnimator(controller); - if (animator) { - - const lockKeyframes = animator.keyframes.filter((kf: any) => - kf.channel === 'ik_lock' - ); - - if (lockKeyframes.length > 0) { - - lockKeyframes.sort((a: any, b: any) => a.time - b.time); - - let beforeKf: any = null; - let afterKf: any = null; - - for (const kf of lockKeyframes) { - if (kf.time <= time) { - beforeKf = kf; - } else if (!afterKf) { - afterKf = kf; - break; - } - } - - if (beforeKf && afterKf) { - - const t = (time - beforeKf.time) / (afterKf.time - beforeKf.time); - const beforeLock = (beforeKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; - const afterLock = (afterKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; - const interpolated = beforeLock ? (1 - t) : t; - return interpolated > 0.5; - } else if (beforeKf) { - - return (beforeKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; - } else if (afterKf) { - - return (afterKf.data_points?.[0]?.x ?? (baseLock ? 1 : 0)) > 0.5; - } - } - } - } catch (e) { - - console.warn('Error getting animatable IK lock state:', e); - } - - return baseLock; -} - -/** - * Blends IK-driven rotation with manual keyframes and constraints. - * Allows manual keyframe overrides to take precedence over IK when present. - * - * @param bone - The bone to blend rotation for - * @param ikRotation - The IK-calculated rotation - * @param animation - The current animation - * @param time - The current time - * @param constraintData - IK constraint configuration - * @param weight - IK weight (0-1) - * @returns Final blended rotation with constraints applied - */ -export function blendIKWithManual( - bone: any, - ikRotation: [number, number, number], - animation: any, - time: number, - constraintData: IKConstraintData, - weight: number -): [number, number, number] { - const animator = animation.getBoneAnimator(bone); - if (!animator) { - return applyRotationConstraints(bone, ikRotation, constraintData); - } - - const rotationKeyframes = animator.keyframes.filter((kf: any) => - kf.channel === 'rotation' && Math.abs(kf.time - time) < KEYFRAME_TIME_TOLERANCE - ); - - if (rotationKeyframes.length > 0) { - - const manualRot = rotationKeyframes[0].data_points[0]; - const manualRotation: [number, number, number] = [ - manualRot.x || 0, - manualRot.y || 0, - manualRot.z || 0 - ]; - - const blended: [number, number, number] = [ - manualRotation[0] * (1 - weight) + ikRotation[0] * weight, - manualRotation[1] * (1 - weight) + ikRotation[1] * weight, - manualRotation[2] * (1 - weight) + ikRotation[2] * weight - ]; - - return applyRotationConstraints(bone, blended, constraintData); - } - - const orientationInfluence = getOrientationInfluence(constraintData); - if (orientationInfluence) { - - const blended: [number, number, number] = [ - ikRotation[0] * 0.5 + orientationInfluence[0] * 0.5, - ikRotation[1] * 0.5 + orientationInfluence[1] * 0.5, - ikRotation[2] * 0.5 + orientationInfluence[2] * 0.5 - ]; - return applyRotationConstraints(bone, blended, constraintData); - } - - return applyRotationConstraints(bone, ikRotation, constraintData); -} - -/** - * Bakes IK animations to keyframes for VS export. - * Since VS doesn't support IK natively, this converts IK-driven animations - * to regular keyframe animations by sampling the IK-driven bone transforms. - * - * Process: - * 1. Samples animation at regular intervals (VS_DEFAULT_FPS) - * 2. Applies IK constraints and weights - * 3. Creates rotation and position keyframes for all bones in IK chains - * 4. Shows progress notification when complete - * - * @param is_vs_project - Function to check if current project is a VS project - */ -export function bakeIKAnimations(is_vs_project: (project: any) => boolean) { - if (!Project || !is_vs_project(Project)) return; - - const ikControllers = findAllIKControllers(); - if (ikControllers.length === 0) return; - - const animations = (Animation as unknown as typeof _Animation).all; - const fps = VS_DEFAULT_FPS; - - let totalBakedKeyframes = 0; - - setForcedIKAnimationContext(true); - - try { - animations.forEach((animation: any) => { - - const ikBones = new Set(); - ikControllers.forEach(({ chain }) => { - chain.forEach(bone => ikBones.add(bone)); - }); - - if (ikBones.size === 0) return; - - const frameCount = Math.ceil(animation.length * fps); - const sampleInterval = 1 / fps; // Sample every frame - - const originalTime = animation.time; - const originalSelected = animation.selected; - const wasPlaying = Animator.playing; - - if (wasPlaying) { - Animator.pause(); - } - - animation.select(); - - const wasInAnimationMode = Format.animation_mode; - if (!wasInAnimationMode) { - Format.animation_mode = true; - } - - for (let frame = 0; frame <= frameCount; frame++) { - const time = Math.min(frame * sampleInterval, animation.length); - - animation.time = time; - - ikControllers.forEach(({ controller }) => { - const constraintData = getIKConstraintData(controller); - const isLocked = getIKLockAtTime(controller, animation, time); - - if (isLocked && constraintData.lockedPosition) { - - controller.origin[0] = constraintData.lockedPosition[0]; - controller.origin[1] = constraintData.lockedPosition[1]; - controller.origin[2] = constraintData.lockedPosition[2]; - } else if (isLocked && !constraintData.lockedPosition) { - - const pos = controller.origin; - constraintData.lockedPosition = [pos[0], pos[1], pos[2]]; - setIKConstraintData(controller, constraintData); - } - }); - - if (Animator.update) { - Animator.update(); - } - - Blockbench.updateViewport(); - - ikControllers.forEach(({ controller, chain }) => { - const constraintData = getIKConstraintData(controller); - const weight = getIKWeightAtTime(controller, animation, time); - - if (weight <= 0) { - return; - } - - chain.forEach(bone => { - const animator = animation.getBoneAnimator(bone); - if (!animator) return; - - const ikRotation: [number, number, number] = [ - bone.rotation[0] || 0, - bone.rotation[1] || 0, - bone.rotation[2] || 0 - ]; - - const finalRotation = blendIKWithManual(bone, ikRotation, animation, time, constraintData, weight); - - let originalRotation: [number, number, number] = [0, 0, 0]; - if (animator.interpolate) { - try { - const interp = animator.interpolate('rotation'); - originalRotation = [interp[0] || 0, interp[1] || 0, interp[2] || 0]; - } catch (e) { - - originalRotation = [bone.rotation[0] || 0, bone.rotation[1] || 0, bone.rotation[2] || 0]; - } - } else { - originalRotation = [bone.rotation[0] || 0, bone.rotation[1] || 0, bone.rotation[2] || 0]; - } - - const weightedRotation: [number, number, number] = [ - originalRotation[0] * (1 - weight) + finalRotation[0] * weight, - originalRotation[1] * (1 - weight) + finalRotation[1] * weight, - originalRotation[2] * (1 - weight) + finalRotation[2] * weight - ]; - - const origin = bone.origin; - - const parentOrigin = bone.parent ? bone.parent.origin : [0, 0, 0]; - const offset = [ - origin[0] - parentOrigin[0], - origin[1] - parentOrigin[1], - origin[2] - parentOrigin[2] - ]; - - const existingKf = animator.keyframes.find((kf: any) => - Math.abs(kf.time - time) < sampleInterval / 4 - ); - - if (!existingKf) { - - animator.addKeyframe({ - interpolation: 'linear', - time, - channel: 'rotation', - data_points: [{ - x: weightedRotation[0], - y: weightedRotation[1], - z: weightedRotation[2] - }] - }); - totalBakedKeyframes++; - - if (Math.abs(offset[0]) > POSITION_THRESHOLD || Math.abs(offset[1]) > POSITION_THRESHOLD || Math.abs(offset[2]) > POSITION_THRESHOLD) { - animator.addKeyframe({ - interpolation: 'linear', - time, - channel: 'position', - data_points: [{ - x: offset[0], - y: offset[1], - z: offset[2] - }] - }); - totalBakedKeyframes++; - } - } else { - - const kf = existingKf; - if (kf.channel === 'rotation' && kf.data_points && kf.data_points[0]) { - kf.data_points[0].x = weightedRotation[0]; - kf.data_points[0].y = weightedRotation[1]; - kf.data_points[0].z = weightedRotation[2]; - } else if (kf.channel === 'position' && kf.data_points && kf.data_points[0]) { - kf.data_points[0].x = offset[0]; - kf.data_points[0].y = offset[1]; - kf.data_points[0].z = offset[2]; - } - } - }); - }); - } - - animation.time = originalTime; - if (originalSelected) { - animation.select(); - } else { - animation.deselect(); - } - - if (!wasInAnimationMode) { - Format.animation_mode = false; - } - - if (wasPlaying) { - Animator.play(); - } - }); - } finally { - setForcedIKAnimationContext(false); - } - - Blockbench.showQuickMessage( - `Baked ${ikControllers.length} IK controller(s) to ${totalBakedKeyframes} keyframes`, - 3000 - ); -} diff --git a/src/mods/ik/chain_utils.ts b/src/mods/ik/chain_utils.ts deleted file mode 100644 index a2271e2..0000000 --- a/src/mods/ik/chain_utils.ts +++ /dev/null @@ -1,105 +0,0 @@ - -// Blockbench global types -declare var Group: any; -declare var NullObject: any; -declare var OutlinerNode: any; -declare var Project: any; - -/** - * Chain Utility helper interface - */ -export interface IKControllerInfo { - controller: any; - target: any; - chain: any[]; -} - -/** - * Resolves a value that may be a UUID string to the actual outliner node. - */ -function resolveNode(nodeOrUuid: any): any { - if (typeof nodeOrUuid === 'string') { - return OutlinerNode.uuids[nodeOrUuid] ?? null; - } - return nodeOrUuid; -} - -/** - * Gets the IK chain for a controller. - * Walks up from the ik_target's parent bone to either the ik_source bone - * or the controller's parent bone (whichever is hit first). - * Returns the chain ordered from root to leaf. - * - * @param controller - The NullObject IK controller - * @returns Array of Group bones forming the IK chain, ordered root to leaf - */ -export function getIKChain(controller: any): any[] { - const chain: any[] = []; - - const target = resolveNode(controller.ik_target); - if (!target) return chain; - - const source = resolveNode(controller.ik_source); - const controllerParent = controller.parent; - - // Find the starting bone: the target's parent if target is a locator/element, - // or the target itself if it's a Group - let start: any = target instanceof Group ? target : target.parent; - - // Walk up from the start bone to the source or controller's parent - let current: any = start; - while (current && current instanceof Group) { - chain.unshift(current); - if (source && current === source) break; - if (!source && current === controllerParent) break; - current = current.parent; - } - - return chain; -} - -/** - * Finds all IK controllers in the current project. - * IK controllers are NullObjects that have an ik_target property set. - * - * @returns Array of IK controller information including controller, target, and chain - */ -export function findAllIKControllers(): IKControllerInfo[] { - const ikControllers: IKControllerInfo[] = []; - - const nulls = NullObject.all; - - for (const nullObj of nulls) { - if (nullObj.ik_target) { - const target = resolveNode(nullObj.ik_target); - if (!target) continue; - const chain = getIKChain(nullObj); - ikControllers.push({ - controller: nullObj, - target, - chain - }); - } - } - - return ikControllers; -} - -/** - * Checks if the current project has any IK controllers. - * - * @returns True if at least one IK controller exists in the project - */ -export function checkForIKControllers(): boolean { - if (!Project) return false; - - const nulls = NullObject.all; - - for (const nullObj of nulls) { - if (nullObj.ik_target) { - return true; - } - } - - return false; -} diff --git a/src/mods/ik/constraint_visualizer.ts b/src/mods/ik/constraint_visualizer.ts deleted file mode 100644 index 722e4a9..0000000 --- a/src/mods/ik/constraint_visualizer.ts +++ /dev/null @@ -1,309 +0,0 @@ - -import { BoneConstraint } from "./types"; - -// @ts-expect-error: THREE is global in Blockbench -declare const THREE: typeof import('three'); -declare var Canvas: any; - -const ARC_SEGMENTS = 32; -const ARC_RADIUS = 3; -const FILL_OPACITY = 0.15; -const OUTLINE_OPACITY = 0.8; -const RENDER_ORDER = 998; -const DISABLED_MARKER_SIZE = 1.5; -const DEG_TO_RAD = Math.PI / 180; - -const AXIS_COLORS = { - x: 0xef3333, - y: 0x7bef37, - z: 0x3b8aef, -}; - -type Axis = 'x' | 'y' | 'z'; -const AXES: Axis[] = ['x', 'y', 'z']; - -interface AxisVisual { - fillMesh: any | null; - outlineLine: any | null; - disabledMarker: any | null; -} - -interface BoneVisual { - bone: any; - container: any; - axes: Record; -} - -function getAxisRotation(axis: Axis): any { - switch (axis) { - case 'x': return new THREE.Euler(0, Math.PI / 2, 0); - case 'y': return new THREE.Euler(Math.PI / 2, 0, 0); - case 'z': return new THREE.Euler(0, 0, 0); - } -} - -function createArcFillGeometry(minDeg: number, maxDeg: number): any { - const minRad = minDeg * DEG_TO_RAD; - const maxRad = maxDeg * DEG_TO_RAD; - const range = maxRad - minRad; - const step = range / ARC_SEGMENTS; - const vertices: number[] = []; - - for (let i = 0; i < ARC_SEGMENTS; i++) { - const a1 = minRad + step * i; - const a2 = minRad + step * (i + 1); - vertices.push(0, 0, 0); - vertices.push(Math.cos(a1) * ARC_RADIUS, Math.sin(a1) * ARC_RADIUS, 0); - vertices.push(Math.cos(a2) * ARC_RADIUS, Math.sin(a2) * ARC_RADIUS, 0); - } - - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)); - return geometry; -} - -function createArcOutlineGeometry(minDeg: number, maxDeg: number): any { - const minRad = minDeg * DEG_TO_RAD; - const maxRad = maxDeg * DEG_TO_RAD; - const range = maxRad - minRad; - const step = range / ARC_SEGMENTS; - const points: number[] = []; - - // Radial line to min - points.push(0, 0, 0); - points.push(Math.cos(minRad) * ARC_RADIUS, Math.sin(minRad) * ARC_RADIUS, 0); - - // Arc from min to max - for (let i = 0; i <= ARC_SEGMENTS; i++) { - const a = minRad + step * i; - points.push(Math.cos(a) * ARC_RADIUS, Math.sin(a) * ARC_RADIUS, 0); - } - - // Radial line back to center - points.push(0, 0, 0); - - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute('position', new THREE.Float32BufferAttribute(points, 3)); - return geometry; -} - -function createCircleOutlineGeometry(): any { - const step = (Math.PI * 2) / ARC_SEGMENTS; - const points: number[] = []; - - for (let i = 0; i <= ARC_SEGMENTS; i++) { - const a = step * i; - points.push(Math.cos(a) * ARC_RADIUS, Math.sin(a) * ARC_RADIUS, 0); - } - - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute('position', new THREE.Float32BufferAttribute(points, 3)); - return geometry; -} - -function createDisabledMarkerGeometry(): any { - const s = DISABLED_MARKER_SIZE; - const vertices = [ - -s, -s, 0, s, s, 0, - s, -s, 0, -s, s, 0, - ]; - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)); - return geometry; -} - -export class ConstraintVisualizer { - private boneVisuals: Map = new Map(); - private fillMaterials: Record = {} as any; - private outlineMaterials: Record = {} as any; - private disabledMaterials: Record = {} as any; - - constructor(private chain: any[], constraints: Record) { - this.createMaterials(); - - for (const bone of chain) { - const constraint = constraints[bone.name] || {}; - this.createBoneVisual(bone, constraint); - } - } - - private createMaterials(): void { - for (const axis of AXES) { - const color = AXIS_COLORS[axis]; - this.fillMaterials[axis] = new THREE.MeshBasicMaterial({ - color, - transparent: true, - opacity: FILL_OPACITY, - depthTest: false, - side: THREE.DoubleSide, - }); - this.outlineMaterials[axis] = new THREE.LineBasicMaterial({ - color, - transparent: true, - opacity: OUTLINE_OPACITY, - depthTest: false, - }); - this.disabledMaterials[axis] = new THREE.LineBasicMaterial({ - color, - transparent: true, - opacity: OUTLINE_OPACITY, - depthTest: false, - }); - } - } - - private createBoneVisual(bone: any, constraint: BoneConstraint): void { - const container = new THREE.Group(); - container.renderOrder = RENDER_ORDER; - - const axes: Record = { - x: { fillMesh: null, outlineLine: null, disabledMarker: null }, - y: { fillMesh: null, outlineLine: null, disabledMarker: null }, - z: { fillMesh: null, outlineLine: null, disabledMarker: null }, - }; - - const visual: BoneVisual = { bone, container, axes }; - this.boneVisuals.set(bone.name, visual); - - for (const axis of AXES) { - const allowed = constraint.allowedAxes?.[axis] !== false; - const limits = constraint.rotationLimits?.[axis]; - this.updateAxisVisual(visual, axis, allowed, limits); - } - - if (bone.mesh) { - bone.mesh.add(container); - } else { - Canvas.scene.add(container); - } - } - - private updateAxisVisual( - visual: BoneVisual, - axis: Axis, - allowed: boolean, - limits: { min: number; max: number } | undefined - ): void { - const av = visual.axes[axis]; - const container = visual.container; - - if (!allowed) { - // Hide arcs - if (av.fillMesh) av.fillMesh.visible = false; - if (av.outlineLine) av.outlineLine.visible = false; - - // Show disabled marker - if (!av.disabledMarker) { - const geom = createDisabledMarkerGeometry(); - av.disabledMarker = new THREE.LineSegments(geom, this.disabledMaterials[axis]); - av.disabledMarker.rotation.copy(getAxisRotation(axis)); - av.disabledMarker.renderOrder = RENDER_ORDER; - container.add(av.disabledMarker); - } - av.disabledMarker.visible = true; - return; - } - - // Axis is enabled — hide disabled marker - if (av.disabledMarker) av.disabledMarker.visible = false; - - const minDeg = limits?.min ?? -180; - const maxDeg = limits?.max ?? 180; - const isFullRange = minDeg <= -180 && maxDeg >= 180; - - if (isFullRange) { - // Full range — outline circle only, no fill - if (av.fillMesh) av.fillMesh.visible = false; - - if (av.outlineLine) { - av.outlineLine.geometry.dispose(); - av.outlineLine.geometry = createCircleOutlineGeometry(); - } else { - const geom = createCircleOutlineGeometry(); - av.outlineLine = new THREE.Line(geom, this.outlineMaterials[axis]); - av.outlineLine.rotation.copy(getAxisRotation(axis)); - av.outlineLine.renderOrder = RENDER_ORDER; - container.add(av.outlineLine); - } - av.outlineLine.visible = true; - } else { - // Limited range — fill wedge + outline - const safeMin = Math.min(minDeg, maxDeg); - const safeMax = Math.max(minDeg, maxDeg); - - // Fill - if (av.fillMesh) { - av.fillMesh.geometry.dispose(); - av.fillMesh.geometry = createArcFillGeometry(safeMin, safeMax); - } else { - const geom = createArcFillGeometry(safeMin, safeMax); - av.fillMesh = new THREE.Mesh(geom, this.fillMaterials[axis]); - av.fillMesh.rotation.copy(getAxisRotation(axis)); - av.fillMesh.renderOrder = RENDER_ORDER; - container.add(av.fillMesh); - } - av.fillMesh.visible = true; - - // Outline - if (av.outlineLine) { - av.outlineLine.geometry.dispose(); - av.outlineLine.geometry = createArcOutlineGeometry(safeMin, safeMax); - } else { - const geom = createArcOutlineGeometry(safeMin, safeMax); - av.outlineLine = new THREE.Line(geom, this.outlineMaterials[axis]); - av.outlineLine.rotation.copy(getAxisRotation(axis)); - av.outlineLine.renderOrder = RENDER_ORDER; - container.add(av.outlineLine); - } - av.outlineLine.visible = true; - } - } - - update(constraints: Record): void { - for (const bone of this.chain) { - const visual = this.boneVisuals.get(bone.name); - if (!visual) continue; - - const constraint = constraints[bone.name] || {}; - for (const axis of AXES) { - const allowed = constraint.allowedAxes?.[axis] !== false; - const limits = constraint.rotationLimits?.[axis]; - this.updateAxisVisual(visual, axis, allowed, limits); - } - } - } - - dispose(): void { - for (const [, visual] of this.boneVisuals) { - for (const axis of AXES) { - const av = visual.axes[axis]; - if (av.fillMesh) { - av.fillMesh.geometry.dispose(); - visual.container.remove(av.fillMesh); - } - if (av.outlineLine) { - av.outlineLine.geometry.dispose(); - visual.container.remove(av.outlineLine); - } - if (av.disabledMarker) { - av.disabledMarker.geometry.dispose(); - visual.container.remove(av.disabledMarker); - } - } - - if (visual.bone.mesh) { - visual.bone.mesh.remove(visual.container); - } else { - Canvas.scene.remove(visual.container); - } - } - - for (const axis of AXES) { - this.fillMaterials[axis].dispose(); - this.outlineMaterials[axis].dispose(); - this.disabledMaterials[axis].dispose(); - } - - this.boneVisuals.clear(); - } -} diff --git a/src/mods/ik/constraints.ts b/src/mods/ik/constraints.ts deleted file mode 100644 index 27b3e16..0000000 --- a/src/mods/ik/constraints.ts +++ /dev/null @@ -1,525 +0,0 @@ - -import * as PACKAGE from "../../../package.json"; -import { IKConstraintData, BoneConstraint } from "./types"; -import { ConstraintVisualizer } from "./constraint_visualizer"; -import { findAllIKControllers } from "./chain_utils"; - -// Blockbench global types -declare var Group: any; -declare var NullObject: any; -declare var Dialog: any; -declare var Blockbench: any; -declare var Project: any; -declare var Animation: any; -declare var Animator: any; - -/** - * Gets IK constraint data for a controller. - * Loads persisted boneConstraints from the vsIKBoneConstraints string property. - */ -export function getIKConstraintData(controller: any): IKConstraintData { - if (!controller.vsIKConstraints) { - let boneConstraints: Record = {}; - try { - if (controller.vsIKBoneConstraints && controller.vsIKBoneConstraints !== '{}') { - boneConstraints = JSON.parse(controller.vsIKBoneConstraints); - } - } catch (e) { - console.error('Error parsing vsIKBoneConstraints:', e); - } - controller.vsIKConstraints = { - weight: controller.vsIKWeight ?? 1.0, - lockPosition: controller.vsIKLockPosition ?? false, - boneConstraints - }; - } - return controller.vsIKConstraints; -} - -/** - * Sets IK constraint data for a controller - */ -export function setIKConstraintData(controller: any, data: IKConstraintData): void { - controller.vsIKConstraints = { ...getIKConstraintData(controller), ...data }; - constraintCache = null; - - // Persist boneConstraints to the Blockbench Property (saved in bbmodel file) - try { - const bc = controller.vsIKConstraints.boneConstraints; - controller.vsIKBoneConstraints = bc ? JSON.stringify(bc) : '{}'; - } catch (e) { - console.error('Error serializing boneConstraints:', e); - } - - try { - if (Project && Project.save) { - Project.save(); - } - } catch (e) { - console.error('Error saving project after IK constraint update:', e); - } - - ensureCache(); -} - -/** - * Cached constraint lookup for playback performance. - * Invalidated whenever setIKConstraintData is called. - */ -let constraintCache: Map | null = null; - -/** Callback invoked after constraint cache is rebuilt */ -let onCacheRebuilt: (() => void) | null = null; - -/** - * Registers a callback that fires whenever the constraint cache is rebuilt. - * Used by the mesh enforcement system to re-patch bone meshes. - */ -export function setOnConstraintCacheRebuilt(cb: () => void): void { - onCacheRebuilt = cb; -} - -/** - * Returns the full constraint cache (rebuilding if needed). - * Used by the mesh enforcement system. - */ -export function getConstraintCacheEntries(): Map | null { - // Ensure cache is built - ensureCache(); - return constraintCache; -} - -function ensureCache(): void { - if (!constraintCache) { - constraintCache = new Map(); - try { - const controllers = findAllIKControllers(); - for (const { controller } of controllers) { - const data = getIKConstraintData(controller); - if (data.boneConstraints) { - for (const [name, constraint] of Object.entries(data.boneConstraints)) { - constraintCache.set(name, constraint); - } - } - } - } catch (e) { - // During early load, NullObject etc. may not exist yet - } - if (onCacheRebuilt) onCacheRebuilt(); - } -} - -/** - * Gets the bone constraint for a given bone name across all IK controllers. - * Uses a lazy cache rebuilt on constraint changes. - */ -export function getConstraintsForBone(boneName: string): BoneConstraint | null { - ensureCache(); - return constraintCache?.get(boneName) ?? null; -} - -function getSelectedAnimation(): any | null { - try { - return Animation?.selected ?? Animator?.selected ?? null; - } catch { - return null; - } -} - -function getExistingAnimator(animation: any, node: any): any | null { - if (!animation?.animators || !node) { - return null; - } - - return animation.animators[node.uuid] - ?? Object.values(animation.animators).find((animator: any) => - animator?.uuid === node.uuid || animator?.name === node.name - ) - ?? null; -} - -export function isIKControllerAnimated(controller: any, animation: any | null = getSelectedAnimation()): boolean { - const animator = getExistingAnimator(animation, controller); - if (!animator?.keyframes?.length) { - return false; - } - - return animator.keyframes.some((kf: any) => - kf?.channel === 'position' || - kf?.channel === 'rotation' || - kf?.channel === 'scale' - ); -} - -export function getAnimatedConstraintsForBone( - boneName: string, - animation: any | null = getSelectedAnimation() -): BoneConstraint | null { - let activeConstraint: BoneConstraint | null = null; - - try { - const controllers = findAllIKControllers(); - for (const { controller } of controllers) { - if (!isIKControllerAnimated(controller, animation)) { - continue; - } - - const constraint = getIKConstraintData(controller).boneConstraints?.[boneName] ?? null; - if (constraint) { - activeConstraint = constraint; - } - } - } catch { - return null; - } - - return activeConstraint; -} - -/** - * Applies rotation constraints to a bone rotation - */ -export function applyRotationConstraints(bone: any, rotation: [number, number, number], constraintData: IKConstraintData): [number, number, number] { - const boneName = bone.name; - const boneConstraint = constraintData.boneConstraints?.[boneName]; - - if (!boneConstraint) { - return rotation; - } - - let [rx, ry, rz] = rotation; - - if (boneConstraint.allowedAxes) { - // Disabled axis: keep the bone's current rotation (don't move on this axis) - if (!boneConstraint.allowedAxes.x) rx = bone.rotation[0] || 0; - if (!boneConstraint.allowedAxes.y) ry = bone.rotation[1] || 0; - if (!boneConstraint.allowedAxes.z) rz = bone.rotation[2] || 0; - } - - if (boneConstraint.rotationLimits) { - const limits = boneConstraint.rotationLimits; - - if (limits.x) { - rx = Math.max(limits.x.min, Math.min(limits.x.max, rx)); - } - if (limits.y) { - ry = Math.max(limits.y.min, Math.min(limits.y.max, ry)); - } - if (limits.z) { - rz = Math.max(limits.z.min, Math.min(limits.z.max, rz)); - } - } - - return [rx, ry, rz]; -} - -/** - * Gets orientation influence from external helper object - */ -export function getOrientationInfluence(constraintData: IKConstraintData): [number, number, number] | null { - if (!constraintData.orientationHelper) { - return null; - } - - const helper = NullObject.all.find((g: any) => g.name === constraintData.orientationHelper); - if (!helper) { - return null; - } - - return [helper.rotation[0] || 0, helper.rotation[1] || 0, helper.rotation[2] || 0]; -} - -const JOINT_PRESETS: Record = { - 'Unconstrained': { - allowedAxes: { x: true, y: true, z: true }, - rotationLimits: {} - }, - 'Hinge (X)': { - allowedAxes: { x: true, y: false, z: false }, - rotationLimits: { x: { min: 0, max: 150 } } - }, - 'Hinge (Y)': { - allowedAxes: { x: false, y: true, z: false }, - rotationLimits: { y: { min: 0, max: 150 } } - }, - 'Hinge (Z)': { - allowedAxes: { x: false, y: false, z: true }, - rotationLimits: { z: { min: 0, max: 150 } } - }, - 'Ball & Socket': { - allowedAxes: { x: true, y: true, z: true }, - rotationLimits: { x: { min: -90, max: 90 }, y: { min: -90, max: 90 }, z: { min: -90, max: 90 } } - }, - 'Pivot (X)': { - allowedAxes: { x: true, y: true, z: true }, - rotationLimits: { y: { min: -45, max: 45 }, z: { min: -45, max: 45 } } - }, - 'Pivot (Y)': { - allowedAxes: { x: true, y: true, z: true }, - rotationLimits: { x: { min: -45, max: 45 }, z: { min: -45, max: 45 } } - }, - 'Fixed': { - allowedAxes: { x: false, y: false, z: false }, - rotationLimits: { x: { min: 0, max: 0 }, y: { min: 0, max: 0 }, z: { min: 0, max: 0 } } - } -}; - -function detectPreset(boneConstraint: BoneConstraint): string { - for (const [name, preset] of Object.entries(JOINT_PRESETS)) { - const axMatch = - (boneConstraint.allowedAxes?.x !== false) === (preset.allowedAxes?.x !== false) && - (boneConstraint.allowedAxes?.y !== false) === (preset.allowedAxes?.y !== false) && - (boneConstraint.allowedAxes?.z !== false) === (preset.allowedAxes?.z !== false); - if (!axMatch) continue; - - const limMatch = (['x', 'y', 'z'] as const).every(axis => { - const a = boneConstraint.rotationLimits?.[axis]; - const b = preset.rotationLimits?.[axis]; - if (!a && !b) return true; - if (!a || !b) return false; - return a.min === b.min && a.max === b.max; - }); - if (limMatch) return name; - } - return ''; -} - -function getPresetFormValues(boneName: string, preset: BoneConstraint): Record { - return { - [`${boneName}_axis_x`]: preset.allowedAxes?.x !== false, - [`${boneName}_axis_y`]: preset.allowedAxes?.y !== false, - [`${boneName}_axis_z`]: preset.allowedAxes?.z !== false, - [`${boneName}_rot_x_min`]: preset.rotationLimits?.x?.min ?? -180, - [`${boneName}_rot_x_max`]: preset.rotationLimits?.x?.max ?? 180, - [`${boneName}_rot_y_min`]: preset.rotationLimits?.y?.min ?? -180, - [`${boneName}_rot_y_max`]: preset.rotationLimits?.y?.max ?? 180, - [`${boneName}_rot_z_min`]: preset.rotationLimits?.z?.min ?? -180, - [`${boneName}_rot_z_max`]: preset.rotationLimits?.z?.max ?? 180, - }; -} - -/** - * Opens a dialog to edit IK constraints for a controller - */ -export function openIKConstraintEditor(controller: any, getIKChain: (controller: any) => any[]) { - if (!controller.ik_target) return; - - const chain = getIKChain(controller); - const constraintData = getIKConstraintData(controller); - - const presetOptions: Record = { '': 'Custom' }; - for (const name of Object.keys(JOINT_PRESETS)) { - presetOptions[name] = name; - } - - const form: any = {}; - - chain.forEach((bone, index) => { - const boneName = bone.name; - const boneConstraint = constraintData.boneConstraints?.[boneName] || {}; - - form[`${boneName}_preset`] = { - label: `${boneName} - Joint Preset`, - type: 'select', - options: presetOptions, - value: detectPreset(boneConstraint) - }; - form[`${boneName}_axis_x`] = { - label: `${boneName} - Allow X Rotation`, - type: 'checkbox', - value: boneConstraint.allowedAxes?.x !== false - }; - form[`${boneName}_axis_y`] = { - label: `${boneName} - Allow Y Rotation`, - type: 'checkbox', - value: boneConstraint.allowedAxes?.y !== false - }; - form[`${boneName}_axis_z`] = { - label: `${boneName} - Allow Z Rotation`, - type: 'checkbox', - value: boneConstraint.allowedAxes?.z !== false - }; - form[`${boneName}_rot_x_min`] = { - label: `${boneName} - X Rotation Min (degrees)`, - type: 'number', - value: boneConstraint.rotationLimits?.x?.min ?? -180, - step: 1 - }; - form[`${boneName}_rot_x_max`] = { - label: `${boneName} - X Rotation Max (degrees)`, - type: 'number', - value: boneConstraint.rotationLimits?.x?.max ?? 180, - step: 1 - }; - form[`${boneName}_rot_y_min`] = { - label: `${boneName} - Y Rotation Min (degrees)`, - type: 'number', - value: boneConstraint.rotationLimits?.y?.min ?? -180, - step: 1 - }; - form[`${boneName}_rot_y_max`] = { - label: `${boneName} - Y Rotation Max (degrees)`, - type: 'number', - value: boneConstraint.rotationLimits?.y?.max ?? 180, - step: 1 - }; - form[`${boneName}_rot_z_min`] = { - label: `${boneName} - Z Rotation Min (degrees)`, - type: 'number', - value: boneConstraint.rotationLimits?.z?.min ?? -180, - step: 1 - }; - form[`${boneName}_rot_z_max`] = { - label: `${boneName} - Z Rotation Max (degrees)`, - type: 'number', - value: boneConstraint.rotationLimits?.z?.max ?? 180, - step: 1 - }; - }); - - // Track last preset selection per bone to detect actual preset changes - const lastPreset: Record = {}; - chain.forEach(bone => { - lastPreset[bone.name] = detectPreset(constraintData.boneConstraints?.[bone.name] || {}); - }); - - let visualizer: ConstraintVisualizer | null = null; - - const dialog = new Dialog('ik_constraint_editor', { - title: `IK Constraints: ${controller.name}`, - form, - width: 500, - onFormChange(formResult: any) { - const updates: Record = {}; - let presetChanged = false; - - chain.forEach(bone => { - const boneName = bone.name; - const presetName = formResult[`${boneName}_preset`]; - - // Preset dropdown changed — apply preset values - if (presetName !== lastPreset[boneName]) { - lastPreset[boneName] = presetName; - presetChanged = true; - const preset = JOINT_PRESETS[presetName]; - if (preset) { - Object.assign(updates, getPresetFormValues(boneName, preset)); - } - } - }); - - // If no preset dropdown changed, check if manual edits - // invalidate the current preset selection - if (!presetChanged) { - chain.forEach(bone => { - const boneName = bone.name; - const currentPreset = formResult[`${boneName}_preset`]; - if (currentPreset) { - // Build a BoneConstraint from current form values to compare - const current: BoneConstraint = { - allowedAxes: { - x: formResult[`${boneName}_axis_x`] !== false, - y: formResult[`${boneName}_axis_y`] !== false, - z: formResult[`${boneName}_axis_z`] !== false, - }, - rotationLimits: { - x: { min: formResult[`${boneName}_rot_x_min`], max: formResult[`${boneName}_rot_x_max`] }, - y: { min: formResult[`${boneName}_rot_y_min`], max: formResult[`${boneName}_rot_y_max`] }, - z: { min: formResult[`${boneName}_rot_z_min`], max: formResult[`${boneName}_rot_z_max`] }, - } - }; - const detected = detectPreset(current); - if (detected !== currentPreset) { - lastPreset[boneName] = ''; - updates[`${boneName}_preset`] = ''; - } - } - }); - } - - if (Object.keys(updates).length > 0) { - dialog.setFormValues(updates); - } - - // Update visualizer with current form state - if (visualizer) { - const currentConstraints: Record = {}; - const merged = { ...formResult, ...updates }; - chain.forEach(bone => { - const boneName = bone.name; - currentConstraints[boneName] = { - allowedAxes: { - x: merged[`${boneName}_axis_x`] !== false, - y: merged[`${boneName}_axis_y`] !== false, - z: merged[`${boneName}_axis_z`] !== false, - }, - rotationLimits: { - x: { min: merged[`${boneName}_rot_x_min`], max: merged[`${boneName}_rot_x_max`] }, - y: { min: merged[`${boneName}_rot_y_min`], max: merged[`${boneName}_rot_y_max`] }, - z: { min: merged[`${boneName}_rot_z_min`], max: merged[`${boneName}_rot_z_max`] }, - } - }; - }); - visualizer.update(currentConstraints); - } - }, - onConfirm: (formResult: any) => { - try { - const updatedConstraints: Record = {}; - - chain.forEach(bone => { - const boneName = bone.name; - const constraint: BoneConstraint = {}; - - constraint.allowedAxes = { - x: formResult[`${boneName}_axis_x`] !== false, - y: formResult[`${boneName}_axis_y`] !== false, - z: formResult[`${boneName}_axis_z`] !== false - }; - - const xMin = formResult[`${boneName}_rot_x_min`]; - const xMax = formResult[`${boneName}_rot_x_max`]; - const yMin = formResult[`${boneName}_rot_y_min`]; - const yMax = formResult[`${boneName}_rot_y_max`]; - const zMin = formResult[`${boneName}_rot_z_min`]; - const zMax = formResult[`${boneName}_rot_z_max`]; - - constraint.rotationLimits = {}; - if (xMin !== -180 || xMax !== 180) { - constraint.rotationLimits.x = { min: xMin, max: xMax }; - } - if (yMin !== -180 || yMax !== 180) { - constraint.rotationLimits.y = { min: yMin, max: yMax }; - } - if (zMin !== -180 || zMax !== 180) { - constraint.rotationLimits.z = { min: zMin, max: zMax }; - } - - if (!constraint.allowedAxes.x || !constraint.allowedAxes.y || !constraint.allowedAxes.z || - constraint.rotationLimits.x || constraint.rotationLimits.y || constraint.rotationLimits.z) { - updatedConstraints[boneName] = constraint; - } - }); - - constraintData.boneConstraints = updatedConstraints; - setIKConstraintData(controller, constraintData); - - Blockbench.showQuickMessage('IK constraints updated', 2000); - } catch (e) { - console.error('Error saving IK constraints:', e); - Blockbench.showQuickMessage('Error saving IK constraints', 2000); - } - - visualizer?.dispose(); - visualizer = null; - }, - onCancel: () => { - visualizer?.dispose(); - visualizer = null; - } - }); - dialog.show(); - - // Create visualizer after dialog is shown so it appears in the viewport - visualizer = new ConstraintVisualizer(chain, constraintData.boneConstraints || {}); -} diff --git a/src/mods/ik/index.ts b/src/mods/ik/index.ts deleted file mode 100644 index 5e6acb0..0000000 --- a/src/mods/ik/index.ts +++ /dev/null @@ -1,77 +0,0 @@ - -import { createBlockbenchMod } from "../../util/moddingTools"; -import * as PACKAGE from "../../../package.json"; -import { is_vs_project } from "../../util"; -import { codecVS } from "../../codec"; -import { setupIKProperties } from "./properties"; -import { setupInteractiveIK, updatePinnedBones } from "./interactive"; -import { bakeIKAnimations } from "./baking"; -import { checkForIKControllers } from "./chain_utils"; -import { setOnConstraintCacheRebuilt } from "./constraints"; -import { refreshIKPreview } from "./utils"; -import { syncConstraintEnforcement, clearConstraintEnforcement } from "../boneAnimatorMod"; - -// Blockbench global types -declare var Blockbench: any; -declare var Project: any; - -/** - * IK Improvement Mod for VS Plugin - * - * This mod addresses IK limitations for VS export and adds interactive IK features: - * 1. Detects IK usage and warns users that VS doesn't support IK natively - * 2. Bakes IK animations to keyframes on export (converts IK-driven motion to keyframe animation) - * 3. Provides user feedback during the baking process - * 4. Adds advanced IK constraint features - * 5. Interactive IK features for faster animation workflow - */ -createBlockbenchMod( - `${PACKAGE.name}:ik_mod`, - { - - originalCompile: codecVS.compile.bind(codecVS), - }, - context => { - const { editIKConstraintsAction, togglePinBoneAction } = setupIKProperties(context); - - setupInteractiveIK(); - - // Re-patch bone meshes whenever constraint cache is rebuilt - setOnConstraintCacheRebuilt(() => { - refreshIKPreview(undefined, true); - syncConstraintEnforcement(); - }); - - Blockbench.on('load_project', () => { - setTimeout(() => { - updatePinnedBones(); - refreshIKPreview(undefined, true); - syncConstraintEnforcement(); - }, 100); - }); - - codecVS.compile = function (options: any) { - if (is_vs_project(Project)) { - const hasIK = checkForIKControllers(); - if (hasIK) { - bakeIKAnimations(is_vs_project); - } - } - return context.originalCompile(options); - }; - - return { ...context, editIKConstraintsAction, togglePinBoneAction }; - }, - context => { - - codecVS.compile = context.originalCompile; - clearConstraintEnforcement(); - - if (context.editIKConstraintsAction) { - context.editIKConstraintsAction.delete(); - } - if (context.togglePinBoneAction) { - context.togglePinBoneAction.delete(); - } - } -); diff --git a/src/mods/ik/interactive.ts b/src/mods/ik/interactive.ts deleted file mode 100644 index 6ba678b..0000000 --- a/src/mods/ik/interactive.ts +++ /dev/null @@ -1,306 +0,0 @@ - -import { vec3Length, vec3Sub, vec3Normalize, vec3Add } from "./math"; -import { getBoneWorldPosition, getBoneEndWorldPosition, refreshIKPreview } from "./utils"; -import { getChainMaxReach, clampTargetToChainReach } from "./solver"; -import { getIKConstraintData, setIKConstraintData } from "./constraints"; -import { DragState } from "./types"; -import { solveIKChain } from "./solver"; -import { findAllIKControllers as findAllControllers } from "./chain_utils"; - -// Blockbench global types -declare var Blockbench: any; -declare var Group: any; -declare var NullObject: any; -declare var Outliner: any; -declare var Format: any; -declare var Animation: any; - -// Constants -const VIEWPORT_UPDATE_THROTTLE = 2; -const KEYFRAME_TIME_TOLERANCE = 0.01; - -const dragState: DragState = { - isActive: false, - draggedBone: null, - originalBoneState: new Map(), - ikChain: null, - controller: null, - constraintData: null, - startPosition: null -}; - -/** - * Toggles the pin state for a bone. - * Pinned bones will not move during IK solving. - * - * @param bone - The bone to toggle pin state for - */ -export function togglePinBone(bone: any): void { - bone.vsIKPinned = !bone.vsIKPinned; - updatePinnedBones(); - - Blockbench.showQuickMessage( - `${bone.name} ${bone.vsIKPinned ? 'pinned' : 'unpinned'}`, - 2000 - ); -} - -/** - * Updates the pinned bones list in all IK controllers. - * Syncs the global pinned bones state across all IK controller constraints. - */ -export function updatePinnedBones(): void { - const pinnedBones = Group.all - .filter((g: any) => (g as any).vsIKPinned) - .map((g: any) => g.name); - - const ikControllers = findAllControllers(); - ikControllers.forEach(({ controller }) => { - const constraintData = getIKConstraintData(controller); - constraintData.pinnedBones = pinnedBones; - setIKConstraintData(controller, constraintData); - }); -} - -/** - * Sets up interactive IK by hooking into Blockbench bone dragging events. - * Implements real-time IK solving when dragging bones in IK chains. - * - * Features: - * - Automatic IK solving during bone transforms - * - Respects pinned bones - * - Creates keyframes in animation mode - * - Throttled viewport updates for performance - */ -export function setupInteractiveIK(): void { - let transformCount = 0; - - Blockbench.on('select', () => { - - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return; - - const bone = selected[0]; - if (!(bone instanceof Group)) return; - - const ikControllers = findAllControllers(); - for (const { controller, chain } of ikControllers) { - const constraintData = getIKConstraintData(controller); - - if (chain.includes(bone)) { - const pinnedBones = new Set(constraintData.pinnedBones || []); - - if (pinnedBones.has(bone.name)) { - Blockbench.showQuickMessage( - `Cannot drag pinned bone: ${bone.name}`, - 2000 - ); - return; - } - - if (!dragState.isActive || dragState.draggedBone !== bone) { - dragState.isActive = true; - dragState.draggedBone = bone; - dragState.ikChain = chain; - dragState.controller = controller; - dragState.constraintData = constraintData; - dragState.startPosition = getBoneWorldPosition(bone); - dragState.originalBoneState.clear(); - transformCount = 0; - - chain.forEach((chainBone: any) => { - dragState.originalBoneState.set(chainBone.name, { - position: [chainBone.origin[0], chainBone.origin[1], chainBone.origin[2]], - rotation: [ - chainBone.rotation[0] || 0, - chainBone.rotation[1] || 0, - chainBone.rotation[2] || 0 - ] - }); - }); - } - - break; - } - } - }); - - Blockbench.on('transform_selection', () => { - - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return; - - const bone = selected[0]; - if (!(bone instanceof Group)) return; - - if (!dragState.isActive || dragState.draggedBone !== bone) { - - const ikControllers = findAllControllers(); - for (const { controller, chain } of ikControllers) { - const constraintData = getIKConstraintData(controller); - - if (chain.includes(bone)) { - const pinnedBones = new Set(constraintData.pinnedBones || []); - if (pinnedBones.has(bone.name)) return; - - dragState.isActive = true; - dragState.draggedBone = bone; - dragState.ikChain = chain; - dragState.controller = controller; - dragState.constraintData = constraintData; - dragState.startPosition = getBoneWorldPosition(bone); - dragState.originalBoneState.clear(); - transformCount = 0; - - chain.forEach((chainBone: any) => { - dragState.originalBoneState.set(chainBone.name, { - position: [chainBone.origin[0], chainBone.origin[1], chainBone.origin[2]], - rotation: [ - chainBone.rotation[0] || 0, - chainBone.rotation[1] || 0, - chainBone.rotation[2] || 0 - ] - }); - }); - break; - } - } - } - - if (!dragState.isActive || !dragState.draggedBone || !dragState.ikChain || !dragState.constraintData) { - return; - } - - if (dragState.draggedBone !== bone) return; - - transformCount++; - const chain = dragState.ikChain; - const constraintData = dragState.constraintData; - const pinnedBones = new Set(constraintData.pinnedBones || []); - - let targetPosition = getBoneWorldPosition(bone); - - const endEffectorIndex = chain.length - 1; - if (chain[endEffectorIndex] === bone) { - - const maxReach = getChainMaxReach(chain); - const rootPos = getBoneWorldPosition(chain[0]); - const distance = vec3Length(vec3Sub(targetPosition, rootPos)); - - if (distance > maxReach) { - const clampedTarget = clampTargetToChainReach(chain, targetPosition, maxReach); - - const boneParent = bone.parent; - if (boneParent instanceof Group) { - const parentPos = getBoneWorldPosition(boneParent); - bone.origin[0] = clampedTarget[0] - parentPos[0]; - bone.origin[1] = clampedTarget[1] - parentPos[1]; - bone.origin[2] = clampedTarget[2] - parentPos[2]; - } else { - bone.origin[0] = clampedTarget[0]; - bone.origin[1] = clampedTarget[1]; - bone.origin[2] = clampedTarget[2]; - } - - targetPosition = clampedTarget; - } - - solveIKChain(chain, targetPosition, pinnedBones, constraintData); - } else { - - const boneIndex = chain.indexOf(bone); - if (boneIndex === -1) return; - - const subChain = chain.slice(boneIndex); - const endEffector = subChain[subChain.length - 1]; - - const maxReach = getChainMaxReach(subChain); - const rootPos = getBoneWorldPosition(subChain[0]); - const distance = vec3Length(vec3Sub(targetPosition, rootPos)); - - if (distance > maxReach) { - const clampedTarget = clampTargetToChainReach(subChain, targetPosition, maxReach); - const boneParent = bone.parent; - if (boneParent instanceof Group) { - const parentPos = getBoneWorldPosition(boneParent); - bone.origin[0] = clampedTarget[0] - parentPos[0]; - bone.origin[1] = clampedTarget[1] - parentPos[1]; - bone.origin[2] = clampedTarget[2] - parentPos[2]; - } else { - bone.origin[0] = clampedTarget[0]; - bone.origin[1] = clampedTarget[1]; - bone.origin[2] = clampedTarget[2]; - } - targetPosition = clampedTarget; - } - - const endEffectorTarget = getBoneEndWorldPosition(endEffector); - - solveIKChain(subChain, endEffectorTarget, pinnedBones, constraintData); - } - - if (transformCount % VIEWPORT_UPDATE_THROTTLE === 0) { - refreshIKPreview(chain); - } - }); - - Blockbench.on('finish_edit', () => { - if (dragState.isActive && dragState.ikChain && dragState.draggedBone) { - const finalChain = dragState.ikChain; - - if (Format.animation_mode) { - const currentAnimation = Animation.selected; - if (currentAnimation) { - const currentTime = currentAnimation.time; - - dragState.ikChain.forEach((bone: any) => { - const animator = currentAnimation.getBoneAnimator(bone); - if (!animator) return; - - const existingKf = animator.keyframes.find((kf: any) => - kf.channel === 'rotation' && Math.abs(kf.time - currentTime) < KEYFRAME_TIME_TOLERANCE - ); - - if (!existingKf) { - - animator.addKeyframe({ - interpolation: 'linear', - time: currentTime, - channel: 'rotation', - data_points: [{ - x: bone.rotation[0] || 0, - y: bone.rotation[1] || 0, - z: bone.rotation[2] || 0 - }] - }); - } else { - - if (existingKf.data_points && existingKf.data_points[0]) { - existingKf.data_points[0].x = bone.rotation[0] || 0; - existingKf.data_points[0].y = bone.rotation[1] || 0; - existingKf.data_points[0].z = bone.rotation[2] || 0; - } - } - }); - } - } - - refreshIKPreview(finalChain); - - dragState.isActive = false; - dragState.draggedBone = null; - dragState.ikChain = null; - dragState.controller = null; - dragState.constraintData = null; - dragState.startPosition = null; - dragState.originalBoneState.clear(); - transformCount = 0; - } - }); - - Blockbench.on('update_selection', () => { - - if (!dragState.isActive) { - } - }); -} diff --git a/src/mods/ik/math.ts b/src/mods/ik/math.ts deleted file mode 100644 index a91ec59..0000000 --- a/src/mods/ik/math.ts +++ /dev/null @@ -1,102 +0,0 @@ - -/** - * Vector math utilities - */ -export function vec3Sub(a: [number, number, number], b: [number, number, number]): [number, number, number] { - return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; -} - -export function vec3Add(a: [number, number, number], b: [number, number, number]): [number, number, number] { - return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; -} - -export function vec3Length(v: [number, number, number]): number { - return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); -} - -export function vec3Normalize(v: [number, number, number]): [number, number, number] { - const len = vec3Length(v); - if (len < 0.0001) return [0, 0, 0]; - return [v[0] / len, v[1] / len, v[2] / len]; -} - -export function vec3Dot(a: [number, number, number], b: [number, number, number]): number { - return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; -} - -export function vec3Cross(a: [number, number, number], b: [number, number, number]): [number, number, number] { - return [ - a[1] * b[2] - a[2] * b[1], - a[2] * b[0] - a[0] * b[2], - a[0] * b[1] - a[1] * b[0] - ]; -} - -/** - * Convert Euler angles (degrees) to rotation matrix - */ -export function eulerToRotationMatrix(euler: [number, number, number]): number[][] { - const [rx, ry, rz] = euler.map(deg => deg * Math.PI / 180); - - const cx = Math.cos(rx), sx = Math.sin(rx); - const cy = Math.cos(ry), sy = Math.sin(ry); - const cz = Math.cos(rz), sz = Math.sin(rz); - - return [ - [cy * cz, -cy * sz, sy], - [cx * sz + sx * sy * cz, cx * cz - sx * sy * sz, -sx * cy], - [sx * sz - cx * sy * cz, sx * cz + cx * sy * sz, cx * cy] - ]; -} - -/** - * Apply rotation to a vector - */ -export function rotateVector(v: [number, number, number], euler: [number, number, number]): [number, number, number] { - const m = eulerToRotationMatrix(euler); - return [ - m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], - m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], - m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2] - ]; -} - -/** - * Calculate rotation needed to align one vector to another - * Returns Euler angles in degrees - */ -export function alignVectors(from: [number, number, number], to: [number, number, number]): [number, number, number] { - const fromNorm = vec3Normalize(from); - const toNorm = vec3Normalize(to); - - const dot = vec3Dot(fromNorm, toNorm); - - if (Math.abs(dot - 1) < 0.0001) { - return [0, 0, 0]; - } - - if (Math.abs(dot + 1) < 0.0001) { - - const perp = Math.abs(fromNorm[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]; - const axis = vec3Normalize(vec3Cross(fromNorm, perp)); - const angle = Math.PI; - - return [0, angle * 180 / Math.PI, 0]; - } - - const axis = vec3Normalize(vec3Cross(fromNorm, toNorm)); - const angle = Math.acos(Math.max(-1, Math.min(1, dot))); - - const angleDeg = angle * 180 / Math.PI; - - if (Math.abs(axis[1]) > 0.9) { - - return [0, angleDeg * (axis[1] > 0 ? 1 : -1), 0]; - } else if (Math.abs(axis[0]) > 0.9) { - - return [angleDeg * (axis[0] > 0 ? 1 : -1), 0, 0]; - } else { - - return [0, 0, angleDeg * (axis[2] > 0 ? 1 : -1)]; - } -} \ No newline at end of file diff --git a/src/mods/ik/properties.ts b/src/mods/ik/properties.ts deleted file mode 100644 index 87002c5..0000000 --- a/src/mods/ik/properties.ts +++ /dev/null @@ -1,366 +0,0 @@ - -import * as PACKAGE from "../../../package.json"; -import { getIKConstraintData, setIKConstraintData, openIKConstraintEditor } from "./constraints"; -import { getIKChain } from "./chain_utils"; -import { updatePinnedBones, togglePinBone } from "./interactive"; - -// Blockbench global types -declare var Property: any; -declare var Action: any; -declare var Group: any; -declare var NullObject: any; -declare var Outliner: any; -declare var Format: any; -declare var Animation: any; -declare var Blockbench: any; -declare var MenuBar: any; - -function isNullObject(obj: any): boolean { - return obj instanceof NullObject; -} - -export function setupIKProperties(context: any): any { - - const weightProp = new Property(NullObject, "number", "vsIKWeight", { - default: 1.0, - label: "IK Weight", - exposed: true, - condition: () => { - try { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - return isNullObject(selected[0]); - } catch (e) { - console.error('IK Weight condition error:', e); - return false; - } - }, - inputs: { - element_panel: { - input: { - label: 'IK Weight (0-1)', - type: 'number', - min: 0, - max: 1, - step: 0.01 - } - } - }, - onChange() { - const obj = this as any; - if (isNullObject(obj)) { - - if (obj.vsIKWeight === undefined) { - const constraintData = getIKConstraintData(obj); - obj.vsIKWeight = constraintData.weight ?? 1.0; - } - - const constraintData = getIKConstraintData(obj); - constraintData.weight = Math.max(0, Math.min(1, obj.vsIKWeight ?? 1.0)); - setIKConstraintData(obj, constraintData); - - if (Format.animation_mode) { - - const currentAnimation = Animation.selected; - if (currentAnimation) { - const animator = currentAnimation.getBoneAnimator(obj); - if (animator) { - const currentTime = currentAnimation.time; - const weight = Math.max(0, Math.min(1, obj.vsIKWeight ?? 1.0)); - - const existingKf = animator.keyframes.find((kf: any) => - kf.channel === 'ik_weight' && Math.abs(kf.time - currentTime) < 0.01 - ); - - if (existingKf) { - - if (existingKf.data_points && existingKf.data_points[0]) { - existingKf.data_points[0].x = weight; - } - } else { - - animator.addKeyframe({ - interpolation: 'linear', - time: currentTime, - channel: 'ik_weight', - data_points: [{ x: weight, y: 0, z: 0 }] - }); - } - } - } - } - } - }, - keyframeable: true - }); - - const lockProp = new Property(NullObject, "boolean", "vsIKLockPosition", { - default: false, - label: "Lock IK Position", - exposed: true, - condition: () => { - try { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - return isNullObject(selected[0]); - } catch (e) { - console.error('IK Lock Position condition error:', e); - return false; - } - }, - inputs: { - element_panel: { - input: { - label: 'Lock Controller Position', - type: 'checkbox' - } - } - }, - onChange() { - const obj = this as any; - if (isNullObject(obj)) { - - if (obj.vsIKLockPosition === undefined) { - const constraintData = getIKConstraintData(obj); - obj.vsIKLockPosition = constraintData.lockPosition ?? false; - } - - const constraintData = getIKConstraintData(obj); - constraintData.lockPosition = obj.vsIKLockPosition ?? false; - if (constraintData.lockPosition) { - - constraintData.lockedPosition = [obj.position[0], obj.position[1], obj.position[2]]; - } - setIKConstraintData(obj, constraintData); - - if (Format.animation_mode) { - - const currentAnimation = Animation.selected; - if (currentAnimation) { - const animator = currentAnimation.getBoneAnimator(obj); - if (animator) { - const currentTime = currentAnimation.time; - const lockValue = obj.vsIKLockPosition ? 1 : 0; - - const existingKf = animator.keyframes.find((kf: any) => - kf.channel === 'ik_lock' && Math.abs(kf.time - currentTime) < 0.01 - ); - - if (existingKf) { - - if (existingKf.data_points && existingKf.data_points[0]) { - existingKf.data_points[0].x = lockValue; - } - } else { - - animator.addKeyframe({ - interpolation: 'linear', - time: currentTime, - channel: 'ik_lock', - data_points: [{ x: lockValue, y: 0, z: 0 }] - }); - } - } - } - } - } - }, - keyframeable: true - }); - - const helperProp = new Property(NullObject, "string", "vsIKOrientationHelper", { - default: '', - label: "IK Orientation Helper", - exposed: true, - condition: () => { - try { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - return isNullObject(selected[0]); - } catch (e) { - console.error('IK Orientation Helper condition error:', e); - return false; - } - }, - options: () => { - const nulls = NullObject.all; - const options: { [key: string]: string } = { '': 'None' }; - nulls.forEach((nullObj: any) => { - options[nullObj.name] = nullObj.name; - }); - return options; - }, - inputs: { - element_panel: { - input: { - label: 'Orientation Helper (Null Object)', - type: 'select', - options: () => { - const nulls = NullObject.all; - const options: { [key: string]: string } = { '': 'None' }; - nulls.forEach((nullObj: any) => { - options[nullObj.name] = nullObj.name; - }); - return options; - } - } - } - }, - onChange() { - const obj = this as any; - if (isNullObject(obj)) { - - if (obj.vsIKOrientationHelper === undefined) { - const constraintData = getIKConstraintData(obj); - obj.vsIKOrientationHelper = constraintData.orientationHelper || ''; - } - - const constraintData = getIKConstraintData(obj); - constraintData.orientationHelper = obj.vsIKOrientationHelper || undefined; - setIKConstraintData(obj, constraintData); - } - } - }); - - const editIKConstraintsAction = new Action(`${PACKAGE.name}:edit_ik_constraints`, { - name: 'Edit IK Constraints...', - icon: 'settings', - condition: () => { - try { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - return isNullObject(selected[0]); - } catch (e) { - console.error('Edit IK Constraints condition error:', e); - return false; - } - }, - click: () => { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return; - const controller = selected[0] as any; - - if (!isNullObject(controller) || !controller.ik_target) return; - - openIKConstraintEditor(controller, getIKChain); - } - }); - - MenuBar.addAction(editIKConstraintsAction, 'edit'); - - const togglePinBoneAction = new Action(`${PACKAGE.name}:toggle_pin_bone`, { - name: 'Toggle Pin Bone', - icon: 'push_pin', - condition: () => { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - const obj = selected[0]; - return obj instanceof Group; - }, - click: () => { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return; - const bone = selected[0] as any; - - togglePinBone(bone); - } - }); - - MenuBar.addAction(togglePinBoneAction, 'edit'); - - new Property(NullObject, "string", "vsIKBoneConstraints", { - default: '{}', - label: "IK Bone Constraints", - exposed: false - }); - - new Property(NullObject, "boolean", "vsIKInteractiveMode", { - default: false, - label: "Interactive IK Mode", - exposed: true, - condition: () => { - try { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - return isNullObject(selected[0]); - } catch (e) { - console.error('IK Interactive Mode condition error:', e); - return false; - } - }, - inputs: { - element_panel: { - input: { - label: 'Enable Interactive IK (Drag limbs to auto-adjust joints)', - type: 'checkbox' - } - } - }, - onChange() { - const obj = this as any; - if (isNullObject(obj)) { - const constraintData = getIKConstraintData(obj); - constraintData.interactiveMode = obj.vsIKInteractiveMode ?? false; - setIKConstraintData(obj, constraintData); - - if (constraintData.interactiveMode) { - Blockbench.showQuickMessage( - 'Interactive IK enabled: Drag bones in IK chains to auto-adjust connected joints. Use "Toggle Pin Bone" to pin joints.', - 4000 - ); - } - } - } - }); - - new Property(Group, "boolean", "vsIKPinned", { - default: false, - label: "IK Pinned", - exposed: true, - condition: () => { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return false; - const obj = selected[0]; - return obj instanceof Group; - }, - inputs: { - element_panel: { - input: { - label: 'Pin Bone (prevents IK from moving this bone)', - type: 'checkbox' - } - } - }, - onChange() { - const obj = this as any; - if (obj && obj instanceof Group) { - updatePinnedBones(); - } - } - }); - - const syncIKProperties = () => { - const selected = Outliner.selected; - if (!selected || selected.length !== 1) return; - const obj = selected[0]; - - if (isNullObject(obj) && obj.ik_target) { - const constraintData = getIKConstraintData(obj); - - if (obj.vsIKWeight === undefined) { - obj.vsIKWeight = constraintData.weight ?? 1.0; - } - if (obj.vsIKLockPosition === undefined) { - obj.vsIKLockPosition = constraintData.lockPosition ?? false; - } - if (obj.vsIKOrientationHelper === undefined) { - obj.vsIKOrientationHelper = constraintData.orientationHelper || ''; - } - } - }; - - Blockbench.on('select', syncIKProperties); - Blockbench.on('update_selection', syncIKProperties); - - return { editIKConstraintsAction, togglePinBoneAction }; -} diff --git a/src/mods/ik/solver.ts b/src/mods/ik/solver.ts deleted file mode 100644 index b624aaf..0000000 --- a/src/mods/ik/solver.ts +++ /dev/null @@ -1,155 +0,0 @@ - -import { vec3Length, vec3Sub, alignVectors, vec3Normalize, vec3Add } from "./math"; -import { getBoneWorldPosition, getBoneEndWorldPosition } from "./utils"; -import { IKConstraintData, BoneConstraint } from "./types"; -import { applyRotationConstraints } from "./constraints"; - -// Blockbench global types -declare var Blockbench: any; -declare var Group: any; - -// Constants -const CHAIN_REACH_MARGIN = 0.95; -const IK_SOLVER_DAMPING = 0.5; -const IK_SOLVER_MAX_ITERATIONS = 15; -const IK_SOLVER_TOLERANCE = 0.1; - -/** - * Calculates the maximum reachable distance of an IK chain. - * This is the sum of all bone lengths in the chain. - * - * @param chain - Array of bones forming the IK chain - * @returns Total maximum reach distance - */ -export function getChainMaxReach(chain: any[]): number { - let totalLength = 0; - for (let i = 0; i < chain.length - 1; i++) { - const bone = chain[i]; - const nextBone = chain[i + 1]; - const bonePos = getBoneWorldPosition(bone); - const nextBonePos = getBoneWorldPosition(nextBone); - totalLength += vec3Length(vec3Sub(nextBonePos, bonePos)); - } - return totalLength; -} - -/** - * Clamps a target position to be within the IK chain's reach. - * Uses a margin (CHAIN_REACH_MARGIN) to prevent overextension. - * - * @param chain - Array of bones forming the IK chain - * @param targetPosition - Desired target position - * @param maxReach - Maximum reach distance of the chain - * @returns Clamped position within chain reach - */ -export function clampTargetToChainReach( - chain: any[], - targetPosition: [number, number, number], - maxReach: number -): [number, number, number] { - const root = chain[0]; - const rootPos = getBoneWorldPosition(root); - const toTarget = vec3Sub(targetPosition, rootPos); - const distance = vec3Length(toTarget); - - if (distance <= maxReach) { - return targetPosition; - } - - const clampedDirection = vec3Normalize(toTarget); - const clampedDistance = maxReach * CHAIN_REACH_MARGIN; - const clampedTarget = vec3Add(rootPos, [ - clampedDirection[0] * clampedDistance, - clampedDirection[1] * clampedDistance, - clampedDirection[2] * clampedDistance - ]); - - return clampedTarget; -} - -/** - * CCD (Cyclic Coordinate Descent) IK solver. - * Solves IK chain to reach target position, respecting pinned bones and rotation constraints. - * - * @param chain - Array of bones forming the IK chain - * @param targetPosition - Target position for the end effector - * @param pinnedBones - Set of bone names that should not move - * @param constraintData - IK constraint configuration - * @param maxIterations - Maximum solver iterations (default: IK_SOLVER_MAX_ITERATIONS) - * @param tolerance - Distance tolerance for convergence (default: IK_SOLVER_TOLERANCE) - * @returns True if target was reached within tolerance - */ -export function solveIKChain( - chain: any[], - targetPosition: [number, number, number], - pinnedBones: Set, - constraintData: IKConstraintData, - maxIterations: number = IK_SOLVER_MAX_ITERATIONS, - tolerance: number = IK_SOLVER_TOLERANCE -): boolean { - if (chain.length < 2) return false; - - const endEffector = chain[chain.length - 1]; - const root = chain[0]; - - if (pinnedBones.has(endEffector.name) || pinnedBones.has(root.name)) { - return false; - } - - for (let iter = 0; iter < maxIterations; iter++) { - - const currentEndPos = getBoneEndWorldPosition(endEffector); - - const distance = vec3Length(vec3Sub(targetPosition, currentEndPos)); - if (distance < tolerance) { - return true; - } - - for (let i = chain.length - 2; i >= 0; i--) { - const bone = chain[i]; - if (pinnedBones.has(bone.name)) { - continue; - } - - const boneWorldPos = getBoneWorldPosition(bone); - const currentEndEffectorPos = getBoneEndWorldPosition(endEffector); - - const toEnd = vec3Sub(currentEndEffectorPos, boneWorldPos); - const toEndLen = vec3Length(toEnd); - - const toTarget = vec3Sub(targetPosition, boneWorldPos); - const toTargetLen = vec3Length(toTarget); - - if (toEndLen < 0.001 || toTargetLen < 0.001) { - continue; - } - - const damping = IK_SOLVER_DAMPING; - const rotationDelta = alignVectors(toEnd, toTarget); - - const currentRot: [number, number, number] = [ - bone.rotation[0] || 0, - bone.rotation[1] || 0, - bone.rotation[2] || 0 - ]; - - let newRot: [number, number, number] = [ - currentRot[0] + rotationDelta[0] * damping, - currentRot[1] + rotationDelta[1] * damping, - currentRot[2] + rotationDelta[2] * damping - ]; - - newRot = applyRotationConstraints(bone, newRot, constraintData); - - bone.rotation[0] = newRot[0]; - bone.rotation[1] = newRot[1]; - bone.rotation[2] = newRot[2]; - - } - } - - const finalEndPos = getBoneEndWorldPosition(endEffector); - const finalDistance = vec3Length(vec3Sub(targetPosition, finalEndPos)); - - return finalDistance < tolerance * 2; -} diff --git a/src/mods/ik/types.ts b/src/mods/ik/types.ts deleted file mode 100644 index c8f1cdd..0000000 --- a/src/mods/ik/types.ts +++ /dev/null @@ -1,51 +0,0 @@ - -// Blockbench global types -declare var Group: any; -declare var Locator: any; - -/** - * IK Constraint Data Structure - */ -export interface IKConstraintData { - - weight?: number; - - lockPosition?: boolean; - - lockedPosition?: [number, number, number]; - - boneConstraints?: Record; - - orientationHelper?: string; - - interactiveMode?: boolean; - - pinnedBones?: string[]; -} - -/** - * Bone rotation constraints - */ -export interface BoneConstraint { - - allowedAxes?: { x: boolean; y: boolean; z: boolean }; - - rotationLimits?: { - x?: { min: number; max: number }; - y?: { min: number; max: number }; - z?: { min: number; max: number }; - }; -} - -/** - * Interactive IK drag state tracking - */ -export interface DragState { - isActive: boolean; - draggedBone: any | null; - originalBoneState: Map; - ikChain: any[] | null; - controller: any | null; - constraintData: IKConstraintData | null; - startPosition: [number, number, number] | null; -} \ No newline at end of file diff --git a/src/mods/ik/utils.ts b/src/mods/ik/utils.ts deleted file mode 100644 index c57ed06..0000000 --- a/src/mods/ik/utils.ts +++ /dev/null @@ -1,106 +0,0 @@ - -import { vec3Length, vec3Sub } from "./math"; -import { findAllIKControllers } from "./chain_utils"; - -// Blockbench global types -declare var Group: any; -declare var Canvas: any; -declare var Blockbench: any; -declare var Format: any; -declare var Timeline: any; -declare var Animation: any; -declare var Animator: any; - -let forcedIKAnimationContext = false; - -/** - * Gets the world position of a bone (accounting for parent transforms) - */ -export function getBoneWorldPosition(bone: any): [number, number, number] { - let pos: [number, number, number] = [bone.origin[0], bone.origin[1], bone.origin[2]]; - let current: any = bone.parent; - - while (current && current instanceof Group) { - pos[0] += current.origin[0]; - pos[1] += current.origin[1]; - pos[2] += current.origin[2]; - current = current.parent; - } - - return pos; -} - -/** - * Gets the world position of a bone's end (considering bone length/direction) - * In Blockbench, bones are points, so we calculate the "end" based on children - */ -export function getBoneEndWorldPosition(bone: any): [number, number, number] { - const start = getBoneWorldPosition(bone); - - if (bone.children && bone.children.length > 0) { - const firstChild = bone.children[0]; - if (firstChild instanceof Group) { - return getBoneWorldPosition(firstChild); - } - } - - const ikControllers = findAllIKControllers(); - for (const { chain } of ikControllers) { - const boneIndex = chain.indexOf(bone); - if (boneIndex >= 0 && boneIndex < chain.length - 1) { - const nextBone = chain[boneIndex + 1]; - return getBoneWorldPosition(nextBone); - } - } - - return [start[0], start[1] + 1, start[2]]; -} - -/** - * Gets the length of a bone (distance to its end) - */ -export function getBoneLength(bone: any): number { - const start = getBoneWorldPosition(bone); - const end = getBoneEndWorldPosition(bone); - return vec3Length(vec3Sub(end, start)); -} - -export function setForcedIKAnimationContext(active: boolean): void { - forcedIKAnimationContext = active; -} - -export function isIKAnimationContextActive(): boolean { - if (forcedIKAnimationContext) { - return true; - } - - if (!Format?.animation_mode) { - return false; - } - - return !!( - Timeline?.playing || - Animation?.selected?.playing || - Animator?.selected?.playing - ); -} - -/** - * Refreshes Blockbench's preview state after IK updates. - * `Blockbench.updateViewport()` only re-renders the scene; it does not - * force the canvas controllers to pull updated bone transforms. - */ -export function refreshIKPreview(bones?: any[] | null, fullRefresh = false): void { - const targetBones = bones && bones.length > 0 ? bones : undefined; - - try { - Canvas.updateAllBones?.(targetBones); - - if (fullRefresh) { - Canvas.updateAllPositions?.(); - Canvas.updateAll?.(); - } - } finally { - Blockbench.updateViewport?.(); - } -} diff --git a/src/mods/nodePreviewControllerMod.ts b/src/mods/nodePreviewControllerMod.ts index 7bf0191..71aab23 100644 --- a/src/mods/nodePreviewControllerMod.ts +++ b/src/mods/nodePreviewControllerMod.ts @@ -1,8 +1,6 @@ import { createBlockbenchMod } from "../util/moddingTools"; import * as PACKAGE from "../../package.json"; import { is_vs_project } from "../util"; -import { getAnimatedConstraintsForBone } from "./ik/constraints"; -import { isIKAnimationContextActive } from "./ik/utils"; createBlockbenchMod(`${PACKAGE.name}:node_preview_controller_mod`, @@ -16,9 +14,7 @@ createBlockbenchMod(`${PACKAGE.name}:node_preview_controller_mod`, return updateStepChildTransform(this, node); } - const result = inject_context.original.call(this, node); - applyConstrainedGroupRotation(node); - return result; + return inject_context.original.call(this, node); } return inject_context.original.call(this, node); }; @@ -44,7 +40,11 @@ function updateStepChildTransform(controller: NodePreviewController, element: an //@ts-expect-error: missing types if (element.getTypeBehavior('rotatable')) { - const rotation = getPreviewRotation(element); + const rotation: [number, number, number] = [ + element.rotation?.[0] || 0, + element.rotation?.[1] || 0, + element.rotation?.[2] || 0 + ]; //@ts-expect-error: missing types mesh.rotation.x = Math.degToRad(rotation[0]); //@ts-expect-error: missing types @@ -95,62 +95,3 @@ function updateStepChildTransform(controller: NodePreviewController, element: an controller.dispatchEvent('update_transform', { element }); } -function getPreviewRotation(element: any): [number, number, number] { - const rotation: [number, number, number] = [ - element.rotation?.[0] || 0, - element.rotation?.[1] || 0, - element.rotation?.[2] || 0 - ]; - - if (!isIKAnimationContextActive()) { - return rotation; - } - - if (!(element instanceof Group)) { - return rotation; - } - - const constraint = getAnimatedConstraintsForBone(element.name); - if (!constraint) { - return rotation; - } - - if (constraint.allowedAxes) { - if (!constraint.allowedAxes.x) rotation[0] = 0; - if (!constraint.allowedAxes.y) rotation[1] = 0; - if (!constraint.allowedAxes.z) rotation[2] = 0; - } - - if (constraint.rotationLimits) { - if (constraint.rotationLimits.x) { - rotation[0] = Math.max(constraint.rotationLimits.x.min, Math.min(constraint.rotationLimits.x.max, rotation[0])); - } - if (constraint.rotationLimits.y) { - rotation[1] = Math.max(constraint.rotationLimits.y.min, Math.min(constraint.rotationLimits.y.max, rotation[1])); - } - if (constraint.rotationLimits.z) { - rotation[2] = Math.max(constraint.rotationLimits.z.min, Math.min(constraint.rotationLimits.z.max, rotation[2])); - } - } - - return rotation; -} - -function applyConstrainedGroupRotation(node: any): void { - if (!(node instanceof Group)) { - return; - } - - //@ts-expect-error: missing types - const mesh = node.mesh; - if (!mesh?.rotation) { - return; - } - - const rotation = getPreviewRotation(node); - mesh.rotation.x = Math.degToRad(rotation[0]); - mesh.rotation.y = Math.degToRad(rotation[1]); - mesh.rotation.z = Math.degToRad(rotation[2]); - mesh.updateMatrixWorld?.(); -} - diff --git a/src/vs_plugin.ts b/src/vs_plugin.ts index c9ddf60..bfe44e9 100644 --- a/src/vs_plugin.ts +++ b/src/vs_plugin.ts @@ -20,7 +20,6 @@ import "./mods/settingsMod"; import "./mods/legacyFormatConverterMod"; import "./mods/nodePreviewControllerMod"; import "./mods/attachmentsMod"; -import "./mods/ik/index"; // Panels (loaded after mods to avoid blocking critical format registration) import "./panels/vs_face_panel";