|
| 1 | +import { |
| 2 | + getKeyframeCommands, |
| 3 | + getKeyframeExecuteCondition, |
| 4 | + getKeyframeRepeat, |
| 5 | + getKeyframeRepeatFrequency, |
| 6 | + getKeyframeVariant, |
| 7 | +} from '../../blockbench-mods/misc/customKeyframes' |
| 8 | +import { type defaultValues } from '../../blueprintSettings' |
| 9 | +import { type EasingKey } from '../../util/easing' |
| 10 | +import { resolvePath } from '../../util/fileUtil' |
| 11 | +import { detectCircularReferences, mapObjEntries, scrubUndefined } from '../../util/misc' |
| 12 | +import { Variant } from '../../variants' |
| 13 | +import type { INodeTransform, IRenderedAnimation, IRenderedFrame } from '../animation-renderer' |
| 14 | +import type { |
| 15 | + AnyRenderedNode, |
| 16 | + IRenderedModel, |
| 17 | + IRenderedRig, |
| 18 | + IRenderedVariant, |
| 19 | + IRenderedVariantModel, |
| 20 | +} from '../rig-renderer' |
| 21 | + |
| 22 | +type ExportedNodetransform = Omit< |
| 23 | + INodeTransform, |
| 24 | + 'type' | 'name' | 'uuid' | 'node' | 'matrix' | 'decomposed' | 'executeCondition' |
| 25 | +> & { |
| 26 | + matrix: number[] |
| 27 | + decomposed: { |
| 28 | + translation: ArrayVector3 |
| 29 | + left_rotation: ArrayVector4 |
| 30 | + scale: ArrayVector3 |
| 31 | + } |
| 32 | + pos: ArrayVector3 |
| 33 | + rot: ArrayVector3 |
| 34 | + scale: ArrayVector3 |
| 35 | + execute_condition?: string |
| 36 | +} |
| 37 | +type ExportedRenderedNode = Omit< |
| 38 | + AnyRenderedNode, |
| 39 | + 'node' | 'parentNode' | 'model' | 'boundingBox' | 'configs' | 'baseScale' | 'safe_name' |
| 40 | +> & { |
| 41 | + default_transform: ExportedNodetransform |
| 42 | + bounding_box?: { min: ArrayVector3; max: ArrayVector3 } |
| 43 | + configs?: Record<string, IBlueprintBoneConfigJSON> |
| 44 | +} |
| 45 | +type ExportedAnimationFrame = Omit<IRenderedFrame, 'nodes' | 'node_transforms'> & { |
| 46 | + node_transforms: Record<string, ExportedNodetransform> |
| 47 | +} |
| 48 | +type ExportedBakedAnimation = Omit< |
| 49 | + IRenderedAnimation, |
| 50 | + 'uuid' | 'frames' | 'modified_nodes' | 'safe_name' |
| 51 | +> & { |
| 52 | + frames: ExportedAnimationFrame[] |
| 53 | + modified_nodes: string[] |
| 54 | +} |
| 55 | +interface ExportedKeyframe { |
| 56 | + time: number |
| 57 | + channel: string |
| 58 | + value?: [string, string, string] |
| 59 | + post?: [string, string, string] |
| 60 | + interpolation?: |
| 61 | + | { |
| 62 | + type: 'linear' |
| 63 | + easing: EasingKey |
| 64 | + easingArgs?: number[] |
| 65 | + } |
| 66 | + | { |
| 67 | + type: 'bezier' |
| 68 | + bezier_linked?: boolean |
| 69 | + bezier_left_time?: ArrayVector3 |
| 70 | + bezier_left_value?: ArrayVector3 |
| 71 | + bezier_right_time?: ArrayVector3 |
| 72 | + bezier_right_value?: ArrayVector3 |
| 73 | + } |
| 74 | + | { |
| 75 | + type: 'catmullrom' |
| 76 | + } |
| 77 | + | { |
| 78 | + type: 'step' |
| 79 | + } |
| 80 | + commands?: string |
| 81 | + variant?: string |
| 82 | + execute_condition?: string |
| 83 | + repeat?: boolean |
| 84 | + repeat_frequency?: number |
| 85 | +} |
| 86 | +type ExportedAnimator = ExportedKeyframe[] |
| 87 | +interface ExportedDynamicAnimation { |
| 88 | + name: string |
| 89 | + loop_mode: 'once' | 'hold' | 'loop' |
| 90 | + duration: number |
| 91 | + excluded_nodes: string[] |
| 92 | + animators: Record<string, ExportedAnimator> |
| 93 | +} |
| 94 | +interface ExportedTexture { |
| 95 | + name: string |
| 96 | + src: string |
| 97 | +} |
| 98 | +type ExportedVariantModel = Omit< |
| 99 | + IRenderedVariantModel, |
| 100 | + 'model_path' | 'resource_location' | 'item_model' |
| 101 | +> & { |
| 102 | + model: IRenderedModel | null |
| 103 | + custom_model_data: number |
| 104 | +} |
| 105 | +type ExportedVariant = Omit<IRenderedVariant, 'models'> & { |
| 106 | + /** |
| 107 | + * A map of bone UUID -> IRenderedVariantModel |
| 108 | + */ |
| 109 | + models: Record<string, ExportedVariantModel> |
| 110 | +} |
| 111 | + |
| 112 | +export interface IExportedJSON { |
| 113 | + /** |
| 114 | + * The Blueprint's Settings |
| 115 | + */ |
| 116 | + settings: { |
| 117 | + export_namespace: (typeof defaultValues)['export_namespace'] |
| 118 | + bounding_box: (typeof defaultValues)['bounding_box'] |
| 119 | + // Resource Pack Settings |
| 120 | + custom_model_data_offset: (typeof defaultValues)['custom_model_data_offset'] |
| 121 | + // Plugin Settings |
| 122 | + baked_animations: (typeof defaultValues)['baked_animations'] |
| 123 | + } |
| 124 | + textures: Record<string, ExportedTexture> |
| 125 | + nodes: Record<string, ExportedRenderedNode> |
| 126 | + variants: Record<string, ExportedVariant> |
| 127 | + /** |
| 128 | + * If `blueprint_settings.baked_animations` is true, this will be an array of `ExportedAnimation` objects. Otherwise, it will be an array of `AnimationUndoCopy` objects, just like the `.bbmodel`'s animation list. |
| 129 | + */ |
| 130 | + animations: Record<string, ExportedBakedAnimation> | Record<string, ExportedDynamicAnimation> |
| 131 | +} |
| 132 | + |
| 133 | +function transferKey(obj: any, oldKey: string, newKey: string) { |
| 134 | + obj[newKey] = obj[oldKey] |
| 135 | + delete obj[oldKey] |
| 136 | +} |
| 137 | + |
| 138 | +function serailizeKeyframe(kf: _Keyframe): ExportedKeyframe { |
| 139 | + const json = { |
| 140 | + time: kf.time, |
| 141 | + channel: kf.channel, |
| 142 | + commands: getKeyframeCommands(kf), |
| 143 | + variant: getKeyframeVariant(kf), |
| 144 | + execute_condition: getKeyframeExecuteCondition(kf), |
| 145 | + repeat: getKeyframeRepeat(kf), |
| 146 | + repeat_frequency: getKeyframeRepeatFrequency(kf), |
| 147 | + } as ExportedKeyframe |
| 148 | + |
| 149 | + switch (json.channel) { |
| 150 | + case 'variant': |
| 151 | + case 'commands': |
| 152 | + break |
| 153 | + default: { |
| 154 | + json.value = [ |
| 155 | + kf.get('x', 0).toString(), |
| 156 | + kf.get('y', 0).toString(), |
| 157 | + kf.get('z', 0).toString(), |
| 158 | + ] |
| 159 | + json.interpolation = { type: kf.interpolation } as any |
| 160 | + } |
| 161 | + } |
| 162 | + |
| 163 | + if (json.interpolation) { |
| 164 | + switch (json.interpolation.type) { |
| 165 | + case 'linear': { |
| 166 | + json.interpolation.easing = kf.easing! |
| 167 | + if (kf.easingArgs?.length) json.interpolation.easingArgs = kf.easingArgs |
| 168 | + break |
| 169 | + } |
| 170 | + case 'bezier': { |
| 171 | + json.interpolation.bezier_linked = kf.bezier_linked |
| 172 | + json.interpolation.bezier_left_time = kf.bezier_left_time.slice() as ArrayVector3 |
| 173 | + json.interpolation.bezier_left_value = kf.bezier_left_value.slice() as ArrayVector3 |
| 174 | + json.interpolation.bezier_right_time = kf.bezier_right_time.slice() as ArrayVector3 |
| 175 | + json.interpolation.bezier_right_value = |
| 176 | + kf.bezier_right_value.slice() as ArrayVector3 |
| 177 | + break |
| 178 | + } |
| 179 | + case 'catmullrom': { |
| 180 | + break |
| 181 | + } |
| 182 | + case 'step': { |
| 183 | + break |
| 184 | + } |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + if (kf.data_points.length === 2) { |
| 189 | + json.post = [ |
| 190 | + kf.get('x', 1).toString(), |
| 191 | + kf.get('y', 1).toString(), |
| 192 | + kf.get('z', 1).toString(), |
| 193 | + ] |
| 194 | + } |
| 195 | + |
| 196 | + return json |
| 197 | +} |
| 198 | + |
| 199 | +function serializeVariant(rig: IRenderedRig, variant: IRenderedVariant): ExportedVariant { |
| 200 | + const json: ExportedVariant = { |
| 201 | + ...variant, |
| 202 | + models: mapObjEntries(variant.models, (uuid, model) => { |
| 203 | + const json: ExportedVariantModel = { |
| 204 | + model: model.model, |
| 205 | + custom_model_data: model.custom_model_data, |
| 206 | + } |
| 207 | + return [uuid, json] |
| 208 | + }), |
| 209 | + } |
| 210 | + return json |
| 211 | +} |
| 212 | + |
| 213 | +export function exportJSON(options: { |
| 214 | + rig: IRenderedRig |
| 215 | + animations: IRenderedAnimation[] |
| 216 | + displayItemPath: string |
| 217 | + textureExportFolder: string |
| 218 | + modelExportFolder: string |
| 219 | +}) { |
| 220 | + const aj = Project!.animated_java |
| 221 | + const { rig, animations } = options |
| 222 | + |
| 223 | + console.log('Exporting JSON...', options) |
| 224 | + |
| 225 | + function serializeTexture(texture: Texture): ExportedTexture { |
| 226 | + return { |
| 227 | + name: texture.name, |
| 228 | + src: texture.getDataURL(), |
| 229 | + } |
| 230 | + } |
| 231 | + |
| 232 | + const json: IExportedJSON = { |
| 233 | + settings: { |
| 234 | + export_namespace: aj.export_namespace, |
| 235 | + bounding_box: aj.bounding_box, |
| 236 | + custom_model_data_offset: aj.custom_model_data_offset, |
| 237 | + baked_animations: aj.baked_animations, |
| 238 | + }, |
| 239 | + textures: mapObjEntries(rig.textures, (_, texture) => [ |
| 240 | + texture.uuid, |
| 241 | + serializeTexture(texture), |
| 242 | + ]), |
| 243 | + nodes: mapObjEntries(rig.nodes, (uuid, node) => [uuid, serailizeRenderedNode(node)]), |
| 244 | + variants: mapObjEntries(rig.variants, (uuid, variant) => [ |
| 245 | + uuid, |
| 246 | + serializeVariant(rig, variant), |
| 247 | + ]), |
| 248 | + animations: {}, |
| 249 | + } |
| 250 | + |
| 251 | + if (aj.baked_animations) { |
| 252 | + for (const animation of animations) { |
| 253 | + json.animations[animation.uuid] = serializeAnimation(animation) |
| 254 | + } |
| 255 | + } else { |
| 256 | + for (const animation of Blockbench.Animation.all) { |
| 257 | + const animJSON: ExportedDynamicAnimation = { |
| 258 | + name: animation.name, |
| 259 | + loop_mode: animation.loop, |
| 260 | + duration: animation.length, |
| 261 | + excluded_nodes: animation.excluded_nodes.map(node => node.value), |
| 262 | + animators: {}, |
| 263 | + } |
| 264 | + for (const [uuid, animator] of Object.entries(animation.animators)) { |
| 265 | + // Only include animators with keyframes |
| 266 | + if (animator.keyframes.length === 0) continue |
| 267 | + animJSON.animators[uuid] = animator.keyframes.map(serailizeKeyframe) |
| 268 | + } |
| 269 | + json.animations[animation.uuid] = animJSON |
| 270 | + } |
| 271 | + } |
| 272 | + |
| 273 | + console.log('Exported JSON:', json) |
| 274 | + if (detectCircularReferences(json)) { |
| 275 | + throw new Error('Circular references detected in exported JSON.') |
| 276 | + } |
| 277 | + console.log('Scrubbed:', scrubUndefined(json)) |
| 278 | + |
| 279 | + let exportPath: string |
| 280 | + try { |
| 281 | + exportPath = resolvePath(aj.json_file) |
| 282 | + } catch (e) { |
| 283 | + console.log(`Failed to resolve export path '${aj.json_file}'`) |
| 284 | + console.error(e) |
| 285 | + return |
| 286 | + } |
| 287 | + |
| 288 | + fs.writeFileSync(exportPath, compileJSON(json).toString()) |
| 289 | +} |
| 290 | + |
| 291 | +function serailizeNodeTransform(node: INodeTransform): ExportedNodetransform { |
| 292 | + const json: ExportedNodetransform = { |
| 293 | + matrix: node.matrix.elements, |
| 294 | + decomposed: { |
| 295 | + translation: node.decomposed.translation.toArray(), |
| 296 | + left_rotation: node.decomposed.leftRotation.toArray() as ArrayVector4, |
| 297 | + scale: node.decomposed.scale.toArray(), |
| 298 | + }, |
| 299 | + pos: node.pos, |
| 300 | + rot: node.rot, |
| 301 | + head_rot: node.head_rot, |
| 302 | + scale: node.scale, |
| 303 | + interpolation: node.interpolation, |
| 304 | + commands: node.commands, |
| 305 | + execute_condition: node.execute_condition, |
| 306 | + } |
| 307 | + return json |
| 308 | +} |
| 309 | + |
| 310 | +function serailizeRenderedNode(node: AnyRenderedNode): ExportedRenderedNode { |
| 311 | + const json: any = { ...node } |
| 312 | + delete json.node |
| 313 | + delete json.parentNode |
| 314 | + delete json.safe_name |
| 315 | + delete json.model |
| 316 | + transferKey(json, 'lineWidth', 'line_width') |
| 317 | + transferKey(json, 'backgroundColor', 'background_color') |
| 318 | + transferKey(json, 'backgroundAlpha', 'background_alpha') |
| 319 | + |
| 320 | + json.default_transform = serailizeNodeTransform(json.default_transform as INodeTransform) |
| 321 | + switch (node.type) { |
| 322 | + case 'bone': { |
| 323 | + delete json.boundingBox |
| 324 | + json.bounding_box = { |
| 325 | + min: node.bounding_box.min.toArray(), |
| 326 | + max: node.bounding_box.max.toArray(), |
| 327 | + } |
| 328 | + delete json.configs |
| 329 | + json.configs = { ...node.configs?.variants } |
| 330 | + const defaultVariant = Variant.getDefault() |
| 331 | + if (node.configs?.default && defaultVariant) { |
| 332 | + json.configs[defaultVariant.uuid] = node.configs.default |
| 333 | + } |
| 334 | + break |
| 335 | + } |
| 336 | + case 'text_display': { |
| 337 | + json.text = node.text?.toJSON() |
| 338 | + break |
| 339 | + } |
| 340 | + } |
| 341 | + return json as ExportedRenderedNode |
| 342 | +} |
| 343 | + |
| 344 | +function serializeAnimation(animation: IRenderedAnimation): ExportedBakedAnimation { |
| 345 | + const json: ExportedBakedAnimation = { |
| 346 | + name: animation.name, |
| 347 | + duration: animation.duration, |
| 348 | + loop_delay: animation.loop_delay, |
| 349 | + loop_mode: animation.loop_mode, |
| 350 | + frames: [], |
| 351 | + modified_nodes: Object.keys(animation.modified_nodes), |
| 352 | + } |
| 353 | + |
| 354 | + const frames: ExportedAnimationFrame[] = [] |
| 355 | + for (const frame of animation.frames) { |
| 356 | + const nodeTransforms: Record<string, ExportedNodetransform> = {} |
| 357 | + for (const [uuid, nodeTransform] of Object.entries(frame.node_transforms)) { |
| 358 | + nodeTransforms[uuid] = serailizeNodeTransform(nodeTransform) |
| 359 | + } |
| 360 | + frames.push({ ...frame, node_transforms: nodeTransforms }) |
| 361 | + } |
| 362 | + json.frames = frames |
| 363 | + |
| 364 | + return json |
| 365 | +} |
0 commit comments