diff --git a/src/components/blueprintSettingsDialog.svelte b/src/components/blueprintSettingsDialog.svelte index 63146e3a..19ece9f6 100644 --- a/src/components/blueprintSettingsDialog.svelte +++ b/src/components/blueprintSettingsDialog.svelte @@ -271,7 +271,7 @@ console.error(e) return { type: 'error', - message: translate('dialog.blueprint_settings.json_file.error.file_does_not_exist'), + message: translate('dialog.blueprint_settings.json_file.error.invalid_path'), } } switch (true) { @@ -333,8 +333,6 @@ // Export Settings export let exportNamespace: Valuable export let enablePluginMode: Valuable - // FIXME - Force-disable plugin mode for now - $enablePluginMode = false export let resourcePackExportMode: Valuable export let dataPackExportMode: Valuable export let targetMinecraftVersion: Valuable @@ -433,12 +431,12 @@ valueChecker={exportNamespaceChecker} /> - + {#if $enablePluginMode} [1] + ) { + super(message) + this.name = 'IntentionalExportError' + } +} + +export class IntentionalExportErrorFromInvalidFile extends IntentionalExportError { + constructor(filePath: string, public originalError: Error) { + const parsed = PathModule.parse(filePath) + super( + `Failed to read file ${parsed.base}:\n\n` + + '```\n' + + originalError + + '\n```', + { + commands: { + open_file: { + text: 'Open File Location', + icon: 'folder_open', + }, + }, + }, + button => { + if (button === 'open_file') { + shell.showItemInFolder(filePath) + } + } + ) + this.name = 'IntentionalExportErrorFromInvalidFile' + } +} + diff --git a/src/systems/exporter.ts b/src/systems/exporter.ts index 67bed5c0..7136e0a5 100644 --- a/src/systems/exporter.ts +++ b/src/systems/exporter.ts @@ -9,48 +9,13 @@ import { isResourcePackPath } from '../util/minecraftUtil' import { translate } from '../util/translation' import { Variant } from '../variants' import { hashAnimations, renderProjectAnimations } from './animationRenderer' +import { exportPluginBlueprint } from './pluginCompiler' import compileDataPack from './datapackCompiler' +import { IntentionalExportError } from './errors' import resourcepackCompiler from './resourcepackCompiler' import { hashRig, renderRig } from './rigRenderer' import { isCubeValid } from './util' -export class IntentionalExportError extends Error { - constructor( - message: string, - public messageBoxOptions?: MessageBoxOptions, - public messageBoxCallback?: Parameters[1] - ) { - super(message) - this.name = 'IntentionalExportError' - } -} - -export class IntentionalExportErrorFromInvalidFile extends IntentionalExportError { - constructor(filePath: string, public originalError: Error) { - const parsed = PathModule.parse(filePath) - super( - `Failed to read file ${parsed.base}:\n\n` + - '```\n' + - originalError + - '\n```', - { - commands: { - open_file: { - text: 'Open File Location', - icon: 'folder_open', - }, - }, - }, - button => { - if (button === 'open_file') { - shell.showItemInFolder(filePath) - } - } - ) - this.name = 'IntentionalExportErrorFromInvalidFile' - } -} - export function getExportPaths() { const aj = Project!.animated_java @@ -164,7 +129,7 @@ async function actuallyExportProject({ debugMode, }) - if (aj.data_pack_export_mode !== 'none') { + if (!aj.enable_plugin_mode && aj.data_pack_export_mode !== 'none') { await compileDataPack([aj.target_minecraft_version], { rig, animations, @@ -175,6 +140,11 @@ async function actuallyExportProject({ }) } + if (aj.enable_plugin_mode) { + PROGRESS_DESCRIPTION.set('Exporting Plugin JSON...') + exportPluginBlueprint({ rig, animations }) + } + Project!.last_used_export_namespace = aj.export_namespace if (forceSave) saveBlueprint() diff --git a/src/systems/global.ts b/src/systems/global.ts index 8e322dbf..0f6fe29c 100644 --- a/src/systems/global.ts +++ b/src/systems/global.ts @@ -1,5 +1,5 @@ import { normalizePath } from '../util/fileUtil' -import { IntentionalExportError, IntentionalExportErrorFromInvalidFile } from './exporter' +import { IntentionalExportError, IntentionalExportErrorFromInvalidFile } from './errors' import { sortObjectKeys } from './util' export enum SUPPORTED_MINECRAFT_VERSIONS { diff --git a/src/systems/jsonCompiler.ts b/src/systems/jsonCompiler.ts index 2dba4d0c..cad1388b 100644 --- a/src/systems/jsonCompiler.ts +++ b/src/systems/jsonCompiler.ts @@ -2,6 +2,7 @@ /// /// +import { PACKAGE } from '../constants' import type { IBlueprintDisplayEntityConfigJSON } from '../formats/blueprint' import { type defaultValues } from '../formats/blueprint/settings' import type { EasingKey } from '../util/easing' @@ -9,6 +10,7 @@ import { resolvePath } from '../util/fileUtil' import { detectCircularReferences, mapObjEntries, scrubUndefined } from '../util/misc' import { Variant } from '../variants' import type { INodeTransform, IRenderedAnimation, IRenderedFrame } from './animationRenderer' +import { IntentionalExportError } from './errors' import { JsonText } from './jsonText' import type { AnyRenderedNode, @@ -18,42 +20,34 @@ import type { IRenderedVariantModel, } from './rigRenderer' -type ExportedNodetransform = Omit< - INodeTransform, - 'type' | 'name' | 'uuid' | 'node' | 'matrix' | 'decomposed' | 'executeCondition' -> & { +type ExportedNodetransform = Omit & { matrix: number[] decomposed: { translation: ArrayVector3 left_rotation: ArrayVector4 scale: ArrayVector3 } - pos: ArrayVector3 - rot: ArrayVector3 - scale: ArrayVector3 - execute_condition?: string } type ExportedRenderedNode = Omit< AnyRenderedNode, - | 'node' - | 'parentNode' - | 'model' - | 'boundingBox' - | 'configs' - | 'baseScale' - | 'path_name' + 'default_transform' + | 'bounding_box' + | 'configs' | 'storage_name' > & { default_transform: ExportedNodetransform bounding_box?: { min: ArrayVector3; max: ArrayVector3 } configs?: Record } -type ExportedAnimationFrame = Omit & { +type ExportedAnimationFrame = Omit & { node_transforms: Record } type ExportedBakedAnimation = Omit< IRenderedAnimation, - 'uuid' | 'frames' | 'modified_nodes' | 'path_name' | 'storage_name' + 'uuid' + | 'frames' + | 'modified_nodes' + | 'storage_name' > & { frames: ExportedAnimationFrame[] modified_nodes: string[] @@ -98,16 +92,16 @@ interface ExportedDynamicAnimation { animators: Record } interface ExportedTexture { + uuid: string name: string src: string } -type ExportedVariantModel = Omit< +type ExportedVariantModel = Pick< IRenderedVariantModel, - 'model_path' | 'resource_location' | 'item_model' -> & { - model: IRenderedModel | null - custom_model_data: number -} + 'custom_model_data' + | 'resource_location' + | 'item_model' +> & { model: IRenderedModel | null } type ExportedVariant = Omit & { /** * A map of bone UUID -> IRenderedVariantModel @@ -116,16 +110,25 @@ type ExportedVariant = Omit & { } export interface IExportedJSON { + format_version: '2.0.0' + exported_with: { + name: string + version: string + } /** * The Blueprint's Settings */ settings: { export_namespace: (typeof defaultValues)['export_namespace'] + target_minecraft_version: (typeof defaultValues)['target_minecraft_version'] + display_item: (typeof defaultValues)['display_item'] bounding_box: (typeof defaultValues)['render_box'] // Resource Pack Settings custom_model_data_offset: (typeof defaultValues)['custom_model_data_offset'] // Plugin Settings baked_animations: (typeof defaultValues)['baked_animations'] + interpolation_duration: (typeof defaultValues)['interpolation_duration'] + teleportation_duration: (typeof defaultValues)['teleportation_duration'] } textures: Record nodes: Record @@ -137,7 +140,12 @@ export interface IExportedJSON { } function transferKey(obj: any, oldKey: string, newKey: string) { - obj[newKey] = obj[oldKey] + if (!Object.prototype.hasOwnProperty.call(obj, oldKey)) return + const value = obj[oldKey] + if (value === undefined) return + if (obj[newKey] === undefined) { + obj[newKey] = value + } delete obj[oldKey] } @@ -209,6 +217,8 @@ function serializeVariant(rig: IRenderedRig, variant: IRenderedVariant): Exporte const json: ExportedVariantModel = { model: model.model, custom_model_data: model.custom_model_data, + resource_location: model.resource_location, + item_model: model.item_model, } return [uuid, json] }), @@ -230,22 +240,29 @@ export function exportJSON(options: { function serializeTexture(texture: Texture): ExportedTexture { return { + uuid: texture.uuid, name: texture.name, src: texture.getDataURL(), } } const json: IExportedJSON = { + format_version: '2.0.0', + exported_with: { + name: PACKAGE.name, + version: PACKAGE.version, + }, settings: { export_namespace: aj.export_namespace, + target_minecraft_version: aj.target_minecraft_version, + display_item: aj.display_item, bounding_box: aj.render_box, custom_model_data_offset: aj.custom_model_data_offset, baked_animations: aj.baked_animations, + interpolation_duration: aj.interpolation_duration, + teleportation_duration: aj.teleportation_duration, }, - textures: mapObjEntries(rig.textures, (_, texture) => [ - texture.uuid, - serializeTexture(texture), - ]), + textures: mapObjEntries(rig.textures, (id, texture) => [id, serializeTexture(texture)]), nodes: mapObjEntries(rig.nodes, (uuid, node) => [uuid, serailizeRenderedNode(node)]), variants: mapObjEntries(rig.variants, (uuid, variant) => [ uuid, @@ -286,12 +303,22 @@ export function exportJSON(options: { try { exportPath = resolvePath(aj.json_file) } catch (e) { - console.log(`Failed to resolve export path '${aj.json_file}'`) - console.error(e) - return + throw new IntentionalExportError( + `Failed to resolve export path ${aj.json_file}: ${String(e)}` + ) } - fs.writeFileSync(exportPath, compileJSON(json).toString()) + try { + const dir = PathModule.dirname(exportPath) + if (dir && dir !== '.' && !fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + fs.writeFileSync(exportPath, compileJSON(json).toString()) + } catch (e: any) { + throw new IntentionalExportError( + `Failed to write JSON file ${exportPath}: ${String(e)}` + ) + } } function serailizeNodeTransform(node: INodeTransform): ExportedNodetransform { @@ -325,19 +352,24 @@ function serailizeRenderedNode(node: AnyRenderedNode): ExportedRenderedNode { transferKey(json, 'backgroundAlpha', 'background_alpha') json.default_transform = serailizeNodeTransform(json.default_transform as INodeTransform) + + if (node.type !== 'struct' && (node as any).bounding_box) { + json.bounding_box = { + min: (node as any).bounding_box.min.toArray(), + max: (node as any).bounding_box.max.toArray(), + } + } + + if ((node as any).configs) { + json.configs = { ...(node as any).configs?.variants } + const defaultVariant = Variant.getDefault() + if ((node as any).configs?.default && defaultVariant) { + json.configs[defaultVariant.uuid] = (node as any).configs.default + } + } + switch (node.type) { case 'bone': { - delete json.boundingBox - json.bounding_box = { - min: node.bounding_box.min.toArray(), - max: node.bounding_box.max.toArray(), - } - delete json.configs - json.configs = { ...node.configs?.variants } - const defaultVariant = Variant.getDefault() - if (node.configs?.default && defaultVariant) { - json.configs[defaultVariant.uuid] = node.configs.default - } break } case 'text_display': { diff --git a/src/systems/pluginCompiler.ts b/src/systems/pluginCompiler.ts index e4c4a200..144d0a7a 100644 --- a/src/systems/pluginCompiler.ts +++ b/src/systems/pluginCompiler.ts @@ -1,44 +1,657 @@ -namespace PluginBlueprint { - type TextureAnimationFrame = - | number - | { - index: number - time?: number - } +/// - interface TextureAnimation { - width?: number - height?: number - interpolate?: boolean - frametime?: number - frames?: TextureAnimationFrame[] +import type { IBlueprintDisplayEntityConfigJSON } from '../formats/blueprint' +import { resolvePath } from '../util/fileUtil' +import { isResourcePackPath, parseResourcePackPath, sanitizeStorageKey } from '../util/minecraftUtil' +import { detectCircularReferences, scrubUndefined } from '../util/misc' +import { Variant } from '../variants' +import type { INodeTransform, IRenderedAnimation } from './animationRenderer' +import { IntentionalExportError } from './errors' +import type { AnyRenderedNode, IRenderedElement, IRenderedFace, IRenderedModel, IRenderedRig } from './rigRenderer' + +type TextureAnimationFrame = + | number + | { + index: number + time: number + } + +interface TextureAnimation { + interpolate?: boolean + width?: number + height?: number + frametime?: number + frames?: TextureAnimationFrame[] +} + +type PluginTexture = + | { + type: 'custom' + base64_string: string + mime_type?: string + animation?: TextureAnimation + } + | { + type: 'reference' + resource_location: string + } + +interface TexturePalette { + active_state: string + states: Record +} + +type TextureProvider = + | { type: 'texture'; texture: string } + | { type: 'texture_palette'; texture_palette: string } + +interface BoneElementFace { + uv: ArrayVector4 + texture_provider: TextureProvider + tintindex?: number + rotation?: number +} + +type BoneElementFaces = Partial< + Record<'north' | 'east' | 'south' | 'west' | 'up' | 'down', BoneElementFace> +> + +interface BoneElementRotation { + angle: number + axis: 'x' | 'y' | 'z' + origin: ArrayVector3 + rescale?: boolean +} + +interface BoneElement { + from: ArrayVector3 + to: ArrayVector3 + rotation: BoneElementRotation + shade?: boolean + light_emission?: number + faces: BoneElementFaces + display_rotation?: ArrayVector3 +} + +interface NodeTransformation { + matrix?: number[] + decomposed?: { + translation?: ArrayVector3 + left_rotation?: ArrayVector4 + scale?: ArrayVector3 + } + position?: ArrayVector3 + rotation?: ArrayVector3 + head_rotation?: ArrayVector2 + scale?: ArrayVector3 +} + +type NodeType = 'bone' | 'item_display' | 'block_display' | 'text_display' | 'structure' | 'camera' | 'locator' + +type PluginNode = + | { + type: 'bone' + default_transformation?: NodeTransformation + display_properties?: Record + elements: BoneElement[] + } + | { + type: Exclude + default_transformation?: NodeTransformation + display_properties?: Record + } + +type LoopMode = { type: 'once' } | { type: 'hold' } | { type: 'loop'; loop_delay?: string } + +type TransformationKeyframeInterpolation = + | { type: 'linear'; easing: string; easing_arguments?: number[] } + | { + type: 'bezier' + left_handle_time: ArrayVector3 + left_handle_value: ArrayVector3 + right_handle_time: ArrayVector3 + right_handle_value: ArrayVector3 + } + | { type: 'catmullrom' } + | { type: 'step' } + +interface TransformationKeyframe { + value: [string, string, string] + post?: [string, string, string] + interpolation: TransformationKeyframeInterpolation +} + +interface PluginAnimation { + loop_mode: LoopMode + length: number + blend_weight?: string + start_delay?: string + global_keyframes?: { + texture?: Record> + event?: Record } + node_keyframes?: Record< + string, + { + position?: Record + rotation?: Record + scale?: Record + } + > +} - interface CustomTexture { - type: 'custom' - base64: string - mime_type: 'image/png' - animation?: TextureAnimation +export interface PluginBlueprintJson { + $schema?: string + format_version: number + settings: { + id: string } + textures?: Record + texture_palettes?: Record + nodes?: Record + animations?: Record +} - interface ReferenceTexture { - type: 'reference' - resource_location: string +function ensureUniqueKey(baseKey: string, usedKeys: Set) { + const sanitizedBaseKey = sanitizeStorageKey(baseKey || 'unnamed') + let key = sanitizedBaseKey + let i = 2 + while (usedKeys.has(key)) { + key = `${sanitizedBaseKey}_${i++}` } + usedKeys.add(key) + return key +} - export type Texture = CustomTexture | ReferenceTexture +function formatTimestamp(timestamp: number): string { + let s = timestamp.toString() + if (s.includes('e') || s.includes('E')) s = timestamp.toFixed(6) + if (!s.includes('.')) s += '.0' + return s +} - export interface Json { - format_version: string - settings: { - id: string +function toMolangNumber(value: number): string { + if (!Number.isFinite(value)) return '0' + const rounded = Math.round(value * 100000) / 100000 + if (Object.is(rounded, -0)) return '0' + return rounded.toString() +} + +function parseDataUrl(dataUrl: string): { mimeType: string; base64: string } { + const [header, base64] = dataUrl.split(',', 2) + if (!header || !base64) throw new Error('Invalid data URL') + const mimeType = header.replace(/^data:/, '').split(';')[0] || 'image/png' + return { mimeType, base64 } +} + +function readTextureAnimation(texture: Texture): TextureAnimation | undefined { + if (!texture.path) return undefined + const mcmetaPath = texture.path + '.mcmeta' + if (!fs.existsSync(mcmetaPath)) return undefined + try { + const parsed = JSON.parse(fs.readFileSync(mcmetaPath, 'utf-8')) as { + animation?: Record + } + const anim = parsed.animation as any + if (!anim) return undefined + return scrubUndefined({ + interpolate: anim.interpolate, + width: anim.width, + height: anim.height, + frametime: anim.frametime, + frames: anim.frames, + } satisfies TextureAnimation) + } catch (e) { + console.warn(`Failed to parse texture animation mcmeta for ${texture.name}:`, e) + return undefined + } +} + +function serializeNodeTransformation(transform: INodeTransform): NodeTransformation { + return scrubUndefined({ + matrix: transform.matrix.elements.slice(), + decomposed: { + translation: transform.decomposed.translation.toArray() as ArrayVector3, + left_rotation: transform.decomposed.left_rotation.toArray() as ArrayVector4, + scale: transform.decomposed.scale.toArray() as ArrayVector3, + }, + position: transform.pos, + rotation: transform.rot, + head_rotation: transform.head_rot, + scale: transform.scale, + } satisfies NodeTransformation) +} + +function serializeDisplayProperties( + node: AnyRenderedNode, + config: IBlueprintDisplayEntityConfigJSON | undefined +): Record | undefined { + const props: Record = {} + if (config?.billboard !== undefined) props.billboard = config.billboard + + const overrideBrightness = config?.override_brightness ?? false + if (overrideBrightness) { + props.is_custom_brightness_enabled = true + props.custom_brightness = config?.brightness_override ?? 0 + } + + if (config?.glowing !== undefined) props.is_glowing = config.glowing + if (config?.override_glow_color) { + const color = (config.glow_color ?? '#ffffff').replace('#', '') + props.glow_color_override = parseInt(color, 16) + } + + if (config?.shadow_radius !== undefined) props.shadow_radius = config.shadow_radius + if (config?.shadow_strength !== undefined) props.shadow_strength = config.shadow_strength + + if (node.type === 'bone' && config?.enchanted !== undefined) { + props.is_enchanted = config.enchanted + } + + if (Object.keys(props).length === 0) return undefined + return props +} + +function intFromHex8(hex: string): number { + if (hex.startsWith('#')) hex = hex.slice(1) + const unsigned = parseInt(hex, 16) + if (!Number.isFinite(unsigned)) return 0 + return unsigned > 0x7fffffff ? unsigned - 0x100000000 : unsigned +} + +function serializeTextureProvider(options: { + textureId: string + textureIdToKey: Map + textureKeyToPaletteId: Map +}): TextureProvider { + const textureKey = options.textureIdToKey.get(options.textureId) + if (!textureKey) throw new Error(`Missing texture mapping for texture id '${options.textureId}'`) + + const paletteId = options.textureKeyToPaletteId.get(textureKey) + if (paletteId) return { type: 'texture_palette', texture_palette: paletteId } + + return { type: 'texture', texture: textureKey } +} + +function serializeFace(face: IRenderedFace, options: { + textureIdToKey: Map + textureKeyToPaletteId: Map +}): BoneElementFace | undefined { + if (!face.uv) return undefined + const textureId = face.texture?.startsWith('#') ? face.texture.slice(1) : face.texture + if (!textureId) return undefined + + return scrubUndefined({ + uv: face.uv as ArrayVector4, + tintindex: face.tintindex, + rotation: face.rotation, + texture_provider: serializeTextureProvider({ + textureId, + textureIdToKey: options.textureIdToKey, + textureKeyToPaletteId: options.textureKeyToPaletteId, + }), + } satisfies BoneElementFace) +} + +function serializeBoneElements(model: IRenderedModel, options: { + textureIdToKey: Map + textureKeyToPaletteId: Map +}): BoneElement[] { + const elements = model.elements ?? [] + return elements.map((el: IRenderedElement) => { + const faces: BoneElementFaces = {} + for (const [dir, face] of Object.entries(el.faces ?? {})) { + const serializedFace = serializeFace(face as IRenderedFace, options) + if (!serializedFace) continue + ;(faces as any)[dir] = serializedFace + } + + const rotation: BoneElementRotation = + el.rotation && !Array.isArray(el.rotation) + ? { + angle: el.rotation.angle, + axis: el.rotation.axis as BoneElementRotation['axis'], + origin: el.rotation.origin as ArrayVector3, + rescale: (el.rotation as any).rescale, + } + : { angle: 0, axis: 'y', origin: [0, 0, 0] } + + return scrubUndefined({ + from: el.from as ArrayVector3, + to: el.to as ArrayVector3, + rotation, + shade: el.shade, + light_emission: el.light_emission, + faces, + display_rotation: (el as any).display_rotation, + } satisfies BoneElement) + }) +} + +function serializeNode( + node: AnyRenderedNode, + options: { + defaultVariantModels: Record + textureIdToKey: Map + textureKeyToPaletteId: Map + } +): PluginNode { + const base = { + default_transformation: serializeNodeTransformation(node.default_transform), + } as const + + const config = (node as any).configs?.default as IBlueprintDisplayEntityConfigJSON | undefined + const displayProps = serializeDisplayProperties(node, config) + + switch (node.type) { + case 'bone': { + const model = options.defaultVariantModels[node.uuid]?.model + if (!model) { + throw new Error(`Missing model for bone node '${node.name}' (${node.uuid})`) + } + return scrubUndefined({ + type: 'bone', + ...base, + display_properties: displayProps, + elements: serializeBoneElements(model, { + textureIdToKey: options.textureIdToKey, + textureKeyToPaletteId: options.textureKeyToPaletteId, + }), + } satisfies PluginNode) + } + case 'item_display': { + return scrubUndefined({ + type: 'item_display', + ...base, + display_properties: scrubUndefined({ + ...displayProps, + item: (node as any).item, + item_display: (node as any).item_display, + }), + } satisfies PluginNode) + } + case 'block_display': { + return scrubUndefined({ + type: 'block_display', + ...base, + display_properties: scrubUndefined({ + ...displayProps, + block_state: (node as any).block, + }), + } satisfies PluginNode) + } + case 'text_display': { + const argb = intFromHex8((node as any).background_color ?? '#40000000') + return scrubUndefined({ + type: 'text_display', + ...base, + display_properties: scrubUndefined({ + ...displayProps, + alignment: (node as any).align, + background_color: argb, + is_default_background: argb === 0x40000000, + is_see_through: (node as any).see_through, + is_shadowed: (node as any).shadow, + line_width: (node as any).line_width, + text: (node as any).text, + }), + } satisfies PluginNode) + } + case 'struct': + return { type: 'structure', ...base } + case 'camera': + return { type: 'camera', ...base } + case 'locator': + return { type: 'locator', ...base } + default: + throw new Error(`Unsupported node type: ${(node as any).type}`) + } +} + +function buildPalettes(options: { + textures: Record + textureIdToKey: Map +}): { palettes: Record; textureKeyToPaletteId: Map } { + const variants = Variant.allExcludingDefault() + if (variants.length === 0) { + return { palettes: {}, textureKeyToPaletteId: new Map() } + } + + const usedPaletteKeys = new Set() + const palettes: Record = {} + const textureKeyToPaletteId = new Map() + + for (const texture of Object.values(Texture.all)) { + // only exported textures + const textureKey = options.textureIdToKey.get(texture.id) + if (!textureKey) continue + if (!options.textures[textureKey]) continue + + const states: Record = { + default: { texture: textureKey }, + } + + let hasAnyAlternative = false + for (const variant of variants) { + const mapped = variant.textureMap.getMappedTexture(texture.uuid) + let mappedKey = textureKey + if (mapped) { + const key = options.textureIdToKey.get(mapped.id) + if (key) mappedKey = key + } + states[variant.name] = { texture: mappedKey } + if (mappedKey !== textureKey) hasAnyAlternative = true + } + + if (!hasAnyAlternative) continue + + const paletteId = ensureUniqueKey(`${textureKey}_palette`, usedPaletteKeys) + palettes[paletteId] = { + active_state: 'default', + states, + } + textureKeyToPaletteId.set(textureKey, paletteId) + } + + return { palettes, textureKeyToPaletteId } +} + +function serializeTexture(texture: Texture): PluginTexture { + if (texture.path && isResourcePackPath(texture.path)) { + const parsed = parseResourcePackPath(texture.path) + if (parsed?.namespace === 'minecraft') { + return { + type: 'reference', + resource_location: parsed.resourceLocation, + } } - textures: Record } + + const dataUrl = texture.getDataURL() + const { mimeType, base64 } = parseDataUrl(dataUrl) + return scrubUndefined({ + type: 'custom', + base64_string: base64, + mime_type: mimeType, + animation: readTextureAnimation(texture), + } satisfies PluginTexture) } -// export function compilePluginBlueprint(): PluginBlueprint.Json { -// const blueprint: PluginBlueprint.Json = {} +function serializeAnimation(options: { + animation: IRenderedAnimation + nodeUuidToId: Map + paletteIds: string[] +}): PluginAnimation { + const { animation, nodeUuidToId } = options + + const loop_mode: LoopMode = + animation.loop_mode === 'loop' + ? { type: 'loop', loop_delay: String(animation.loop_delay ?? 0) } + : animation.loop_mode === 'hold' + ? { type: 'hold' } + : { type: 'once' } + + const maxTime = animation.frames.at(-1)?.time ?? 0 + + const node_keyframes: NonNullable = {} + + for (const frame of animation.frames) { + const timeKey = formatTimestamp(frame.time) + for (const [uuid, transform] of Object.entries(frame.node_transforms)) { + const nodeId = nodeUuidToId.get(uuid) + if (!nodeId) continue + node_keyframes[nodeId] ??= {} + + const createInterpolation = (): TransformationKeyframeInterpolation => + transform.interpolation === 'step' || transform.interpolation === 'pre-post' + ? { type: 'step' } + : { type: 'linear', easing: 'linear' } + + node_keyframes[nodeId].position ??= {} + node_keyframes[nodeId].rotation ??= {} + node_keyframes[nodeId].scale ??= {} -// return blueprint -// } + node_keyframes[nodeId].position![timeKey] = { + value: [ + toMolangNumber(transform.pos[0]), + toMolangNumber(transform.pos[1]), + toMolangNumber(transform.pos[2]), + ], + interpolation: createInterpolation(), + } + node_keyframes[nodeId].rotation![timeKey] = { + value: [ + toMolangNumber(transform.rot[0]), + toMolangNumber(transform.rot[1]), + toMolangNumber(transform.rot[2]), + ], + interpolation: createInterpolation(), + } + node_keyframes[nodeId].scale![timeKey] = { + value: [ + toMolangNumber(transform.scale[0]), + toMolangNumber(transform.scale[1]), + toMolangNumber(transform.scale[2]), + ], + interpolation: createInterpolation(), + } + } + } + + let global_keyframes: NonNullable | undefined + + // map the baked variant for each frame into the texture keyframes + if (options.paletteIds.length) { + const textureKeyframes: Record> = {} + for (const frame of animation.frames) { + if (!frame.variants?.length) continue + const variant = Variant.getByUUID(frame.variants[0]) + if (!variant) continue + const timeKey = formatTimestamp(frame.time) + textureKeyframes[timeKey] ??= {} + for (const paletteId of options.paletteIds) { + textureKeyframes[timeKey][paletteId] = variant.name + } + } + if (Object.keys(textureKeyframes).length) { + global_keyframes ??= {} + global_keyframes.texture = textureKeyframes + } + } + + return scrubUndefined({ + loop_mode, + blend_weight: '1', + start_delay: '0', + length: maxTime, + global_keyframes, + node_keyframes, + } satisfies PluginAnimation) +} + +export function exportPluginBlueprint(options: { + rig: IRenderedRig + animations: IRenderedAnimation[] +}): void { + const aj = Project!.animated_java + + const usedTextureKeys = new Set() + const textures: Record = {} + const textureIdToKey = new Map() + + for (const texture of Object.values(options.rig.textures)) { + const baseKey = texture.name.replace(/\\.png$/i, '') + const key = ensureUniqueKey(baseKey, usedTextureKeys) + textureIdToKey.set(texture.id, key) + textures[key] = serializeTexture(texture) + } + + const { palettes, textureKeyToPaletteId } = buildPalettes({ textures, textureIdToKey }) + const paletteIds = Object.keys(palettes) + + const usedNodeKeys = new Set() + const nodeUuidToId = new Map() + for (const [uuid, node] of Object.entries(options.rig.nodes)) { + nodeUuidToId.set(uuid, ensureUniqueKey(node.storage_name, usedNodeKeys)) + } + + const defaultVariant = Variant.getDefault() + const defaultVariantModels = options.rig.variants[defaultVariant.uuid]?.models ?? {} + + const nodes: Record = {} + for (const [uuid, node] of Object.entries(options.rig.nodes)) { + const nodeId = nodeUuidToId.get(uuid) + if (!nodeId) continue + nodes[nodeId] = serializeNode(node, { + defaultVariantModels, + textureIdToKey, + textureKeyToPaletteId, + }) + } + + const animations: Record = {} + for (const animation of options.animations) { + const key = ensureUniqueKey(animation.storage_name, new Set(Object.keys(animations))) + animations[key] = serializeAnimation({ + animation, + nodeUuidToId, + paletteIds, + }) + } + + const blueprint: PluginBlueprintJson = scrubUndefined({ + format_version: 1, + settings: { + id: `animated_java:${aj.export_namespace}`, + }, + textures, + texture_palettes: palettes, + nodes, + animations, + }) + + if (detectCircularReferences(blueprint)) { + throw new Error('Circular references detected in exported plugin blueprint.') + } + + let exportPath: string + try { + exportPath = resolvePath(aj.json_file) + } catch (e) { + throw new IntentionalExportError( + `Failed to resolve export path ${aj.json_file}: ${String(e)}` + ) + } + + try { + const dir = PathModule.dirname(exportPath) + if (dir && dir !== '.' && !fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + fs.writeFileSync(exportPath, compileJSON(blueprint).toString()) + } catch (e: any) { + throw new IntentionalExportError( + `Failed to write JSON file ${exportPath}: ${String(e)}` + ) + } +} diff --git a/src/systems/resourcepackCompiler/index.ts b/src/systems/resourcepackCompiler/index.ts index d80d0f10..8071fa42 100644 --- a/src/systems/resourcepackCompiler/index.ts +++ b/src/systems/resourcepackCompiler/index.ts @@ -1,6 +1,6 @@ import { MAX_PROGRESS, PROGRESS, PROGRESS_DESCRIPTION } from '../../interface/dialog/exportProgress' import { getNextSupportedVersion, getResourcePackFormat } from '../../util/minecraftUtil' -import { IntentionalExportError } from '../exporter' +import { IntentionalExportError } from '../errors' import { type IRenderedRig } from '../rigRenderer' import type { ExportedFile } from '../util' diff --git a/src/systems/rigRenderer.ts b/src/systems/rigRenderer.ts index 389907fd..95b8924b 100644 --- a/src/systems/rigRenderer.ts +++ b/src/systems/rigRenderer.ts @@ -20,7 +20,7 @@ import { restoreSceneAngle, updatePreview, } from './animationRenderer' -import { IntentionalExportError } from './exporter' +import { IntentionalExportError } from './errors' import { JsonText } from './jsonText' export interface IRenderedFace {