From c9b25d871231c7dbd86040f192d808dde79f361b Mon Sep 17 00:00:00 2001 From: KitCat962 Date: Sun, 3 Mar 2024 11:09:36 -0500 Subject: [PATCH 1/5] Figura Format 0.1.3 --- plugins/figura_format/about.md | 16 +- plugins/figura_format/figura_format.js | 716 +++++++++++++++---------- 2 files changed, 451 insertions(+), 281 deletions(-) diff --git a/plugins/figura_format/about.md b/plugins/figura_format/about.md index 90fdd080..d6991946 100644 --- a/plugins/figura_format/about.md +++ b/plugins/figura_format/about.md @@ -4,10 +4,9 @@ This Plugin adds a Project Format that will make the following changes to Blockb * New Animations will be named `new` instead of `animation.model.new`. * The "Anim Time Update" Animation property has been renamed to "Start Offset" to reflect how the property is parsed by Figura. * The "Override" Animation property has been renamed to "Override Vanilla Animations" to better reflect how the property is used by Figura. -* Added "Copy ModelPart Path" under the Right Click context menu for Cubes, Groups, and Meshes. - * Copies the full script path of the ModelPart as dictated by Figura's scripting API. -* Added "Match Project UV with Texture Size" under Edit. - * When enabled and in PerFaceUV Mode, the ProjectUV will be changed to match the current active texture making using Textures of different sizes less of a pain. +* Added "Copy X Path" under the Right Click context menu for Cubes, Groups, Meshes, Animations, and Textures. + * Copies the full script path as dictated by Figura's scripting API. + * Assumes the bbmodel is at the root of the avatar. * Added "Add Animations..." under Animation. * Allows you to select a bbmodel and imports all animations you select, replacing old animations. * Intended to replace "Export Animations to file, then import file" workflow. @@ -16,7 +15,16 @@ This Plugin adds a Project Format that will make the following changes to Blockb * Added "Cycle Vertex Order" as one of the mesh editing buttons when at least one Face is selected. * Cycles the vertices of a Quad in order to change how textures are rendered on it when triangulated. * Will invert the face. Use the "Invert Face" button to fix this. +* Added "Allow Duplicate Names" which can be found in the Figura Plugin Settings (File->Plugins->Figura Format->Settings) + * Enabling this bypasses the group name restrictions, such as duplicate group names and special characters in group names. + * Will break certain Blockbench Animation features. Use at own risk. +* Added "Recalculate UVs" under UV. + * Scales an entire texture's uvs to match a new texture size. + * Useful for fixing Texture Size related issues. +* Added "Optimize Model" under Tools. + * Provides batch operations related to optimizing a bbmodel for filesize when compressed to a Figura Avatar. * Removed Blockbench Animated Textures. +* Removed File Name textbox. * Removed Model Identifier. * Removed Molang Errors. * Removed Texture Render Mode from the Texture Properties. diff --git a/plugins/figura_format/figura_format.js b/plugins/figura_format/figura_format.js index 2ae079ff..0282caf0 100644 --- a/plugins/figura_format/figura_format.js +++ b/plugins/figura_format/figura_format.js @@ -1,42 +1,6 @@ (function () { - let _width = 0, _height = 0 - function updateProjectUV() { - if (Project.box_uv) return - - let texture = UVEditor.texture != 0 ? UVEditor.texture : Texture.selected - if (!texture) return - - Cube.all.forEach(cube => { - cube.setUVMode(false); - }) - - let texture_width = texture.width, - texture_height = texture.height - if (texture != null && (texture_width != _width || texture_height != _height)) { - Cube.all.forEach(cube => { - for (var key in cube.faces) { - var uv = cube.faces[key].uv; - uv[0] *= texture_width / Project.texture_width; - uv[2] *= texture_width / Project.texture_width; - uv[1] *= texture_height / Project.texture_height; - uv[3] *= texture_height / Project.texture_height; - } - }) - Mesh.all.forEach(mesh => { - for (var key in mesh.faces) { - var uv = mesh.faces[key].uv; - for (let vkey in uv) { - uv[vkey][0] *= texture_width / Project.texture_width; - uv[vkey][1] *= texture_height / Project.texture_height; - } - } - }) - - Project.texture_width = _width = texture_width; - Project.texture_height = _height = texture_height; - Canvas.updateAllUVs() - } - } + const Path = require('path') + const toDelete = [] function isValidLuaIdentifier(str) { const keywords = [ @@ -69,6 +33,9 @@ && !keywords.includes(str) ) } + function getValidLuaIndex(str) { + return isValidLuaIdentifier(str) ? `.${str}` : `["${str}"]` + } // Stolen from line 92 of timeline_animators.js function getOrMakeKeyframe(animator, channel, time, snapping = 24) { @@ -95,8 +62,8 @@ await_loading: true, creation_date: "2023-07-22", onload() { - let particle = EffectAnimator.prototype.channels.particle, sound = EffectAnimator.prototype.channels.sound - let format = new ModelFormat('figura', { + const particle = EffectAnimator.prototype.channels.particle, sound = EffectAnimator.prototype.channels.sound + const format = new ModelFormat('figura', { icon: 'change_history', name: 'Figura Model', description: 'Model for the Figura mod.', @@ -106,6 +73,7 @@ box_uv: false, optional_box_uv: true, single_texture: false, + per_texture_uv_size: true, model_identifier: false, parent_model_id: false, vertex_color_ambient_occlusion: false, @@ -147,266 +115,339 @@ EffectAnimator.prototype.channels.particle = particle EffectAnimator.prototype.channels.sound = sound EffectAnimator.prototype.channels.timeline.name = tl('timeline.timeline') - } + }, }) + toDelete.push(format) - - // Removed the Render Order field from the Right Click context menu. - let elementRenderOrderCondition = BarItems.element_render_order.condition - BarItems.element_render_order.condition = () => Format === format ? false : elementRenderOrderCondition() - - // Change the default name of new Animations from `animation.model.new` to just `new` - let addAnimationClick = BarItems['add_animation'].click - BarItems['add_animation'].click = function () { - if (Format !== format) addAnimationClick.call(this) - else - new Animation({ - name: 'new', - saved: false - }).add(true).propertiesDialog() - } - // Add a popup when clicking on Export Textures to notify new users - let exportAnimationClick = BarItems['export_animation_file'].click - BarItems['export_animation_file'].click = function (...args) { - let button = this - if (Format !== format) { - exportAnimationClick.call(button, ...args) - return - } - new Dialog({ - id: "figura_confirm_export_animation", - title: "Confirm Export Animations", - lines: [ - "

Figura does not read these exported Animation files.

", - "

Figura reads animations directly from the Blockbench file itself.

", - "

The Export Animmations button should only be used when transfering animations from one bbmodel to another.

", - "

Otherwise, do not touch this button.

", - "

Do you understand, and want the exported animations anyways?

" - ], - onConfirm() { - exportAnimationClick.call(button, ...args) - } - }).show() - } - - new Action('figura_copy_path', { + const copyPathModelPart = new Action('figura_copy_path_modelpart', { name: "Copy ModelPart Path", description: "Calculates the scripting path to this ModelPart and copies it to the clipboard.", icon: "fa-clipboard", - condition: () => Format === format && Outliner.selected.length === 1, + condition: () => Format === format && (Group.selected != null || Outliner.selected.length === 1), click() { let path = [] - let element = Outliner.selected[0] + let element = Group.selected || Outliner.selected[0] while (element !== "root") { path.unshift(element.name) element = element.parent; } path.unshift(Project.name || "modelName") - path = path.map(index => isValidLuaIdentifier(index) ? `.${index}` : `["${index}"]`) + path = path.map(getValidLuaIndex) path.unshift('models') navigator.clipboard.writeText(path.join("")) } }) - Cube.prototype.menu.addAction('figura_copy_path', '#manage'); - Mesh.prototype.menu.addAction('figura_copy_path', '#manage'); - Group.prototype.menu.addAction('figura_copy_path', '#manage'); - Toolbars.main_tools.add( - new Action('figura_cycle_vertex_order', { - name: 'Cycle Vertex Order', - icon: 'fa-sync', - category: 'edit', - condition: { modes: ['edit'], features: ['meshes'], method: () => Format === format && (Mesh.selected[0] && Mesh.selected[0].getSelectedFaces().length) }, - click() { - Undo.initEdit({ elements: Mesh.selected }); - Mesh.selected.forEach(mesh => { - for (let key in mesh.faces) { - let face = mesh.faces[key]; - if (face.isSelected()) { - if (face.vertices.length < 3) continue; - [face.vertices[0], face.vertices[1], face.vertices[2], face.vertices[3]] = [face.vertices[1], face.vertices[2], face.vertices[3], face.vertices[0]]; - } - } - }) - Undo.finishEdit('Cycle face vertices'); - Canvas.updateView({ elements: Mesh.selected, element_aspects: { geometry: true, uv: true, faces: true } }); + const copyPathAnimation = new Action('figura_copy_path_animation', { + name: "Copy Animation Path", + description: "Calculates the scripting path to this Animation and copies it to the clipboard.", + icon: "fa-clipboard", + condition: () => Format === format && (Animation.selected !== null), + click() { + let path = [Project.name || "modelName", Animation.selected.name] + path = path.map(getValidLuaIndex) + path.unshift('animations') + navigator.clipboard.writeText(path.join("")) + } + }) + const copyPathTextures = new Action('figura_copy_path_texture', { + name: "Copy Texture Path", + description: "Calculates the scripting path to this Texture and copies it to the clipboard. Results may vary.", + icon: "fa-clipboard", + condition: () => Format === format && (Texture.selected !== null), + click() { + let texture = Path.parse(Texture.selected.path) + let project = Path.parse(Project.save_path) + let relative = Path.relative(project.dir, texture.dir) + let path + if (texture.dir == '' || relative.startsWith(`..`)) + path = `textures["${Project.name}.${Texture.selected.name.replace(/\.png$/, "")}"]` + else + path = `textures["${relative.replace(`\\${Path.sep}`, '.')}${relative == '' ? '' : '.'}${texture.name}"]` + navigator.clipboard.writeText(path) + } + }) + const recalculateUV = new Action('figura_recalculate_uv', { + name: "Recalculate UVs", + description: "Calculate the uvs for all of a texture's parts", + icon: "fa-expand", + condition: { method: () => Format === format }, + click() { + if (Texture.all.length == 0) { + Blockbench.showQuickMessage('No textures in bbmodel'); + return } - }) - ) - MenuBar.menus.edit.addAction( - new Toggle('figura_match_texture_size', { - name: "Match Project UV with Texture Size", - default: false, - description: "Changes the ProjectUV so that it will always match the size of the active Texture.", - condition: () => Format === format && !Project.box_uv, - onChange(state) { - if (state) { - this.callback = Blockbench.on('update_selection', () => { - if (this.value && Format === format) - updateProjectUV() + let _texture = null; + let _width = 0, _height = 0; + let dialog = new Dialog({ + id: 'figura_recalculate_uv', + title: 'Recalculate UVs', + form: { + texture: { + label: 'Texture', + type: 'select', + options: Texture.all.reduce((o, t) => { o[t.uuid] = t.name; return o }, {}) + }, + prev_width: { + label: 'Current Width', + type: 'number', + }, + prev_height: { + label: 'Current Height', + type: 'number', + }, + _: "_", + new_width: { + label: 'New Width', + type: 'number', + min: 1, + step: 1 + }, + new_height: { + label: 'New Height', + type: 'number', + min: 1, + step: 1 + }, + }, + onFormChange(form_result) { + let texture = Texture.all.find(t => t.uuid === form_result.texture) + if (form_result.texture != _texture) { + _texture = form_result.texture + _width = texture.width + _height = texture.height + dialog.setFormValues({ + prev_width: texture.width, + prev_height: texture.height, + new_width: texture.width, + new_height: texture.height + }) + dialog.updateFormValues(false) + } else if (form_result.prev_width != _width || form_result.prev_height != _height) { + dialog.setFormValues({ + prev_width: _width, + prev_height: _height, + }) + dialog.updateFormValues(false) + } + }, + onConfirm(form_result) { + console.log("?") + console.log(Cube.all.filter(c => !c.box_uv)) + Undo.initEdit({ elements: [...Cube.all, ...Mesh.all] }) + Cube.all.filter(c => !c.box_uv).forEach(cube => { + for (var key in cube.faces) { + if (cube.faces[key].texture != form_result.texture) continue + var uv = cube.faces[key].uv; + uv[0] *= form_result.new_width / form_result.prev_width; + uv[2] *= form_result.new_width / form_result.prev_width; + uv[1] *= form_result.new_height / form_result.prev_height; + uv[3] *= form_result.new_height / form_result.prev_height; + } }) - updateProjectUV() - } - else { - this.callback?.delete() + Mesh.all.forEach(mesh => { + for (var key in mesh.faces) { + if (mesh.faces[key].texture != form_result.texture) continue + var uv = mesh.faces[key].uv; + for (let vkey in uv) { + uv[vkey][0] *= form_result.new_width / form_result.prev_width; + uv[vkey][1] *= form_result.new_height / form_result.prev_height; + } + } + }) + Canvas.updateAllUVs() + Interface.Panels.textures.inside_vue.$forceUpdate(); + Canvas.updateLayeredTextures(); + UVEditor.vue.$forceUpdate(); + Undo.finishEdit('Recalculated UVs') + }, + onCancel() { } - } - }), '#editing_mode') - MenuBar.menus.animation.addAction( - new Action('figura_import_animations', { - name: "Import Animations...", - description: "Import animations from another bbmodel", - icon: "fa-file-import", - condition: { modes: ['animate'], method: () => Format === format }, - click() { - Blockbench.import({ - resource_id: 'model', - extensions: ['bbmodel'], - type: 'Model', - readtype: 'text', - multiple: false - }, function (files) { - let file = files[0] - if (!file) return + }); + dialog.show(); + dialog.updateFormValues(false) + } + }) + const importAnimations = new Action('figura_import_animations', { + name: "Import Animations...", + description: "Import animations from another bbmodel", + icon: "fa-file-import", + condition: { modes: ['animate'], method: () => Format === format }, + click() { + Blockbench.import({ + resource_id: 'model', + extensions: ['bbmodel'], + type: 'Model', + readtype: 'text', + multiple: false + }, function (files) { + let file = files[0] + if (!file) return - // get the animation data in JSON form via opening the project file and generating the json from the current file. - let currentProject = Project - let close = !(isApp && ModelProject.all.find(project => ( - project.save_path == file.path || project.export_path == file.path - ))) - loadModelFile(file); - let loadedProject = Project - let animJson = Animator.buildFile() - currentProject.select() - if (close) loadedProject.close() + // get the animation data in JSON form via opening the project file and generating the json from the current file. + let currentProject = Project + let close = !(isApp && ModelProject.all.find(project => ( + project.save_path == file.path || project.export_path == file.path + ))) + loadModelFile(file); + let loadedProject = Project + let animJson = Animator.buildFile() + currentProject.select() + if (close) loadedProject.close() - // load the animations into the current project, deleting the originals if desired. - // stolen from function importFile at line 1525 of animation.js - let form = {}; - let keys = []; - for (var key in animJson.animations) { - form[key.hashCode()] = { label: key, type: 'checkbox', value: true, nocolon: true }; - keys.push(key) - } - if (keys.length == 0) { - Blockbench.showQuickMessage('message.no_animation_to_import'); - } else { - let dialog = new Dialog({ - id: 'figura_animation_import', - title: 'dialog.animation_import.title', - form, - onConfirm(form_result) { - let names = []; - let animsToRemove = []; - for (var key of keys) { - if (form_result[key.hashCode()]) { - names.push(key); - let a = Animation.all.find(anim => anim.name == key) - if (a) animsToRemove.push(a) - } + // load the animations into the current project, deleting the originals if desired. + // stolen from function importFile at line 1525 of animation.js + let form = {}; + let keys = []; + for (var key in animJson.animations) { + form[key.hashCode()] = { label: key, type: 'checkbox', value: true, nocolon: true }; + keys.push(key) + } + if (keys.length == 0) { + Blockbench.showQuickMessage('message.no_animation_to_import'); + } else { + let dialog = new Dialog({ + id: 'figura_animation_import', + title: 'dialog.animation_import.title', + form, + onConfirm(form_result) { + let names = []; + let animsToRemove = []; + for (var key of keys) { + if (form_result[key.hashCode()]) { + names.push(key); + let a = Animation.all.find(anim => anim.name == key) + if (a) animsToRemove.push(a) } - Undo.initEdit({ animations: animsToRemove }) - if (form_result.replace_animations) - animsToRemove.forEach(anim => anim.remove(false)) - let new_animations = Animator.loadFile({ json: animJson, path: null }, names); - Undo.finishEdit('Figura Import animations', { animations: new_animations }) - }, - onCancel() { - } - }); - form.select_all_none = { - type: 'buttons', - buttons: ['generic.select_all', 'generic.select_none'], - click(index) { - let values = {}; - keys.forEach(key => values[key.hashCode()] = (index == 0)); - dialog.setFormValues(values); } + Undo.initEdit({ animations: animsToRemove }) + if (form_result.replace_animations) + animsToRemove.forEach(anim => anim.remove(false)) + let new_animations = Animator.loadFile({ json: animJson, path: null }, names); + Undo.finishEdit('Figura Import animations', { animations: new_animations }) + }, + onCancel() { } - form.properties_break = '_' - form.replace_animations = { - label: "Replace Animations?", - description: "If enabled, the imported animations will replace the old ones", - type: 'checkbox', - value: true, - nocolon: true - }; - dialog.show(); - } - }) - } - }), '#file') - MenuBar.menus.animation.addAction( - new Action('figura_bake_ik', { - name: "Bake IK into Animations", - description: "Bakes Inverse Kinematics into raw Keyframes for use in Figura", - icon: "fa-bone", - condition: { modes: ['animate'], method: () => Format === format && Animation.selected }, - click() { - new Dialog({ - id: "figura_confirm_bake_ik", - title: "Confirm Bake Inverse Kinematics", - lines: [ - "

This bakes the IK of all NullObjects onto the keyframes of the groups themselves, allowing it to be visible in Figura

", - "

However, NullObjects override the keyframes of the affected groups while it is present

", - "

Leaving the NullObject in the model has no effect on Figura, so just leave it incase you want to rebake the IK later

" - ], - form: { - "all_animations": { - type: 'checkbox', - label: 'Bake all Animations?', - description: "This Action will normally only bake the selected Animation. Do you want to bake all Animations in one swoop?", - value: false, - full_width: false + }); + form.select_all_none = { + type: 'buttons', + buttons: ['generic.select_all', 'generic.select_none'], + click(index) { + let values = {}; + keys.forEach(key => values[key.hashCode()] = (index == 0)); + dialog.setFormValues(values); } - }, - onConfirm() { - const animations = this.getFormResult().all_animations ? Animator.animations : [Animation.selected] - for (const animation of animations) { - let animators = animation.animators + } + form.properties_break = '_' + form.replace_animations = { + label: "Replace Animations?", + description: "If enabled, the imported animations will replace the old ones", + type: 'checkbox', + value: true, + nocolon: true + }; + dialog.show(); + } + }) + } + }) + const bakeIK = new Action('figura_bake_ik', { + name: "Bake IK into Animations", + description: "Bakes Inverse Kinematics into raw Keyframes for use in Figura", + icon: "fa-bone", + condition: { modes: ['animate'], method: () => Format === format && Animation.selected }, + click() { + new Dialog({ + id: "figura_confirm_bake_ik", + title: "Confirm Bake Inverse Kinematics", + lines: [ + "

This bakes the IK of all NullObjects onto the keyframes of the groups themselves, allowing it to be visible in Figura

", + "

However, NullObjects override the keyframes of the affected groups while it is present

", + "

Leaving the NullObject in the model has no effect on Figura, so just leave it incase you want to rebake the IK later

" + ], + form: { + "all_animations": { + type: 'checkbox', + label: 'Bake all Animations?', + description: "This Action will normally only bake the selected Animation. Do you want to bake all Animations in one swoop?", + value: false, + full_width: false + } + }, + onConfirm() { + const animations = this.getFormResult().all_animations ? Animator.animations : [Animation.selected] + for (const animation of animations) { + let animators = animation.animators - // Inverse Kinematics - let ik_samples = animation.sampleIK(); - for (let uuid in ik_samples) { - //let group = OutlinerNode.uuids[uuid]; - ik_samples[uuid].forEach((rotation, i) => { - let timecode = i / animation.snapping - let kf = getOrMakeKeyframe(animators[uuid], 'rotation', timecode, animation.snapping) - kf.set('x', rotation.array[0]) - kf.set('y', rotation.array[1]) - kf.set('z', rotation.array[2]) - }) - } + // Inverse Kinematics + let ik_samples = animation.sampleIK(); + for (let uuid in ik_samples) { + //let group = OutlinerNode.uuids[uuid]; + ik_samples[uuid].forEach((rotation, i) => { + let timecode = i / animation.snapping + let kf = getOrMakeKeyframe(animators[uuid], 'rotation', timecode, animation.snapping) + kf.set('x', rotation.array[0]) + kf.set('y', rotation.array[1]) + kf.set('z', rotation.array[2]) + }) } } - }).show() - } - }), '#edit') - - - // Remove the Texture Render Mode field from the Right Click context menu. - Texture.prototype.menu.structure.find(v => v.name == 'menu.texture.render_mode').condition = () => Format !== format - // In the Texture Properties Dialog specifically, remove the Render Mode field - let DialogBuild = Dialog.prototype.build - Dialog.prototype.build = function () { - if (Format === format && this.id == 'texture_edit') delete this.form.render_mode - DialogBuild.call(this) - } - - // Prevents the Timeline from erroring when Sound and Particle channels are removed - let displayFrame = EffectAnimator.prototype.displayFrame - EffectAnimator.prototype.displayFrame = function () { - if (Format === format) return - displayFrame.call(this) - } + } + }).show() + } + }) + const cycleVertexOrder = new Action('figura_cycle_vertex_order', { + name: 'Cycle Vertex Order', + icon: 'fa-sync', + category: 'edit', + condition: { modes: ['edit'], features: ['meshes'], method: () => Format === format && (Mesh.selected[0] && Mesh.selected[0].getSelectedFaces().length) }, + click() { + Undo.initEdit({ elements: Mesh.selected }); + Mesh.selected.forEach(mesh => { + for (const face of mesh.faces) { + if (face.isSelected()) { + if (face.vertices.length < 3) continue; + [face.vertices[0], face.vertices[1], face.vertices[2], face.vertices[3]] = [face.vertices[1], face.vertices[2], face.vertices[3], face.vertices[0]]; + } + } + }) + Undo.finishEdit('Cycle face vertices'); + Canvas.updateView({ elements: Mesh.selected, element_aspects: { geometry: true, uv: true, faces: true } }); + } + }) + const optimizeModel = new Action('figura_optimize_model', { + name: 'Optimize Model', + icon: 'fa-gear', + condition: { method: () => Format == format }, + click() { + new Dialog('figura_optimize_model_dialog', { + title: "Optimize Model", + form: { + clear_unused_pivots: { + type: "checkbox", + label: "Clear Unused Pivot Data", + description: "If a cube does not have a rotation, set the pivot point to the origin.\nFigura does not store pivot points at the origin, resulting in saved bytes", + value: true, + nocolon: true, + } + }, + onConfirm(form_result) { + Undo.initEdit({ elements: Cube.all }) + if (form_result.clear_unused_pivots) { + Cube.all.forEach((cube) => { + if (cube.rotation.allEqual(0)) { + cube.origin.V3_set(0, 0, 0) + } + }) + Canvas.updatePositions(Cube.all) + } + Undo.finishEdit('Optimize Model'); + } + }).show() + } + }) - // Remove molang validation, as Figura uses Lua not molang - let molangSyntax = Validator.checks.find(element => element.id == 'molang_syntax') - if (molangSyntax) { - let method = molangSyntax.condition.method - molangSyntax.condition.method = (context) => Format === format ? false : (method ? method(context) : false) - } - new ValidatorCheck('figura_mesh_face_rule', { + const validateFaces = new ValidatorCheck('figura_mesh_face_rule', { update_triggers: ['update_selection'], condition: { method: (context) => Format === format && Mesh.hasAny() @@ -451,15 +492,136 @@ }) } }) + const validateTextureNames = new ValidatorCheck('figura_duplicate_texture_name_rule', { + update_triggers: ['add_texture', 'update_selection'], + condition: { + method: (context) => Format === format && Texture.all.length > 0 + }, + run() { + let textureCount = Texture.all.reduce((arr, t) => { + if (arr[t.name]) + arr[t.name]++ + else + arr[t.name] = 1 + return arr + }, {}) + for (const [name, cnt] of Object.entries(textureCount)) + if (cnt > 1) + this.fail({ + message: `${cnt} textures have the name "${name}". Figura does not support textures with duplicate names` + }) + } + }) + + const arbitraryGroupNames = new Setting('figura_allow_duplicate_names', { + name: "Allow arbitrary group names", + description: "Enabling this removes the group name restrictions imposed by Blockbench. This can break Animations. Figura Model Format is not liable for any harm caused by this setting. You have been warned.", + category: 'edit', + type: 'toggle', + value: false + }) + + Cube.prototype.menu.addAction(copyPathModelPart, '#manage'); + Mesh.prototype.menu.addAction(copyPathModelPart, '#manage'); + Group.prototype.menu.addAction(copyPathModelPart, '#manage'); + Animation.prototype.menu.addAction(copyPathAnimation, '#properties'); + Texture.prototype.menu.addAction(copyPathTextures, '#properties'); + MenuBar.menus.uv.addAction(recalculateUV) + MenuBar.menus.animation.addAction(importAnimations, '#file') + MenuBar.menus.animation.addAction(bakeIK, '#edit') + MenuBar.menus.tools.addAction(optimizeModel) + Toolbars.main_tools.add(cycleVertexOrder) + + toDelete.push( + copyPathModelPart, + copyPathAnimation, + copyPathTextures, + recalculateUV, + importAnimations, + bakeIK, + cycleVertexOrder, + validateFaces, + validateTextureNames, + arbitraryGroupNames, + optimizeModel, + ) + + // Removes the FileName in the Project dialog + ModelProject.properties.name.condition = () => Format != format + + // Removed the Render Order field from the Right Click context menu. + const elementRenderOrderCondition = BarItems.element_render_order.condition + BarItems['element_render_order'].condition = () => Format === format ? false : elementRenderOrderCondition() + + // Change the default name of new Animations from `animation.model.new` to just `new` + const addAnimationClick = BarItems['add_animation'].click + BarItems['add_animation'].click = function () { + if (Format !== format) addAnimationClick.call(this) + else + new Animation({ + name: 'new', + saved: false + }).add(true).propertiesDialog() + } + // Add a popup when clicking on Export Textures to notify new users + const exportAnimationClick = BarItems['export_animation_file'].click + BarItems['export_animation_file'].click = function (...args) { + let button = this + if (Format !== format) { + exportAnimationClick.call(button, ...args) + return + } + new Dialog({ + id: "figura_confirm_export_animation", + title: "Confirm Export Animations", + lines: [ + "

Figura does not read these exported Animation files.

", + "

Figura reads animations directly from the Blockbench file itself.

", + "

The Export Animmations button should only be used when transfering animations from one bbmodel to another.

", + "

Otherwise, do not touch this button.

", + "

Do you understand, and want the exported animations anyways?

" + ], + onConfirm() { + exportAnimationClick.call(button, ...args) + } + }).show() + } + + const name_regex = Group.prototype.name_regex, needsUniqueName = Group.prototype.needsUniqueName + Group.prototype.name_regex = () => (Format === format && arbitraryGroupNames.value) ? false : name_regex(); + Group.prototype.needsUniqueName = () => (Format === format && arbitraryGroupNames.value) ? false : needsUniqueName(); + const showMessageBox = Blockbench.showMessageBox + Blockbench.showMessageBox = function (options, callback) { + if (Format === format && arbitraryGroupNames.value && options.translateKey == "duplicate_groups") return + showMessageBox.apply(this, [options, callback]) + } + + // Remove the Texture Render Mode field from the Right Click context menu. + Texture.prototype.menu.structure.find(v => v.name == 'menu.texture.render_mode').condition = () => Format !== format + // In the Texture Properties Dialog specifically, remove the Render Mode field + const DialogBuild = Dialog.prototype.build + Dialog.prototype.build = function () { + if (Format === format && this.id == 'texture_edit') delete this.form.render_mode + DialogBuild.call(this) + } + + // Prevents the Timeline from erroring when Sound and Particle channels are removed + const displayFrame = EffectAnimator.prototype.displayFrame + EffectAnimator.prototype.displayFrame = function () { + if (Format === format) return + displayFrame.call(this) + } + + // Remove molang validation, as Figura uses Lua not molang + const molangSyntax = Validator.checks.find(element => element.id == 'molang_syntax') + if (molangSyntax) { + let method = molangSyntax.condition.method + molangSyntax.condition.method = (context) => Format === format ? false : (method ? method(context) : false) + } }, onunload() { - BarItems.figura_copy_path?.delete() - BarItems.figura_cycle_vertex_order?.delete() - BarItems.figura_match_texture_size?.delete() - BarItems.figura_import_animations?.delete() - BarItems.figura_bake_ik?.delete() - Validator.checks.find(element => element.id == 'figura_mesh_face_rule')?.delete() - format.delete() + for (const deletable of toDelete) + deletable.delete() } }); From 362ad64901feab90a2d4c509853a1883cf906679 Mon Sep 17 00:00:00 2001 From: KitCat962 Date: Sun, 3 Mar 2024 11:13:26 -0500 Subject: [PATCH 2/5] increment version --- plugins.json | 2 +- plugins/figura_format/figura_format.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins.json b/plugins.json index ad96ff04..a3021bd0 100644 --- a/plugins.json +++ b/plugins.json @@ -870,7 +870,7 @@ "icon": "icon.svg", "description": "Create models for the Figura mod in a custom format that optimizes Blockbench to work with Figura models.", "tags": ["Minecraft: Java Edition", "Figura"], - "version": "0.1.2", + "version": "0.1.3", "min_version": "4.8.0", "variant": "both", "await_loading": true, diff --git a/plugins/figura_format/figura_format.js b/plugins/figura_format/figura_format.js index 0282caf0..e570e781 100644 --- a/plugins/figura_format/figura_format.js +++ b/plugins/figura_format/figura_format.js @@ -56,7 +56,7 @@ icon: "icon.svg", description: "Create models for the Figura mod in a custom format that optimizes Blockbench to work with Figura models.", tags: ["Minecraft: Java Edition", "Figura"], - version: "0.1.2", + version: "0.1.3", min_version: "4.8.0", variant: "both", await_loading: true, From 630bb303375a3594fae7b8cf5bc913e9d9e09aae Mon Sep 17 00:00:00 2001 From: KitCat962 Date: Mon, 11 Mar 2024 12:14:18 -0400 Subject: [PATCH 3/5] implimented Jannis sugggestions --- plugins/figura_format/about.md | 7 +- plugins/figura_format/figura_format.js | 88 ++++---------------------- 2 files changed, 14 insertions(+), 81 deletions(-) diff --git a/plugins/figura_format/about.md b/plugins/figura_format/about.md index d6991946..c944a552 100644 --- a/plugins/figura_format/about.md +++ b/plugins/figura_format/about.md @@ -7,14 +7,9 @@ This Plugin adds a Project Format that will make the following changes to Blockb * Added "Copy X Path" under the Right Click context menu for Cubes, Groups, Meshes, Animations, and Textures. * Copies the full script path as dictated by Figura's scripting API. * Assumes the bbmodel is at the root of the avatar. -* Added "Add Animations..." under Animation. +* Added "Add Animations from .bbmodel..." under Animation. * Allows you to select a bbmodel and imports all animations you select, replacing old animations. * Intended to replace "Export Animations to file, then import file" workflow. -* Added "Bake IK into Animations" under Animation when in the Animate tab. - * Bakes Inverse Kinematics to raw keyframes. Figura cannot parse IK, so baking it to raw rotation keyframes is required to use IK in Figura. -* Added "Cycle Vertex Order" as one of the mesh editing buttons when at least one Face is selected. - * Cycles the vertices of a Quad in order to change how textures are rendered on it when triangulated. - * Will invert the face. Use the "Invert Face" button to fix this. * Added "Allow Duplicate Names" which can be found in the Figura Plugin Settings (File->Plugins->Figura Format->Settings) * Enabling this bypasses the group name restrictions, such as duplicate group names and special characters in group names. * Will break certain Blockbench Animation features. Use at own risk. diff --git a/plugins/figura_format/figura_format.js b/plugins/figura_format/figura_format.js index e570e781..61777ee2 100644 --- a/plugins/figura_format/figura_format.js +++ b/plugins/figura_format/figura_format.js @@ -1,5 +1,4 @@ (function () { - const Path = require('path') const toDelete = [] function isValidLuaIdentifier(str) { @@ -67,7 +66,7 @@ icon: 'change_history', name: 'Figura Model', description: 'Model for the Figura mod.', - category: 'low_poly', + category: 'minecraft', target: ['Figura'], show_on_start_screen: true, box_uv: false, @@ -135,7 +134,9 @@ path.unshift(Project.name || "modelName") path = path.map(getValidLuaIndex) path.unshift('models') - navigator.clipboard.writeText(path.join("")) + let pathString = path.join("") + navigator.clipboard.writeText(pathString) + Blockbench.showQuickMessage(`Coppied "${pathString}" to the clipboard`) } }) const copyPathAnimation = new Action('figura_copy_path_animation', { @@ -147,7 +148,9 @@ let path = [Project.name || "modelName", Animation.selected.name] path = path.map(getValidLuaIndex) path.unshift('animations') - navigator.clipboard.writeText(path.join("")) + let pathString = path.join("") + navigator.clipboard.writeText(pathString) + Blockbench.showQuickMessage(`Coppied "${pathString}" to the clipboard`) } }) const copyPathTextures = new Action('figura_copy_path_texture', { @@ -156,15 +159,16 @@ icon: "fa-clipboard", condition: () => Format === format && (Texture.selected !== null), click() { - let texture = Path.parse(Texture.selected.path) - let project = Path.parse(Project.save_path) - let relative = Path.relative(project.dir, texture.dir) + let texture = PathModule.parse(Texture.selected.path) + let project = PathModule.parse(Project.save_path) + let relative = PathModule.relative(project.dir, texture.dir) let path if (texture.dir == '' || relative.startsWith(`..`)) path = `textures["${Project.name}.${Texture.selected.name.replace(/\.png$/, "")}"]` else - path = `textures["${relative.replace(`\\${Path.sep}`, '.')}${relative == '' ? '' : '.'}${texture.name}"]` + path = `textures["${relative.replace(`\\${PathModule.sep}`, '.')}${relative == '' ? '' : '.'}${texture.name}"]` navigator.clipboard.writeText(path) + Blockbench.showQuickMessage(`Coppied "${path}" to the clipboard`) } }) const recalculateUV = new Action('figura_recalculate_uv', { @@ -269,7 +273,7 @@ } }) const importAnimations = new Action('figura_import_animations', { - name: "Import Animations...", + name: "Import Animations from .bbmodel...", description: "Import animations from another bbmodel", icon: "fa-file-import", condition: { modes: ['animate'], method: () => Format === format }, @@ -351,70 +355,6 @@ }) } }) - const bakeIK = new Action('figura_bake_ik', { - name: "Bake IK into Animations", - description: "Bakes Inverse Kinematics into raw Keyframes for use in Figura", - icon: "fa-bone", - condition: { modes: ['animate'], method: () => Format === format && Animation.selected }, - click() { - new Dialog({ - id: "figura_confirm_bake_ik", - title: "Confirm Bake Inverse Kinematics", - lines: [ - "

This bakes the IK of all NullObjects onto the keyframes of the groups themselves, allowing it to be visible in Figura

", - "

However, NullObjects override the keyframes of the affected groups while it is present

", - "

Leaving the NullObject in the model has no effect on Figura, so just leave it incase you want to rebake the IK later

" - ], - form: { - "all_animations": { - type: 'checkbox', - label: 'Bake all Animations?', - description: "This Action will normally only bake the selected Animation. Do you want to bake all Animations in one swoop?", - value: false, - full_width: false - } - }, - onConfirm() { - const animations = this.getFormResult().all_animations ? Animator.animations : [Animation.selected] - for (const animation of animations) { - let animators = animation.animators - - // Inverse Kinematics - let ik_samples = animation.sampleIK(); - for (let uuid in ik_samples) { - //let group = OutlinerNode.uuids[uuid]; - ik_samples[uuid].forEach((rotation, i) => { - let timecode = i / animation.snapping - let kf = getOrMakeKeyframe(animators[uuid], 'rotation', timecode, animation.snapping) - kf.set('x', rotation.array[0]) - kf.set('y', rotation.array[1]) - kf.set('z', rotation.array[2]) - }) - } - } - } - }).show() - } - }) - const cycleVertexOrder = new Action('figura_cycle_vertex_order', { - name: 'Cycle Vertex Order', - icon: 'fa-sync', - category: 'edit', - condition: { modes: ['edit'], features: ['meshes'], method: () => Format === format && (Mesh.selected[0] && Mesh.selected[0].getSelectedFaces().length) }, - click() { - Undo.initEdit({ elements: Mesh.selected }); - Mesh.selected.forEach(mesh => { - for (const face of mesh.faces) { - if (face.isSelected()) { - if (face.vertices.length < 3) continue; - [face.vertices[0], face.vertices[1], face.vertices[2], face.vertices[3]] = [face.vertices[1], face.vertices[2], face.vertices[3], face.vertices[0]]; - } - } - }) - Undo.finishEdit('Cycle face vertices'); - Canvas.updateView({ elements: Mesh.selected, element_aspects: { geometry: true, uv: true, faces: true } }); - } - }) const optimizeModel = new Action('figura_optimize_model', { name: 'Optimize Model', icon: 'fa-gear', @@ -538,8 +478,6 @@ copyPathTextures, recalculateUV, importAnimations, - bakeIK, - cycleVertexOrder, validateFaces, validateTextureNames, arbitraryGroupNames, From 3420b173c39ae73195b88d3f66a467a933876e2d Mon Sep 17 00:00:00 2001 From: KitCat962 Date: Mon, 11 Mar 2024 12:17:19 -0400 Subject: [PATCH 4/5] removed unused function --- plugins/figura_format/figura_format.js | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/plugins/figura_format/figura_format.js b/plugins/figura_format/figura_format.js index 61777ee2..12a7f072 100644 --- a/plugins/figura_format/figura_format.js +++ b/plugins/figura_format/figura_format.js @@ -36,19 +36,6 @@ return isValidLuaIdentifier(str) ? `.${str}` : `["${str}"]` } - // Stolen from line 92 of timeline_animators.js - function getOrMakeKeyframe(animator, channel, time, snapping = 24) { - let before; - let epsilon = (1 / Math.clamp(snapping, 1, 120)) / 2 || 0.01; - - for (let kf of animator[channel]) { - if (Math.abs(kf.time - time) <= epsilon) { - before = kf; - } - } - return before ? before : animator.createKeyframe(null, time, channel, false, false); - } - BBPlugin.register('figura_format', { title: "Figura Model Format", author: "Katt (KitCat962)", @@ -468,9 +455,7 @@ Texture.prototype.menu.addAction(copyPathTextures, '#properties'); MenuBar.menus.uv.addAction(recalculateUV) MenuBar.menus.animation.addAction(importAnimations, '#file') - MenuBar.menus.animation.addAction(bakeIK, '#edit') MenuBar.menus.tools.addAction(optimizeModel) - Toolbars.main_tools.add(cycleVertexOrder) toDelete.push( copyPathModelPart, From eee4b02316bb2f84581a92c210fcd5c9925b995d Mon Sep 17 00:00:00 2001 From: KitCat962 Date: Mon, 11 Mar 2024 13:52:02 -0400 Subject: [PATCH 5/5] fixed webapp issues --- plugins/figura_format/figura_format.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/plugins/figura_format/figura_format.js b/plugins/figura_format/figura_format.js index 12a7f072..9551b6b8 100644 --- a/plugins/figura_format/figura_format.js +++ b/plugins/figura_format/figura_format.js @@ -146,14 +146,18 @@ icon: "fa-clipboard", condition: () => Format === format && (Texture.selected !== null), click() { - let texture = PathModule.parse(Texture.selected.path) - let project = PathModule.parse(Project.save_path) - let relative = PathModule.relative(project.dir, texture.dir) let path - if (texture.dir == '' || relative.startsWith(`..`)) + if (!isApp) path = `textures["${Project.name}.${Texture.selected.name.replace(/\.png$/, "")}"]` - else - path = `textures["${relative.replace(`\\${PathModule.sep}`, '.')}${relative == '' ? '' : '.'}${texture.name}"]` + else { + let texture = PathModule.parse(Texture.selected.path) + let project = PathModule.parse(Project.save_path) + let relative = PathModule.relative(project.dir, texture.dir) + if (texture.dir == '' || relative.startsWith(`..`)) + path = `textures["${Project.name}.${Texture.selected.name.replace(/\.png$/, "")}"]` + else + path = `textures["${relative.replace(`\\${PathModule.sep}`, '.')}${relative == '' ? '' : '.'}${texture.name}"]` + } navigator.clipboard.writeText(path) Blockbench.showQuickMessage(`Coppied "${path}" to the clipboard`) }