From 2226a7e4e961669f133743f6f6f61e91cd352a4d Mon Sep 17 00:00:00 2001 From: therealarthur Date: Fri, 10 Apr 2026 22:43:50 -0400 Subject: [PATCH] Add Myrlin's Tools v3.0.0 Hytale modding toolkit for Blockbench with: - Box optimizer (spatial-hash O(n) merging) - Art style validator (5 checks against Hytale requirements) - Auto-repair (naming, aspect ratio, hierarchy, node limits, symmetry) - Multi-engine export (Hytale .blockymodel, Blockbench .bbmodel, ModelEngine YAML) - Engine-specific validation (Hytale, ModelEngine, FreeMinecraftModels) - Template wizard (9 starter templates) - Config generator (.zip mod package export) - Model browser (Myrlin gallery integration) No AI features in this plugin (AI add-on available separately via URL). Plugin size: 156KB. Tested on Blockbench 4.8.0+. Website: https://forge.myrlin.io/plugins --- plugins.json | 467 +++- plugins/myrlins_tools.js | 4773 ++++++++++++++++++++++++++++++++ plugins/myrlins_tools/about.md | 54 + 3 files changed, 5192 insertions(+), 102 deletions(-) create mode 100644 plugins/myrlins_tools.js create mode 100644 plugins/myrlins_tools/about.md diff --git a/plugins.json b/plugins.json index 42abb9a43..82ca5d3c2 100644 --- a/plugins.json +++ b/plugins.json @@ -2,7 +2,11 @@ "sam3dj": { "title": "sam3DJ", "author": "1Turtle", - "tags": ["Minecraft: Java Edition", "Format", "Exporter"], + "tags": [ + "Minecraft: Java Edition", + "Format", + "Exporter" + ], "version": "1.1.1", "variant": "both", "description": "Generate 3D prints in MC (Java Edition) via the .3DJ format within the SC-Peripherals mod!", @@ -17,7 +21,11 @@ "await_loading": true, "icon": "icon.svg", "variant": "desktop", - "tags": ["Minecraft: Java Edition", "Animation", "Display Entities"], + "tags": [ + "Minecraft: Java Edition", + "Animation", + "Display Entities" + ], "min_version": "4.12.0", "has_changelog": true }, @@ -31,14 +39,20 @@ "variant": "both", "new_repository_format": true, "website": "https://www.cazfps.com/links", - "tags": ["Minecraft", "Animation", "Tools"] + "tags": [ + "Minecraft", + "Animation", + "Tools" + ] }, "armor_stand_animator": { "title": "Armor Stand Animator", "author": "DoubleFelix", "description": "Provides an interface to animate armor stands which is converted to a data pack", "about": "To start, create the armor stand model by using the \"Create Armor Stand Model\" action in the toolbar. Then, create your animation in the animate tab as normal. Make sure the snapping value for keyframes is set to 20 for best results. When you're finished, use the \"Export Armor Stand Animation\" action in the toolbar. Change the settings as needed, then click \"Confirm\". Animations can be run with \"/execute as @e[tag=entity_tag] run function animation_pack:start\". The armor stand for an animation can be summoned with \"/function animation_pack:create\". Supports looping, time scale, block/blockbench unit scale, pausing and playback, and start delays.", - "tags": ["Minecraft: Java Edition"], + "tags": [ + "Minecraft: Java Edition" + ], "icon": "fa-forward", "version": "1.1.0", "min_version": "4.0.0", @@ -49,7 +63,9 @@ "author": "Command master", "description": "Takes two java item models and outputs a zip with a resourcepack and a datapack to make a clear transaction between them in the players hand (the plugin can also work for other animations but the datapack have to be coded manually).", "about": "The animation is activated using \"/scoreboard players set @s animation 0\".\nTo use click Filter -> Save starting model the save the first model, and then click on File -> Export -> Export animation to download the ZIP file of the animation", - "tags": ["Minecraft: Java Edition"], + "tags": [ + "Minecraft: Java Edition" + ], "icon": "compare", "variant": "both" }, @@ -57,7 +73,10 @@ "title": "Bedrock Entity Model Presets", "author": "Geenium", "description": "Loads Bedrock vanilla entity models up to version 1.13", - "tags": ["Deprecated", "Minecraft: Bedrock Edition"], + "tags": [ + "Deprecated", + "Minecraft: Bedrock Edition" + ], "deprecation_note": "This plugin is no longer updated. To get models for texturing, use File > New > Minecraft Skin. To get Minecraft Bedrock Edition models, use the plugin 'Minecraft Entity Wizard'. To get template models for OptiFine CEM, use the plugin 'CEM Template Loader'.", "icon": "pets", "variant": "both" @@ -75,7 +94,9 @@ "author": "SnaveSutit", "icon": "flip_to_back", "description": "Creates inverted duplicates of the selected cube(s) to allow double-sided rendering in Minecraft: Java Edition.", - "tags": ["Minecraft: Java Edition"], + "tags": [ + "Minecraft: Java Edition" + ], "version": "1.0.1", "variant": "both" }, @@ -135,7 +156,9 @@ "title": "Player Statue Generator", "author": "Wither, dragonmaster95 and 3XH6R", "description": "Generates player shaped models.", - "tags": ["Minecraft: Java Edition"], + "tags": [ + "Minecraft: Java Edition" + ], "icon": "accessibility", "min_version": "3.0.0", "variant": "both" @@ -154,7 +177,9 @@ "author": "JTK222 (Maintainer), Wither (For the original Techne importer), Ocraftyone (VoxelShape improvements)", "icon": "fa-cubes", "description": "Allows importing Tabula files, and exporting VoxelShapes", - "tags": ["Minecraft: Java Edition"], + "tags": [ + "Minecraft: Java Edition" + ], "version": "1.7.1", "variant": "desktop" }, @@ -172,7 +197,9 @@ "icon": "event_seat", "author": "JannisX11", "description": "Preview seat positions, hit/selection boxes, and collision boxes for custom MC Bedrock entities and blocks", - "tags": ["Minecraft: Bedrock Edition"], + "tags": [ + "Minecraft: Bedrock Edition" + ], "version": "1.3.2", "min_version": "3.0.0", "variant": "both" @@ -184,7 +211,9 @@ "description": "Adds a screen space ambient occlusion shader", "about": "Ambient Occlusion is enabled by default. You can adjust the intensity or disable it in the settings.", "variant": "both", - "tags": ["Deprecated"], + "tags": [ + "Deprecated" + ], "deprecation_note": "This plugin can cause visual artifacts and a decreased framerate", "version": "1.0.4", "min_version": "3.4.0" @@ -195,7 +224,9 @@ "author": "JannisX11", "description": "Allows to import and export CraftStudio Models (.csmodel).", "about": "To **import** a model from CraftStudio, go to the CraftStudio project settings and export a cspack file. Open this file in an archive manager (like 7zip) and extract the model file. Import the file into Blockbench using the import menu. \nTo **export** a file, export a .csmodel file from Blockbench and drop it into an existing .cspack file into the Models folder. Make sure it is using the same file name as the old model in the pack. Import the .cspack into CraftStudio and select the models you want to import.", - "tags": ["CraftStudio"], + "tags": [ + "CraftStudio" + ], "variant": "both", "version": "0.1.3", "min_version": "3.2.0" @@ -223,7 +254,11 @@ "icon": "icon.png", "author": "Ewan Howell", "description": "Load template Java Edition entity models for use with OptiFine CEM. Also includes an animation editor, so that you can create custom entity animations.", - "tags": ["Minecraft: Java Edition", "OptiFine", "Templates"], + "tags": [ + "Minecraft: Java Edition", + "OptiFine", + "Templates" + ], "version": "9.1.0", "min_version": "5.0.0", "variant": "both", @@ -238,14 +273,20 @@ "icon": "icon-player", "author": "Ewan Howell", "description": "Adds a new format that allows you to create OptiFine player models.", - "tags": ["Minecraft: Java Edition", "OptiFine", "Player Models"], + "tags": [ + "Minecraft: Java Edition", + "OptiFine", + "Player Models" + ], "version": "1.5.0", "min_version": "5.0.0", "variant": "both", "await_loading": true, "creation_date": "2022-05-29", "contributes": { - "formats": ["optifine_player_model"] + "formats": [ + "optifine_player_model" + ] } }, "colour_gradient_generator": { @@ -253,7 +294,11 @@ "icon": "icon.png", "author": "Ewan Howell", "description": "Generate hue shifted gradient palettes from a single colour.", - "tags": ["Paint", "Palette", "Color"], + "tags": [ + "Paint", + "Palette", + "Color" + ], "version": "2.1.0", "min_version": "5.0.0", "variant": "both", @@ -269,7 +314,10 @@ "author": "Ewan Howell", "description": "Add the ability to collapse the start screen categories.", "about": "This plugin adds a small dropdown arrow next to each category on the start screen, allowing you to collapse each category and hide their respective formats.", - "tags": ["Menu", "Start screen"], + "tags": [ + "Menu", + "Start screen" + ], "version": "1.0.0", "min_version": "4.4.1", "variant": "both" @@ -279,23 +327,30 @@ "icon": "icon.png", "author": "Ewan Howell", "description": "Create your very own preview scenes. Download preview scenes from the Preview Scene Store. Edit and manage existing preview scenes.", - "tags": ["Preview Scenes", "Blockbench"], + "tags": [ + "Preview Scenes", + "Blockbench" + ], "version": "1.2.0", "min_version": "5.0.0", "variant": "both", "creation_date": "2022-10-14", "await_loading": true, "has_changelog": true, - "website": "https://ewanhowell.com/plugins/preview-scene-customiser/", - "repository": "https://github.com/ewanhowell5195/blockbenchPlugins/tree/main/preview-scene-customiser", - "bug_tracker": "https://github.com/ewanhowell5195/blockbenchPlugins/issues/new?title=[Preview Scene Customiser]" + "website": "https://ewanhowell.com/plugins/preview-scene-customiser/", + "repository": "https://github.com/ewanhowell5195/blockbenchPlugins/tree/main/preview-scene-customiser", + "bug_tracker": "https://github.com/ewanhowell5195/blockbenchPlugins/issues/new?title=[Preview Scene Customiser]" }, "splash_art_customiser": { "title": "Splash Art Customiser", "icon": "palette", "author": "Ewan Howell", "description": "Customise the start screen's splash art, by adding your own!", - "tags": ["Splash art", "Start screen", "Blockbench"], + "tags": [ + "Splash art", + "Start screen", + "Blockbench" + ], "version": "1.1.4", "min_version": "5.0.0", "variant": "both", @@ -306,7 +361,10 @@ "icon": "icon.png", "author": "Ewan Howell", "description": "Enhance the GIF recorder by adding new formats for recording your model. Replace the built-in GIF format with a higher quality one.", - "tags": ["Recording", "Media"], + "tags": [ + "Recording", + "Media" + ], "version": "2.1.1", "min_version": "5.0.0", "variant": "desktop", @@ -321,7 +379,11 @@ "icon": "icon.png", "author": "Ewan Howell", "description": "View the project backups from the Blockbench start screen.", - "tags": ["Backups", "Start screen", "Blockbench"], + "tags": [ + "Backups", + "Start screen", + "Blockbench" + ], "version": "2.1.0", "min_version": "5.0.0", "variant": "desktop", @@ -337,7 +399,11 @@ "author": "Ewan Howell", "description": "Track how long you spend using Blockbench and working on each project.", "about": "This plugin allows you to keep track of how much time you spend using Blockbench and how long you spend working on each project.\n\nTo view your activity stats, go to `Tools > Activity Tracker`. From here you can see all your stats for both Blockbench and the current project.", - "tags": ["Tracking", "Stats", "Utility"], + "tags": [ + "Tracking", + "Stats", + "Utility" + ], "version": "1.1.1", "min_version": "4.8.0", "variant": "both", @@ -352,14 +418,20 @@ "icon": "icon.png", "author": "Ewan Howell", "description": "Create Minecraft-styled title models!", - "tags": ["Minecraft", "Title", "Logo"], + "tags": [ + "Minecraft", + "Title", + "Logo" + ], "version": "1.10.3", "min_version": "5.0.0", "variant": "both", "creation_date": "2023-06-10", "await_loading": true, "contributes": { - "formats": ["minectaft_title"] + "formats": [ + "minectaft_title" + ] }, "has_changelog": true, "website": "https://ewanhowell.com/plugins/minecraft-title-generator/", @@ -371,7 +443,11 @@ "icon": "icon.png", "author": "Ewan Howell", "description": "Organise your recent projects into workspaces that you can switch between.", - "tags": ["Files", "Management", "Blockbench"], + "tags": [ + "Files", + "Management", + "Blockbench" + ], "version": "1.0.0", "min_version": "4.8.0", "variant": "desktop", @@ -382,7 +458,11 @@ "icon": "icon.png", "author": "Ewan Howell", "description": "Edit plugins and themes live in any text editor and have them automatically update in Blockbench.", - "tags": ["Plugins", "Themes", "Blockbench"], + "tags": [ + "Plugins", + "Themes", + "Blockbench" + ], "version": "1.1.0", "min_version": "5.0.0", "variant": "desktop", @@ -397,7 +477,11 @@ "icon": "icon.png", "author": "Ewan Howell", "description": "A collection of utilities to assist with resource pack creation.", - "tags": ["Minecraft: Java Edition", "Resource Packs", "Utilities"], + "tags": [ + "Minecraft: Java Edition", + "Resource Packs", + "Utilities" + ], "version": "1.9.2", "min_version": "5.0.2", "variant": "desktop", @@ -412,7 +496,11 @@ "icon": "icon.png", "author": "Ewan Howell", "description": "Browse the Minecraft assets from within Blockbench.", - "tags": ["Minecraft", "Assets", "Browser"], + "tags": [ + "Minecraft", + "Assets", + "Browser" + ], "version": "1.2.2", "min_version": "5.0.0", "variant": "desktop", @@ -427,7 +515,11 @@ "icon": "icon.png", "author": "Godlander & Ewan Howell", "description": "Create Java Item models without any rotation limitations.", - "tags": ["Minecraft: Java Edition", "Items", "Rotation"], + "tags": [ + "Minecraft: Java Edition", + "Items", + "Rotation" + ], "version": "1.2.1", "min_version": "5.0.0", "variant": "desktop", @@ -502,17 +594,26 @@ "icon": "icon.png", "version": "0.8.5", "description": "Create models and animations for Hytale", - "tags": ["Hytale"], + "tags": [ + "Hytale" + ], "contributes": { - "formats": ["hytale_character", "hytale_prop"], - "open_extensions": ["blockymodel"] + "formats": [ + "hytale_character", + "hytale_prop" + ], + "open_extensions": [ + "blockymodel" + ] }, "variant": "both", "min_version": "5.0.5", "creation_date": "2025-12-22", "await_loading": true, "has_changelog": true, - "contributors": ["Hedaox"], + "contributors": [ + "Hedaox" + ], "repository": "https://github.com/JannisX11/hytale-blockbench-plugin", "bug_tracker": "https://github.com/JannisX11/hytale-blockbench-plugin/issues" }, @@ -521,7 +622,9 @@ "author": "Eliot Lash", "icon": "icon-format_java", "description": "Plugin for exporting Modded Entities using Fabric/Yarn Sourcemap", - "tags": ["Minecraft: Java Edition"], + "tags": [ + "Minecraft: Java Edition" + ], "version": "0.2.2", "min_version": "3.6.6", "variant": "both" @@ -531,7 +634,9 @@ "icon": "layers", "author": "aidancbrady", "description": "Allows exporting in Forge's multi-layer model format.", - "tags": ["Minecraft: Java Edition"], + "tags": [ + "Minecraft: Java Edition" + ], "version": "1.0", "variant": "both" }, @@ -574,7 +679,9 @@ "description": "Stitch multiple textures into a single texture", "version": "1.0.7", "min_version": "4.8.0", - "tags": ["Texture"], + "tags": [ + "Texture" + ], "variant": "both", "has_changelog": true }, @@ -583,7 +690,9 @@ "icon": "icon-format_block", "author": "Arcaniax", "description": "Helps making new Bedrock blocks (requires experimental mode)", - "tags": ["Minecraft: Bedrock Edition"], + "tags": [ + "Minecraft: Bedrock Edition" + ], "variant": "desktop", "version": "0.1.1" }, @@ -609,7 +718,9 @@ "icon": "icon.png", "author": "JannisX11", "description": "Adds multiple sliders to tweak keyframes", - "tags": ["Animation"], + "tags": [ + "Animation" + ], "version": "0.5.0", "min_version": "4.8.0", "variant": "both" @@ -621,7 +732,9 @@ "description": "Generates Voxel Shapes (Supports Mojang Mappings and MCP)", "version": "0.2.0", "variant": "both", - "tags": ["Minecraft: Java Edition"] + "tags": [ + "Minecraft: Java Edition" + ] }, "voxel_shape_fabric_generator": { "title": "VoxelShape Fabric Generator", @@ -630,14 +743,18 @@ "description": "Generates Voxel Shapes for Fabric", "version": "1.0.0", "variant": "both", - "tags": ["Minecraft: Java Edition"] + "tags": [ + "Minecraft: Java Edition" + ] }, "mimodel_format": { "title": "Mine-imator Model Exporter", "icon": "fas.fa-box-open", "author": "JannisX11", "description": "Export .mimodel files for Mine-imator and Modelbench", - "tags": ["Exporter"], + "tags": [ + "Exporter" + ], "version": "1.0.2", "min_version": "3.7.0", "variant": "both" @@ -647,7 +764,9 @@ "author": "JannisX11 & Mojang Studios", "icon": "icon.png", "description": "Create entities for Minecraft: Bedrock Edition! Start with the looks and behavior of a vanilla entity, and turn it into your own creation!", - "tags": ["Minecraft: Bedrock Edition"], + "tags": [ + "Minecraft: Bedrock Edition" + ], "version": "1.8.2", "min_version": "4.8.0", "variant": "both", @@ -660,7 +779,9 @@ "author": "JannisX11 & Mojang Studios", "icon": "icon.png", "description": "Create custom blocks for Minecraft: Bedrock Edition!", - "tags": ["Minecraft: Bedrock Edition"], + "tags": [ + "Minecraft: Bedrock Edition" + ], "version": "1.6.1", "min_version": "4.8.0", "variant": "both", @@ -673,7 +794,9 @@ "author": "JannisX11 & Mojang Studios", "icon": "icon.png", "description": "Create custom items for Minecraft: Bedrock Edition!", - "tags": ["Minecraft: Bedrock Edition"], + "tags": [ + "Minecraft: Bedrock Edition" + ], "version": "1.2.4", "min_version": "4.8.0", "variant": "both", @@ -704,7 +827,9 @@ "icon": "icon-format_java", "description": "Exports Blockbench animations as the JSON format for Endimator animations from the Blueprint Mod Library. https://github.com/team-abnormals/blueprint", "about": "Go to Animation -> Export Endimations...", - "tags": ["Minecraft: Java Edition"], + "tags": [ + "Minecraft: Java Edition" + ], "version": "1.0.0", "min_version": "4.0.0", "variant": "both" @@ -737,7 +862,11 @@ "variant": "both", "creation_date": "2022-04-09", "has_changelog": true, - "tags": ["Format: Generic Model", "Mesh", "Tool"] + "tags": [ + "Format: Generic Model", + "Mesh", + "Tool" + ] }, "wasd_controls": { "title": "WASD Controls", @@ -755,7 +884,10 @@ "icon": "icon-player", "description": "Create Skin Packs for the Minecraft Marketplace", "about": "Generates skin packs for the Minecraft Marketplace.\n\nAll textures in all open tabs count as skins. You can load individual skins into Blockbench via **File** > **Import Minecraft Skins**.\n\nYou can also create new skins via **New** > **Minecraft Skin**, in the File menu or on the start screen.\n\nExport a skin pack via **File** > **Export** > **Export Skin Pack**.", - "tags": ["Minecraft: Bedrock Edition", "Minecraft Marketplace"], + "tags": [ + "Minecraft: Bedrock Edition", + "Minecraft Marketplace" + ], "version": "0.3.2", "min_version": "4.2.3", "variant": "desktop" @@ -768,7 +900,9 @@ "version": "1.1.2", "variant": "both", "min_version": "4.3.0", - "tags": ["Minecraft: Java Edition"], + "tags": [ + "Minecraft: Java Edition" + ], "about": "Tint Preview is a tool to help designers who work with the tint face feature of Java Block/Item models. It features the ability to quickly preview a color on tint enabled faces. Tint Preview adds two new actions to the Tools menu that allows you to toggle tinting and change the tint color.\n**Important:** This plugin is designed for Java Block/Item models only and will not work for other formats." }, "pie_menu": { @@ -779,7 +913,9 @@ "about": "

Built-in Pies

Add Pie Keybind:

ctrl + shift + a

Import Pie Keybind:

shift + q

Export Pie Keybind:

shift + w

Mode Pie Keybind:

shift + tab (web: shift + x)

View Mode Pie Keybind:

alt + z

Toolbox Pie Keybind:

shift + t

New Pie Keybind:

shift + n

Preferences Pie Keybind:

shift + o

Rotate Pie Keybind:

shift + r

Flip Pie Keybind:

shift + f

Center Pie Keybind:

shift + c

Properties Pie Keybind:

shift + p

API

CSS Variables

NameDefaultDescription
--pie-dir_idct_size10%sets the angle indicator size
--pie-major_radius30pxsets the center pie's radius
--pie-major_thickness22pxsets the center pie's thickness
", "version": "1.0.0", "variant": "both", - "tags": ["Interface"] + "tags": [ + "Interface" + ] }, "quick_box_uv_layout": { "title": "Quick Box-UV Layout", @@ -800,7 +936,10 @@ "variant": "both", "about": "This plugin exports your Blockbench animations as Java code to be used for the new 1.19+ keyframe system. Please note that this system does not support Molang or step interpolation.", "deprecation_note": "This feature is now built into Blockbench, the plugin is no longer required.", - "tags": ["Deprecated", "Minecraft: Java Edition"] + "tags": [ + "Deprecated", + "Minecraft: Java Edition" + ] }, "root_motion_extractor": { "title": "Root Motion Extractor", @@ -808,7 +947,11 @@ "author": "Tschipp", "description": "Attempts to extract root motion from an animation", "about": "This plugin extracts the velocity in m/s for an animation, and provides a value to be used in a movement component to achieve that exact speed. This helps reduce/eliminate foot sliding.", - "tags": ["Minecraft: Bedrock Edition", "Animation", "Root Motion"], + "tags": [ + "Minecraft: Bedrock Edition", + "Animation", + "Root Motion" + ], "version": "1.1.0", "variant": "both" }, @@ -818,7 +961,11 @@ "author": "SirJain", "description": "Edits the opacity and color of the ground plane feature in Blockbench.", "about": "This simple plugin allows you to customize the ground plane feature in Blockbench; more specifically, the opacity and color.\n## How to use\nTo use this plugin, simply go to `Tools > Ground Plane Editor`, fill out the appropriate categories, and hit `Done`. You can choose to edit either the color, the opacity, or both!\n\nPlease report any bugs or suggestions you may have.", - "tags": ["Ground Plane", "Animation", "Customization"], + "tags": [ + "Ground Plane", + "Animation", + "Customization" + ], "version": "1.1.1", "min_version": "4.2.0", "variant": "both" @@ -829,7 +976,11 @@ "author": "SirJain and Geode", "description": "Allows users to add their own marker colors.", "about": "To make a new custom marker, go to Tools > Marker Colors > Add Custom Color to get started with making your own custom marker color. Once you are done, click Confirm. You will now see your marker color in the default color list. And finally, if you ever choose to view or edit your marker colors, you can use Tools > Marker Colors > Manage Marker Colors", - "tags": ["Marker Color", "Customize", "UX"], + "tags": [ + "Marker Color", + "Customize", + "UX" + ], "version": "1.1.0", "min_version": "4.2.0", "variant": "both" @@ -839,7 +990,11 @@ "author": "leopoldhub", "description": "Export your java models and texture into a resourcepack", "icon": "fa-file-zipper", - "tags": ["Minecraft: Java Edition", "Resourcepack", "Exporter"], + "tags": [ + "Minecraft: Java Edition", + "Resourcepack", + "Exporter" + ], "version": "1.0.1", "min_version": "4.4.0", "variant": "both" @@ -849,7 +1004,9 @@ "author": "JannisX11", "description": "Adds a Grayscale preview mode. The mode can be toggled from the View menu.", "icon": "filter_b_and_w", - "tags": ["Viewport"], + "tags": [ + "Viewport" + ], "version": "1.0.0", "min_version": "4.3.0", "variant": "both" @@ -862,14 +1019,21 @@ "version": "1.0.1", "min_version": "4.4.0", "variant": "both", - "tags": ["Paint", "Customization", "Interface"] + "tags": [ + "Paint", + "Customization", + "Interface" + ] }, "smudge_brush": { "title": "Smudge Brush", "icon": "touch_app", "author": "JannisX11", "description": "Adds a smudge brush to smear, soften, or blur details on textures and images", - "tags": ["Brush", "Paint"], + "tags": [ + "Brush", + "Paint" + ], "version": "1.0.0", "min_version": "4.8.0", "creation_date": "2025-12-27", @@ -882,7 +1046,11 @@ "description": "View download statistics of your blockbench plugin", "version": "1.3.0", "variant": "both", - "tags": ["Blockbench", "Plugins", "Development"] + "tags": [ + "Blockbench", + "Plugins", + "Development" + ] }, "repeat_it": { "title": "Repeat It", @@ -907,7 +1075,10 @@ "title": "Image Centering", "icon": "center_focus_strong", "author": "SirJain", - "tags": ["Format: Image", "UX"], + "tags": [ + "Format: Image", + "UX" + ], "description": "Adds a button and a keybind that centers the image viewport in an Image format.", "about": "This plugin adds a button that allows you to reset your viewport in the Image format.\n## How to use\nTo use this plugin, go to the brush toolbar (top of the image mode) and click the `Center Image Viewport` button. If it doesn't show up, make sure you are in the Image format. If you are, you can add the tool using the `Customize Toolbar` option. There will be a confirmation message displayed on-screen once you center the viewport.\n\nPlease report any bugs or suggestions you may have.", "version": "1.1.1", @@ -921,7 +1092,10 @@ "description": "Adds actions to export/import models in BBS format, which is used by BBS mod.", "version": "1.3.1", "variant": "both", - "tags": ["Exporter", "Importer"], + "tags": [ + "Exporter", + "Importer" + ], "min_version": "4.8.0", "has_changelog": true }, @@ -932,7 +1106,9 @@ "description": "Generates Shape JSONs for the Highlight mod. Mod can be found at: https://links.resourcefulbees.com/highlight", "version": "1.0.0", "variant": "both", - "tags": ["Minecraft: Java Edition"] + "tags": [ + "Minecraft: Java Edition" + ] }, "obj_animation_export": { "title": "OBJ Animation Exporter", @@ -943,7 +1119,9 @@ "version": "0.1.0", "min_version": "4.0.0", "variant": "both", - "tags": ["Exporter"] + "tags": [ + "Exporter" + ] }, "explorer": { "title": "Explorer", @@ -951,7 +1129,9 @@ "author": "JannisX11", "description": "Navigate the files in your project from the sidebar in Blockbench!", "about": "Use the folder icon in the left corner of the tab bar to open the explorer. Click files to peak into them, double click to jump into the file. Right click a file to bring up the context menu.", - "tags": ["Files"], + "tags": [ + "Files" + ], "version": "1.0.5", "min_version": "4.6.0", "variant": "desktop" @@ -972,7 +1152,11 @@ "author": "SirJain", "description": "Generates blocky text in cubes.", "about": "This plugin adds a button under the `Tools` menu that allows you to generate Minecraft-like text.\n## How to use\nTo use this plugin, go to `Tools > Generate Text`. Simply enter some text, configure your settings how you like, and press `Generate`!\n\nPlease report any bugs or suggestions you may have.", - "tags": ["Deprecated", "Minecraft", "Font"], + "tags": [ + "Deprecated", + "Minecraft", + "Font" + ], "version": "2.0.1", "min_version": "4.2.0", "variant": "both" @@ -983,7 +1167,11 @@ "author": "SirJain and DerfX", "description": "Flashes elements based on template conditions.", "about": "This plugin allows you to flash elements that meet certain preset conditions on-screen.\\n## How to use\\nTo use this plugin, simply go to `Tools -> Utility Flaggers`. Pick a preset option, fill out your preferred settings, and press `Flag`!", - "tags": ["Utility", "BoxUV", "Format: Generic Model"], + "tags": [ + "Utility", + "BoxUV", + "Format: Generic Model" + ], "version": "1.1.0", "min_version": "4.2.0", "variant": "both" @@ -994,18 +1182,23 @@ "author": "oectway", "description": "Tool that allows you to mirror the arms and legs of existing skins to the other side.", "about": "This plugin adds a button under the `Tools` menu that allows you to mirror the arms and legs of existing skins to the other side. Select the type of the skin: Alek (3 Pixel Arm, customSlim) or Steve (4 Pixel Arm, custom). Next, choose whether you want to mirror from left to right, or the other way around. If everything is correct, click \"Confirm\".", - "tags": ["Minecraft", "Skins"], + "tags": [ + "Minecraft", + "Skins" + ], "version": "1.1.0", "min_version": "4.6.0", "variant": "both" }, - "bamo":{ + "bamo": { "title": "BAMO Exporter", "author": "Ryytikki", "description": "Create custom Minecraft blocks without having to write a single line of code when paired with the BAMO mod.", "website": "https://www.curseforge.com/minecraft/mc-mods/bamo-block-and-move-on", "icon": "icon.png", - "tags": ["Minecraft: Java Edition"], + "tags": [ + "Minecraft: Java Edition" + ], "version": "0.5.1", "variant": "desktop", "new_repository_format": true @@ -1015,7 +1208,10 @@ "author": "Gaming32", "description": "Converts Blockbench animations to the JSON format for the NeoForge mod loader", "icon": "fa-cube", - "tags": ["Animation", "Minecraft: Java Edition"], + "tags": [ + "Animation", + "Minecraft: Java Edition" + ], "variant": "both", "version": "1.0.1" }, @@ -1024,7 +1220,9 @@ "author": "AzureDoom, Eliot Lash, Gecko, McHorse", "icon": "azureicon.png", "description": "Create animated blocks, items, entity, and armor using the AzureLib library and plugin. This is a fork of Geckolib and cross compatibility will not be promised in the future.", - "tags": ["Minecraft: Java Edition"], + "tags": [ + "Minecraft: Java Edition" + ], "version": "2.0.0", "min_version": "4.8.0", "new_repository_format": true, @@ -1032,7 +1230,9 @@ "variant": "both", "creation_date": "2023-04-04", "contributes": { - "formats": ["azure_model"] + "formats": [ + "azure_model" + ] }, "has_changelog": true, "website": "https://wiki.azuredoom.com/" @@ -1042,7 +1242,9 @@ "author": "SirJain", "icon": "swap_horiz", "description": "Adds a button that inverts selected cube sizes.", - "tags": ["Deprecated"], + "tags": [ + "Deprecated" + ], "about": "This plugin adds a button that inverts the size values of each selected cube.\n## How to use\nTo use this plugin, go to the toolbar at the top of the screen (the one with the move, resize, and rotation tools) and click the `Invert Cubes` button. If you don't see the button, make sure to select some cubes. The button is also keybinded with a default of `Shift + I`.\n\nPlease report any bugs or suggestions you may have.", "version": "1.0.0", "min_version": "4.2.0", @@ -1053,7 +1255,10 @@ "author": "Katt (KitCat962)", "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"], + "tags": [ + "Minecraft: Java Edition", + "Figura" + ], "version": "0.1.3", "min_version": "4.8.0", "variant": "both", @@ -1065,7 +1270,9 @@ "author": "JannisX11", "icon": "icon.png", "description": "The rainbow road racing minigame from April fools day 2024! Turn your model into a race car and take it for a spin!", - "tags": ["Minigame"], + "tags": [ + "Minigame" + ], "version": "0.1.1", "min_version": "4.8.0", "variant": "both", @@ -1076,7 +1283,9 @@ "author": "Z. Hoeshin", "icon": "icon.png", "description": "Allows creating, editing, importing and exporting Cosmic Reach block models.", - "tags": ["Cosmic Reach"], + "tags": [ + "Cosmic Reach" + ], "version": "2.2.0", "min_version": "4.8.0", "variant": "both", @@ -1089,11 +1298,17 @@ "author": "Traben & Ewan Howell", "icon": "icon.png", "description": "Adds extra animation support to CEM Template Loader so that it is compatible with the Entity Model Features mod.", - "tags": ["Minecraft: Java Edition", "Entity Models", "Animation"], + "tags": [ + "Minecraft: Java Edition", + "Entity Models", + "Animation" + ], "version": "1.0.4", "min_version": "4.9.0", "variant": "both", - "dependencies": ["cem_template_loader"], + "dependencies": [ + "cem_template_loader" + ], "creation_date": "2024-06-05", "has_changelog": true }, @@ -1114,7 +1329,9 @@ "version": "1.0.0", "min_version": "4.9.0", "variant": "both", - "tags": ["Minecraft: Bedrock Edition"] + "tags": [ + "Minecraft: Bedrock Edition" + ] }, "pbr_preview": { "title": "PBR Tools", @@ -1147,7 +1364,9 @@ "variant": "both", "version": "1.1.0", "min_version": "4.8.0", - "tags": ["Blockbench"] + "tags": [ + "Blockbench" + ] }, "meshy": { "title": "Meshy", @@ -1242,7 +1461,11 @@ "title": "Export to 3MF", "author": "Ayden Hodgins-de Jonge", "description": "Export geometry as 3MF for 3D printing", - "tags": ["Exporter", "3D Printing", "3MF"], + "tags": [ + "Exporter", + "3D Printing", + "3MF" + ], "icon": "icon.png", "variant": "both", "version": "0.0.1", @@ -1294,20 +1517,27 @@ "variant": "both", "min_version": "4.12.4", "has_changelog": false, - "tags": ["Texture'", "Viewport", "Utility"], + "tags": [ + "Texture'", + "Viewport", + "Utility" + ], "repository": "https://github.com/JannisX11/blockbench-plugins/tree/master/plugins/texture_filtering" }, "transparency_fix": { "title": "Transparency Fix", "author": "Larsonix", - "description": "Shows objects inside transparent containers (glass, windows, etc.)", - "about": "This plugin fixes the visibility of objects inside transparent containers by adjusting render order.\n\n## Features\n- Auto-detects transparent textures by scanning alpha values\n- Name-based detection (glass, window, crystal, ice, liquid, glow, etc.)\n- Geometry detection - finds cubes inside transparent containers\n\n## Usage\nGo to **View > Toggle Transparency Fix** to enable/disable.", - "icon": "visibility", - "version": "2.1.0", - "min_version": "4.0.0", - "variant": "both", - "tags": ["Rendering", "Utility"] - }, + "description": "Shows objects inside transparent containers (glass, windows, etc.)", + "about": "This plugin fixes the visibility of objects inside transparent containers by adjusting render order.\n\n## Features\n- Auto-detects transparent textures by scanning alpha values\n- Name-based detection (glass, window, crystal, ice, liquid, glow, etc.)\n- Geometry detection - finds cubes inside transparent containers\n\n## Usage\nGo to **View > Toggle Transparency Fix** to enable/disable.", + "icon": "visibility", + "version": "2.1.0", + "min_version": "4.0.0", + "variant": "both", + "tags": [ + "Rendering", + "Utility" + ] + }, "translation_plane_gizmo": { "title": "Translation Plane Gizmo", "author": "AnnJ", @@ -1316,7 +1546,11 @@ "version": "1.0.1", "min_version": "4.8.0", "variant": "both", - "tags": ["Gizmo", "Translation", "Tools"], + "tags": [ + "Gizmo", + "Translation", + "Tools" + ], "repository": "https://github.com/JannisX11/blockbench-plugins/tree/master/plugins/translation_plane_gizmo" }, "gltf_importer": { @@ -1340,7 +1574,11 @@ "icon": "icon.png", "author": "Jatzylap", "description": "Exports an animation into a sequence of Minecraft block/item models.", - "tags": ["Minecraft: Java Edition", "Exporter", "Animation"], + "tags": [ + "Minecraft: Java Edition", + "Exporter", + "Animation" + ], "version": "1.0.0", "min_version": "5.0.5", "variant": "both", @@ -1368,7 +1606,7 @@ "block_multi_collisions": { "title": "Block Multi-Collision Editor", "author": "minato4743", - "description": "This Plugin allow you to make and generate multiple collision boxes for Minecraft Bedrock blocks.", + "description": "This Plugin allow you to make and generate multiple collision boxes for Minecraft Bedrock blocks.", "icon": "icon.svg", "version": "1.0.0", "variant": "both", @@ -1385,7 +1623,9 @@ "author": "PasteDev", "icon": "icon.png", "description": "Loads Hytale avatar models with textures and colors from local JSON files or crafthead.net. Automatically applies gradient maps to greyscale textures based on color selections and imports default animations.", - "tags": ["Hytale"], + "tags": [ + "Hytale" + ], "version": "1.2.0", "min_version": "5.0.7", "variant": "desktop", @@ -1394,7 +1634,7 @@ "has_changelog": true, "repository": "https://github.com/PasteDev/blockbench-plugins/tree/master/plugins/hytale_avatar_loader" }, - "hytale_hitbox_helper": { + "hytale_hitbox_helper": { "title": "Hytale Hitbox Helper", "author": "Marck.A.A", "version": "1.0.1", @@ -1402,11 +1642,13 @@ "icon": "icon.png", "variant": "both", "min_version": "4.8.0", - "tags": ["Hytale"] + "tags": [ + "Hytale" + ] }, "baked_ambient_occlusion": { "title": "Mr Salmon's Baked Ambient Occlusion", - "author": "Kai Salmon", + "author": "Kai Salmon", "description": "Baked Ambient Occlusion, creating instant shading", "icon": "icon.png", "version": "1.0.0", @@ -1425,8 +1667,12 @@ "icon": "icon.png", "version": "0.1.1", "description": "A Hytale/Blockbench plugin that bridges the two together seamlessly and effortlessly", - "tags": ["Hytale"], - "dependencies": ["hytale_plugin"], + "tags": [ + "Hytale" + ], + "dependencies": [ + "hytale_plugin" + ], "variant": "desktop", "min_version": "5.0.7", "creation_date": "2026-02-10", @@ -1434,5 +1680,22 @@ "has_changelog": true, "repository": "https://github.com/tazercopter/Hytale-Blockbench-Bridge", "bug_tracker": "https://github.com/tazercopter/Hytale-Blockbench-Bridge/issues" + }, + "myrlins_tools": { + "title": "Myrlins Tools", + "author": "Myrlin", + "description": "Hytale modding toolkit with box optimizer, art style validator, auto-repair, multi-engine export, template wizard, and model browser.", + "icon": "auto_fix_high", + "version": "3.0.0", + "min_version": "4.8.0", + "variant": "both", + "tags": [ + "Hytale", + "Optimization", + "Validation", + "Export" + ], + "has_changelog": true, + "website": "https://forge.myrlin.io/plugins" } -} +} \ No newline at end of file diff --git a/plugins/myrlins_tools.js b/plugins/myrlins_tools.js new file mode 100644 index 000000000..5c5f317d7 --- /dev/null +++ b/plugins/myrlins_tools.js @@ -0,0 +1,4773 @@ +var MyrlinsTools = (() => { + // src/utils/cleanup.ts + var trackedItems = []; + var trackedEvents = []; + var trackedStyles = []; + function track(item) { + trackedItems.push(item); + return item; + } + function trackStyle(css) { + const style = document.createElement("style"); + style.textContent = css; + document.head.appendChild(style); + trackedStyles.push(style); + return style; + } + function cleanup() { + for (const item of trackedItems) { + try { + item.delete(); + } catch (e) { + console.warn("[Myrlin Tools] Failed to cleanup item:", e); + } + } + trackedItems.length = 0; + for (const { target, event, handler } of trackedEvents) { + try { + target.removeEventListener(event, handler); + } catch (e) { + console.warn("[Myrlin Tools] Failed to remove event listener:", e); + } + } + trackedEvents.length = 0; + for (const style of trackedStyles) { + try { + style.remove(); + } catch (e) { + console.warn("[Myrlin Tools] Failed to remove style:", e); + } + } + trackedStyles.length = 0; + } + + // src/utils/geometry.ts + function getCubeBounds(cube) { + return { + min: { x: cube.from[0], y: cube.from[1], z: cube.from[2] }, + max: { x: cube.to[0], y: cube.to[1], z: cube.to[2] } + }; + } + function getVolume(bounds) { + return (bounds.max.x - bounds.min.x) * (bounds.max.y - bounds.min.y) * (bounds.max.z - bounds.min.z); + } + function combineBounds(a, b) { + return { + min: { + x: Math.min(a.min.x, b.min.x), + y: Math.min(a.min.y, b.min.y), + z: Math.min(a.min.z, b.min.z) + }, + max: { + x: Math.max(a.max.x, b.max.x), + y: Math.max(a.max.y, b.max.y), + z: Math.max(a.max.z, b.max.z) + } + }; + } + function sharesCompleteFace(a, b) { + const tolerance = 1e-3; + if (Math.abs(a.max.x - b.min.x) < tolerance || Math.abs(b.max.x - a.min.x) < tolerance) { + const yOverlap = Math.min(a.max.y, b.max.y) - Math.max(a.min.y, b.min.y); + const zOverlap = Math.min(a.max.z, b.max.z) - Math.max(a.min.z, b.min.z); + const aYSize = a.max.y - a.min.y; + const aZSize = a.max.z - a.min.z; + const bYSize = b.max.y - b.min.y; + const bZSize = b.max.z - b.min.z; + if (Math.abs(aYSize - bYSize) < tolerance && Math.abs(aZSize - bZSize) < tolerance && Math.abs(yOverlap - aYSize) < tolerance && Math.abs(zOverlap - aZSize) < tolerance) { + return true; + } + } + if (Math.abs(a.max.y - b.min.y) < tolerance || Math.abs(b.max.y - a.min.y) < tolerance) { + const xOverlap = Math.min(a.max.x, b.max.x) - Math.max(a.min.x, b.min.x); + const zOverlap = Math.min(a.max.z, b.max.z) - Math.max(a.min.z, b.min.z); + const aXSize = a.max.x - a.min.x; + const aZSize = a.max.z - a.min.z; + const bXSize = b.max.x - b.min.x; + const bZSize = b.max.z - b.min.z; + if (Math.abs(aXSize - bXSize) < tolerance && Math.abs(aZSize - bZSize) < tolerance && Math.abs(xOverlap - aXSize) < tolerance && Math.abs(zOverlap - aZSize) < tolerance) { + return true; + } + } + if (Math.abs(a.max.z - b.min.z) < tolerance || Math.abs(b.max.z - a.min.z) < tolerance) { + const xOverlap = Math.min(a.max.x, b.max.x) - Math.max(a.min.x, b.min.x); + const yOverlap = Math.min(a.max.y, b.max.y) - Math.max(a.min.y, b.min.y); + const aXSize = a.max.x - a.min.x; + const aYSize = a.max.y - a.min.y; + const bXSize = b.max.x - b.min.x; + const bYSize = b.max.y - b.min.y; + if (Math.abs(aXSize - bXSize) < tolerance && Math.abs(aYSize - bYSize) < tolerance && Math.abs(xOverlap - aXSize) < tolerance && Math.abs(yOverlap - aYSize) < tolerance) { + return true; + } + } + return false; + } + function canMergeToBox(a, b) { + const combined = combineBounds(a, b); + const combinedVolume = getVolume(combined); + const volumeA = getVolume(a); + const volumeB = getVolume(b); + const tolerance = 1e-3; + return Math.abs(combinedVolume - (volumeA + volumeB)) < tolerance; + } + + // src/optimizer/box_merge.ts + var SpatialHashGrid = class { + constructor(cellSize = 16) { + this.cellSize = cellSize; + this.grid = /* @__PURE__ */ new Map(); + this.cubeKeys = /* @__PURE__ */ new Map(); + } + getKey(x, y, z) { + const cx = Math.floor(x / this.cellSize); + const cy = Math.floor(y / this.cellSize); + const cz = Math.floor(z / this.cellSize); + return `${cx},${cy},${cz}`; + } + getCellsForBounds(bounds) { + const keys = []; + const minCx = Math.floor(bounds.min.x / this.cellSize); + const minCy = Math.floor(bounds.min.y / this.cellSize); + const minCz = Math.floor(bounds.min.z / this.cellSize); + const maxCx = Math.floor(bounds.max.x / this.cellSize); + const maxCy = Math.floor(bounds.max.y / this.cellSize); + const maxCz = Math.floor(bounds.max.z / this.cellSize); + for (let cx = minCx; cx <= maxCx; cx++) { + for (let cy = minCy; cy <= maxCy; cy++) { + for (let cz = minCz; cz <= maxCz; cz++) { + keys.push(`${cx},${cy},${cz}`); + } + } + } + return keys; + } + insert(cube, bounds) { + const keys = this.getCellsForBounds(bounds); + this.cubeKeys.set(cube, keys); + for (const key of keys) { + if (!this.grid.has(key)) { + this.grid.set(key, /* @__PURE__ */ new Set()); + } + this.grid.get(key).add(cube); + } + } + remove(cube) { + const keys = this.cubeKeys.get(cube); + if (!keys) + return; + for (const key of keys) { + const cell = this.grid.get(key); + if (cell) { + cell.delete(cube); + if (cell.size === 0) { + this.grid.delete(key); + } + } + } + this.cubeKeys.delete(cube); + } + update(cube, bounds) { + this.remove(cube); + this.insert(cube, bounds); + } + getNearby(bounds) { + const nearby = /* @__PURE__ */ new Set(); + const keys = this.getCellsForBounds(bounds); + for (const key of keys) { + const cell = this.grid.get(key); + if (cell) { + for (const cube of cell) { + nearby.add(cube); + } + } + } + return nearby; + } + clear() { + this.grid.clear(); + this.cubeKeys.clear(); + } + }; + function getCubeColorSignature(cube) { + const faces = cube.faces || {}; + const parts = []; + for (const face of ["north", "south", "east", "west", "up", "down"]) { + const faceData = faces[face]; + if (faceData) { + parts.push(`${face}:${faceData.texture || "none"}`); + } + } + if (cube.color !== void 0) { + parts.push(`color:${cube.color}`); + } + return parts.join("|"); + } + function cubesCanMerge(a, b) { + if (!(a instanceof Cube) || !(b instanceof Cube)) { + return false; + } + if (a.parent !== b.parent) { + return false; + } + const sigA = getCubeColorSignature(a); + const sigB = getCubeColorSignature(b); + return sigA === sigB; + } + function findMergeableNeighbor(cube, spatialGrid) { + const bounds = getCubeBounds(cube); + const searchBounds = { + min: { x: bounds.min.x - 1, y: bounds.min.y - 1, z: bounds.min.z - 1 }, + max: { x: bounds.max.x + 1, y: bounds.max.y + 1, z: bounds.max.z + 1 } + }; + const nearby = spatialGrid.getNearby(searchBounds); + for (const other of nearby) { + if (other === cube) + continue; + if (other._markedForRemoval) + continue; + if (!cubesCanMerge(cube, other)) + continue; + const otherBounds = getCubeBounds(other); + if (!sharesCompleteFace(bounds, otherBounds)) + continue; + if (!canMergeToBox(bounds, otherBounds)) + continue; + return other; + } + return null; + } + function findMergeableNeighborLegacy(cube, cubes) { + const bounds = getCubeBounds(cube); + for (const other of cubes) { + if (other === cube) + continue; + if (other._markedForRemoval) + continue; + if (!cubesCanMerge(cube, other)) + continue; + const otherBounds = getCubeBounds(other); + if (!sharesCompleteFace(bounds, otherBounds)) + continue; + if (!canMergeToBox(bounds, otherBounds)) + continue; + return other; + } + return null; + } + function mergeCubes(a, b) { + const boundsA = getCubeBounds(a); + const boundsB = getCubeBounds(b); + const combined = combineBounds(boundsA, boundsB); + a.from[0] = combined.min.x; + a.from[1] = combined.min.y; + a.from[2] = combined.min.z; + a.to[0] = combined.max.x; + a.to[1] = combined.max.y; + a.to[2] = combined.max.z; + b._markedForRemoval = true; + } + var SPATIAL_HASH_THRESHOLD = 500; + function findMergeableNeighborOnAxis(cube, axis, spatialGrid, allCubes) { + const bounds = getCubeBounds(cube); + const tolerance = 1e-3; + let candidates; + if (spatialGrid) { + const searchBounds = { + min: { x: bounds.min.x - 1, y: bounds.min.y - 1, z: bounds.min.z - 1 }, + max: { x: bounds.max.x + 1, y: bounds.max.y + 1, z: bounds.max.z + 1 } + }; + candidates = spatialGrid.getNearby(searchBounds); + } else { + candidates = allCubes; + } + for (const other of candidates) { + if (other === cube) + continue; + if (other._markedForRemoval) + continue; + if (!cubesCanMerge(cube, other)) + continue; + const otherBounds = getCubeBounds(other); + if (axis === "x") { + const adjacentX = Math.abs(bounds.max.x - otherBounds.min.x) < tolerance || Math.abs(otherBounds.max.x - bounds.min.x) < tolerance; + if (!adjacentX) + continue; + if (Math.abs(bounds.min.y - otherBounds.min.y) > tolerance) + continue; + if (Math.abs(bounds.max.y - otherBounds.max.y) > tolerance) + continue; + if (Math.abs(bounds.min.z - otherBounds.min.z) > tolerance) + continue; + if (Math.abs(bounds.max.z - otherBounds.max.z) > tolerance) + continue; + } else if (axis === "y") { + const adjacentY = Math.abs(bounds.max.y - otherBounds.min.y) < tolerance || Math.abs(otherBounds.max.y - bounds.min.y) < tolerance; + if (!adjacentY) + continue; + if (Math.abs(bounds.min.x - otherBounds.min.x) > tolerance) + continue; + if (Math.abs(bounds.max.x - otherBounds.max.x) > tolerance) + continue; + if (Math.abs(bounds.min.z - otherBounds.min.z) > tolerance) + continue; + if (Math.abs(bounds.max.z - otherBounds.max.z) > tolerance) + continue; + } else { + const adjacentZ = Math.abs(bounds.max.z - otherBounds.min.z) < tolerance || Math.abs(otherBounds.max.z - bounds.min.z) < tolerance; + if (!adjacentZ) + continue; + if (Math.abs(bounds.min.x - otherBounds.min.x) > tolerance) + continue; + if (Math.abs(bounds.max.x - otherBounds.max.x) > tolerance) + continue; + if (Math.abs(bounds.min.y - otherBounds.min.y) > tolerance) + continue; + if (Math.abs(bounds.max.y - otherBounds.max.y) > tolerance) + continue; + } + return other; + } + return null; + } + function optimizeBoxes() { + const allCubes = Cube.all.filter((c) => c.export !== false); + const originalCount = allCubes.length; + if (originalCount === 0) { + return { + originalCount: 0, + optimizedCount: 0, + mergeCount: 0, + success: false, + message: "No cubes found in the model." + }; + } + Undo.initEdit({ elements: allCubes }); + let mergeCount = 0; + let iterations = 0; + const maxIterations = originalCount * 2; + const useSpatialHash = originalCount >= SPATIAL_HASH_THRESHOLD; + let spatialGrid = null; + if (useSpatialHash) { + let totalSize = 0; + for (const cube of allCubes) { + const bounds = getCubeBounds(cube); + totalSize += Math.max( + bounds.max.x - bounds.min.x, + bounds.max.y - bounds.min.y, + bounds.max.z - bounds.min.z + ); + } + const avgSize = totalSize / originalCount; + const cellSize = Math.max(4, Math.ceil(avgSize * 2)); + spatialGrid = new SpatialHashGrid(cellSize); + for (const cube of allCubes) { + spatialGrid.insert(cube, getCubeBounds(cube)); + } + } + const activeCubes = new Set(allCubes); + const axisOrder = ["x", "y", "z"]; + for (const axis of axisOrder) { + let passChanged = true; + let passIterations = 0; + const passMaxIterations = activeCubes.size; + while (passChanged && passIterations < passMaxIterations) { + passChanged = false; + passIterations++; + for (const cube of activeCubes) { + if (cube._markedForRemoval) { + activeCubes.delete(cube); + continue; + } + const neighbor = findMergeableNeighborOnAxis( + cube, + axis, + spatialGrid, + allCubes + ); + if (neighbor) { + if (spatialGrid) + spatialGrid.remove(neighbor); + mergeCubes(cube, neighbor); + mergeCount++; + if (spatialGrid) + spatialGrid.update(cube, getCubeBounds(cube)); + activeCubes.delete(neighbor); + passChanged = true; + iterations++; + } + } + } + } + let changed = true; + while (changed && iterations < maxIterations) { + changed = false; + iterations++; + for (const cube of activeCubes) { + if (cube._markedForRemoval) { + activeCubes.delete(cube); + continue; + } + const neighbor = useSpatialHash && spatialGrid ? findMergeableNeighbor(cube, spatialGrid) : findMergeableNeighborLegacy(cube, allCubes); + if (neighbor) { + if (spatialGrid) { + spatialGrid.remove(neighbor); + } + mergeCubes(cube, neighbor); + mergeCount++; + if (spatialGrid) { + spatialGrid.update(cube, getCubeBounds(cube)); + } + activeCubes.delete(neighbor); + changed = true; + } + } + } + if (spatialGrid) { + spatialGrid.clear(); + } + const toRemove = allCubes.filter((c) => c._markedForRemoval); + for (const cube of toRemove) { + delete cube._markedForRemoval; + cube.remove(); + } + const remaining = allCubes.filter((c) => !c._markedForRemoval); + for (const cube of remaining) { + delete cube._markedForRemoval; + } + Undo.finishEdit("Optimize Boxes (Myrlin's Tools)"); + Canvas.updateAll(); + const optimizedCount = originalCount - mergeCount; + const reduction = originalCount > 0 ? Math.round(mergeCount / originalCount * 100) : 0; + return { + originalCount, + optimizedCount, + mergeCount, + success: true, + message: `Optimized: ${originalCount} \u2192 ${optimizedCount} boxes (${reduction}% reduction)` + }; + } + function estimateSavings() { + const allCubes = Cube.all.filter((c) => c.export !== false); + const current = allCubes.length; + if (current === 0) { + return { current: 0, potential: 0, savings: 0 }; + } + let potentialMerges = 0; + if (current >= SPATIAL_HASH_THRESHOLD) { + const spatialGrid = new SpatialHashGrid(16); + for (const cube of allCubes) { + spatialGrid.insert(cube, getCubeBounds(cube)); + } + const checked = /* @__PURE__ */ new Set(); + for (const cube of allCubes) { + const bounds = getCubeBounds(cube); + const searchBounds = { + min: { x: bounds.min.x - 1, y: bounds.min.y - 1, z: bounds.min.z - 1 }, + max: { x: bounds.max.x + 1, y: bounds.max.y + 1, z: bounds.max.z + 1 } + }; + const nearby = spatialGrid.getNearby(searchBounds); + for (const other of nearby) { + if (other === cube) + continue; + const pairKey = cube.uuid < other.uuid ? `${cube.uuid}-${other.uuid}` : `${other.uuid}-${cube.uuid}`; + if (checked.has(pairKey)) + continue; + checked.add(pairKey); + if (!cubesCanMerge(cube, other)) + continue; + const otherBounds = getCubeBounds(other); + if (sharesCompleteFace(bounds, otherBounds) && canMergeToBox(bounds, otherBounds)) { + potentialMerges++; + } + } + } + spatialGrid.clear(); + } else { + for (let i = 0; i < allCubes.length; i++) { + for (let j = i + 1; j < allCubes.length; j++) { + const a = allCubes[i]; + const b = allCubes[j]; + if (!cubesCanMerge(a, b)) + continue; + const boundsA = getCubeBounds(a); + const boundsB = getCubeBounds(b); + if (sharesCompleteFace(boundsA, boundsB) && canMergeToBox(boundsA, boundsB)) { + potentialMerges++; + } + } + } + } + const estimatedSavings = Math.min(potentialMerges, Math.floor(current * 0.5)); + return { + current, + potential: current - estimatedSavings, + savings: estimatedSavings + }; + } + + // src/optimizer/hollow.ts + function buildOccupancyGrid(cubes) { + const grid = /* @__PURE__ */ new Map(); + for (const cube of cubes) { + const bounds = getCubeBounds(cube); + for (let x = Math.floor(bounds.min.x); x < Math.ceil(bounds.max.x); x++) { + for (let y = Math.floor(bounds.min.y); y < Math.ceil(bounds.max.y); y++) { + for (let z = Math.floor(bounds.min.z); z < Math.ceil(bounds.max.z); z++) { + const key = `${x},${y},${z}`; + if (!grid.has(key)) { + grid.set(key, []); + } + grid.get(key).push(cube); + } + } + } + } + return grid; + } + function isInterior(cube, grid) { + const bounds = getCubeBounds(cube); + const tolerance = 1e-3; + const faces = [ + { dir: { x: 1, y: 0, z: 0 }, face: "right" }, + // +X + { dir: { x: -1, y: 0, z: 0 }, face: "left" }, + // -X + { dir: { x: 0, y: 1, z: 0 }, face: "up" }, + // +Y + { dir: { x: 0, y: -1, z: 0 }, face: "down" }, + // -Y + { dir: { x: 0, y: 0, z: 1 }, face: "front" }, + // +Z + { dir: { x: 0, y: 0, z: -1 }, face: "back" } + // -Z + ]; + for (const { dir, face } of faces) { + let faceCovered = true; + const faceMin = { x: 0, y: 0, z: 0 }; + const faceMax = { x: 0, y: 0, z: 0 }; + if (dir.x !== 0) { + faceMin.x = faceMax.x = dir.x > 0 ? bounds.max.x : bounds.min.x; + faceMin.y = bounds.min.y; + faceMax.y = bounds.max.y; + faceMin.z = bounds.min.z; + faceMax.z = bounds.max.z; + } else if (dir.y !== 0) { + faceMin.y = faceMax.y = dir.y > 0 ? bounds.max.y : bounds.min.y; + faceMin.x = bounds.min.x; + faceMax.x = bounds.max.x; + faceMin.z = bounds.min.z; + faceMax.z = bounds.max.z; + } else { + faceMin.z = faceMax.z = dir.z > 0 ? bounds.max.z : bounds.min.z; + faceMin.x = bounds.min.x; + faceMax.x = bounds.max.x; + faceMin.y = bounds.min.y; + faceMax.y = bounds.max.y; + } + const checkX = Math.floor(faceMin.x + dir.x * 0.5); + const checkY = Math.floor(faceMin.y + dir.y * 0.5); + const checkZ = Math.floor(faceMin.z + dir.z * 0.5); + for (let x = Math.floor(faceMin.x); x < Math.ceil(faceMax.x); x++) { + for (let y = Math.floor(faceMin.y); y < Math.ceil(faceMax.y); y++) { + for (let z = Math.floor(faceMin.z); z < Math.ceil(faceMax.z); z++) { + const neighborKey = `${x + dir.x},${y + dir.y},${z + dir.z}`; + const neighbors = grid.get(neighborKey); + if (!neighbors || neighbors.length === 0) { + faceCovered = false; + break; + } + const point = { + x: x + 0.5 + dir.x, + y: y + 0.5 + dir.y, + z: z + 0.5 + dir.z + }; + let covered = false; + for (const neighbor of neighbors) { + if (neighbor === cube) + continue; + const nBounds = getCubeBounds(neighbor); + if (point.x >= nBounds.min.x - tolerance && point.x <= nBounds.max.x + tolerance && point.y >= nBounds.min.y - tolerance && point.y <= nBounds.max.y + tolerance && point.z >= nBounds.min.z - tolerance && point.z <= nBounds.max.z + tolerance) { + covered = true; + break; + } + } + if (!covered) { + faceCovered = false; + break; + } + } + if (!faceCovered) + break; + } + if (!faceCovered) + break; + } + if (!faceCovered) { + return false; + } + } + return true; + } + function hollowInterior(preserveShell = true) { + const allCubes = Cube.all.filter((c) => c.export !== false); + const originalCount = allCubes.length; + if (originalCount === 0) { + return { + originalCount: 0, + removedCount: 0, + finalCount: 0, + success: false, + message: "No cubes found in the model." + }; + } + const grid = buildOccupancyGrid(allCubes); + const interiorCubes = []; + for (const cube of allCubes) { + if (isInterior(cube, grid)) { + interiorCubes.push(cube); + } + } + if (interiorCubes.length === 0) { + return { + originalCount, + removedCount: 0, + finalCount: originalCount, + success: true, + message: "No interior cubes found - model is already hollow." + }; + } + Undo.initEdit({ elements: interiorCubes }); + for (const cube of interiorCubes) { + cube.remove(); + } + Undo.finishEdit("Hollow Interior (Myrlin's Tools)"); + Canvas.updateAll(); + const removedCount = interiorCubes.length; + const finalCount = originalCount - removedCount; + const reduction = Math.round(removedCount / originalCount * 100); + return { + originalCount, + removedCount, + finalCount, + success: true, + message: `Hollowed: ${originalCount} \u2192 ${finalCount} boxes (removed ${removedCount}, ${reduction}% reduction)` + }; + } + function countInteriorCubes() { + const allCubes = Cube.all.filter((c) => c.export !== false); + if (allCubes.length === 0) + return 0; + const grid = buildOccupancyGrid(allCubes); + let count = 0; + for (const cube of allCubes) { + if (isInterior(cube, grid)) { + count++; + } + } + return count; + } + + // src/utils/debounce.ts + function debounce(fn, ms) { + let timeout = null; + return (...args) => { + if (timeout) + clearTimeout(timeout); + timeout = setTimeout(() => { + fn(...args); + timeout = null; + }, ms); + }; + } + + // src/features/node_budget.ts + function countGroup(group) { + let cubeCount = 0; + let groupCount = 0; + const children = []; + for (const child of group.children) { + if (child instanceof Cube) { + cubeCount++; + } else if (child instanceof Group) { + groupCount++; + children.push(countGroup(child)); + } + } + const childDescendants = children.reduce((sum, c) => sum + c.totalDescendants, 0); + const totalDescendants = cubeCount + groupCount + childDescendants; + return { + name: group.name, + uuid: group.uuid, + cubeCount, + groupCount, + totalDescendants, + children + }; + } + function calculateNodeBudget() { + const roots = []; + for (const item of Outliner.root) { + if (item instanceof Group) { + roots.push(countGroup(item)); + } + } + return roots; + } + function getTotalNodeCount() { + const cubes = Cube.all.filter((c) => c.export !== false).length; + const groups = Group.all.filter((g) => g.export !== false).length; + return cubes + groups; + } + + // src/ui/panel.ts + var HYTALE_MAX_NODES = 255; + function getQuickStats() { + const nodeCount = getTotalNodeCount(); + const texture = Texture.all[0]; + const textureSize = texture ? `${texture.width}x${texture.height}` : "No texture"; + return { + nodeCount, + maxNodes: HYTALE_MAX_NODES, + textureSize, + isValid: nodeCount <= HYTALE_MAX_NODES + }; + } + function getExpensiveStats() { + const estimate = estimateSavings(); + const interiorCount = countInteriorCubes(); + return { + interiorCount, + potentialSavings: estimate.savings + }; + } + function setupPanel() { + const panel = new Panel("myrlins_tools_panel", { + name: "Myrlin's Tools", + id: "myrlins_tools_panel", + icon: "auto_fix_high", + growable: true, + condition: { + modes: ["edit"], + method: () => true + }, + default_position: { + slot: "right_bar", + float_position: [0, 0], + float_size: [300, 500], + height: 500 + }, + component: { + template: ` +
+
+

Model Status

+
+
+ {{ quickStats.isValid ? '\u2705' : '\u274C' }} + Nodes: + {{ quickStats.nodeCount }} / {{ quickStats.maxNodes }} +
+
+ \u{1F3A8} + Texture: + {{ quickStats.textureSize }} +
+
+ {{ expensiveStale ? '\u23F3' : '\u26A0\uFE0F' }} + Hidden interior: + {{ expensiveStats.interiorCount }} boxes +
+
+ {{ expensiveStale ? '\u23F3' : '\u{1F4A1}' }} + Potential savings: + ~{{ expensiveStats.potentialSavings }} boxes +
+
+
+ +
+

+ Node Budget + {{ quickStats.nodeCount }}/{{ quickStats.maxNodes }} +

+
+
+
+ + {{ expandedBones[bone.uuid] ? '\u25BC' : '\u25B6' }} {{ bone.name }} + + {{ bone.totalDescendants }} +
+
+
+ {{ child.name }} + {{ child.totalDescendants }} +
+
+
+
+
+ +
+

Tools

+
+ + + + +
+
+ +
+

Import

+
+ +
+
+
+ `, + data() { + return { + quickStats: getQuickStats(), + expensiveStats: { interiorCount: 0, potentialSavings: 0 }, + expensiveStale: true, + nodeBudget: [], + expandedBones: {}, + debouncedExpensiveUpdate: null + }; + }, + methods: { + updateQuickStats() { + this.quickStats = getQuickStats(); + this.expensiveStale = true; + this.nodeBudget = calculateNodeBudget(); + if (this.debouncedExpensiveUpdate) { + this.debouncedExpensiveUpdate(); + } + }, + updateExpensiveStats() { + this.expensiveStats = getExpensiveStats(); + this.expensiveStale = false; + }, + runAction(actionId) { + if (BarItems[actionId]) { + BarItems[actionId].click(); + } + }, + selectBone(uuid) { + const group = Group.all.find((g) => g.uuid === uuid); + if (group) { + group.select(); + } + }, + toggleBone(uuid) { + this.$set(this.expandedBones, uuid, !this.expandedBones[uuid]); + }, + getBudgetClass(bone) { + const fairShare = Math.floor(HYTALE_MAX_NODES / Math.max(this.nodeBudget.length, 1)); + if (bone.totalDescendants > fairShare) + return "myrlin-budget-red"; + if (bone.totalDescendants > fairShare * 0.5) + return "myrlin-budget-yellow"; + return "myrlin-budget-green"; + } + }, + mounted() { + this.debouncedExpensiveUpdate = debounce(() => { + this.updateExpensiveStats(); + }, 500); + this.updateExpensiveStats(); + this.nodeBudget = calculateNodeBudget(); + this._updateListener = Blockbench.on("update_selection", () => { + this.updateQuickStats(); + }); + this._undoListener = Blockbench.on("undo", () => { + this.updateQuickStats(); + }); + this._redoListener = Blockbench.on("redo", () => { + this.updateQuickStats(); + }); + }, + beforeDestroy() { + if (this._updateListener) + this._updateListener.delete(); + if (this._undoListener) + this._undoListener.delete(); + if (this._redoListener) + this._redoListener.delete(); + } + } + }); + track(panel); + const panelCSS = Blockbench.addCSS(PANEL_CSS); + track({ delete: () => panelCSS.delete() }); + } + var PANEL_CSS = ` + .myrlin-panel { + padding: 8px; + font-size: 12px; + } + + .myrlin-section { + margin-bottom: 16px; + } + + .myrlin-section-title { + margin: 0 0 8px 0; + padding: 4px 0; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: var(--color-subtle_text); + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: space-between; + } + + .myrlin-section-badge { + font-size: 10px; + font-weight: 400; + padding: 1px 6px; + border-radius: 8px; + background: var(--color-back); + } + + .myrlin-stats { + display: flex; + flex-direction: column; + gap: 4px; + } + + .myrlin-stat-row { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + background: var(--color-back); + border-radius: 4px; + } + + .myrlin-stat-row.myrlin-stat-error { + background: rgba(255, 100, 100, 0.2); + } + + .myrlin-stat-icon { + font-size: 14px; + width: 20px; + text-align: center; + } + + .myrlin-stat-label { + color: var(--color-subtle_text); + } + + .myrlin-stat-value { + margin-left: auto; + font-weight: 500; + } + + .myrlin-stale { + opacity: 0.5; + font-style: italic; + } + + /* Node Budget Tree */ + .myrlin-budget-tree { + display: flex; + flex-direction: column; + gap: 2px; + } + + .myrlin-budget-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 3px 6px; + border-radius: 3px; + cursor: pointer; + font-size: 11px; + } + + .myrlin-budget-row:hover { + background: var(--color-button); + } + + .myrlin-budget-name { + cursor: pointer; + user-select: none; + } + + .myrlin-budget-count { + font-weight: 500; + font-variant-numeric: tabular-nums; + } + + .myrlin-budget-children { + padding-left: 16px; + } + + .myrlin-budget-child { + font-size: 10px; + color: var(--color-subtle_text); + } + + .myrlin-budget-green .myrlin-budget-count { color: #4ade80; } + .myrlin-budget-yellow .myrlin-budget-count { color: #facc15; } + .myrlin-budget-red .myrlin-budget-count { color: #f87171; } + + /* Buttons */ + .myrlin-buttons { + display: flex; + flex-direction: column; + gap: 6px; + } + + .myrlin-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--color-button); + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + color: var(--color-text); + transition: background 0.15s; + } + + .myrlin-btn:hover:not(:disabled) { + background: var(--color-accent); + } + + .myrlin-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .myrlin-btn-icon { + font-size: 16px; + } + + .myrlin-btn-text { + flex: 1; + } +`; + + // src/utils/color.ts + function rgbToLab(rgb) { + let r = rgb.r / 255; + let g = rgb.g / 255; + let b = rgb.b / 255; + r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92; + g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92; + b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92; + let x = r * 0.4124564 + g * 0.3575761 + b * 0.1804375; + let y = r * 0.2126729 + g * 0.7151522 + b * 0.072175; + let z = r * 0.0193339 + g * 0.119192 + b * 0.9503041; + const refX = 0.95047; + const refY = 1; + const refZ = 1.08883; + x = x / refX; + y = y / refY; + z = z / refZ; + x = x > 8856e-6 ? Math.pow(x, 1 / 3) : 7.787 * x + 16 / 116; + y = y > 8856e-6 ? Math.pow(y, 1 / 3) : 7.787 * y + 16 / 116; + z = z > 8856e-6 ? Math.pow(z, 1 / 3) : 7.787 * z + 16 / 116; + return { + l: 116 * y - 16, + a: 500 * (x - y), + b: 200 * (y - z) + }; + } + function deltaE(lab1, lab2) { + return Math.sqrt( + Math.pow(lab2.l - lab1.l, 2) + Math.pow(lab2.a - lab1.a, 2) + Math.pow(lab2.b - lab1.b, 2) + ); + } + function findNearestColor(color, palette) { + const colorLab = rgbToLab(color); + let nearest = palette[0]; + let minDist = Infinity; + for (const p of palette) { + const pLab = rgbToLab(p); + const dist = deltaE(colorLab, pLab); + if (dist < minDist) { + minDist = dist; + nearest = p; + } + } + return nearest; + } + function averageColor(colors) { + if (colors.length === 0) { + return { r: 0, g: 0, b: 0 }; + } + const sum = colors.reduce( + (acc, c) => ({ r: acc.r + c.r, g: acc.g + c.g, b: acc.b + c.b }), + { r: 0, g: 0, b: 0 } + ); + return { + r: Math.round(sum.r / colors.length), + g: Math.round(sum.g / colors.length), + b: Math.round(sum.b / colors.length) + }; + } + function colorsAreSimilar(a, b, threshold = 10) { + return deltaE(rgbToLab(a), rgbToLab(b)) < threshold; + } + function extractDominantColors(colors, k = 8, maxIterations = 50) { + if (colors.length <= k) { + return colors; + } + let centroids = []; + const usedIndices = /* @__PURE__ */ new Set(); + while (centroids.length < k) { + const idx = Math.floor(Math.random() * colors.length); + if (!usedIndices.has(idx)) { + usedIndices.add(idx); + centroids.push({ ...colors[idx] }); + } + } + for (let iter = 0; iter < maxIterations; iter++) { + const clusters = Array.from({ length: k }, () => []); + for (const color of colors) { + let minDist = Infinity; + let nearestIdx = 0; + for (let i = 0; i < centroids.length; i++) { + const dist = deltaE(rgbToLab(color), rgbToLab(centroids[i])); + if (dist < minDist) { + minDist = dist; + nearestIdx = i; + } + } + clusters[nearestIdx].push(color); + } + let changed = false; + for (let i = 0; i < k; i++) { + if (clusters[i].length > 0) { + const newCentroid = averageColor(clusters[i]); + if (!colorsAreSimilar(centroids[i], newCentroid, 1)) { + changed = true; + centroids[i] = newCentroid; + } + } + } + if (!changed) + break; + } + return centroids; + } + var HYTALE_PALETTES = { + official: [ + { r: 74, g: 63, b: 50 }, + // Dark brown + { r: 139, g: 119, b: 92 }, + // Medium brown + { r: 194, g: 178, b: 152 }, + // Light brown + { r: 67, g: 87, b: 58 }, + // Dark green + { r: 109, g: 143, b: 92 }, + // Medium green + { r: 162, g: 194, b: 133 }, + // Light green + { r: 58, g: 68, b: 92 }, + // Dark blue + { r: 92, g: 117, b: 150 }, + // Medium blue + { r: 147, g: 175, b: 207 }, + // Light blue + { r: 194, g: 152, b: 108 }, + // Tan + { r: 150, g: 92, b: 68 }, + // Rust + { r: 207, g: 175, b: 147 } + // Cream + ], + warm: [ + { r: 139, g: 69, b: 50 }, + // Dark rust + { r: 178, g: 92, b: 68 }, + // Rust + { r: 207, g: 133, b: 92 }, + // Orange + { r: 225, g: 175, b: 133 }, + // Peach + { r: 109, g: 74, b: 58 }, + // Dark brown + { r: 150, g: 109, b: 74 }, + // Brown + { r: 194, g: 152, b: 108 }, + // Tan + { r: 225, g: 207, b: 175 } + // Cream + ], + cool: [ + { r: 50, g: 58, b: 92 }, + // Dark navy + { r: 68, g: 92, b: 139 }, + // Navy + { r: 92, g: 133, b: 178 }, + // Blue + { r: 147, g: 175, b: 207 }, + // Light blue + { r: 58, g: 50, b: 74 }, + // Dark purple + { r: 92, g: 74, b: 109 }, + // Purple + { r: 139, g: 119, b: 150 }, + // Lavender + { r: 178, g: 162, b: 194 } + // Light lavender + ], + nature: [ + { r: 50, g: 58, b: 42 }, + // Dark forest + { r: 74, g: 92, b: 58 }, + // Forest + { r: 109, g: 139, b: 92 }, + // Green + { r: 150, g: 178, b: 133 }, + // Light green + { r: 74, g: 63, b: 50 }, + // Dark bark + { r: 109, g: 92, b: 74 }, + // Bark + { r: 150, g: 133, b: 109 }, + // Light bark + { r: 194, g: 178, b: 152 } + // Sand + ] + }; + + // src/tools/palette.ts + function getPalette(name) { + switch (name) { + case "official": + return HYTALE_PALETTES.official; + case "warm": + return HYTALE_PALETTES.warm; + case "cool": + return HYTALE_PALETTES.cool; + case "nature": + return HYTALE_PALETTES.nature; + default: + return HYTALE_PALETTES.official; + } + } + function convertToPalette(paletteName = "official") { + const texture = Texture.all[0]; + if (!texture) { + return { + success: false, + colorsChanged: 0, + message: "No texture found in the model." + }; + } + const palette = getPalette(paletteName); + const canvas = texture.canvas; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return { + success: false, + colorsChanged: 0, + message: "Could not get canvas context." + }; + } + Undo.initEdit({ textures: [texture] }); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + let colorsChanged = 0; + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const a = data[i + 3]; + if (a < 10) + continue; + const original = { r, g, b }; + const nearest = findNearestColor(original, palette); + if (nearest.r !== r || nearest.g !== g || nearest.b !== b) { + data[i] = nearest.r; + data[i + 1] = nearest.g; + data[i + 2] = nearest.b; + colorsChanged++; + } + } + ctx.putImageData(imageData, 0, 0); + texture.updateSource(canvas.toDataURL()); + Undo.finishEdit("Convert to Palette (Myrlin's Tools)"); + return { + success: true, + colorsChanged, + message: `Converted ${colorsChanged} pixels to ${paletteName} palette.` + }; + } + function quantizeColors(targetCount = 8) { + const texture = Texture.all[0]; + if (!texture) { + return { + success: false, + colorsChanged: 0, + message: "No texture found in the model." + }; + } + const canvas = texture.canvas; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return { + success: false, + colorsChanged: 0, + message: "Could not get canvas context." + }; + } + Undo.initEdit({ textures: [texture] }); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + const colors = []; + for (let i = 0; i < data.length; i += 4) { + const a = data[i + 3]; + if (a >= 10) { + colors.push({ + r: data[i], + g: data[i + 1], + b: data[i + 2] + }); + } + } + const palette = extractDominantColors(colors, targetCount); + let colorsChanged = 0; + for (let i = 0; i < data.length; i += 4) { + const a = data[i + 3]; + if (a < 10) + continue; + const original = { + r: data[i], + g: data[i + 1], + b: data[i + 2] + }; + const nearest = findNearestColor(original, palette); + if (nearest.r !== original.r || nearest.g !== original.g || nearest.b !== original.b) { + data[i] = nearest.r; + data[i + 1] = nearest.g; + data[i + 2] = nearest.b; + colorsChanged++; + } + } + ctx.putImageData(imageData, 0, 0); + texture.updateSource(canvas.toDataURL()); + Undo.finishEdit("Quantize Colors (Myrlin's Tools)"); + return { + success: true, + colorsChanged, + message: `Reduced to ${targetCount} colors (${colorsChanged} pixels changed).` + }; + } + + // src/actions.ts + function showResultDialog(title, result) { + Blockbench.showQuickMessage(result.message, 3e3); + if (!result.success) { + Blockbench.showMessageBox({ + title, + message: result.message, + icon: "warning" + }); + } + } + function setupActions() { + const optimizeAction = new Action("myrlin_optimize_boxes", { + name: "Optimize Boxes", + description: "Merge adjacent boxes with same material to reduce box count (Myrlin's Tools)", + icon: "compress", + category: "tools", + condition: () => { + return Cube.all.length > 0; + }, + click: () => { + const estimate = estimateSavings(); + new Dialog({ + id: "myrlin_optimize_dialog", + title: "Optimize Boxes - Myrlin's Tools", + lines: [ + `Current box count: **${estimate.current}**`, + `Estimated after optimization: **~${estimate.potential}**`, + `Potential savings: **~${estimate.savings} boxes**`, + "", + "This will merge adjacent boxes that share the same material/color.", + "The operation can be undone with Ctrl+Z." + ], + buttons: ["Optimize", "Cancel"], + onConfirm: () => { + const result = optimizeBoxes(); + showResultDialog("Optimize Boxes", result); + } + }).show(); + } + }); + track(optimizeAction); + const hollowAction = new Action("myrlin_hollow_interior", { + name: "Hollow Interior", + description: "Remove boxes that are completely hidden inside the model (Myrlin's Tools)", + icon: "select_all", + // Using available icon + category: "tools", + condition: () => { + return Cube.all.length > 0; + }, + click: () => { + const interiorCount = countInteriorCubes(); + const totalCount = Cube.all.length; + if (interiorCount === 0) { + Blockbench.showQuickMessage("No interior cubes found - model is already hollow.", 2e3); + return; + } + new Dialog({ + id: "myrlin_hollow_dialog", + title: "Hollow Interior - Myrlin's Tools", + lines: [ + `Current box count: **${totalCount}**`, + `Interior boxes found: **${interiorCount}**`, + `Final count after hollowing: **${totalCount - interiorCount}**`, + "", + "This will remove boxes that are completely surrounded by other boxes.", + "The operation can be undone with Ctrl+Z." + ], + buttons: ["Hollow", "Cancel"], + onConfirm: () => { + const result = hollowInterior(true); + showResultDialog("Hollow Interior", result); + } + }).show(); + } + }); + track(hollowAction); + const paletteAction = new Action("myrlin_palette_converter", { + name: "Palette Converter", + description: "Remap texture colors to a Hytale-style palette (Myrlin's Tools)", + icon: "palette", + category: "tools", + condition: () => { + return Texture.all.length > 0; + }, + click: () => { + new Dialog({ + id: "myrlin_palette_dialog", + title: "Palette Converter - Myrlin's Tools", + form: { + palette: { + label: "Target Palette", + type: "select", + options: { + official: "Hytale Official", + warm: "Warm Tones", + cool: "Cool Tones", + nature: "Nature" + }, + value: "official" + }, + quantize: { + label: "Quantize Colors", + type: "checkbox", + value: false, + description: "Reduce unique color count before palette conversion" + }, + quantize_count: { + label: "Target Color Count", + type: "number", + value: 8, + min: 2, + max: 64 + } + }, + onConfirm: (formResult) => { + if (formResult.quantize) { + const qResult = quantizeColors(formResult.quantize_count || 8); + if (qResult.success) { + Blockbench.showQuickMessage(qResult.message, 2e3); + } + } + const result = convertToPalette(formResult.palette); + showResultDialog("Palette Converter", result); + } + }).show(); + } + }); + track(paletteAction); + MenuBar.addAction(optimizeAction, "tools"); + MenuBar.addAction(hollowAction, "tools"); + MenuBar.addAction(paletteAction, "tools"); + const optimizeKeybind = new Keybind({ + key: "o", + ctrl: true, + shift: true + }); + optimizeAction.setKeybind(optimizeKeybind); + } + + // src/ui/status.ts + var HYTALE_MAX_NODES2 = 255; + function getStatusColor(count) { + if (count > HYTALE_MAX_NODES2) + return "#ef4444"; + if (count > 200) + return "#f59e0b"; + return "#22c55e"; + } + function getStatusEmoji(count) { + if (count > HYTALE_MAX_NODES2) + return "\u274C"; + if (count > 200) + return "\u26A0\uFE0F"; + return "\u2705"; + } + function setupStatusBar() { + let statusElement = null; + function updateStatus() { + const count = Cube.all.filter((c) => c.export !== false).length; + const emoji = getStatusEmoji(count); + const color = getStatusColor(count); + if (statusElement) { + statusElement.innerHTML = ` + + \u{1F4E6} ${count}/${HYTALE_MAX_NODES2} + + `; + statusElement.title = `Hytale Node Count: ${count} / ${HYTALE_MAX_NODES2} (Myrlin's Tools)`; + } + } + function createStatusElement() { + const statusBar = document.querySelector("#status_bar"); + if (!statusBar) { + console.warn("[Myrlin's Tools] Status bar not found"); + return; + } + statusElement = document.createElement("div"); + statusElement.id = "myrlin_status"; + statusElement.className = "f_left"; + statusElement.style.cssText = ` + padding: 0 8px; + font-size: 12px; + display: flex; + align-items: center; + cursor: pointer; + `; + statusElement.addEventListener("click", () => { + const panel = Panels.myrlins_tools_panel; + if (panel) { + if (panel.slot === "hidden") { + panel.moveTo("right_bar"); + } + if (panel.folded) { + panel.fold(); + } + } + }); + const firstChild = statusBar.firstChild; + if (firstChild) { + statusBar.insertBefore(statusElement, firstChild.nextSibling); + } else { + statusBar.appendChild(statusElement); + } + updateStatus(); + } + const modeListener = Blockbench.on("select_mode", (data) => { + if (data && data.mode === "edit") { + if (!statusElement) { + createStatusElement(); + } + updateStatus(); + } + }); + track({ delete: () => modeListener.delete() }); + const updateListener = Blockbench.on("update_selection", updateStatus); + track({ delete: () => updateListener.delete() }); + const undoListener = Blockbench.on("undo", updateStatus); + track({ delete: () => undoListener.delete() }); + const redoListener = Blockbench.on("redo", updateStatus); + track({ delete: () => redoListener.delete() }); + track({ + delete: () => { + if (statusElement && statusElement.parentNode) { + statusElement.parentNode.removeChild(statusElement); + } + statusElement = null; + } + }); + if (Modes.edit) { + setTimeout(createStatusElement, 100); + } + } + + // src/ui/browser.ts + var MYRLIN_API = "https://api.myrlin.io"; + var MYRLIN_GALLERY = "https://forge.myrlin.io/gallery"; + async function fetchGalleryItems(category, search, limit = 20) { + try { + const params = new URLSearchParams({ + limit: limit.toString(), + ...category && { category }, + ...search && { search } + }); + const response = await fetch(`${MYRLIN_API}/api/gallery?${params}`); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + const data = await response.json(); + return data.items || []; + } catch (error) { + console.error("[Myrlin Browser] Failed to fetch gallery:", error); + return []; + } + } + async function importBlockyModel(url, name, autoOptimize = false) { + try { + Blockbench.showQuickMessage(`Downloading ${name}...`, 2e3); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Download failed: ${response.status}`); + } + const data = await response.json(); + if (!Formats.hytale_character && !Formats.hytale_prop) { + Blockbench.showMessageBox({ + title: "Hytale Plugin Required", + message: "Please install the official Hytale plugin to import .blockymodel files.", + icon: "warning" + }); + return false; + } + newProject(Formats.hytale_character); + Codecs.blockymodel?.load(data, { path: `${name}.blockymodel` }); + if (autoOptimize) { + Blockbench.showQuickMessage("Optimizing model...", 1500); + setTimeout(() => { + const result = optimizeBoxes(); + if (result.mergeCount > 0) { + Blockbench.showQuickMessage( + `Optimized: ${result.originalCount} \u2192 ${result.optimizedCount} boxes`, + 3e3 + ); + } + }, 500); + } + Blockbench.showQuickMessage(`Imported: ${name}`, 2e3); + return true; + } catch (error) { + console.error("[Myrlin Browser] Import failed:", error); + Blockbench.showMessageBox({ + title: "Import Failed", + message: `Could not import model: ${error.message}`, + icon: "error" + }); + return false; + } + } + function showModelBrowser() { + const dialog = new Dialog({ + id: "myrlin_model_browser", + title: "Myrlin Model Browser", + width: 800, + singleButton: false, + component: { + template: ` +
+
+ +
+ +
+ +
+ +
+
+ Loading models... +
+ +
+

No models found.

+

Browse the full gallery at forge.myrlin.io

+
+ +
+
+
+ +
+
+
{{ item.name }}
+
+ {{ item.boxCount }} boxes + {{ item.category }} +
+
+
+
+
+ + +
+ `, + data() { + return { + items: [], + selectedItem: null, + selectedCategory: "", + searchQuery: "", + autoOptimize: true, + loading: false, + searchTimeout: null + }; + }, + methods: { + async loadItems() { + this.loading = true; + this.items = await fetchGalleryItems( + this.selectedCategory || void 0, + this.searchQuery || void 0 + ); + this.loading = false; + }, + selectItem(item) { + this.selectedItem = item; + }, + async importItem(item) { + if (item.blockymodelUrl) { + const success = await importBlockyModel( + item.blockymodelUrl, + item.name, + this.autoOptimize + ); + if (success) { + dialog.close(); + } + } + }, + importSelected() { + if (this.selectedItem) { + this.importItem(this.selectedItem); + } + }, + openGallery() { + Blockbench.openLink(MYRLIN_GALLERY); + }, + debouncedSearch() { + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + this.searchTimeout = setTimeout(() => { + this.loadItems(); + }, 300); + } + }, + mounted() { + this.loadItems(); + } + } + }); + dialog.show(); + } + var BROWSER_CSS = ` + .myrlin-browser { + display: flex; + flex-direction: column; + height: 500px; + gap: 12px; + } + + .myrlin-browser-header { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; + } + + .myrlin-browser-search { + flex: 1; + min-width: 200px; + } + + .myrlin-browser-search input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-back); + color: var(--color-text); + } + + .myrlin-browser-filters select { + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-back); + color: var(--color-text); + } + + .myrlin-browser-option { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--color-subtle_text); + } + + .myrlin-browser-content { + flex: 1; + overflow-y: auto; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-back); + } + + .myrlin-browser-loading, + .myrlin-browser-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--color-subtle_text); + } + + .myrlin-browser-empty a { + color: var(--color-accent); + } + + .myrlin-browser-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 12px; + padding: 12px; + } + + .myrlin-browser-item { + border: 2px solid transparent; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: border-color 0.15s, transform 0.15s; + background: var(--color-button); + } + + .myrlin-browser-item:hover { + border-color: var(--color-border); + transform: translateY(-2px); + } + + .myrlin-browser-item.selected { + border-color: var(--color-accent); + } + + .myrlin-browser-thumbnail { + aspect-ratio: 1; + background: var(--color-back); + display: flex; + align-items: center; + justify-content: center; + } + + .myrlin-browser-thumbnail img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + .myrlin-browser-item-info { + padding: 8px; + } + + .myrlin-browser-item-name { + font-weight: 500; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .myrlin-browser-item-meta { + display: flex; + justify-content: space-between; + font-size: 10px; + color: var(--color-subtle_text); + margin-top: 4px; + } + + .myrlin-browser-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + } + + .myrlin-browser-selected { + font-size: 12px; + color: var(--color-subtle_text); + } + + .myrlin-browser-actions { + display: flex; + gap: 8px; + } + + .myrlin-btn-link { + background: transparent; + border: 1px solid var(--color-border); + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + color: var(--color-text); + } + + .myrlin-btn-link:hover { + border-color: var(--color-accent); + color: var(--color-accent); + } + + .myrlin-btn-primary { + background: var(--color-accent); + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + color: white; + font-weight: 500; + } + + .myrlin-btn-primary:hover:not(:disabled) { + filter: brightness(1.1); + } + + .myrlin-btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + function setupBrowser() { + const browserCSS = Blockbench.addCSS(BROWSER_CSS); + track({ delete: () => browserCSS.delete() }); + const browserAction = new Action("myrlin_model_browser", { + name: "Model Browser", + description: "Browse and import models from Myrlin gallery (Myrlin's Tools)", + icon: "view_module", + category: "tools", + click: () => { + showModelBrowser(); + } + }); + track(browserAction); + MenuBar.addAction(browserAction, "tools"); + } + + // src/features/template_wizard.ts + function cubeDef(name, posX, posY, posZ, sizeX, sizeY, sizeZ) { + return { + name, + from: [ + posX - sizeX / 2, + posY - sizeY / 2, + posZ - sizeZ / 2 + ], + to: [ + posX + sizeX / 2, + posY + sizeY / 2, + posZ + sizeZ / 2 + ] + }; + } + var TEMPLATES = { + sword: { + label: "Sword", + format: "hytale_prop", + textureWidth: 64, + textureHeight: 64, + bones: [ + { + name: "R-Attachment", + origin: [0, 0, 0], + children: [ + { + name: "Handle", + origin: [0, 6, 0], + cube: cubeDef("Handle", 0, 6, 0, 4, 3, 3), + children: [ + { + name: "Blade", + origin: [0, 20, 0], + cube: cubeDef("Blade", 0, 20, 0, 3, 20, 3) + } + ] + } + ] + } + ] + }, + axe: { + label: "Axe", + format: "hytale_prop", + textureWidth: 64, + textureHeight: 64, + bones: [ + { + name: "R-Attachment", + origin: [0, 0, 0], + children: [ + { + name: "Handle", + origin: [0, 12, 0], + cube: cubeDef("Handle", 0, 12, 0, 3, 24, 3), + children: [ + { + name: "Head", + origin: [0, 22, 0], + cube: cubeDef("Head", 0, 22, 0, 8, 8, 4) + } + ] + } + ] + } + ] + }, + pickaxe: { + label: "Pickaxe", + format: "hytale_prop", + textureWidth: 64, + textureHeight: 64, + bones: [ + { + name: "R-Attachment", + origin: [0, 0, 0], + children: [ + { + name: "Handle", + origin: [0, 12, 0], + cube: cubeDef("Handle", 0, 12, 0, 3, 24, 3), + children: [ + { + name: "Head", + origin: [0, 24, 0], + cube: cubeDef("Head", 0, 24, 0, 12, 6, 4) + } + ] + } + ] + } + ] + }, + bow: { + label: "Bow", + format: "hytale_prop", + textureWidth: 64, + textureHeight: 64, + bones: [ + { + name: "R-Attachment", + origin: [0, 0, 0], + children: [ + { + name: "Body", + origin: [0, 0, 0], + cube: cubeDef("Body", 0, 12, 0, 2, 24, 2), + children: [ + { + name: "TopLimb", + origin: [0, 24, 0], + cube: cubeDef("TopLimb", 0, 30, 0, 2, 12, 2) + }, + { + name: "BottomLimb", + origin: [0, 0, 0], + cube: cubeDef("BottomLimb", 0, -6, 0, 2, 12, 2) + } + ] + } + ] + } + ] + }, + shield: { + label: "Shield", + format: "hytale_prop", + textureWidth: 64, + textureHeight: 64, + bones: [ + { + name: "L-Attachment", + origin: [0, 0, 0], + children: [ + { + name: "Body", + origin: [0, 8, 0], + cube: cubeDef("Body", 0, 8, 0, 12, 16, 3), + children: [ + { + name: "Handle", + origin: [0, 8, -1.5], + cube: cubeDef("Handle", 0, 8, -1.5, 3, 6, 2) + } + ] + } + ] + } + ] + }, + furniture: { + label: "Furniture", + format: "hytale_prop", + textureWidth: 64, + textureHeight: 64, + bones: [ + { + name: "Root", + origin: [0, 0, 0], + children: [ + { + name: "Body", + origin: [0, 8, 0], + cube: cubeDef("Body", 0, 8, 0, 16, 16, 16) + } + ] + } + ] + }, + decoration: { + label: "Decoration", + format: "hytale_prop", + textureWidth: 32, + textureHeight: 32, + bones: [ + { + name: "Root", + origin: [0, 0, 0], + children: [ + { + name: "Body", + origin: [0, 4, 0], + cube: cubeDef("Body", 0, 4, 0, 8, 8, 8) + } + ] + } + ] + }, + character: { + label: "Character", + format: "hytale_character", + textureWidth: 256, + textureHeight: 256, + bones: [ + { + name: "Root", + origin: [0, 0, 0], + children: [ + { + name: "Body", + origin: [0, 12, 0], + children: [ + { + name: "Head", + origin: [0, 28, 0], + cube: cubeDef("Head", 0, 28, 0, 8, 8, 8) + }, + { + name: "Chest", + origin: [0, 18, 0], + cube: cubeDef("Chest", 0, 18, 0, 8, 12, 4) + }, + { + name: "Pelvis", + origin: [0, 12, 0], + cube: cubeDef("Pelvis", 0, 12, 0, 8, 4, 4) + }, + { + name: "Arm_Right", + origin: [8, 18, 0], + cube: cubeDef("Arm_Right", 8, 18, 0, 4, 12, 4) + }, + { + name: "Arm_Left", + origin: [-8, 18, 0], + cube: cubeDef("Arm_Left", -8, 18, 0, 4, 12, 4) + }, + { + name: "Leg_Right", + origin: [2, 6, 0], + cube: cubeDef("Leg_Right", 2, 6, 0, 4, 12, 4) + }, + { + name: "Leg_Left", + origin: [-2, 6, 0], + cube: cubeDef("Leg_Left", -2, 6, 0, 4, 12, 4) + } + ] + } + ] + } + ] + }, + creature: { + label: "Creature", + format: "hytale_character", + textureWidth: 128, + textureHeight: 128, + bones: [ + { + name: "Root", + origin: [0, 0, 0], + children: [ + { + name: "Body", + origin: [0, 8, 0], + cube: cubeDef("Body", 0, 8, 0, 10, 8, 6) + }, + { + name: "Head", + origin: [0, 14, 0], + cube: cubeDef("Head", 0, 14, 4, 8, 8, 8) + }, + { + name: "Leg_Front_Right", + origin: [3, 4, 3], + cube: cubeDef("Leg_Front_Right", 3, 3, 3, 3, 6, 3) + }, + { + name: "Leg_Front_Left", + origin: [-3, 4, 3], + cube: cubeDef("Leg_Front_Left", -3, 3, 3, 3, 6, 3) + }, + { + name: "Leg_Back_Right", + origin: [3, 4, -3], + cube: cubeDef("Leg_Back_Right", 3, 3, -3, 3, 6, 3) + }, + { + name: "Leg_Back_Left", + origin: [-3, 4, -3], + cube: cubeDef("Leg_Back_Left", -3, 3, -3, 3, 6, 3) + } + ] + } + ] + }, + helmet: { + label: "Helmet", + format: "hytale_character", + textureWidth: 64, + textureHeight: 64, + bones: [{ + name: "Head-Attachment", + origin: [0, 0, 0], + children: [{ + name: "Helmet", + origin: [0, 28, 0], + cube: cubeDef("Helmet", 0, 28, 0, 10, 10, 10) + }] + }] + }, + chestplate: { + label: "Chestplate", + format: "hytale_character", + textureWidth: 128, + textureHeight: 128, + bones: [{ + name: "Body-Attachment", + origin: [0, 0, 0], + children: [ + { + name: "Torso", + origin: [0, 18, 0], + cube: cubeDef("Torso", 0, 18, 0, 10, 12, 6) + }, + { + name: "Shoulder_Right", + origin: [7, 24, 0], + cube: cubeDef("Shoulder_Right", 7, 24, 0, 6, 4, 6) + }, + { + name: "Shoulder_Left", + origin: [-7, 24, 0], + cube: cubeDef("Shoulder_Left", -7, 24, 0, 6, 4, 6) + } + ] + }] + }, + boots: { + label: "Boots", + format: "hytale_character", + textureWidth: 64, + textureHeight: 64, + bones: [{ + name: "Feet-Attachment", + origin: [0, 0, 0], + children: [ + { + name: "Boot_Right", + origin: [2, 2, 0], + cube: cubeDef("Boot_Right", 2, 2, 0, 6, 4, 6) + }, + { + name: "Boot_Left", + origin: [-2, 2, 0], + cube: cubeDef("Boot_Left", -2, 2, 0, 6, 4, 6) + } + ] + }] + }, + gauntlets: { + label: "Gauntlets", + format: "hytale_character", + textureWidth: 64, + textureHeight: 64, + bones: [{ + name: "Hands-Attachment", + origin: [0, 0, 0], + children: [ + { + name: "Gauntlet_Right", + origin: [8, 12, 0], + cube: cubeDef("Gauntlet_Right", 8, 12, 0, 6, 6, 6) + }, + { + name: "Gauntlet_Left", + origin: [-8, 12, 0], + cube: cubeDef("Gauntlet_Left", -8, 12, 0, 6, 6, 6) + } + ] + }] + }, + staff: { + label: "Staff", + format: "hytale_prop", + textureWidth: 64, + textureHeight: 64, + bones: [{ + name: "R-Attachment", + origin: [0, 0, 0], + children: [{ + name: "Shaft", + origin: [0, 16, 0], + cube: cubeDef("Shaft", 0, 16, 0, 2, 32, 2), + children: [{ + name: "Orb", + origin: [0, 34, 0], + cube: cubeDef("Orb", 0, 34, 0, 6, 6, 6) + }] + }] + }] + }, + spear: { + label: "Spear", + format: "hytale_prop", + textureWidth: 64, + textureHeight: 64, + bones: [{ + name: "R-Attachment", + origin: [0, 0, 0], + children: [{ + name: "Shaft", + origin: [0, 18, 0], + cube: cubeDef("Shaft", 0, 18, 0, 2, 36, 2), + children: [{ + name: "Tip", + origin: [0, 38, 0], + cube: cubeDef("Tip", 0, 38, 0, 4, 6, 2) + }] + }] + }] + }, + hammer: { + label: "Hammer", + format: "hytale_prop", + textureWidth: 64, + textureHeight: 64, + bones: [{ + name: "R-Attachment", + origin: [0, 0, 0], + children: [{ + name: "Handle", + origin: [0, 12, 0], + cube: cubeDef("Handle", 0, 12, 0, 3, 24, 3), + children: [{ + name: "Head", + origin: [0, 24, 0], + cube: cubeDef("Head", 0, 24, 0, 10, 6, 8) + }] + }] + }] + }, + wand: { + label: "Wand", + format: "hytale_prop", + textureWidth: 32, + textureHeight: 32, + bones: [{ + name: "R-Attachment", + origin: [0, 0, 0], + children: [{ + name: "Stick", + origin: [0, 8, 0], + cube: cubeDef("Stick", 0, 8, 0, 2, 16, 2), + children: [{ + name: "Crystal", + origin: [0, 17, 0], + cube: cubeDef("Crystal", 0, 17, 0, 3, 3, 3) + }] + }] + }] + }, + tree: { + label: "Tree", + format: "hytale_prop", + textureWidth: 128, + textureHeight: 128, + bones: [{ + name: "Root", + origin: [0, 0, 0], + children: [ + { + name: "Trunk", + origin: [0, 12, 0], + cube: cubeDef("Trunk", 0, 12, 0, 6, 24, 6) + }, + { + name: "Canopy", + origin: [0, 28, 0], + cube: cubeDef("Canopy", 0, 28, 0, 16, 14, 16) + } + ] + }] + }, + rock: { + label: "Rock", + format: "hytale_prop", + textureWidth: 32, + textureHeight: 32, + bones: [{ + name: "Root", + origin: [0, 0, 0], + children: [{ + name: "Body", + origin: [0, 4, 0], + cube: cubeDef("Body", 0, 4, 0, 10, 8, 10) + }] + }] + }, + crate: { + label: "Crate", + format: "hytale_prop", + textureWidth: 64, + textureHeight: 64, + bones: [{ + name: "Root", + origin: [0, 0, 0], + children: [ + { + name: "Body", + origin: [0, 6, 0], + cube: cubeDef("Body", 0, 6, 0, 12, 12, 12) + }, + { + name: "Lid", + origin: [0, 13, 0], + cube: cubeDef("Lid", 0, 13, 0, 14, 2, 14) + } + ] + }] + }, + bird: { + label: "Bird", + format: "hytale_character", + textureWidth: 64, + textureHeight: 64, + bones: [{ + name: "Root", + origin: [0, 0, 0], + children: [ + { + name: "Body", + origin: [0, 6, 0], + cube: cubeDef("Body", 0, 6, 0, 6, 5, 8) + }, + { + name: "Head", + origin: [0, 10, 5], + cube: cubeDef("Head", 0, 10, 5, 5, 5, 5) + }, + { + name: "Wing_Right", + origin: [4, 8, 0], + cube: cubeDef("Wing_Right", 6, 7, 0, 6, 2, 6) + }, + { + name: "Wing_Left", + origin: [-4, 8, 0], + cube: cubeDef("Wing_Left", -6, 7, 0, 6, 2, 6) + }, + { + name: "Tail", + origin: [0, 6, -4], + cube: cubeDef("Tail", 0, 7, -6, 4, 2, 4) + } + ] + }] + }, + fish: { + label: "Fish", + format: "hytale_character", + textureWidth: 64, + textureHeight: 64, + bones: [{ + name: "Root", + origin: [0, 0, 0], + children: [ + { + name: "Body", + origin: [0, 4, 0], + cube: cubeDef("Body", 0, 4, 0, 4, 6, 12) + }, + { + name: "Head", + origin: [0, 5, 8], + cube: cubeDef("Head", 0, 5, 8, 5, 5, 4) + }, + { + name: "Tail", + origin: [0, 5, -8], + cube: cubeDef("Tail", 0, 5, -8, 2, 6, 4) + }, + { + name: "Fin_Top", + origin: [0, 9, 0], + cube: cubeDef("Fin_Top", 0, 9, 0, 1, 3, 4) + } + ] + }] + } + }; + function buildBoneHierarchy(boneDef, parent) { + const group = new Group({ + name: boneDef.name, + origin: boneDef.origin + }).init(); + if (parent) { + group.addTo(parent); + } + if (boneDef.cube) { + new Cube({ + name: boneDef.cube.name, + from: boneDef.cube.from, + to: boneDef.cube.to + }).init().addTo(group); + } + if (boneDef.children) { + for (const childDef of boneDef.children) { + buildBoneHierarchy(childDef, group); + } + } + return group; + } + function resolveFormat(formatId) { + if (Formats[formatId]) { + return Formats[formatId]; + } + if (Formats.free) { + Blockbench.showQuickMessage( + `Hytale plugin format "${formatId}" not found. Using free format instead.`, + 3e3 + ); + return Formats.free; + } + return null; + } + function createFromTemplate(template, assetName) { + const format = resolveFormat(template.format); + if (!format) { + Blockbench.showMessageBox({ + title: "Template Wizard - Error", + message: "Could not find a compatible format. Please ensure the Hytale Model Creator plugin is installed.", + icon: "error" + }); + return; + } + newProject(format); + Project.texture_width = template.textureWidth; + Project.texture_height = template.textureHeight; + if (assetName) { + Project.name = assetName; + } + for (const rootBone of template.bones) { + buildBoneHierarchy(rootBone); + } + const tex = new Texture({ + name: assetName || template.label, + width: template.textureWidth, + height: template.textureHeight + }).add(false).fillRecursive("#808080"); + if (typeof Canvas !== "undefined" && Canvas.updateAll) { + Canvas.updateAll(); + } + Blockbench.showQuickMessage( + `Created ${template.label} template "${assetName || "Untitled"}" (${template.textureWidth}x${template.textureHeight})`, + 3e3 + ); + } + function buildTemplateOptions() { + const CATEGORIES = { + "Weapons": ["sword", "axe", "pickaxe", "bow", "staff", "spear", "hammer", "wand"], + "Defense": ["shield"], + "Armor": ["helmet", "chestplate", "boots", "gauntlets"], + "Props": ["furniture", "decoration", "crate"], + "Environment": ["tree", "rock"], + "Characters": ["character"], + "Creatures": ["creature", "bird", "fish"] + }; + const options = {}; + for (const [category, keys] of Object.entries(CATEGORIES)) { + for (const key of keys) { + const tpl = TEMPLATES[key]; + if (tpl) { + const formatLabel = tpl.format === "hytale_character" ? "Character" : "Prop"; + options[key] = `[${category}] ${tpl.label} (${formatLabel} - ${tpl.textureWidth}x${tpl.textureHeight})`; + } + } + } + return options; + } + function showTemplateWizardDialog() { + new Dialog({ + id: "myrlin_template_wizard_dialog", + title: "New Hytale Asset - Myrlin's Tools", + form: { + asset_type: { + label: "Asset Type", + type: "select", + options: buildTemplateOptions(), + value: "sword" + }, + asset_name: { + label: "Asset Name", + type: "text", + value: "", + placeholder: "e.g. Diamond Sword" + } + }, + onConfirm(formData) { + const templateKey = formData.asset_type; + const template = TEMPLATES[templateKey]; + if (!template) { + Blockbench.showQuickMessage("Unknown template type selected.", 2e3); + return; + } + createFromTemplate(template, formData.asset_name); + } + }).show(); + } + function setupTemplateWizard() { + const hytaleAvailable = !!(Formats && (Formats.hytale_prop || Formats.hytale_character)); + if (!hytaleAvailable) { + console.warn( + "[Myrlin's Tools] Hytale Model Creator plugin not detected. Template Wizard will fall back to free format." + ); + } + const wizardAction = new Action("myrlin_template_wizard", { + name: "New Hytale Asset", + description: "Create a new Hytale model from a structured template (Myrlin's Tools)", + icon: "note_add", + category: "file", + click: () => { + const hytaleReady = !!(Formats && (Formats.hytale_prop || Formats.hytale_character)); + if (!hytaleReady) { + Blockbench.showMessageBox({ + title: "Hytale Plugin Required", + message: 'The Hytale Model Creator plugin is not installed.\n\nTemplates will use the "free" format instead, which may not include Hytale-specific features like attachment points.\n\nInstall the Hytale Model Creator plugin from the Blockbench plugin store for the best experience.', + icon: "warning", + buttons: ["Continue Anyway", "Cancel"], + confirm: 0, + cancel: 1, + // @ts-ignore + onConfirm: () => { + showTemplateWizardDialog(); + } + }); + return; + } + showTemplateWizardDialog(); + } + }); + track(wizardAction); + MenuBar.addAction(wizardAction, "file"); + } + + // src/features/validator.ts + var HYTALE_MAX_NODES3 = 255; + var HYTALE_WARN_NODES = 200; + var CHARACTER_PIXELS_PER_UNIT = 64; + var PROP_PIXELS_PER_UNIT = 32; + var PIXEL_RATIO_TOLERANCE = 0.5; + var MIN_STRETCH_RATIO = 0.7; + var MAX_STRETCH_RATIO = 1.3; + var MAX_TEXTURE_DIMENSION = 512; + var MIN_TEXTURE_DIMENSION = 16; + var REQUIRED_CHARACTER_BONES = [ + { + canonical: "Head", + variations: ["head"] + }, + { + canonical: "Body", + variations: ["body", "torso", "chest"] + }, + { + canonical: "Arm_Right", + variations: ["arm_right", "armright", "arm right", "right_arm", "rightarm", "right arm"] + }, + { + canonical: "Arm_Left", + variations: ["arm_left", "armleft", "arm left", "left_arm", "leftarm", "left arm"] + }, + { + canonical: "Leg_Right", + variations: ["leg_right", "legright", "leg right", "right_leg", "rightleg", "right leg"] + }, + { + canonical: "Leg_Left", + variations: ["leg_left", "legleft", "leg left", "left_leg", "leftleg", "left leg"] + } + ]; + function worstSeverity(a, b) { + const priority = { pass: 0, warn: 1, fail: 2 }; + return priority[a] >= priority[b] ? a : b; + } + function isCharacterFormat() { + return Format && Format.id && Format.id.toLowerCase().includes("character"); + } + function isPropFormat() { + return Format && Format.id && Format.id.toLowerCase().includes("prop"); + } + function isPowerOfTwo(n) { + return n > 0 && (n & n - 1) === 0; + } + function severityIcon(severity) { + switch (severity) { + case "pass": + return "\u2705"; + case "warn": + return "\u26A0\uFE0F"; + case "fail": + return "\u274C"; + } + } + function severityLabel(severity) { + switch (severity) { + case "pass": + return "PASS"; + case "warn": + return "WARN"; + case "fail": + return "FAIL"; + } + } + function checkNodeCount() { + const exportableCubes = Cube.all.filter((c) => c.export !== false); + const exportableGroups = Group.all.filter((g) => g.export !== false); + let freeSlots = 0; + for (const group of exportableGroups) { + const groupHasCube = exportableCubes.some((cube) => { + let parent = cube.parent; + while (parent) { + if (parent === group) + return true; + parent = parent.parent; + } + return false; + }); + if (groupHasCube) { + freeSlots++; + } + } + const rawCount = exportableCubes.length + exportableGroups.length; + const effectiveCount = rawCount - freeSlots; + let severity = "pass"; + if (effectiveCount > HYTALE_MAX_NODES3) { + severity = "fail"; + } else if (effectiveCount > HYTALE_WARN_NODES) { + severity = "warn"; + } + const details = freeSlots > 0 ? `${exportableCubes.length} cubes + ${exportableGroups.length} groups = ${rawCount} raw nodes. ${freeSlots} free slot(s) from first-cube-in-group rule. Effective: ${effectiveCount}.` : `${exportableCubes.length} cubes + ${exportableGroups.length} groups = ${effectiveCount} nodes.`; + return { + id: "node_count", + name: "Node Count", + severity, + message: `${effectiveCount} / ${HYTALE_MAX_NODES3} nodes`, + details + }; + } + function checkPixelRatio() { + const textures = Texture.all; + if (!textures || textures.length === 0) { + return { + id: "pixel_ratio", + name: "Pixel Ratio", + severity: "pass", + message: "Skipped (no texture)", + details: "No texture found in the model. This check requires at least one texture." + }; + } + const texture = textures[0]; + const texWidth = texture.width || 0; + const texHeight = texture.height || 0; + if (texWidth === 0 || texHeight === 0) { + return { + id: "pixel_ratio", + name: "Pixel Ratio", + severity: "pass", + message: "Skipped (texture has no dimensions)", + details: "Texture dimensions could not be determined." + }; + } + let expectedRatio = PROP_PIXELS_PER_UNIT; + let formatName = "prop"; + if (isCharacterFormat()) { + expectedRatio = CHARACTER_PIXELS_PER_UNIT; + formatName = "character"; + } else if (isPropFormat()) { + expectedRatio = PROP_PIXELS_PER_UNIT; + formatName = "prop"; + } else { + return { + id: "pixel_ratio", + name: "Pixel Ratio", + severity: "pass", + message: "Skipped (non-Hytale format)", + details: "Pixel ratio check only applies to Hytale character and prop formats." + }; + } + const cubes = Cube.all.filter((c) => c.export !== false); + if (cubes.length === 0) { + return { + id: "pixel_ratio", + name: "Pixel Ratio", + severity: "pass", + message: "Skipped (no cubes)", + details: "No exportable cubes found in the model." + }; + } + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + for (const cube of cubes) { + minX = Math.min(minX, cube.from[0]); + minY = Math.min(minY, cube.from[1]); + minZ = Math.min(minZ, cube.from[2]); + maxX = Math.max(maxX, cube.to[0]); + maxY = Math.max(maxY, cube.to[1]); + maxZ = Math.max(maxZ, cube.to[2]); + } + const modelSizeUnits = Math.max(maxX - minX, maxY - minY, maxZ - minZ); + if (modelSizeUnits <= 0) { + return { + id: "pixel_ratio", + name: "Pixel Ratio", + severity: "pass", + message: "Skipped (zero-size model)", + details: "Model has zero extent in all dimensions." + }; + } + const maxTexDim = Math.max(texWidth, texHeight); + const actualRatio = maxTexDim / modelSizeUnits; + const deviation = Math.abs(actualRatio - expectedRatio) / expectedRatio; + let severity = "pass"; + if (deviation > PIXEL_RATIO_TOLERANCE) { + severity = "warn"; + } + const ratioFormatted = actualRatio.toFixed(1); + return { + id: "pixel_ratio", + name: "Pixel Ratio", + severity, + message: severity === "pass" ? `${ratioFormatted} px/unit (expected ~${expectedRatio} for ${formatName})` : `${ratioFormatted} px/unit (expected ~${expectedRatio}, off by ${(deviation * 100).toFixed(0)}%)`, + details: `Texture: ${texWidth}x${texHeight}, Model extent: ${modelSizeUnits.toFixed(1)} units, Actual ratio: ${ratioFormatted} px/unit, Expected: ~${expectedRatio} px/unit for ${formatName} format. Deviation: ${(deviation * 100).toFixed(1)}% (threshold: ${PIXEL_RATIO_TOLERANCE * 100}%).` + }; + } + function checkStretchLimits() { + const cubes = Cube.all.filter((c) => c.export !== false); + if (cubes.length === 0) { + return { + id: "stretch_limits", + name: "Stretch Limits", + severity: "pass", + message: "No cubes to check", + details: "No exportable cubes found in the model." + }; + } + const violatingCubes = []; + for (const cube of cubes) { + const sizeX = Math.abs(cube.to[0] - cube.from[0]); + const sizeY = Math.abs(cube.to[1] - cube.from[1]); + const sizeZ = Math.abs(cube.to[2] - cube.from[2]); + const minDim = Math.min(sizeX, sizeY, sizeZ); + if (minDim <= 0) + continue; + const ratioX = sizeX / minDim; + const ratioY = sizeY / minDim; + const ratioZ = sizeZ / minDim; + const maxRatio = Math.max(ratioX, ratioY, ratioZ); + if (maxRatio > MAX_STRETCH_RATIO) { + const cubeName = cube.name || cube.uuid || "unnamed"; + violatingCubes.push(`${cubeName} (${maxRatio.toFixed(2)}x)`); + } + } + const count = violatingCubes.length; + if (count > 0) { + const shownCubes = violatingCubes.slice(0, 5); + const moreCount = count - shownCubes.length; + let detailList = shownCubes.join(", "); + if (moreCount > 0) { + detailList += `, and ${moreCount} more`; + } + return { + id: "stretch_limits", + name: "Stretch Limits", + severity: "fail", + message: `${count} cube${count !== 1 ? "s" : ""} exceed stretch limits`, + details: `Cubes with stretch ratio > ${MAX_STRETCH_RATIO}x (allowed range: ${MIN_STRETCH_RATIO}x - ${MAX_STRETCH_RATIO}x): ${detailList}. The stretch ratio is the largest dimension divided by the smallest dimension of each cube.` + }; + } + return { + id: "stretch_limits", + name: "Stretch Limits", + severity: "pass", + message: "All cubes within stretch limits", + details: `All ${cubes.length} exportable cubes have stretch ratios within the ${MIN_STRETCH_RATIO}x - ${MAX_STRETCH_RATIO}x range.` + }; + } + function checkBoneNaming() { + if (!isCharacterFormat()) { + return { + id: "bone_naming", + name: "Bone Naming", + severity: "pass", + message: "Skipped (non-character format)", + details: "Bone naming validation only applies to Hytale character formats." + }; + } + const groups = Group.all; + if (!groups || groups.length === 0) { + return { + id: "bone_naming", + name: "Bone Naming", + severity: "warn", + message: "No bones found", + details: "No groups (bones) were found in the model. Character models require standard bone hierarchy." + }; + } + const groupNamesLower = groups.map((g) => (g.name || "").toLowerCase().trim()); + const missingBones = []; + const foundBones = []; + for (const bone of REQUIRED_CHARACTER_BONES) { + const found = bone.variations.some( + (variation) => groupNamesLower.includes(variation.toLowerCase()) + ); + if (found) { + foundBones.push(bone.canonical); + } else { + missingBones.push(bone.canonical); + } + } + if (missingBones.length > 0) { + return { + id: "bone_naming", + name: "Bone Naming", + severity: "warn", + message: `${missingBones.length} required bone${missingBones.length !== 1 ? "s" : ""} missing`, + details: `Missing: ${missingBones.join(", ")}. Found: ${foundBones.join(", ") || "none"}. Required bones for character format: ${REQUIRED_CHARACTER_BONES.map((b) => b.canonical).join(", ")}.` + }; + } + return { + id: "bone_naming", + name: "Bone Naming", + severity: "pass", + message: "All required bones present", + details: `Found all ${REQUIRED_CHARACTER_BONES.length} required bones: ${foundBones.join(", ")}.` + }; + } + function checkTextureResolution() { + const textures = Texture.all; + if (!textures || textures.length === 0) { + return { + id: "texture_resolution", + name: "Texture Resolution", + severity: "pass", + message: "Skipped (no texture)", + details: "No texture found in the model. This check requires at least one texture." + }; + } + const texture = textures[0]; + const width = texture.width || 0; + const height = texture.height || 0; + if (width === 0 || height === 0) { + return { + id: "texture_resolution", + name: "Texture Resolution", + severity: "warn", + message: "Texture has no dimensions", + details: "Texture dimensions could not be determined. Ensure the texture is loaded correctly." + }; + } + const widthIsPow2 = isPowerOfTwo(width); + const heightIsPow2 = isPowerOfTwo(height); + if (!widthIsPow2 || !heightIsPow2) { + const nonPow2Dims = []; + if (!widthIsPow2) + nonPow2Dims.push(`width=${width}`); + if (!heightIsPow2) + nonPow2Dims.push(`height=${height}`); + return { + id: "texture_resolution", + name: "Texture Resolution", + severity: "fail", + message: `Non-power-of-2: ${width}x${height}`, + details: `Texture dimensions must be powers of 2 (e.g. 16, 32, 64, 128, 256, 512). Non-compliant: ${nonPow2Dims.join(", ")}.` + }; + } + const maxDim = Math.max(width, height); + const minDim = Math.min(width, height); + if (maxDim > MAX_TEXTURE_DIMENSION) { + return { + id: "texture_resolution", + name: "Texture Resolution", + severity: "warn", + message: `Large texture: ${width}x${height} (>${MAX_TEXTURE_DIMENSION})`, + details: `Texture dimensions (${width}x${height}) exceed the recommended maximum of ${MAX_TEXTURE_DIMENSION}px. Large textures increase file size and may cause performance issues in Hytale.` + }; + } + if (minDim < MIN_TEXTURE_DIMENSION) { + return { + id: "texture_resolution", + name: "Texture Resolution", + severity: "warn", + message: `Small texture: ${width}x${height} (<${MIN_TEXTURE_DIMENSION})`, + details: `Texture dimensions (${width}x${height}) are below the recommended minimum of ${MIN_TEXTURE_DIMENSION}px. Small textures may lack detail for the model.` + }; + } + return { + id: "texture_resolution", + name: "Texture Resolution", + severity: "pass", + message: `${width}x${height} (power-of-2)`, + details: `Texture resolution ${width}x${height} is valid: power-of-2 and within the ${MIN_TEXTURE_DIMENSION}-${MAX_TEXTURE_DIMENSION}px recommended range.` + }; + } + function validateModel() { + const results = [ + checkNodeCount(), + checkPixelRatio(), + checkStretchLimits(), + checkBoneNaming(), + checkTextureResolution() + ]; + let overallSeverity = "pass"; + for (const result of results) { + overallSeverity = worstSeverity(overallSeverity, result.severity); + } + return { + results, + overallSeverity, + timestamp: (/* @__PURE__ */ new Date()).toISOString() + }; + } + function formatReportLines(report) { + const lines = []; + const overallIcon = severityIcon(report.overallSeverity); + const overallLabel = severityLabel(report.overallSeverity); + lines.push(`## ${overallIcon} Overall: **${overallLabel}**`); + lines.push(""); + lines.push("---"); + lines.push(""); + for (const result of report.results) { + const icon = severityIcon(result.severity); + lines.push(`${icon} **${result.name}**: ${result.message}`); + if (result.details) { + lines.push(` _${result.details}_`); + } + lines.push(""); + } + lines.push("---"); + lines.push(`_Validated at ${new Date(report.timestamp).toLocaleTimeString()}_`); + return lines; + } + function setupValidator() { + const validateAction = new Action("myrlin_validate", { + name: "Validate Hytale Model", + description: "Check model against Hytale art style requirements (Myrlin's Tools)", + icon: "verified", + category: "tools", + click: () => { + const report = validateModel(); + const lines = formatReportLines(report); + let titleSuffix = ""; + switch (report.overallSeverity) { + case "pass": + titleSuffix = " - All Checks Passed"; + break; + case "warn": + titleSuffix = " - Warnings Found"; + break; + case "fail": + titleSuffix = " - Issues Found"; + break; + } + new Dialog({ + id: "myrlin_validate_dialog", + title: `Validate Hytale Model${titleSuffix}`, + lines, + buttons: ["Close"], + onConfirm: () => { + } + }).show(); + const passCount = report.results.filter((r) => r.severity === "pass").length; + const warnCount = report.results.filter((r) => r.severity === "warn").length; + const failCount = report.results.filter((r) => r.severity === "fail").length; + Blockbench.showQuickMessage( + `Validation: ${passCount} passed, ${warnCount} warnings, ${failCount} failed`, + 2e3 + ); + } + }); + track(validateAction); + MenuBar.addAction(validateAction, "tools"); + } + + // src/features/config_generator.ts + function generateItemConfig(form) { + return { + TranslationProperties: { + Name: `server.items.${form.assetId}.name` + }, + Categories: [`Items.${form.category}`], + Scale: 1, + Model: `Models/Items/${form.assetId}/${form.assetId}.blockymodel`, + Texture: `Textures/Items/${form.assetId}/${form.assetId}.png`, + Icon: `Icons/Items/${form.assetId}.png`, + Quality: form.quality, + Tags: { + Type: [form.category] + } + }; + } + function generateBlockConfig(form) { + return { + Id: `${form.modName}:${form.assetId}`, + DisplayName: { + en: form.displayName + }, + BlockType: { + Material: "Solid", + DrawType: "Cube", + Textures: [ + { All: `Textures/Blocks/${form.assetId}.png` } + ], + BlockSoundSetId: "stone" + } + }; + } + function generateEntityConfig(form) { + return { + Id: `${form.modName}:${form.assetId}`, + DisplayName: { + en: form.displayName + }, + ModelId: `Models/Creatures/${form.assetId}/${form.assetId}.blockymodel`, + TextureId: `Textures/Creatures/${form.assetId}/${form.assetId}.png`, + Stats: { + Health: 20, + Speed: 0.25 + }, + Behavior: { + Type: "Creature" + } + }; + } + function generateManifest(form) { + return { + name: form.modName, + version: "1.0.0", + description: `Mod package containing ${form.displayName} (${form.assetType})`, + author: "Myrlin Forge", + assets: [ + { + type: form.assetType.toLowerCase(), + id: form.assetId, + displayName: form.displayName + } + ] + }; + } + function getModelPath(assetType, assetId) { + switch (assetType) { + case "Item": + return `Common/Models/Items/${assetId}/${assetId}.blockymodel`; + case "Block": + return `Common/Models/Blocks/${assetId}/${assetId}.blockymodel`; + case "Entity": + return `Common/Models/Creatures/${assetId}/${assetId}.blockymodel`; + default: + return `Common/Models/${assetId}/${assetId}.blockymodel`; + } + } + function getTexturePath(assetType, assetId) { + switch (assetType) { + case "Item": + return `Common/Textures/Items/${assetId}/${assetId}.png`; + case "Block": + return `Common/Textures/Blocks/${assetId}.png`; + case "Entity": + return `Common/Textures/Creatures/${assetId}/${assetId}.png`; + default: + return `Common/Textures/${assetId}/${assetId}.png`; + } + } + function getConfigPath(assetType, assetId) { + switch (assetType) { + case "Item": + return `Server/Item/Items/${assetId}.json`; + case "Block": + return `Server/Block/Blocks/${assetId}.json`; + case "Entity": + return `Server/Entity/Entities/${assetId}.json`; + default: + return `Server/${assetId}.json`; + } + } + function dataUrlToUint8Array(dataUrl) { + const base64 = dataUrl.split(",")[1]; + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + } + function extractTextureData() { + const texture = Texture.all[0]; + if (!texture) { + return null; + } + try { + const dataUrl = texture.canvas.toDataURL("image/png"); + return dataUrlToUint8Array(dataUrl); + } catch (e) { + console.warn("[Myrlin's Tools] Failed to extract texture data:", e); + return null; + } + } + async function generateModPackage(form) { + let config; + switch (form.assetType) { + case "Item": + config = generateItemConfig(form); + break; + case "Block": + config = generateBlockConfig(form); + break; + case "Entity": + config = generateEntityConfig(form); + break; + default: + config = generateItemConfig(form); + } + const manifest = generateManifest(form); + const modelData = Codecs.blockymodel ? Codecs.blockymodel.compile() : JSON.stringify({}); + const textureData = extractTextureData(); + const zip = new JSZip(); + zip.file("manifest.json", JSON.stringify(manifest, null, 2)); + const configPath = getConfigPath(form.assetType, form.assetId); + zip.file(configPath, JSON.stringify(config, null, 2)); + const modelPath = getModelPath(form.assetType, form.assetId); + zip.file(modelPath, typeof modelData === "string" ? modelData : JSON.stringify(modelData, null, 2)); + const texturePath = getTexturePath(form.assetType, form.assetId); + if (textureData) { + zip.file(texturePath, textureData); + } + const blob = await zip.generateAsync({ type: "blob" }); + const filename = `${form.modName}.zip`; + try { + Blockbench.export({ + type: "Zip Archive", + extensions: ["zip"], + name: filename, + content: blob, + savetype: "zip" + }); + } catch (_exportError) { + try { + saveAs(blob, filename); + } catch (saveError) { + console.error("[Myrlin's Tools] Failed to save zip:", saveError); + Blockbench.showMessageBox({ + title: "Export Failed", + message: `Failed to save the mod package. Error: ${saveError}`, + icon: "error" + }); + } + } + } + function showConfigDialog() { + new Dialog({ + id: "myrlin_config_generator_dialog", + title: "Export Hytale Mod Package - Myrlin's Tools", + form: { + assetType: { + label: "Asset Type", + type: "select", + options: { + Item: "Item", + Block: "Block", + Entity: "Entity" + }, + value: "Item" + }, + assetId: { + label: "Asset ID", + type: "text", + placeholder: "e.g., crystal_sword", + value: "" + }, + displayName: { + label: "Display Name", + type: "text", + placeholder: "e.g., Crystal Sword", + value: "" + }, + modName: { + label: "Mod Name", + type: "text", + placeholder: "e.g., my_weapons_mod", + value: "" + }, + quality: { + label: "Quality (Items only)", + type: "select", + options: { + Common: "Common", + Uncommon: "Uncommon", + Rare: "Rare", + Epic: "Epic", + Legendary: "Legendary" + }, + value: "Common" + }, + category: { + label: "Category (Items only)", + type: "select", + options: { + Sword: "Sword", + Axe: "Axe", + Pickaxe: "Pickaxe", + Bow: "Bow", + Shield: "Shield", + Tool: "Tool", + Misc: "Misc" + }, + value: "Sword" + } + }, + onConfirm: (formResult) => { + if (!formResult.assetId || formResult.assetId.trim() === "") { + Blockbench.showQuickMessage("Asset ID is required.", 2e3); + return; + } + if (!formResult.displayName || formResult.displayName.trim() === "") { + Blockbench.showQuickMessage("Display Name is required.", 2e3); + return; + } + if (!formResult.modName || formResult.modName.trim() === "") { + Blockbench.showQuickMessage("Mod Name is required.", 2e3); + return; + } + const sanitizedId = formResult.assetId.trim().toLowerCase().replace(/[^a-z0-9_]/g, "_"); + const configForm = { + assetType: formResult.assetType, + assetId: sanitizedId, + displayName: formResult.displayName.trim(), + modName: formResult.modName.trim().toLowerCase().replace(/[^a-z0-9_]/g, "_"), + quality: formResult.quality, + category: formResult.category + }; + generateModPackage(configForm).then(() => { + Blockbench.showQuickMessage( + `Mod package "${configForm.modName}.zip" exported successfully!`, + 3e3 + ); + }).catch((err) => { + console.error("[Myrlin's Tools] Config generator error:", err); + Blockbench.showMessageBox({ + title: "Export Error", + message: `Failed to generate mod package: ${err.message}`, + icon: "error" + }); + }); + } + }).show(); + } + function setupConfigGenerator() { + const configGeneratorAction = new Action("myrlin_config_generator", { + name: "Export Hytale Mod Package", + description: "Export model with JSON configs as a Hytale mod package (.zip) (Myrlin's Tools)", + icon: "inventory_2", + category: "file", + condition: () => { + return Cube.all.length > 0; + }, + click: () => { + showConfigDialog(); + } + }); + track(configGeneratorAction); + MenuBar.addAction(configGeneratorAction, "file"); + } + + // src/api-client.ts + var MYRLIN_API2 = "https://api.myrlin.io"; + var API_TIMEOUT = 1e4; + var HEALTH_TIMEOUT = 3e3; + function localValidation(modelData) { + const issues = []; + const nodes = modelData.nodes; + if (!nodes || !Array.isArray(nodes)) { + issues.push({ + severity: "error", + code: "NO_NODES", + message: "Model has no nodes array", + auto_fixable: false + }); + return { + valid: false, + issues, + summary: { error: 1, warning: 0, info: 0 } + }; + } + if (nodes.length > 255) { + issues.push({ + severity: "error", + code: "NODE_LIMIT", + message: `Node count ${nodes.length} exceeds Hytale limit of 255`, + auto_fixable: false + }); + } + let zeroSizeCount = 0; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (!node) + continue; + const from = node.from; + const to = node.to; + if (from && to && Array.isArray(from) && Array.isArray(to) && from.length >= 3 && to.length >= 3) { + const dx = Math.abs(to[0] - from[0]); + const dy = Math.abs(to[1] - from[1]); + const dz = Math.abs(to[2] - from[2]); + if (dx === 0 || dy === 0 || dz === 0) { + zeroSizeCount++; + issues.push({ + severity: "warning", + code: "ZERO_SIZE", + message: `Node at index ${i} has zero size on one or more axes`, + location: `nodes[${i}]`, + auto_fixable: true + }); + } + } + } + const errorCount = issues.filter((i) => i.severity === "error").length; + const warningCount = issues.filter((i) => i.severity === "warning").length; + const infoCount = issues.filter((i) => i.severity === "info").length; + return { + valid: errorCount === 0, + issues, + summary: { error: errorCount, warning: warningCount, info: infoCount } + }; + } + var MyrlinApiClient = class { + constructor(baseUrl = MYRLIN_API2) { + this.baseUrl = baseUrl; + this.online = true; + } + /** + * Check if the API is reachable. + * Pings the /health endpoint with a short timeout. + * + * @returns true if the API responded successfully + */ + async checkConnection() { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT); + const resp = await fetch(`${this.baseUrl}/health`, { + method: "GET", + signal: controller.signal + }); + clearTimeout(timer); + this.online = resp.ok; + } catch { + this.online = false; + } + return this.online; + } + /** + * Validate a model against engine-specific rules. + * Falls back to local validation when the API is unreachable. + * + * @param modelData - The model data object to validate + * @param engine - Target engine for validation rules (default: 'hytale') + * @returns Validation report with issues and summary + */ + async validate(modelData, engine = "hytale") { + if (!this.online) { + return localValidation(modelData); + } + const result = await this._post("/api/tools/validate", { + model: modelData, + engine + }); + if (!result) { + return localValidation(modelData); + } + return result; + } + /** + * Request repairs on a model (Phase 28 endpoint). + * Returns a not-available message when offline. + * + * @param modelData - The model data object to repair + * @param repairs - List of repair operation identifiers to apply + * @returns Repair result with applied changes + */ + async repair(modelData, repairs) { + if (!this.online) { + return { + success: false, + model_data: null, + repairs_applied: [], + message: "Repair requires an API connection. Check your network and try again." + }; + } + const result = await this._post("/api/tools/repair", { + model: modelData, + repairs + }); + if (!result) { + return { + success: false, + model_data: null, + repairs_applied: [], + message: "Repair request failed. The API may be temporarily unavailable." + }; + } + return result; + } + /** + * Export model to a different format (Phase 29 endpoint). + * Returns a not-available message when offline. + * + * @param modelData - The model data object to export + * @param targetFormat - Target format identifier (e.g. 'blockymodel', 'gltf') + * @returns Export result with encoded data + */ + async export(modelData, targetFormat) { + if (!this.online) { + return { + success: false, + data: null, + format: targetFormat, + message: "Export requires an API connection. Check your network and try again." + }; + } + const result = await this._post("/api/tools/export", { + model: modelData, + format: targetFormat + }); + if (!result) { + return { + success: false, + data: null, + format: targetFormat, + message: "Export request failed. The API may be temporarily unavailable." + }; + } + return result; + } + /** + * Internal fetch wrapper with timeout and error handling. + * Sets Content-Type to JSON and uses AbortController for timeout. + * + * @param path - API path to POST to (e.g. '/api/tools/validate') + * @param body - Request body to send as JSON + * @returns Parsed response or null on failure + */ + async _post(path, body) { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), API_TIMEOUT); + const resp = await fetch(`${this.baseUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal + }); + clearTimeout(timer); + if (!resp.ok) { + console.warn(`[Myrlin API] ${path} returned ${resp.status}`); + return null; + } + const data = await resp.json(); + return data; + } catch (e) { + console.warn(`[Myrlin API] Request to ${path} failed:`, e); + return null; + } + } + }; + var _client = null; + function getApiClient() { + if (!_client) { + _client = new MyrlinApiClient(); + } + return _client; + } + + // src/features/repair_panel.ts + var DIALOG_WIDTH = 640; + var MAX_DISPLAY_ISSUES = 50; + function severityIconHtml(severity) { + switch (severity) { + case "error": + return ''; + case "warning": + return ''; + case "info": + return ''; + default: + return ""; + } + } + function severityPriority(severity) { + switch (severity) { + case "error": + return 0; + case "warning": + return 1; + case "info": + return 2; + default: + return 3; + } + } + function fixableBadge(fixable) { + if (fixable) { + return 'auto-fix'; + } + return 'manual'; + } + function extractModelData() { + try { + if (typeof Codecs !== "undefined" && Codecs.project && typeof Codecs.project.compile === "function") { + return Codecs.project.compile(); + } + if (typeof Project === "undefined" || !Project) { + return null; + } + const cubes = typeof Cube !== "undefined" ? Cube.all : []; + const groups = typeof Group !== "undefined" ? Group.all : []; + if (cubes.length === 0 && groups.length === 0) { + return null; + } + const nodes = []; + for (const cube of cubes) { + nodes.push({ + name: cube.name || "cube", + from: cube.from ? [...cube.from] : [0, 0, 0], + to: cube.to ? [...cube.to] : [1, 1, 1], + type: "cube" + }); + } + return { + // @ts-ignore + name: Project.name || "untitled", + nodes, + meta: { + group_count: groups.length, + cube_count: cubes.length + } + }; + } catch (e) { + console.warn("[Myrlin Repair] Failed to extract model data:", e); + return null; + } + } + function buildIssueListHtml(issues) { + if (issues.length === 0) { + return ` +
+

No issues found

+

Your model passes all validation checks.

+
+ `; + } + const sorted = [...issues].sort((a, b) => severityPriority(a.severity) - severityPriority(b.severity)); + const displayed = sorted.slice(0, MAX_DISPLAY_ISSUES); + const truncated = sorted.length - displayed.length; + let html = '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ""; + for (let i = 0; i < displayed.length; i++) { + const issue = displayed[i]; + const isChecked = issue.severity !== "info"; + const rowBg = i % 2 === 0 ? "#1e1e1e" : "#252525"; + const disabledAttr = issue.auto_fixable ? "" : "disabled"; + const checkedAttr = isChecked && issue.auto_fixable ? "checked" : ""; + html += ``; + html += ``; + html += ``; + html += `"; + html += ``; + html += `"; + html += ""; + } + html += "
CodeMessageTypeFix
${severityIconHtml(issue.severity)}${escapeHtml(issue.code)}${escapeHtml(issue.message)}`; + if (issue.location) { + html += ` (${escapeHtml(issue.location)})`; + } + html += "${fixableBadge(issue.auto_fixable)}`; + html += ``; + html += "
"; + if (truncated > 0) { + html += `

...and ${truncated} more issue(s) not shown.

`; + } + const errorCount = issues.filter((i) => i.severity === "error").length; + const warnCount = issues.filter((i) => i.severity === "warning").length; + const infoCount = issues.filter((i) => i.severity === "info").length; + const fixableCount = issues.filter((i) => i.auto_fixable).length; + html += '
'; + html += `Found: ${errorCount} error(s), `; + html += `${warnCount} warning(s), `; + html += `${infoCount} info`; + html += ` | ${fixableCount} auto-fixable`; + html += "
"; + return html; + } + function escapeHtml(str) { + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function getSelectedRepairCodes(issues) { + const sorted = [...issues].sort((a, b) => severityPriority(a.severity) - severityPriority(b.severity)); + const displayed = sorted.slice(0, MAX_DISPLAY_ISSUES); + const codes = []; + const checkboxes = document.querySelectorAll(".myrlin-repair-check"); + checkboxes.forEach((cb) => { + const input = cb; + if (input.checked && !input.disabled) { + const idx = parseInt(input.getAttribute("data-index") || "-1", 10); + if (idx >= 0 && idx < displayed.length) { + codes.push(displayed[idx].code); + } + } + }); + return codes; + } + function applyRepairedModel(modelData, repairsApplied) { + try { + if (typeof Undo !== "undefined" && Undo.initEdit) { + Undo.initEdit({ elements: Cube.all, outliner: true }); + } + if (typeof Codecs !== "undefined" && Codecs.project && typeof Codecs.project.parse === "function") { + Codecs.project.parse(modelData); + } + if (typeof Undo !== "undefined" && Undo.finishEdit) { + Undo.finishEdit(`Myrlin Auto-Repair (${repairsApplied.length} fix${repairsApplied.length !== 1 ? "es" : ""})`); + } + if (typeof Canvas !== "undefined" && Canvas.updateAll) { + Canvas.updateAll(); + } + } catch (e) { + console.error("[Myrlin Repair] Failed to apply repaired model:", e); + Blockbench.showMessageBox({ + title: "Repair Apply Failed", + message: `Could not apply the repaired model. Error: ${e}`, + icon: "error" + }); + } + } + function showRepairConfirmation(modelData, repairsApplied, message) { + let listHtml = '"; + const bodyHtml = ` +
+

+ Repair completed successfully +

+

${escapeHtml(message)}

+

+ ${repairsApplied.length} repair(s) applied: +

+ ${listHtml} +

+ An undo snapshot will be created so you can revert these changes. +

+
+ `; + new Dialog({ + id: "myrlin_repair_confirm", + title: "Apply Repairs?", + lines: [bodyHtml], + width: 460, + buttons: ["Apply", "Cancel"], + onConfirm: () => { + applyRepairedModel(modelData, repairsApplied); + Blockbench.showQuickMessage( + `Applied ${repairsApplied.length} repair(s) successfully`, + 2e3 + ); + }, + onCancel: () => { + Blockbench.showQuickMessage("Repair cancelled", 1500); + } + }).show(); + } + async function showRepairDialog() { + const modelData = extractModelData(); + if (!modelData) { + Blockbench.showMessageBox({ + title: "No Model Open", + message: "Please open a model before running auto-repair.", + icon: "warning" + }); + return; + } + Blockbench.showQuickMessage("Validating model...", 1500); + const client = getApiClient(); + await client.checkConnection(); + const report = await client.validate(modelData); + const issues = report.issues || []; + const issueHtml = buildIssueListHtml(issues); + const hasFixable = issues.some((i) => i.auto_fixable); + const headerHtml = ` +
+

+ Scanned your model for Hytale compatibility issues. + Select which issues to auto-repair below. +

+
+ `; + const dialog = new Dialog({ + id: "myrlin_repair_dialog", + title: "Auto-Repair Model", + width: DIALOG_WIDTH, + lines: [headerHtml + issueHtml], + buttons: hasFixable ? ["Apply Selected", "Cancel"] : ["Close"], + onConfirm: async () => { + if (!hasFixable) + return; + const selectedCodes = getSelectedRepairCodes(issues); + if (selectedCodes.length === 0) { + Blockbench.showQuickMessage("No issues selected for repair", 1500); + return; + } + Blockbench.showQuickMessage(`Repairing ${selectedCodes.length} issue(s)...`, 2e3); + const currentModel = extractModelData(); + if (!currentModel) { + Blockbench.showQuickMessage("Model no longer available", 1500); + return; + } + const repairResult = await client.repair(currentModel, selectedCodes); + if (repairResult.success && repairResult.model_data) { + showRepairConfirmation( + repairResult.model_data, + repairResult.repairs_applied, + repairResult.message + ); + } else { + Blockbench.showMessageBox({ + title: "Repair Failed", + message: repairResult.message || "The repair request could not be completed.", + icon: "error" + }); + } + }, + onCancel: () => { + } + }); + dialog.show(); + } + var REPAIR_STYLES = ` + #myrlin-repair-list table tr:hover { + background: #333 !important; + } + #myrlin-repair-list input[type="checkbox"] { + cursor: pointer; + width: 16px; + height: 16px; + } + #myrlin-repair-list input[type="checkbox"]:disabled { + cursor: not-allowed; + opacity: 0.4; + } +`; + function setupRepairPanel() { + trackStyle(REPAIR_STYLES); + const repairAction = new Action("myrlin_auto_repair", { + name: "Auto-Repair Model", + description: "Validate and auto-repair model issues for Hytale compatibility (Myrlin's Tools)", + icon: "build", + category: "tools", + click: () => { + showRepairDialog(); + } + }); + track(repairAction); + MenuBar.addAction(repairAction, "tools"); + } + + // src/features/export_dialog.ts + var DIALOG_WIDTH2 = 560; + var FORMAT_OPTIONS = [ + { + id: "bbmodel", + label: "Blockbench (.bbmodel)", + extension: ".bbmodel", + engine: "Minecraft / Blockbench", + description: "Standard Blockbench project format. Compatible with Minecraft Java and Bedrock resource packs.", + constraints: "Max 256 elements per model. Textures must be 16x16 or multiples thereof." + }, + { + id: "blockymodel", + label: "Hytale (.blockymodel)", + extension: ".blockymodel", + engine: "Hytale", + description: "Native Hytale model format with node hierarchy, axis-aligned boxes, and per-face UVs.", + constraints: "Max 255 nodes (cubes + groups). First cube per group is free. Power-of-2 textures required." + }, + { + id: "modelengine", + label: "ModelEngine (YAML)", + extension: ".yml", + engine: "ModelEngine (Minecraft plugin)", + description: "ModelEngine blueprint format for custom entity models in Minecraft servers.", + constraints: "Bones must follow ModelEngine naming conventions. Hitbox and mount points defined separately." + } + ]; + var DEFAULT_FORMAT = "blockymodel"; + function escapeHtml2(str) { + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function getFormatInfo(formatId) { + return FORMAT_OPTIONS.find((f) => f.id === formatId) || FORMAT_OPTIONS[0]; + } + function extractModelData2() { + try { + if (typeof Codecs !== "undefined" && Codecs.project && typeof Codecs.project.compile === "function") { + return Codecs.project.compile(); + } + if (typeof Project === "undefined" || !Project) { + return null; + } + const cubes = typeof Cube !== "undefined" ? Cube.all : []; + const groups = typeof Group !== "undefined" ? Group.all : []; + if (cubes.length === 0 && groups.length === 0) { + return null; + } + const nodes = []; + for (const cube of cubes) { + nodes.push({ + name: cube.name || "cube", + from: cube.from ? [...cube.from] : [0, 0, 0], + to: cube.to ? [...cube.to] : [1, 1, 1], + type: "cube" + }); + } + return { + // @ts-ignore + name: Project.name || "untitled", + nodes, + meta: { + group_count: groups.length, + cube_count: cubes.length + } + }; + } catch (e) { + console.warn("[Myrlin Export] Failed to extract model data:", e); + return null; + } + } + function runInlineValidation() { + const report = validateModel(); + const passCount = report.results.filter((r) => r.severity === "pass").length; + const warnCount = report.results.filter((r) => r.severity === "warn").length; + const failCount = report.results.filter((r) => r.severity === "fail").length; + let statusColor = "#27ae60"; + let statusText = "All checks passed"; + if (report.overallSeverity === "fail") { + statusColor = "#e74c3c"; + statusText = `${failCount} check(s) failed`; + } else if (report.overallSeverity === "warn") { + statusColor = "#f39c12"; + statusText = `${warnCount} warning(s)`; + } + let html = `
`; + html += `
`; + html += `Validation: ${statusText}`; + html += `
`; + html += `
`; + html += `${passCount} passed, ${warnCount} warning(s), ${failCount} failed`; + html += `
`; + html += ``; + for (const result of report.results) { + let icon = ''; + if (result.severity === "fail") { + icon = ''; + } else if (result.severity === "warn") { + icon = ''; + } + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + } + html += `
${icon}${escapeHtml2(result.name)}${escapeHtml2(result.message)}
`; + html += `
`; + return { html, severity: report.overallSeverity }; + } + function buildFormatPreviewHtml(format) { + return ` +
+
+ ${escapeHtml2(format.label)} +
+ + + + + + + + + + + + + + + + + +
Extension:${escapeHtml2(format.extension)}
Engine:${escapeHtml2(format.engine)}
Description:${escapeHtml2(format.description)}
Constraints:${escapeHtml2(format.constraints)}
+
+ `; + } + function buildDialogHtml() { + let html = ""; + html += `
`; + html += `

`; + html += `Export your model to a specific game engine format. `; + html += `The Myrlin API handles server-side format conversion.`; + html += `

`; + html += `
`; + html += `
Select Format:
`; + html += `
`; + for (const fmt of FORMAT_OPTIONS) { + const checked = fmt.id === DEFAULT_FORMAT ? "checked" : ""; + html += ``; + } + html += `
`; + const defaultInfo = getFormatInfo(DEFAULT_FORMAT); + html += buildFormatPreviewHtml(defaultInfo); + html += `
`; + html += ``; + html += `
`; + html += `
`; + html += ``; + html += `
`; + html += `
`; + return html; + } + function getExtensions(formatId) { + switch (formatId) { + case "bbmodel": + return ["bbmodel"]; + case "blockymodel": + return ["blockymodel"]; + case "modelengine": + return ["yml"]; + default: + return ["bbmodel"]; + } + } + function getSelectedFormat() { + const selected = document.querySelector('input[name="myrlin_export_format"]:checked'); + if (selected) { + return selected.value; + } + return DEFAULT_FORMAT; + } + function isValidateFirstChecked() { + const cb = document.getElementById("myrlin-validate-first"); + return cb ? cb.checked : true; + } + async function performExport() { + const modelData = extractModelData2(); + if (!modelData) { + Blockbench.showMessageBox({ + title: "No Model Open", + message: "Please open a model before exporting.", + icon: "warning" + }); + return; + } + const formatId = getSelectedFormat(); + const formatInfo = getFormatInfo(formatId); + if (isValidateFirstChecked()) { + const report = validateModel(); + if (report.overallSeverity === "fail") { + Blockbench.showMessageBox({ + title: "Validation Failed", + message: 'Your model has validation errors that should be fixed before exporting. Run the validator for details, or uncheck "Validate before export" to skip.', + icon: "error" + }); + return; + } + } + Blockbench.showQuickMessage(`Exporting as ${formatInfo.label}...`, 2e3); + if (formatId === "bbmodel") { + const content = JSON.stringify(modelData, null, 2); + Blockbench.export({ + type: "Export Model", + extensions: getExtensions(formatId), + // @ts-ignore - Project is a global from Blockbench + name: typeof Project !== "undefined" && Project.name ? Project.name : "model", + content + }); + Blockbench.showQuickMessage("Exported as .bbmodel successfully", 2e3); + return; + } + const client = getApiClient(); + await client.checkConnection(); + const result = await client.export(modelData, formatId); + if (result.success && result.data) { + Blockbench.export({ + type: "Export Model", + extensions: getExtensions(formatId), + // @ts-ignore - Project is a global from Blockbench + name: typeof Project !== "undefined" && Project.name ? Project.name : "model", + content: result.data + }); + Blockbench.showQuickMessage(`Exported as ${formatInfo.extension} successfully`, 2e3); + } else { + Blockbench.showMessageBox({ + title: "Export Failed", + message: result.message || "The export could not be completed.", + icon: "error" + }); + } + } + function attachDialogEvents() { + const radios = document.querySelectorAll(".myrlin-format-radio"); + radios.forEach((radio) => { + radio.addEventListener("change", () => { + const formatId = radio.value; + const info = getFormatInfo(formatId); + const previewEl = document.getElementById("myrlin-format-preview"); + if (previewEl) { + previewEl.outerHTML = buildFormatPreviewHtml(info); + } + }); + }); + const validateBtn = document.getElementById("myrlin-run-validation-btn"); + if (validateBtn) { + validateBtn.addEventListener("click", () => { + const resultsEl = document.getElementById("myrlin-validation-results"); + if (resultsEl) { + const validation = runInlineValidation(); + resultsEl.innerHTML = validation.html; + } + }); + } + } + var EXPORT_STYLES = ` + .myrlin-format-radio-label:hover { + background: #2a2a2a !important; + } + #myrlin-format-selector input[type="radio"] { + accent-color: #3498db; + } + #myrlin-run-validation-btn:hover { + background: #333 !important; + border-color: #666 !important; + } +`; + function setupExportDialog() { + trackStyle(EXPORT_STYLES); + const exportAction = new Action("myrlin_export_dialog", { + name: "Export for Engine", + description: "Export model to Hytale, ModelEngine, or Blockbench format (Myrlin's Tools)", + icon: "file_download", + category: "tools", + click: () => { + const dialogHtml = buildDialogHtml(); + const dialog = new Dialog({ + id: "myrlin_export_dialog_ui", + title: "Export for Engine", + width: DIALOG_WIDTH2, + lines: [dialogHtml], + buttons: ["Export", "Cancel"], + onConfirm: () => { + performExport(); + }, + onCancel: () => { + }, + onOpen: () => { + setTimeout(() => attachDialogEvents(), 50); + } + }); + dialog.show(); + } + }); + track(exportAction); + MenuBar.addAction(exportAction, "tools"); + } + + // src/features/validation_panel.ts + var DIALOG_WIDTH3 = 640; + var MAX_DISPLAY_ISSUES2 = 50; + var ENGINE_PROFILES = [ + { + id: "hytale", + label: "Hytale", + description: "Validate against Hytale modding requirements (255 node limit, naming conventions, texture rules)" + }, + { + id: "modelengine", + label: "ModelEngine", + description: "Validate against ModelEngine blueprint rules (bone naming, hitbox, mount points)" + }, + { + id: "freemc", + label: "FreeMinecraftModels", + description: "Validate against FreeMinecraftModels constraints (element limits, rotation rules)" + } + ]; + var DEFAULT_ENGINE = "hytale"; + function severityIconHtml2(severity) { + switch (severity) { + case "error": + return ''; + case "warning": + return ''; + case "info": + return ''; + default: + return ""; + } + } + function severityColor(severity) { + switch (severity) { + case "error": + return "#e74c3c"; + case "warning": + return "#f39c12"; + case "info": + return "#3498db"; + default: + return "#888"; + } + } + function severityPriority2(severity) { + switch (severity) { + case "error": + return 0; + case "warning": + return 1; + case "info": + return 2; + default: + return 3; + } + } + function escapeHtml3(str) { + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function extractModelData3() { + try { + if (typeof Codecs !== "undefined" && Codecs.project && typeof Codecs.project.compile === "function") { + return Codecs.project.compile(); + } + if (typeof Project === "undefined" || !Project) { + return null; + } + const cubes = typeof Cube !== "undefined" ? Cube.all : []; + const groups = typeof Group !== "undefined" ? Group.all : []; + if (cubes.length === 0 && groups.length === 0) { + return null; + } + const nodes = []; + for (const cube of cubes) { + nodes.push({ + name: cube.name || "cube", + from: cube.from ? [...cube.from] : [0, 0, 0], + to: cube.to ? [...cube.to] : [1, 1, 1], + type: "cube" + }); + } + return { + // @ts-ignore + name: Project.name || "untitled", + nodes, + meta: { + group_count: groups.length, + cube_count: cubes.length + } + }; + } catch (e) { + console.warn("[Myrlin Validation Panel] Failed to extract model data:", e); + return null; + } + } + function buildSummaryHtml(issues) { + const errorCount = issues.filter((i) => i.severity === "error").length; + const warnCount = issues.filter((i) => i.severity === "warning").length; + const infoCount = issues.filter((i) => i.severity === "info").length; + const fixableCount = issues.filter((i) => i.auto_fixable).length; + let html = `
`; + html += ``; + html += `${errorCount} error(s)`; + html += ``; + html += `${warnCount} warning(s)`; + html += ``; + html += `${infoCount} info`; + if (fixableCount > 0) { + html += ``; + html += `| ${fixableCount} auto-fixable`; + } + html += `
`; + return html; + } + function buildResultsTableHtml(issues) { + if (issues.length === 0) { + return ` +
+

No issues found

+

Your model passes all validation checks for this engine profile.

+
+ `; + } + const sorted = [...issues].sort((a, b) => severityPriority2(a.severity) - severityPriority2(b.severity)); + const displayed = sorted.slice(0, MAX_DISPLAY_ISSUES2); + const truncated = sorted.length - displayed.length; + const hasFixable = issues.some((i) => i.auto_fixable); + let html = ""; + html += buildSummaryHtml(issues); + if (hasFixable) { + html += `
`; + html += ``; + html += `
`; + } + html += `
`; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + for (let i = 0; i < displayed.length; i++) { + const issue = displayed[i]; + const rowBg = i % 2 === 0 ? "#1e1e1e" : "#252525"; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + } + html += `
CodeMessageLocationAction
${severityIconHtml2(issue.severity)}`; + html += `${escapeHtml3(issue.code)}${escapeHtml3(issue.message)}`; + html += issue.location ? escapeHtml3(issue.location) : "-"; + html += ``; + if (issue.auto_fixable) { + html += ``; + } else { + html += `manual`; + } + html += `
`; + if (truncated > 0) { + html += `

`; + html += `...and ${truncated} more issue(s) not shown.

`; + } + return html; + } + function buildDialogHtml2() { + let html = ""; + html += `
`; + html += `

`; + html += `Validate your model against engine-specific rules. `; + html += `Select a target engine and run validation to see issues.`; + html += `

`; + html += `
`; + html += `
`; + html += ``; + html += ``; + const defaultProfile = ENGINE_PROFILES.find((p) => p.id === DEFAULT_ENGINE) || ENGINE_PROFILES[0]; + html += `
`; + html += escapeHtml3(defaultProfile.description); + html += `
`; + html += `
`; + html += `
`; + html += ``; + html += `
`; + html += `
`; + return html; + } + async function runEngineValidation(engine) { + const modelData = extractModelData3(); + if (!modelData) { + Blockbench.showMessageBox({ + title: "No Model Open", + message: "Please open a model before running validation.", + icon: "warning" + }); + return; + } + const resultsEl = document.getElementById("myrlin-vp-results"); + if (resultsEl) { + resultsEl.innerHTML = ` +
+

Validating model...

+
+ `; + } + Blockbench.showQuickMessage(`Validating for ${engine}...`, 1500); + const client = getApiClient(); + await client.checkConnection(); + const report = await client.validate(modelData, engine); + const issues = (report.issues || []).map((i) => ({ + severity: i.severity, + code: i.code, + message: i.message, + location: i.location, + auto_fixable: i.auto_fixable + })); + if (resultsEl) { + resultsEl.innerHTML = buildResultsTableHtml(issues); + attachResultsEvents(issues); + } + const errorCount = issues.filter((i) => i.severity === "error").length; + const warnCount = issues.filter((i) => i.severity === "warning").length; + Blockbench.showQuickMessage( + `Validation: ${errorCount} error(s), ${warnCount} warning(s), ${issues.length} total`, + 2e3 + ); + } + async function triggerRepairForCode(code) { + const modelData = extractModelData3(); + if (!modelData) { + Blockbench.showQuickMessage("No model data available for repair", 1500); + return; + } + Blockbench.showQuickMessage(`Attempting to fix: ${code}...`, 2e3); + const client = getApiClient(); + await client.checkConnection(); + const result = await client.repair(modelData, [code]); + if (result.success && result.model_data) { + try { + if (typeof Undo !== "undefined" && Undo.initEdit) { + Undo.initEdit({ elements: Cube.all, outliner: true }); + } + if (typeof Codecs !== "undefined" && Codecs.project && typeof Codecs.project.parse === "function") { + Codecs.project.parse(result.model_data); + } + if (typeof Undo !== "undefined" && Undo.finishEdit) { + Undo.finishEdit(`Myrlin Fix: ${code}`); + } + if (typeof Canvas !== "undefined" && Canvas.updateAll) { + Canvas.updateAll(); + } + Blockbench.showQuickMessage(`Fixed: ${code}`, 2e3); + } catch (e) { + console.error("[Myrlin Validation Panel] Failed to apply fix:", e); + Blockbench.showQuickMessage(`Fix apply failed: ${e}`, 2e3); + } + } else { + Blockbench.showQuickMessage(result.message || `Could not fix: ${code}`, 2e3); + } + } + async function triggerFixAll(issues) { + const fixableCodes = issues.filter((i) => i.auto_fixable).map((i) => i.code); + if (fixableCodes.length === 0) { + Blockbench.showQuickMessage("No auto-fixable issues found", 1500); + return; + } + const modelData = extractModelData3(); + if (!modelData) { + Blockbench.showQuickMessage("No model data available for repair", 1500); + return; + } + Blockbench.showQuickMessage(`Fixing ${fixableCodes.length} issue(s)...`, 2e3); + const client = getApiClient(); + await client.checkConnection(); + const result = await client.repair(modelData, fixableCodes); + if (result.success && result.model_data) { + try { + if (typeof Undo !== "undefined" && Undo.initEdit) { + Undo.initEdit({ elements: Cube.all, outliner: true }); + } + if (typeof Codecs !== "undefined" && Codecs.project && typeof Codecs.project.parse === "function") { + Codecs.project.parse(result.model_data); + } + if (typeof Undo !== "undefined" && Undo.finishEdit) { + Undo.finishEdit(`Myrlin Fix All (${result.repairs_applied.length} fixes)`); + } + if (typeof Canvas !== "undefined" && Canvas.updateAll) { + Canvas.updateAll(); + } + Blockbench.showQuickMessage( + `Applied ${result.repairs_applied.length} fix(es) successfully`, + 2e3 + ); + } catch (e) { + console.error("[Myrlin Validation Panel] Failed to apply fixes:", e); + Blockbench.showQuickMessage(`Fix apply failed: ${e}`, 2e3); + } + } else { + Blockbench.showMessageBox({ + title: "Fix All Failed", + message: result.message || "Could not apply fixes. The API may be temporarily unavailable.", + icon: "error" + }); + } + } + function attachResultsEvents(issues) { + const fixBtns = document.querySelectorAll(".myrlin-vp-fix-btn"); + fixBtns.forEach((btn) => { + btn.addEventListener("click", () => { + const code = btn.getAttribute("data-code"); + if (code) { + triggerRepairForCode(code); + } + }); + }); + const fixAllBtn = document.getElementById("myrlin-vp-fix-all"); + if (fixAllBtn) { + fixAllBtn.addEventListener("click", () => { + triggerFixAll(issues); + }); + } + } + function attachDialogEvents2() { + const engineSelect = document.getElementById("myrlin-vp-engine"); + if (engineSelect) { + engineSelect.addEventListener("change", () => { + const selectedId = engineSelect.value; + const profile = ENGINE_PROFILES.find((p) => p.id === selectedId); + const descEl = document.getElementById("myrlin-vp-engine-desc"); + if (descEl && profile) { + descEl.textContent = profile.description; + } + }); + } + const validateBtn = document.getElementById("myrlin-vp-validate-btn"); + if (validateBtn) { + validateBtn.addEventListener("click", () => { + const engine = document.getElementById("myrlin-vp-engine")?.value || DEFAULT_ENGINE; + runEngineValidation(engine); + }); + } + } + var VALIDATION_PANEL_STYLES = ` + #myrlin-vp-issues table tr:hover { + background: #333 !important; + } + .myrlin-vp-fix-btn:hover { + background: #27ae60 !important; + color: #fff !important; + } + #myrlin-vp-fix-all:hover { + background: #27ae60 !important; + color: #fff !important; + } + #myrlin-vp-engine:focus { + border-color: #3498db !important; + outline: none; + } + #myrlin-vp-validate-btn:hover { + background: #3498db !important; + color: #fff !important; + } +`; + function setupValidationPanel() { + trackStyle(VALIDATION_PANEL_STYLES); + const validationAction = new Action("myrlin_validation_panel", { + name: "Validate for Engine", + description: "Validate model against engine-specific rules with repair integration (Myrlin's Tools)", + icon: "rule", + category: "tools", + click: () => { + const dialogHtml = buildDialogHtml2(); + const dialog = new Dialog({ + id: "myrlin_validation_panel_ui", + title: "Validate for Engine", + width: DIALOG_WIDTH3, + lines: [dialogHtml], + buttons: ["Close"], + onConfirm: () => { + }, + onOpen: () => { + setTimeout(() => attachDialogEvents2(), 50); + } + }); + dialog.show(); + } + }); + track(validationAction); + MenuBar.addAction(validationAction, "tools"); + } + + // src/plugin.ts + var PLUGIN_ID = "myrlins_tools"; + var VERSION = "3.0.0"; + BBPlugin.register(PLUGIN_ID, { + title: "Myrlin's Tools", + author: "Myrlin (forge.myrlin.io)", + icon: "auto_fix_high", + version: VERSION, + description: "Hytale modding toolkit - Box optimizer, art style validator, template wizard, config generator, and more.", + tags: ["Hytale", "Optimization", "Validation"], + variant: "both", + min_version: "4.8.0", + onload() { + console.log(`[Myrlin's Tools] Loading v${VERSION}...`); + setupPanel(); + setupActions(); + setupStatusBar(); + setupBrowser(); + setupTemplateWizard(); + setupValidator(); + setupConfigGenerator(); + setupRepairPanel(); + setupExportDialog(); + setupValidationPanel(); + console.log(`[Myrlin's Tools] Loaded successfully! (v${VERSION})`); + }, + onunload() { + console.log(`[Myrlin's Tools] Unloading...`); + cleanup(); + } + }); +})(); diff --git a/plugins/myrlins_tools/about.md b/plugins/myrlins_tools/about.md new file mode 100644 index 000000000..ee5f41461 --- /dev/null +++ b/plugins/myrlins_tools/about.md @@ -0,0 +1,54 @@ +# Myrlin's Tools + +A comprehensive toolkit for Hytale model creation in Blockbench. + +## Features + +**Optimization** +- Box Optimizer: merge adjacent boxes to reduce count (spatial-hash O(n)) +- Hollow Interior: remove hidden internal boxes +- Node Budget: visualize per-bone node distribution + +**Validation** +- Art Style Validator: check against Hytale requirements (255 nodes, pixel ratios, stretch limits) +- Engine Validation: validate for Hytale, ModelEngine, or FreeMinecraftModels +- Severity grading: ERROR / WARNING / INFO with one-click fixes + +**Auto-Repair** +- Naming normalization (PascalCase) +- Aspect ratio fixer (split extreme cuboids) +- Hierarchy repair (auto-grouping) +- Node limit reducer (merge + hollow) +- Symmetry enforcement + +**Export** +- Multi-engine export: Hytale (.blockymodel), Blockbench (.bbmodel), ModelEngine (YAML) +- Format-aware validation before export +- Blockbench 5.0 compatible + +**Templates** +- Template Wizard: create correctly structured starter models +- Config Generator: export model + JSON configs as mod .zip + +**Community** +- Model Browser: browse and import from the Myrlin gallery + +## Installation + +Install from the Blockbench plugin store, or install from URL: +``` +https://forge.myrlin.io/plugins/myrlins_tools.js +``` + +## AI Add-on + +For AI-powered features (semantic naming, repair suggestions), install the separate add-on: +``` +https://forge.myrlin.io/plugins/myrlins_ai.js +``` +Requires the core plugin to be installed first. + +## Requirements + +- Blockbench 4.8.0 or later +- Internet connection for model browser and API-based validation (offline mode available)