
# BetterModel
*- Modern Bedrock model engine for Minecraft Java Edition -*
[](https://modrinth.com/plugin/bettermodel)
[](https://hangar.papermc.io/toxicity188/BetterModel)
[](https://github.com/toxicity188/BetterModel)
* * *


* * *
(In BlockBench / In Minecraft)
# ✨ What is BetterModel?
**BetterModel** is a server-based engine that provides runtime BlockBench model rendering & animating for Minecraft Java Edition.
It implements **fully server-side 3D models** by using an item display entity packet.
- Importing Generic BlockBench model `.bbmodel`
- Auto-generating resource pack
- Playing animation
- Syncing with base entity
- Custom hit box
- 12-limb player animation
## 🚀 Comparison with ModelEngine
The main reason I created it is:
- To reduce network cost—MEG’s network optimization is outdated and insufficient for modern servers.
- To enable faster updates—We can’t afford to wait for MEG’s slow update cycle anymore.
- To provide a more flexible API—MEG is closed-source with a very limited API, which makes extending or integrating difficult.
- To restore vanilla behavior-MEG breaks several vanilla entity features and physics, which this project aims to fix.
Also, you can refer [my document](https://github.com/toxicity188/BetterModel/wiki/Compare-with-ModelEngine) to compare both ModelEngine and BetterModel.
## 🌎 Generic BlockBench model with animation

* * *
[](https://youtu.be/f3U7Lmo3aA8?si=SnglL0YKn20CrR7Y)
BetterModel supports Generic BlockBench models with full animation.
#### Custom hitbox
* * *

* * *
BetterModel provides **custom hitbox** both client and server. (tracking animation rotation)
#### MythicMobs support
* * *

* * *
Like MEG, BetterModel supports **MythicMobs**, you can use some MEG's mechanics in BetterModel too.
## 💡 Player model with animation


* * *
BetterModel supports **player model with using user's custom skin without textures**.
## 📚 Official wiki
[](https://github.com/toxicity188/BetterModel/wiki)
## 🏗️ Supported environment
[](https://www.minecraft.net/en-us/download/server)
[](https://adoptium.net/)
### Bukkit
[](https://papermc.io/downloads/folia)
[](https://papermc.io/downloads/paper)
[](https://purpurmc.org/download/purpur)
[](https://www.spigotmc.org/)
### Mod
[](https://fabricmc.net/)
## 🌈 My community
[](https://discord.com/invite/rePyFESDbk)
## 📊 Project Stats (plugin)
[](https://bstats.org/plugin/bukkit/BetterModel/24237)
## 💖 Support my project
[](https://buymeacoffee.com/toxicity188)
[](https://github.com/sponsors/toxicity188)
[](https://www.paypal.com/paypalme/toxicity188?country.x=KR&locale.x=en_US)
================================================
FILE: LICENSE.md
================================================
MIT License
Copyright (c) 2024–2026 toxicity188
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: LICENSE_HEADER
================================================
This source file is part of BetterModel.
Copyright (c) ${CREATION_YEAR} toxicity188
Licensed under the MIT License.
See LICENSE.md file for full license text.
#year_selection file
================================================
FILE: README.md
================================================

# BetterModel
*- Modern Bedrock model engine for Minecraft Java Edition -*
[](https://central.sonatype.com/artifact/io.github.toxicity188/bettermodel-api)
[](https://modrinth.com/plugin/bettermodel/versions)
[](https://github.com/toxicity188/BetterModel/issues)
[](https://bstats.org/plugin/bukkit/BetterModel/24237)
* * *


* * *
(In BlockBench / In Minecraft)
# ✨ Introduction
**BetterModel** is a server-based engine that provides runtime BlockBench model rendering & animating for Minecraft Java Edition.
It implements **fully server-side 3D models** by using an item display entity packet.
- Importing Generic BlockBench model `.bbmodel`
- Auto-generating resource pack
- Playing animation
- Syncing with base entity
- Custom hit box
- 12-limb player animation
In-Game Screenshots




## 🚀 Key Features & Focus
BetterModel aims to be a reliable engine that provides stable, high-quality animations for Paper-based high-traffic servers.
- **Stability First**: We take a conservative approach to feature expansion. By avoiding the implementation of features that are difficult to maintain or have limited use cases, we focus on providing a stable API and ensuring overall operational safety.
- **Performance Optimized**: Our goal is to minimize runtime computation, memory footprint, and network overhead. Through asynchronous design and optimized packet handling, we ensure the engine runs efficiently even under heavy server loads.
- **Tailored for Large-scale Servers**: We provide essential features specifically designed for high-population servers and MMORPG content creation.
- **Per-player Animation**: Individual animation control tailored to each player's perspective.
- **Player Model Animation**: Support for sophisticated 12-limb animations based on player models.
## 📚 Wiki
[](https://github.com/toxicity188/BetterModel/wiki)
[](https://deepwiki.com/toxicity188/BetterModel)
## 🛠️ Build info
[](https://www.minecraft.net/en-us/download/server)
[](https://adoptium.net/)
#### Build
[](https://gradle.org/)
`./gradlew build`: Builds all jars
`./gradlew shadowJar`: Builds plugin jar
`./gradlew javadocJar`: Builds Javadoc jar
`./gradlew runServer`: Runs Paper test server with test plugin
#### Library
- [Kotlin stdlib](https://github.com/JetBrains/kotlin): modern functional programming
- [semver4j](https://github.com/semver4j/semver4j): semver parser
- [cloud](https://github.com/Incendo/cloud-minecraft): command
- [adventure](https://github.com/KyoriPowered/adventure): component
- [stable player display](https://github.com/bradleyq/stable_player_display): player animation
- [caffeine](https://github.com/ben-manes/caffeine): concurrent map cache
- [DynamicUV](https://github.com/toxicity188/DynamicUV): player model
- [ArmorModel](https://github.com/toxicity188/ArmorModel): armor in player model
- [java-mesh](https://github.com/toxicity188/java-mesh): mesh rendering
- [molang-compiler](https://github.com/Ocelot5836/molang-compiler): compiling and evaluating molang expression
- [libby](https://github.com/AlessioDP/libby): runtime library downloader
#### Tested Bukkit Server Platform
- [Paper](https://papermc.io/downloads/paper)
- [Purpur](https://purpurmc.org/download/purpur)
- [Spigot](https://www.spigotmc.org/)
- [Folia](https://papermc.io/downloads/folia)
- [Leaf](https://www.leafmc.one/download)
- [Canvas](https://canvasmc.io/downloads/canvas)
#### Tested Mod Server Platform
- [Fabric Loader](https://fabricmc.net/)
## 💻 API
[](https://central.sonatype.com/artifact/io.github.toxicity188/bettermodel)
> [!NOTE]\
> For more detailed API specifications, please refer to our [GitHub Wiki](https://github.com/toxicity188/BetterModel/wiki/API-example).
Gradle (Kotlin)
#### Release
```kotlin
repositories {
mavenCentral()
maven("https://maven.blamejared.com/") // For transitive dependency in bettermodel-fabric
maven("https://maven.nucleoid.xyz/") // For transitive dependency in bettermodel-fabric
}
dependencies {
compileOnly("io.github.toxicity188:bettermodel-bukkit-api:VERSION") // bukkit(spigot, paper, etc) api
//api("io.github.toxicity188:bettermodel-fabric:VERSION") // mod(fabric)
}
```
#### Snapshot
```kotlin
repositories {
maven("https://maven.pkg.github.com/toxicity188/BetterModel") {
credentials {
username = YOUR_GITHUB_USERNAME
password = YOUR_GITHUB_TOKEN
}
}
maven("https://maven.blamejared.com/") // For transitive dependency in bettermodel-fabric
maven("https://maven.nucleoid.xyz/") // For transitive dependency in bettermodel-fabric
}
dependencies {
compileOnly("io.github.toxicity188:bettermodel-bukkit-api:VERSION-SNAPSHOT") // bukkit(spigot, paper, etc) api
//api("io.github.toxicity188:bettermodel-fabric:VERSION-SNAPSHOT") // mod(fabric)
}
```
Gradle (Groovy)
#### Release
```groovy
repositories {
mavenCentral()
maven 'https://maven.blamejared.com/' // For transitive dependency in bettermodel-fabric
maven 'https://maven.nucleoid.xyz/' // For transitive dependency in bettermodel-fabric
}
dependencies {
compileOnly 'io.github.toxicity188:bettermodel-bukkit-api:VERSION' // bukkit(spigot, paper, etc) api
//api 'io.github.toxicity188:bettermodel-fabric:VERSION' // mod(fabric)
}
```
#### Snapshot
```groovy
repositories {
maven {
url "https://maven.pkg.github.com/toxicity188/BetterModel"
credentials {
username = YOUR_GITHUB_USERNAME
password = YOUR_GITHUB_TOKEN
}
}
maven 'https://maven.blamejared.com/' // For transitive dependency in bettermodel-fabric
maven 'https://maven.nucleoid.xyz/' // For transitive dependency in bettermodel-fabric
}
dependencies {
compileOnly 'io.github.toxicity188:bettermodel-bukkit-api:VERSION-SNAPSHOT' // bukkit(spigot, paper, etc) api
//api 'io.github.toxicity188:bettermodel-fabric:VERSION-SNAPSHOT' // mod(fabric)
}
```
Maven
#### Release
```xml
centralhttps://repo.maven.apache.org/maven2io.github.toxicity188bettermodel-bukkit-apiVERSIONprovided
```
#### Snapshot
```xml
githubhttps://maven.pkg.github.com/toxicity188/BetterModelio.github.toxicity188bettermodel-apiVERSION-SNAPSHOTprovidedio.github.toxicity188bettermodel-bukkit-apiVERSION-SNAPSHOTprovided
```
Example code
#### Gets some model or limb
```java
BetterModel.model("demon_knight"); //A model file in BetterModel/models (for general model with saving)
BetterModel.limb("steve"); //A model file in BetterModel/players (for player model with no saveing)
BetterModel.modelOrNull("demon_knight"); //general model or null
BetterModel.limbOrNull("steve"); //player model or null
```
#### Creates model (entity)
```java
EntityTracker tracker = BetterModel.model("demon_knight")
.map(r -> r.getOrCreate(BukkitAdapter.adapt(entity))) //Gets or creates entity tracker by this renderer to some entity.
.orElse(null);
```
```java
EntityTracker tracker = BetterModel.model("demon_knight")
.map(r -> r.create(BukkitAdapter.adapt(entity), TrackerModifier.DEFAULT, t -> t.update(TrackerUpdateAction.tint(0x0026FF)))) //Creates entity tracker with pre-spawn task.
.orElse(null);
```
#### Creates model (dummy)
```java
DummyTracker tracker = BetterModel.model("demon_knight")
.map(r -> r.create(BukkitAdapter.adapt(location))) //Creates some dummy tracker to this location.
.orElse(null);
```
```java
DummyTracker tracker = BetterModel.limb("steve")
.map(r -> r.create(BukkitAdapter.adapt(location), ModelProfile.of(BukkitAdapter.adapt(player)))) //Creates some dummy tracker to this location and player's skin profile.
.orElse(null);
```
#### Update some tracker's display data
```java
BetterModel.model("demon_knight")
.map(r -> r.create(BukkitAdapter.adapt(entity), TrackerModifier.DEFAULT, t -> {
t.update(TrackerUpdateAction.tint(rgb)); //Tint
t.update(TrackerUpdateAction.enchant(true), bone -> true); //Enchant with predicate
}))
.ifPresent(tracker -> tracker.update(TrackerUpdateAction.composite( //Composite
TrackerUpdateAction.brightness(15, 15) //Brightness
TrackerUpdateAction.billboard(Display.Billboard.CENTER) //Billboard
)));
}
```
## 💬 Community
[](https://discord.com/invite/rePyFESDbk)
## 💖 Support
[](https://buymeacoffee.com/toxicity188)
[](https://github.com/sponsors/toxicity188)
[](https://www.paypal.com/paypalme/toxicity188?country.x=KR&locale.x=en_US)
================================================
FILE: SECURITY.md
================================================
## Security Policy
BetterModel is a server-side 3D model engine that operates in an isolated environment without direct connections to external clients. As such, the risk of traditional security vulnerabilities is minimal.
#### Key Points
- 🔒 **No Client Connection**
BetterModel does not expose any network interface or accept input from external clients.
- 📦 **No Data Leakage**
Animation and bone data are handled on the server and are never sent to the client directly. Only processed vector packets are transmitted, which do not include raw model data.
- 🧱 **Model Privacy**
Your model files are converted into a Minecraft-compatible resource pack. During this conversion process, most of the original model information is stripped or transformed, minimizing the risk of leakage.
================================================
FILE: api/build.gradle.kts
================================================
plugins {
alias(libs.plugins.convention.publish)
}
dependencies {
compileOnly(libs.bundles.minecraft)
}
================================================
FILE: api/bukkit-api/build.gradle.kts
================================================
plugins {
alias(libs.plugins.convention.publish)
alias(libs.plugins.convention.bukkit)
}
dependencies {
api(project(":bettermodel-api"))
}
================================================
FILE: api/bukkit-api/src/main/java/kr/toxicity/model/api/bukkit/BetterModelBukkit.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bukkit;
import kr.toxicity.model.api.BetterModel;
import kr.toxicity.model.api.BetterModelPlatform;
import kr.toxicity.model.api.bukkit.platform.BukkitAdapter;
import kr.toxicity.model.api.bukkit.scheduler.BukkitModelScheduler;
import org.jetbrains.annotations.NotNull;
import static kr.toxicity.model.api.util.ReflectionUtil.classExists;
/**
* Represents the Bukkit-specific platform interface for BetterModel.
*
* This interface extends {@link BetterModelPlatform} to provide Bukkit-specific implementations
* for scheduling and entity adaptation.
*
*
* @since 2.0.0
*/
public interface BetterModelBukkit extends BetterModelPlatform {
/**
* Checks if the server is running on the Folia platform.
* @since 2.0.0
*/
boolean IS_FOLIA = classExists("io.papermc.paper.threadedregions.RegionizedServer");
/**
* Checks if the server is running on the Purpur platform.
* @since 2.0.0
*/
boolean IS_PURPUR = classExists("org.purpurmc.purpur.PurpurConfig");
/**
* Checks if the server is running on the Paper platform (or a fork like Purpur/Folia).
* @since 2.0.0
*/
boolean IS_PAPER = IS_PURPUR || IS_FOLIA || classExists("io.papermc.paper.configuration.PaperConfigurations");
/**
* Returns the current {@link BetterModelBukkit} instance.
*
* @return the current platform instance
* @since 2.0.0
*/
static @NotNull BetterModelBukkit platform() {
return (BetterModelBukkit) BetterModel.platform();
}
/**
* Returns the Bukkit-specific scheduler.
*
* @return the scheduler
* @since 2.0.0
*/
@Override
@NotNull BukkitModelScheduler scheduler();
/**
* Returns the Bukkit-specific adapter.
*
* @return the adapter
* @since 2.0.0
*/
@Override
@NotNull BukkitAdapter adapter();
/**
* Returns the Bukkit-specific event bus.
*
* @return the event bus
* @since 2.0.0
*/
@Override
@NotNull BukkitModelEventBus eventBus();
}
================================================
FILE: api/bukkit-api/src/main/java/kr/toxicity/model/api/bukkit/BukkitModelEventBus.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bukkit;
import kr.toxicity.model.api.BetterModelEventBus;
import kr.toxicity.model.api.bukkit.event.BukkitEventApplication;
import kr.toxicity.model.api.event.ModelEvent;
import kr.toxicity.model.api.event.ModelEventListener;
import org.bukkit.plugin.Plugin;
import org.jetbrains.annotations.NotNull;
import java.util.function.Consumer;
/**
* A Bukkit-specific extension of the {@link BetterModelEventBus}.
*
* This interface provides convenience methods for subscribing to events using a Bukkit {@link Plugin} instance.
*
*
* @since 2.0.0
*/
public interface BukkitModelEventBus extends BetterModelEventBus {
/**
* Subscribes a consumer to a specific event type, associated with a Bukkit plugin.
*
* @param plugin the plugin that subscribes to the event
* @param eventClass the class of the event to subscribe to
* @param consumer the consumer to handle the event
* @param the type of the event
* @return a listener handle that can be used to unregister the subscription
* @since 2.0.0
*/
@NotNull
default ModelEventListener subscribe(@NotNull Plugin plugin, @NotNull Class eventClass, @NotNull Consumer consumer) {
return subscribe(BukkitEventApplication.of(plugin), eventClass, consumer);
}
}
================================================
FILE: api/bukkit-api/src/main/java/kr/toxicity/model/api/bukkit/entity/BaseBukkitEntity.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bukkit.entity;
import kr.toxicity.model.api.bukkit.platform.BukkitAdapter;
import kr.toxicity.model.api.bukkit.platform.BukkitEntity;
import kr.toxicity.model.api.entity.BaseEntity;
import kr.toxicity.model.api.util.TransformedItemStack;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.persistence.PersistentDataHolder;
import org.bukkit.persistence.PersistentDataType;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
/**
* Represents a Bukkit-specific entity adapter.
*
* This interface extends {@link BaseEntity} and {@link PersistentDataHolder} to provide
* access to the underlying Bukkit entity and its persistent data container.
*
*
* @since 2.0.0
*/
public interface BaseBukkitEntity extends BaseEntity, PersistentDataHolder {
/**
* The namespaced key used for storing tracker data in the entity's persistent data container.
* @since 2.0.0
*/
@NotNull
NamespacedKey TRACKING_ID = Objects.requireNonNull(NamespacedKey.fromString("bettermodel_tracker"));
/**
* Returns the underlying Bukkit entity.
*
* @return the Bukkit entity
* @since 2.0.0
*/
default @NotNull Entity entity() {
return ((BukkitEntity) platform()).source();
}
/**
* Returns the item in the entity's main hand.
*
* @return the main hand item
* @since 2.0.0
*/
@Override
default @NotNull TransformedItemStack mainHand() {
if (entity() instanceof LivingEntity livingEntity) {
var equipment = livingEntity.getEquipment();
if (equipment != null) return TransformedItemStack.of(BukkitAdapter.adapt(equipment.getItemInMainHand()));
}
return TransformedItemStack.empty();
}
/**
* Returns the item in the entity's offhand.
*
* @return the offhand item
* @since 2.0.0
*/
@Override
default @NotNull TransformedItemStack offHand() {
if (entity() instanceof LivingEntity livingEntity) {
var equipment = livingEntity.getEquipment();
if (equipment != null) return TransformedItemStack.of(BukkitAdapter.adapt(equipment.getItemInOffHand()));
}
return TransformedItemStack.empty();
}
/**
* Retrieves the model data stored in the entity's persistent data container.
*
* @return the model data string, or null if not present
* @since 2.0.0
*/
default @Nullable String modelData() {
return getPersistentDataContainer().get(TRACKING_ID, PersistentDataType.STRING);
}
/**
* Stores the model data in the entity's persistent data container.
*
* @param modelData the model data string, or null to remove it
* @since 2.0.0
*/
default void modelData(@Nullable String modelData) {
var container = getPersistentDataContainer();
if (modelData == null) container.remove(TRACKING_ID);
else container.set(TRACKING_ID, PersistentDataType.STRING, modelData);
}
}
================================================
FILE: api/bukkit-api/src/main/java/kr/toxicity/model/api/bukkit/entity/BaseBukkitPlayer.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bukkit.entity;
import kr.toxicity.model.api.bukkit.platform.BukkitPlayer;
import kr.toxicity.model.api.entity.BasePlayer;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
/**
* Represents a Bukkit-specific player adapter.
*
* This interface extends {@link BaseBukkitEntity} and {@link BasePlayer} to provide
* access to the underlying Bukkit player.
*
*
* @since 2.0.0
*/
public interface BaseBukkitPlayer extends BaseBukkitEntity, BasePlayer {
/**
* Returns the underlying Bukkit player.
*
* @return the Bukkit player
* @since 2.0.0
*/
@Override
default @NotNull Player entity() {
return ((BukkitPlayer) platform()).source();
}
}
================================================
FILE: api/bukkit-api/src/main/java/kr/toxicity/model/api/bukkit/event/BetterModelBukkitEvent.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bukkit.event;
import kr.toxicity.model.api.event.ModelEvent;
import org.bukkit.Bukkit;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.function.Consumer;
import java.util.function.Supplier;
/**
* A wrapper class that adapts {@link ModelEvent} to Bukkit's {@link Event} system.
*
* This allows Bukkit plugins to listen for BetterModel events using the standard Bukkit event API.
* The underlying {@link ModelEvent} is lazily initialized when accessed.
*
*
* @since 2.0.0
*/
public final class BetterModelBukkitEvent extends Event {
private static final HandlerList HANDLER_LIST = new HandlerList();
private final Class extends ModelEvent> eventClass;
private final @NotNull Supplier extends ModelEvent> supplier;
private volatile ModelEvent source;
/**
* Creates a new BetterModelBukkitEvent.
*
* @param eventClass the class of the model event
* @param supplier a supplier that creates the model event
* @since 2.0.0
*/
@ApiStatus.Internal
public BetterModelBukkitEvent(@NotNull Class extends ModelEvent> eventClass, @NotNull Supplier extends ModelEvent> supplier) {
super(!Bukkit.isPrimaryThread());
this.eventClass = eventClass;
this.supplier = supplier;
}
/**
* Checks if the wrapped event is an instance of the specified class.
*
* @param eventClass the class to check against
* @param the type of the event
* @return true if the wrapped event is assignable to the class
* @since 2.0.0
*/
public boolean is(@NotNull Class eventClass) {
return eventClass.isAssignableFrom(this.eventClass);
}
/**
* Casts the wrapped event to the specified class if possible.
*
* This method initializes the underlying event if it hasn't been created yet.
*
*
* @param eventClass the class to cast to
* @param the type of the event
* @return the cast event, or null if the cast is not possible
* @since 2.0.0
*/
public @Nullable T as(@NotNull Class eventClass) {
if (!is(eventClass)) return null;
var event = source;
if (event == null) {
synchronized (this) {
event = source;
if (event == null) event = source = supplier.get();
}
}
return eventClass.cast(event);
}
/**
* Executes a consumer if the wrapped event is of the specified type.
*
* @param eventClass the class to check against
* @param consumer the consumer to execute
* @param the type of the event
* @since 2.0.0
*/
public void as(@NotNull Class eventClass, @NotNull Consumer super T> consumer) {
var get = as(eventClass);
if (get != null) consumer.accept(get);
}
/**
* Returns the underlying model event, if initialized.
*
* @return the model event, or null if not yet initialized
* @since 2.0.0
*/
@ApiStatus.Internal
public @Nullable ModelEvent source() {
return source;
}
@Override
public @NotNull HandlerList getHandlers() {
return HANDLER_LIST;
}
/**
* Returns the handler list for this event.
*
* @return the handler list
* @since 2.0.0
*/
public static HandlerList getHandlerList() {
return HANDLER_LIST;
}
}
================================================
FILE: api/bukkit-api/src/main/java/kr/toxicity/model/api/bukkit/event/BukkitEventApplication.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bukkit.event;
import kr.toxicity.model.api.event.ModelEventApplication;
import org.bukkit.plugin.Plugin;
import org.jetbrains.annotations.NotNull;
import java.lang.ref.WeakReference;
/**
* An implementation of {@link ModelEventApplication} for Bukkit plugins.
*
* This record holds a weak reference to a Bukkit plugin to prevent memory leaks
* and checks if the plugin is enabled.
*
*
* @param name the name of the plugin
* @param pluginRef a weak reference to the plugin instance
* @since 2.0.0
*/
public record BukkitEventApplication(@NotNull String name, @NotNull WeakReference pluginRef) implements ModelEventApplication {
/**
* Creates a new BukkitEventApplication for the given plugin.
*
* @param plugin the Bukkit plugin
* @return the event application wrapper
* @since 2.0.0
*/
public static @NotNull BukkitEventApplication of(@NotNull Plugin plugin) {
return new BukkitEventApplication(plugin.getName(), new WeakReference<>(plugin));
}
@Override
public boolean isEnabled() {
var get = pluginRef().get();
return get != null && get.isEnabled();
}
@Override
public boolean equals(Object o) {
if (!(o instanceof BukkitEventApplication that)) return false;
return name.equals(that.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
}
================================================
FILE: api/bukkit-api/src/main/java/kr/toxicity/model/api/bukkit/platform/BukkitAdapter.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bukkit.platform;
import kr.toxicity.model.api.bukkit.BetterModelBukkit;
import kr.toxicity.model.api.platform.*;
import org.bukkit.*;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.UUID;
/**
* Provides an adapter for converting Bukkit objects to BetterModel platform objects.
*
* This class implements {@link PlatformAdapter} and offers static utility methods for adapting
* entities, players, items, locations, and worlds.
*
*
* @since 2.0.0
*/
public final class BukkitAdapter implements PlatformAdapter {
/**
* Adapts a Bukkit entity to a {@link PlatformEntity}.
*
* @param entity the Bukkit entity
* @return the platform entity
* @since 2.0.0
*/
public static @NotNull PlatformEntity adapt(@NotNull Entity entity) {
return new BukkitEntity(entity);
}
/**
* Adapts a Bukkit living entity to a {@link PlatformLivingEntity}.
*
* @param livingEntity the Bukkit living entity
* @return the platform living entity
* @since 2.0.0
*/
public static @NotNull PlatformLivingEntity adapt(@NotNull LivingEntity livingEntity) {
return new BukkitLivingEntity(livingEntity);
}
/**
* Adapts a Bukkit offline player to a {@link PlatformOfflinePlayer}.
*
* @param player the Bukkit offline player
* @return the platform offline player
* @since 2.0.0
*/
public static @NotNull PlatformOfflinePlayer adapt(@NotNull OfflinePlayer player) {
return new BukkitOfflinePlayer(player);
}
/**
* Adapts a Bukkit player to a {@link PlatformPlayer}.
*
* @param player the Bukkit player
* @return the platform player
* @since 2.0.0
*/
public static @NotNull PlatformPlayer adapt(@NotNull Player player) {
return new BukkitPlayer(player);
}
/**
* Adapts a Bukkit item stack to a {@link PlatformItemStack}.
*
* @param itemStack the Bukkit item stack
* @return the platform item stack
* @since 2.0.0
*/
public static @NotNull PlatformItemStack adapt(@NotNull ItemStack itemStack) {
return new BukkitItemStack(itemStack);
}
/**
* Adapts a Bukkit location to a {@link PlatformLocation}.
*
* @param location the Bukkit location
* @return the platform location
* @since 2.0.0
*/
public static @NotNull PlatformLocation adapt(@NotNull Location location) {
return new BukkitLocation(location);
}
/**
* Adapts a Bukkit world to a {@link PlatformWorld}.
*
* @param world the Bukkit world
* @return the platform world
* @since 2.0.0
*/
public static @NotNull PlatformWorld adapt(@NotNull World world) {
return new BukkitWorld(world);
}
@Override
public @Nullable PlatformPlayer player(@NotNull UUID uuid) {
var bukkit = Bukkit.getPlayer(uuid);
return bukkit != null ? adapt(bukkit) : null;
}
@Override
public @NotNull PlatformOfflinePlayer offlinePlayer(@NotNull UUID uuid) {
return adapt(Bukkit.getOfflinePlayer(uuid));
}
@Override
public int serverViewDistance() {
return Bukkit.getViewDistance();
}
@Override
public boolean isTickThread() {
return Bukkit.isPrimaryThread();
}
@Override
public boolean isRegionSafe() {
return !BetterModelBukkit.IS_FOLIA || isTickThread();
}
@Override
public @NotNull PlatformItemStack air() {
return adapt(new ItemStack(Material.AIR));
}
@Override
public @NotNull PlatformLocation zero() {
return adapt(new Location(null, 0, 0, 0));
}
}
================================================
FILE: api/bukkit-api/src/main/java/kr/toxicity/model/api/bukkit/platform/BukkitEntity.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bukkit.platform;
import kr.toxicity.model.api.platform.PlatformEntity;
import kr.toxicity.model.api.platform.PlatformLocation;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.bukkit.entity.Entity;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
/**
* Represents a Bukkit entity wrapped as a {@link PlatformEntity}.
*
* @since 2.0.0
*/
@ToString
@EqualsAndHashCode
public class BukkitEntity implements PlatformEntity {
private final Entity source;
/**
* Creates a new BukkitEntity wrapper.
*
* @param source the source Bukkit entity
* @since 2.0.0
*/
public BukkitEntity(@NotNull Entity source) {
this.source = source;
}
/**
* Returns the underlying Bukkit entity.
*
* @return the source entity
* @since 2.0.0
*/
public Entity source() {
return source;
}
@Override
public @NotNull UUID uuid() {
return source.getUniqueId();
}
@Override
public @NotNull PlatformLocation location() {
return BukkitAdapter.adapt(source.getLocation());
}
}
================================================
FILE: api/bukkit-api/src/main/java/kr/toxicity/model/api/bukkit/platform/BukkitItemStack.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bukkit.platform;
import kr.toxicity.model.api.platform.PlatformItemStack;
import kr.toxicity.model.api.platform.PlatformNamespace;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Represents a Bukkit item stack wrapped as a {@link PlatformItemStack}.
*
* @param source the source Bukkit item stack
* @since 2.0.0
*/
public record BukkitItemStack(@NotNull ItemStack source) implements PlatformItemStack {
@Override
public boolean isAir() {
return source.getType().isAir() || source.getAmount() <= 0;
}
@Override
public @NotNull PlatformItemStack enchant(boolean enchant) {
var meta = source.getItemMeta();
if (meta == null) return this;
meta.setEnchantmentGlintOverride(enchant);
source.setItemMeta(meta);
return this;
}
@SuppressWarnings("deprecation")
@Override
public @NotNull PlatformItemStack modelData(int customModelData, @Nullable PlatformNamespace namespace) {
var meta = source.getItemMeta();
if (meta == null) return this;
meta.setCustomModelData(customModelData);
meta.setItemModel(namespace == null ? null : new NamespacedKey(namespace.namespace(), namespace.path()));
source.setItemMeta(meta);
return this;
}
@Override
public @NotNull PlatformItemStack clone() {
return BukkitAdapter.adapt(source.clone());
}
}
================================================
FILE: api/bukkit-api/src/main/java/kr/toxicity/model/api/bukkit/platform/BukkitLivingEntity.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bukkit.platform;
import kr.toxicity.model.api.platform.PlatformLivingEntity;
import kr.toxicity.model.api.platform.PlatformLocation;
import org.bukkit.entity.LivingEntity;
import org.jetbrains.annotations.NotNull;
/**
* Represents a Bukkit living entity wrapped as a {@link PlatformLivingEntity}.
*
* @since 2.0.0
*/
public class BukkitLivingEntity extends BukkitEntity implements PlatformLivingEntity {
/**
* Creates a new BukkitLivingEntity wrapper.
*
* @param source the source Bukkit living entity
* @since 2.0.0
*/
public BukkitLivingEntity(@NotNull LivingEntity source) {
super(source);
}
/**
* Returns the underlying Bukkit living entity.
*
* @return the source living entity
* @since 2.0.0
*/
@Override
public LivingEntity source() {
return (LivingEntity) super.source();
}
@Override
public @NotNull PlatformLocation eyeLocation() {
return BukkitAdapter.adapt(source().getEyeLocation());
}
}
================================================
FILE: api/bukkit-api/src/main/java/kr/toxicity/model/api/bukkit/platform/BukkitLocation.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bukkit.platform;
import kr.toxicity.model.api.bukkit.BetterModelBukkit;
import kr.toxicity.model.api.platform.PlatformLocation;
import kr.toxicity.model.api.platform.PlatformWorld;
import kr.toxicity.model.api.scheduler.ModelTask;
import org.bukkit.Location;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Represents a Bukkit location wrapped as a {@link PlatformLocation}.
*
* @param source the source Bukkit location
* @since 2.0.0
*/
public record BukkitLocation(@NotNull Location source) implements PlatformLocation {
@Override
public @NotNull PlatformWorld world() {
return BukkitAdapter.adapt(source.getWorld());
}
@Override
public double x() {
return source.getX();
}
@Override
public double y() {
return source.getY();
}
@Override
public double z() {
return source.getZ();
}
@Override
public float pitch() {
return source.getPitch();
}
@Override
public float yaw() {
return source.getYaw();
}
@Override
public @NotNull PlatformLocation add(double x, double y, double z) {
return BukkitAdapter.adapt(source.clone().add(x, y, z));
}
@Override
public @Nullable ModelTask task(@NotNull Runnable runnable) {
return BetterModelBukkit.platform().scheduler().task(source, runnable);
}
@Override
public @Nullable ModelTask taskLater(long delay, @NotNull Runnable runnable) {
return BetterModelBukkit.platform().scheduler().taskLater(source, delay, runnable);
}
}
================================================
FILE: api/bukkit-api/src/main/java/kr/toxicity/model/api/bukkit/platform/BukkitOfflinePlayer.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bukkit.platform;
import kr.toxicity.model.api.platform.PlatformOfflinePlayer;
import org.bukkit.OfflinePlayer;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.UUID;
/**
* Represents a Bukkit offline player wrapped as a {@link PlatformOfflinePlayer}.
*
* @param source the source Bukkit offline player
* @since 2.0.0
*/
public record BukkitOfflinePlayer(@NotNull OfflinePlayer source) implements PlatformOfflinePlayer {
@Override
public @NotNull UUID uuid() {
return source.getUniqueId();
}
@Override
public @Nullable String name() {
return source.getName();
}
}
================================================
FILE: api/bukkit-api/src/main/java/kr/toxicity/model/api/bukkit/platform/BukkitPlayer.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bukkit.platform;
import kr.toxicity.model.api.platform.PlatformPlayer;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
/**
* Represents a Bukkit player wrapped as a {@link PlatformPlayer}.
*
* @since 2.0.0
*/
public final class BukkitPlayer extends BukkitLivingEntity implements PlatformPlayer {
/**
* Creates a new BukkitPlayer wrapper.
*
* @param source the source Bukkit player
* @since 2.0.0
*/
public BukkitPlayer(@NotNull Player source) {
super(source);
}
/**
* Returns the underlying Bukkit player.
*
* @return the source player
* @since 2.0.0
*/
public @NotNull Player source() {
return (Player) super.source();
}
@Override
public @NotNull String name() {
return source().getName();
}
}
================================================
FILE: api/bukkit-api/src/main/java/kr/toxicity/model/api/bukkit/platform/BukkitWorld.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bukkit.platform;
import kr.toxicity.model.api.platform.PlatformWorld;
import org.bukkit.World;
import org.jetbrains.annotations.NotNull;
/**
* Represents a Bukkit world wrapped as a {@link PlatformWorld}.
*
* @param source the source Bukkit world
* @since 2.0.0
*/
public record BukkitWorld(@NotNull World source) implements PlatformWorld {
}
================================================
FILE: api/bukkit-api/src/main/java/kr/toxicity/model/api/bukkit/scheduler/BukkitModelScheduler.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bukkit.scheduler;
import kr.toxicity.model.api.scheduler.ModelScheduler;
import kr.toxicity.model.api.scheduler.ModelTask;
import org.bukkit.Location;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Represents a Bukkit-specific scheduler for model tasks.
*
* This interface extends {@link ModelScheduler} to provide methods for scheduling tasks
* that are synchronized with specific locations (e.g., for Folia compatibility).
*
*
* @since 2.0.0
*/
public interface BukkitModelScheduler extends ModelScheduler {
/**
* Schedules a task to run on the next tick, synchronized with the given location.
*
* @param location the location to synchronize with
* @param runnable the task to run
* @return the scheduled task, or null if scheduling failed
* @since 2.0.0
*/
@Nullable ModelTask task(@NotNull Location location, @NotNull Runnable runnable);
/**
* Schedules a task to run after a delay, synchronized with the given location.
*
* @param location the location to synchronize with
* @param delay the delay in ticks
* @param runnable the task to run
* @return the scheduled task, or null if scheduling failed
* @since 2.0.0
*/
@Nullable ModelTask taskLater(@NotNull Location location, long delay, @NotNull Runnable runnable);
}
================================================
FILE: api/mod-api/build.gradle.kts
================================================
plugins {
alias(libs.plugins.convention.publish)
id("net.neoforged.moddev")
}
dependencies {
api(project(":bettermodel-api"))
}
neoForge {
enable {
neoFormVersion = libs.versions.neoform.get()
}
}
================================================
FILE: api/mod-api/src/main/java/kr/toxicity/model/api/mod/BetterModelMod.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.mod;
import kr.toxicity.model.api.BetterModel;
import kr.toxicity.model.api.BetterModelPlatform;
import kr.toxicity.model.api.mod.scheduler.ModModelScheduler;
import net.minecraft.server.MinecraftServer;
import org.jetbrains.annotations.NotNull;
/**
* Represents the Mod-specific platform interface for BetterModel.
*
* This interface extends {@link BetterModelPlatform} to provide access to the underlying
* Minecraft server instance and region holder for thread-safe operations.
*
*
* @since 2.0.0
*/
public interface BetterModelMod extends BetterModelPlatform {
/**
* Returns the current {@link BetterModelMod} instance.
*
* @return the current platform instance
* @since 2.0.0
*/
static @NotNull BetterModelMod platform() {
return (BetterModelMod) BetterModel.platform();
}
/**
* Returns the underlying Minecraft server instance.
*
* @return the Minecraft server
* @since 2.0.0
*/
@NotNull MinecraftServer server();
/**
* Returns the Mod-specific scheduler.
*
* @return the scheduler
* @since 2.0.0
*/
@Override
@NotNull ModModelScheduler scheduler();
}
================================================
FILE: api/mod-api/src/main/java/kr/toxicity/model/api/mod/entity/BaseModEntity.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.mod.entity;
import kr.toxicity.model.api.entity.BaseEntity;
import net.minecraft.world.entity.Entity;
import org.jetbrains.annotations.NotNull;
/**
* Represents a Mod-specific entity adapter.
*
* This interface extends {@link BaseEntity} to provide access to the underlying NMS entity.
*
*
* @since 2.0.0
*/
public interface BaseModEntity extends BaseEntity {
/**
* Returns the underlying NMS entity.
*
* @return the NMS entity
* @since 2.0.0
*/
default @NotNull Entity entity() {
return (Entity) handle();
}
/**
* Sets the underlying NMS entity.
*
* @param entity the NMS entity
* @since 2.0.0
*/
void entity(@NotNull Entity entity);
}
================================================
FILE: api/mod-api/src/main/java/kr/toxicity/model/api/mod/entity/BaseModPlayer.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.mod.entity;
import kr.toxicity.model.api.entity.BasePlayer;
import net.minecraft.server.level.ServerPlayer;
import org.jetbrains.annotations.NotNull;
/**
* Represents a Mod-specific player adapter.
*
* This interface extends {@link BaseModEntity} and {@link BasePlayer} to provide
* access to the underlying NMS server player.
*
*
* @since 2.0.0
*/
public interface BaseModPlayer extends BaseModEntity, BasePlayer {
/**
* Returns the underlying NMS server player.
*
* @return the server player
* @since 2.0.0
*/
@Override
default @NotNull ServerPlayer entity() {
return (ServerPlayer) handle();
}
}
================================================
FILE: api/mod-api/src/main/java/kr/toxicity/model/api/mod/platform/ModAdapter.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.mod.platform;
import com.mojang.authlib.GameProfile;
import kr.toxicity.model.api.mod.BetterModelMod;
import kr.toxicity.model.api.platform.*;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.network.ServerPlayerConnection;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.UUID;
/**
* Provides an adapter for converting Mod/NMS objects to BetterModel platform objects.
*
* This class implements {@link PlatformAdapter} and offers static utility methods for adapting
* entities, players, items, and worlds.
*
*
* @since 2.0.0
*/
public final class ModAdapter implements PlatformAdapter {
/**
* Adapts an NMS entity to a {@link PlatformEntity}.
*
* @param entity the NMS entity
* @return the platform entity
* @since 2.0.0
*/
public static @NotNull PlatformEntity adapt(@NotNull Entity entity) {
return ModEntity.of(entity);
}
/**
* Adapts an NMS living entity to a {@link PlatformLivingEntity}.
*
* @param livingEntity the NMS living entity
* @return the platform living entity
* @since 2.0.0
*/
public static @NotNull PlatformLivingEntity adapt(@NotNull LivingEntity livingEntity) {
return ModLivingEntity.of(livingEntity);
}
/**
* Adapts an NMS player connection to a {@link PlatformPlayer}.
*
* @param connection the NMS player connection
* @return the platform player
* @since 2.0.0
*/
public static @NotNull PlatformPlayer adapt(@NotNull ServerPlayerConnection connection) {
return ModPlayer.of(connection);
}
/**
* Adapts an NMS server player to a {@link PlatformPlayer}.
*
* @param player the NMS server player
* @return the platform player
* @since 2.0.0
*/
public static @NotNull PlatformPlayer adapt(@NotNull ServerPlayer player) {
return adapt(player.connection);
}
/**
* Adapts a UUID to a {@link PlatformOfflinePlayer}.
*
* @param uuid the player UUID
* @return the platform offline player
* @since 2.0.0
*/
public static @NotNull PlatformOfflinePlayer adapt(@NotNull UUID uuid) {
return ModOfflinePlayer.of(uuid, null);
}
/**
* Adapts a GameProfile to a {@link PlatformOfflinePlayer}.
*
* @param profile the game profile
* @return the platform offline player
* @since 2.0.0
*/
public static @NotNull PlatformOfflinePlayer adapt(@NotNull GameProfile profile) {
return ModOfflinePlayer.of(profile.id(), profile.name());
}
/**
* Adapts an NMS item stack to a {@link PlatformItemStack}.
*
* @param itemStack the NMS item stack
* @return the platform item stack
* @since 2.0.0
*/
public static @NotNull PlatformItemStack adapt(@NotNull ItemStack itemStack) {
return ModItemStack.of(itemStack);
}
/**
* Adapts an NMS level to a {@link PlatformWorld}.
*
* @param world the NMS level
* @return the platform world
* @since 2.0.0
*/
public static @NotNull PlatformWorld adapt(@NotNull Level world) {
return ModWorld.of(world);
}
@Override
public int serverViewDistance() {
return server().getPlayerList().getViewDistance();
}
@Override
public boolean isTickThread() {
return server().isSameThread();
}
@Override
public boolean isRegionSafe() {
return true;
}
@Override
public @Nullable PlatformPlayer player(@NotNull UUID uuid) {
var player = server().getPlayerList().getPlayer(uuid);
return player == null ? null : adapt(player);
}
@Override
public @NotNull PlatformOfflinePlayer offlinePlayer(@NotNull UUID uuid) {
var profile = server().services().profileResolver().fetchById(uuid).orElse(null);
return profile == null ? adapt(uuid) : adapt(profile);
}
@Override
public @NotNull PlatformItemStack air() {
return adapt(ItemStack.EMPTY);
}
@Override
public @NotNull PlatformLocation zero() {
return ModLocation.of(null, 0, 0, 0);
}
private @NotNull MinecraftServer server() {
return BetterModelMod.platform().server();
}
}
================================================
FILE: api/mod-api/src/main/java/kr/toxicity/model/api/mod/platform/ModEntity.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.mod.platform;
import kr.toxicity.model.api.platform.PlatformEntity;
import kr.toxicity.model.api.platform.PlatformLocation;
import net.minecraft.world.entity.Entity;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
/**
* Represents a Mod entity wrapped as a {@link PlatformEntity}.
*
* @param source the source NMS entity
* @since 2.0.0
*/
public record ModEntity(@NotNull Entity source) implements PlatformEntity {
@ApiStatus.Internal
public ModEntity {
}
/**
* Creates a ModEntity from the source.
*
* @param source the source entity
* @return the instance
* @since 2.0.0
*/
public static @NotNull ModEntity of(@NotNull Entity source) {
return new ModEntity(source);
}
@Override
public @NotNull UUID uuid() {
return source.getUUID();
}
@Override
public @NotNull PlatformLocation location() {
return ModLocation.of(source);
}
}
================================================
FILE: api/mod-api/src/main/java/kr/toxicity/model/api/mod/platform/ModItemStack.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.mod.platform;
import kr.toxicity.model.api.platform.PlatformItemStack;
import kr.toxicity.model.api.platform.PlatformNamespace;
import net.minecraft.core.component.DataComponents;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.component.CustomModelData;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
/**
* Represents a Mod item stack wrapped as a {@link PlatformItemStack}.
*
* @param source the source NMS item stack
* @since 2.0.0
*/
public record ModItemStack(@NotNull ItemStack source) implements PlatformItemStack {
@ApiStatus.Internal
public ModItemStack {
}
/**
* Creates a ModItemStack from the source.
*
* @param source the source item stack
* @return the instance
* @since 2.0.0
*/
public static @NotNull ModItemStack of(@NotNull ItemStack source) {
return new ModItemStack(source);
}
@Override
public boolean isAir() {
return source.isEmpty();
}
@Override
public @NotNull PlatformItemStack enchant(boolean enchant) {
source.set(DataComponents.ENCHANTMENT_GLINT_OVERRIDE, enchant);
return this;
}
@Override
public @NotNull PlatformItemStack modelData(int customModelData, @Nullable PlatformNamespace namespace) {
source.set(
DataComponents.CUSTOM_MODEL_DATA,
new CustomModelData(List.of((float) customModelData), List.of(), List.of(), List.of())
);
source.set(
DataComponents.ITEM_MODEL,
namespace == null ? null : Identifier.fromNamespaceAndPath(namespace.namespace(), namespace.path())
);
return this;
}
@Override
public @NotNull PlatformItemStack clone() {
return ModAdapter.adapt(source.copy());
}
}
================================================
FILE: api/mod-api/src/main/java/kr/toxicity/model/api/mod/platform/ModLivingEntity.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.mod.platform;
import kr.toxicity.model.api.platform.PlatformLivingEntity;
import kr.toxicity.model.api.platform.PlatformLocation;
import net.minecraft.world.entity.LivingEntity;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
/**
* Represents a Mod living entity wrapped as a {@link PlatformLivingEntity}.
*
* @param source the source NMS living entity
* @since 2.0.0
*/
public record ModLivingEntity(@NotNull LivingEntity source) implements PlatformLivingEntity {
@ApiStatus.Internal
public ModLivingEntity {
}
/**
* Creates a ModLivingEntity from the source.
*
* @param source the source living entity
* @return the instance
* @since 2.0.0
*/
public static @NotNull ModLivingEntity of(@NotNull LivingEntity source) {
return new ModLivingEntity(source);
}
@Override
public @NotNull UUID uuid() {
return source.getUUID();
}
@Override
public @NotNull PlatformLocation location() {
return ModLocation.of(source);
}
@Override
public @NotNull PlatformLocation eyeLocation() {
return ModLocation.ofEye(source);
}
}
================================================
FILE: api/mod-api/src/main/java/kr/toxicity/model/api/mod/platform/ModLocation.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.mod.platform;
import kr.toxicity.model.api.mod.BetterModelMod;
import kr.toxicity.model.api.mod.scheduler.ModModelScheduler;
import kr.toxicity.model.api.platform.PlatformLocation;
import kr.toxicity.model.api.platform.PlatformWorld;
import kr.toxicity.model.api.scheduler.ModelTask;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Represents a Mod location wrapped as a {@link PlatformLocation}.
*
* @param level the NMS level
* @param x the x coordinate
* @param y the y coordinate
* @param z the z coordinate
* @param pitch the pitch
* @param yaw the yaw
* @since 2.0.0
*/
public record ModLocation(@Nullable Level level, double x, double y, double z, float pitch, float yaw) implements PlatformLocation {
@ApiStatus.Internal
public ModLocation {
}
/**
* Creates a ModLocation from the coordinates.
*
* @param level the NMS level
* @param x the x coordinate
* @param y the y coordinate
* @param z the z coordinate
* @param pitch the pitch
* @param yaw the yaw
* @return the instance
* @since 2.0.0
*/
public static @NotNull ModLocation of(@Nullable Level level, double x, double y, double z, float pitch, float yaw) {
return new ModLocation(
level,
x,
y,
z,
pitch,
yaw
);
}
/**
* Creates a ModLocation from the coordinates with zero pitch and yaw.
*
* @param level the NMS level
* @param x the x coordinate
* @param y the y coordinate
* @param z the z coordinate
* @return the instance
* @since 2.0.0
*/
public static @NotNull ModLocation of(@Nullable Level level, double x, double y, double z) {
return new ModLocation(
level,
x,
y,
z,
0.0f,
0.0f
);
}
/**
* Creates a ModLocation from the position vector.
*
* @param level the NMS level
* @param position the position vector
* @param pitch the pitch
* @param yaw the yaw
* @return the instance
* @since 2.0.0
*/
public static @NotNull ModLocation of(@Nullable Level level, Vec3 position, float pitch, float yaw) {
return new ModLocation(
level,
position.x,
position.y,
position.z,
pitch,
yaw
);
}
/**
* Creates a ModLocation from the position vector with zero pitch and yaw.
*
* @param level the NMS level
* @param position the position vector
* @return the instance
* @since 2.0.0
*/
public static @NotNull ModLocation of(@Nullable Level level, Vec3 position) {
return new ModLocation(
level,
position.x,
position.y,
position.z,
0.0f,
0.0f
);
}
/**
* Creates a ModLocation from an entity's position.
*
* @param entity the entity
* @return the location
* @since 2.0.0
*/
public static @NotNull ModLocation of(@NotNull Entity entity) {
return new ModLocation(
entity.level(),
entity.getX(),
entity.getY(),
entity.getZ(),
entity.getXRot(),
entity.getYRot()
);
}
/**
* Creates a ModLocation from an entity's eye position.
*
* @param entity the entity
* @return the eye location
* @since 2.0.0
*/
public static @NotNull ModLocation ofEye(@NotNull Entity entity) {
return new ModLocation(
entity.level(),
entity.getX(),
entity.getEyeY(),
entity.getZ(),
entity.getXRot(),
entity.getYRot()
);
}
@Override
public @NotNull PlatformWorld world() {
if (level == null) {
throw new IllegalStateException("level is not set");
}
return ModAdapter.adapt(level);
}
@Override
public @NotNull PlatformLocation add(double x, double y, double z) {
return new ModLocation(
this.level,
this.x + x,
this.y + y,
this.z + z,
this.pitch,
this.yaw
);
}
@Override
public @Nullable ModelTask task(@NotNull Runnable runnable) {
return scheduler().task(runnable);
}
@Override
public @Nullable ModelTask taskLater(long delay, @NotNull Runnable runnable) {
return scheduler().taskLater(delay, runnable);
}
private @NotNull ModModelScheduler scheduler() {
return BetterModelMod.platform().scheduler();
}
}
================================================
FILE: api/mod-api/src/main/java/kr/toxicity/model/api/mod/platform/ModOfflinePlayer.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.mod.platform;
import kr.toxicity.model.api.platform.PlatformOfflinePlayer;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.UUID;
/**
* Represents a Mod offline player wrapped as a {@link PlatformOfflinePlayer}.
*
* @param uuid the player UUID
* @param name the player name, or null if unknown
* @since 2.0.0
*/
public record ModOfflinePlayer(@NotNull UUID uuid, @Nullable String name) implements PlatformOfflinePlayer {
@ApiStatus.Internal
public ModOfflinePlayer {
}
/**
* Creates a ModOfflinePlayer from the UUID and name.
*
* @param uuid the player uuid
* @param name the player name
* @return the instance
* @since 2.0.0
*/
public static @NotNull ModOfflinePlayer of(@NotNull UUID uuid, @Nullable String name) {
return new ModOfflinePlayer(uuid, name);
}
}
================================================
FILE: api/mod-api/src/main/java/kr/toxicity/model/api/mod/platform/ModPlayer.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.mod.platform;
import kr.toxicity.model.api.platform.PlatformLocation;
import kr.toxicity.model.api.platform.PlatformPlayer;
import net.minecraft.server.network.ServerPlayerConnection;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
/**
* Represents a Mod player wrapped as a {@link PlatformPlayer}.
*
* @param source the source NMS player connection
* @since 2.0.0
*/
public record ModPlayer(@NotNull ServerPlayerConnection source) implements PlatformPlayer {
@ApiStatus.Internal
public ModPlayer {
}
/**
* Creates a ModPlayer from the source.
*
* @param source the source player connection
* @return the instance
* @since 2.0.0
*/
public static @NotNull ModPlayer of(@NotNull ServerPlayerConnection source) {
return new ModPlayer(source);
}
@Override
public @NotNull UUID uuid() {
return source.getPlayer().getUUID();
}
@Override
public @NotNull PlatformLocation location() {
return ModLocation.of(source.getPlayer());
}
@Override
public @NotNull PlatformLocation eyeLocation() {
return ModLocation.ofEye(source.getPlayer());
}
@Override
public @NotNull String name() {
return source.getPlayer().getPlainTextName();
}
}
================================================
FILE: api/mod-api/src/main/java/kr/toxicity/model/api/mod/platform/ModRegionHolder.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.mod.platform;
import kr.toxicity.model.api.platform.PlatformRegionHolder;
/**
* Represents a Mod-specific region holder for managing thread-safe operations.
*
* This interface extends {@link PlatformRegionHolder} to provide Mod-specific functionality
* for scheduling tasks within specific regions or contexts.
*
*
* @since 2.0.0
*/
public interface ModRegionHolder extends PlatformRegionHolder {
}
================================================
FILE: api/mod-api/src/main/java/kr/toxicity/model/api/mod/platform/ModWorld.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.mod.platform;
import kr.toxicity.model.api.platform.PlatformWorld;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
/**
* Represents a Fabric world wrapped as a {@link PlatformWorld}.
*
* @param level the source NMS level
* @since 2.0.0
*/
public record ModWorld(@NotNull Level level) implements PlatformWorld {
@ApiStatus.Internal
public ModWorld {
}
/**
* Creates a FabricWorld from the level.
*
* @param level the source level
* @return the instance
* @since 2.0.0
*/
public static @NotNull ModWorld of(@NotNull Level level) {
return new ModWorld(level);
}
}
================================================
FILE: api/mod-api/src/main/java/kr/toxicity/model/api/mod/scheduler/ModModelScheduler.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.mod.scheduler;
import kr.toxicity.model.api.scheduler.ModelScheduler;
import kr.toxicity.model.api.scheduler.ModelTask;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Represents a Mod-specific scheduler for model tasks.
*
* This interface extends {@link ModelScheduler} to provide methods for scheduling tasks
* within the Mod environment.
*
*
* @since 2.0.0
*/
public interface ModModelScheduler extends ModelScheduler {
/**
* Schedules a task to run on the next tick.
*
* @param runnable the task to run
* @return the scheduled task, or null if scheduling failed
* @since 2.0.0
*/
@Nullable ModelTask task(@NotNull Runnable runnable);
/**
* Schedules a task to run after a delay.
*
* @param delay the delay in ticks
* @param runnable the task to run
* @return the scheduled task, or null if scheduling failed
* @since 2.0.0
*/
@Nullable ModelTask taskLater(long delay, @NotNull Runnable runnable);
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/BetterModel.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api;
import kr.toxicity.model.api.data.renderer.ModelRenderer;
import kr.toxicity.model.api.entity.BaseEntity;
import kr.toxicity.model.api.nms.NMS;
import kr.toxicity.model.api.nms.PlayerChannelHandler;
import kr.toxicity.model.api.platform.PlatformEntity;
import kr.toxicity.model.api.tracker.EntityTrackerRegistry;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.*;
/**
* The main entry point for the BetterModel API.
*
* This class provides static access to the platform instance, configuration, model managers,
* NMS handlers, and entity registries. It serves as a service provider for interacting with the BetterModel engine.
*
*
* @since 1.15.2
*/
public final class BetterModel {
/**
* Private initializer to prevent instantiation.
*/
private BetterModel() {
throw new RuntimeException();
}
/**
* The singleton platform instance.
*/
private static BetterModelPlatform instance;
/**
* Returns the platform configuration manager.
*
* @return the configuration manager
* @since 1.15.2
*/
public static @NotNull BetterModelConfig config() {
return platform().config();
}
/**
* Retrieves a model renderer by its name, wrapped in an Optional.
*
* @param name the name of the model
* @return an optional containing the renderer if found
* @since 1.15.2
*/
public static @NotNull Optional model(@NotNull String name) {
return Optional.ofNullable(modelOrNull(name));
}
/**
* Retrieves a model renderer by its name, or null if not found.
*
* @param name the name of the model
* @return the renderer, or null
* @since 1.15.2
*/
public static @Nullable ModelRenderer modelOrNull(@NotNull String name) {
return platform().modelManager().model(name);
}
/**
* Retrieves a player limb renderer by its name, wrapped in an Optional.
*
* @param name the name of the limb model
* @return an optional containing the renderer if found
* @since 1.15.2
*/
public static @NotNull Optional limb(@NotNull String name) {
return Optional.ofNullable(limbOrNull(name));
}
/**
* Retrieves a player limb renderer by its name, or null if not found.
*
* @param name the name of the limb model
* @return the renderer, or null
* @since 1.15.2
*/
public static @Nullable ModelRenderer limbOrNull(@NotNull String name) {
return platform().modelManager().limb(name);
}
/**
* Retrieves a player channel handler by the player's UUID.
*
* @param uuid the player's UUID
* @return an optional containing the channel handler if found
* @since 1.15.2
*/
public static @NotNull Optional player(@NotNull UUID uuid) {
return Optional.ofNullable(platform().playerManager().player(uuid));
}
/**
* Retrieves an entity tracker registry by the entity's UUID.
*
* @param uuid the entity's UUID
* @return an optional containing the registry if found
* @since 1.15.2
*/
public static @NotNull Optional registry(@NotNull UUID uuid) {
return Optional.ofNullable(registryOrNull(uuid));
}
/**
* Retrieves an entity tracker registry for a Bukkit entity.
*
* @param entity the Bukkit entity
* @return an optional containing the registry if found
* @since 1.15.2
*/
public static @NotNull Optional registry(@NotNull PlatformEntity entity) {
return Optional.ofNullable(registryOrNull(entity));
}
/**
* Retrieves an entity tracker registry for a base entity.
*
* @param entity the base entity
* @return an optional containing the registry if found
* @since 1.15.2
*/
public static @NotNull Optional registry(@NotNull BaseEntity entity) {
return Optional.ofNullable(registryOrNull(entity));
}
/**
* Retrieves an entity tracker registry by the entity's UUID, or null if not found.
*
* @param uuid the entity's UUID
* @return the registry, or null
* @since 1.15.2
*/
public static @Nullable EntityTrackerRegistry registryOrNull(@NotNull UUID uuid) {
return EntityTrackerRegistry.registry(uuid);
}
/**
* Retrieves an entity tracker registry for a Bukkit entity, or null if not found.
*
* @param entity the Bukkit entity
* @return the registry, or null
* @since 1.15.2
*/
public static @Nullable EntityTrackerRegistry registryOrNull(@NotNull PlatformEntity entity) {
return registryOrNull(nms().adapt(entity));
}
/**
* Retrieves an entity tracker registry for a base entity, or null if not found.
*
* @param entity the base entity
* @return the registry, or null
* @since 1.15.2
*/
public static @Nullable EntityTrackerRegistry registryOrNull(@NotNull BaseEntity entity) {
return EntityTrackerRegistry.registry(entity);
}
/**
* Returns a collection of all loaded model renderers.
*
* @return an unmodifiable collection of models
* @since 1.15.2
*/
public static @NotNull @Unmodifiable Collection models() {
return platform().modelManager().models();
}
/**
* Returns a collection of all loaded player limb renderers.
*
* @return an unmodifiable collection of limb models
* @since 1.15.2
*/
public static @NotNull @Unmodifiable Collection limbs() {
return platform().modelManager().limbs();
}
/**
* Returns a set of all loaded model names.
*
* @return an unmodifiable set of model keys
* @since 1.15.2
*/
public static @NotNull @Unmodifiable Set modelKeys() {
return platform().modelManager().modelKeys();
}
/**
* Returns a set of all loaded player limb model names.
*
* @return an unmodifiable set of limb keys
* @since 1.15.2
*/
public static @NotNull @Unmodifiable Set limbKeys() {
return platform().modelManager().limbKeys();
}
/**
* Returns the singleton instance of the BetterModel platform.
*
* @return the platform instance
* @throws NullPointerException if the platform has not been initialized
* @since 2.0.0
*/
public static @NotNull BetterModelPlatform platform() {
return Objects.requireNonNull(instance, "BetterModel hasn't been initialized yet!");
}
/**
* Returns the NMS handler instance.
*
* @return the NMS handler
* @since 1.15.2
*/
public static @NotNull NMS nms() {
return platform().nms();
}
/**
* Returns the event bus.
*
* @return the event bus
* @since 2.0.0
*/
public static @NotNull BetterModelEventBus eventBus() {
return platform().eventBus();
}
/**
* Registers the platform instance.
*
* This method is intended for internal use only during platform initialization.
*
*
* @param instance the platform instance
* @throws RuntimeException if an instance is already registered
* @since 1.15.2
*/
@ApiStatus.Internal
public static void register(@NotNull BetterModelPlatform instance) {
Objects.requireNonNull(instance, "instance cannot be null.");
if (BetterModel.instance == instance) throw new RuntimeException("Duplicated instance.");
BetterModel.instance = instance;
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/BetterModelConfig.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api;
import kr.toxicity.model.api.config.DebugConfig;
import kr.toxicity.model.api.config.IndicatorConfig;
import kr.toxicity.model.api.config.ModuleConfig;
import kr.toxicity.model.api.config.PackConfig;
import kr.toxicity.model.api.mount.MountController;
import kr.toxicity.model.api.platform.PlatformItemStack;
import org.jetbrains.annotations.NotNull;
import java.util.function.Supplier;
/**
* Represents the main configuration interface for BetterModel.
*
* This interface provides access to various configuration settings, including debug options,
* pack generation settings, module toggles, and runtime behaviors.
*
*
* @since 1.15.2
*/
public interface BetterModelConfig {
/**
* Returns the debug configuration.
*
* @return the debug config
* @since 1.15.2
*/
@NotNull DebugConfig debug();
/**
* Returns the indicator configuration.
*
* @return the indicator config
* @since 1.15.2
*/
@NotNull IndicatorConfig indicator();
/**
* Returns the module configuration.
*
* @return the module config
* @since 1.15.2
*/
@NotNull ModuleConfig module();
/**
* Returns the resource pack configuration.
*
* @return the pack config
* @since 1.15.2
*/
@NotNull PackConfig pack();
/**
* Checks if metrics collection is enabled.
*
* @return true if enabled, false otherwise
* @since 1.15.2
*/
boolean metrics();
/**
* Checks if sight tracing (visibility checking) is enabled.
*
* @return true if enabled, false otherwise
* @since 1.15.2
*/
boolean sightTrace();
/**
* Checks if BetterModel should attempt to merge its resource pack with external plugins/mods.
*
* @return true to merge, false otherwise
* @since 1.15.2
*/
boolean mergeWithExternalResources();
/**
* Returns a supplier for the platform item stack used as the base for model items.
*
* @return a supplier providing the target item stack
* @since 2.0.0
*/
@NotNull Supplier item();
/**
* Returns the item model string identifier used for the resource pack target item.
*
* @return the item model string
* @since 2.0.0
*/
@NotNull String itemModel();
/**
* Returns the namespace used for the target item.
*
* @return the item namespace
* @since 1.15.2
*/
@NotNull String itemNamespace();
/**
* Returns the maximum range for sight tracing.
*
* @return the max range
* @since 1.15.2
*/
double maxSight();
/**
* Returns the minimum range for sight tracing.
*
* @return the min range
* @since 1.15.2
*/
double minSight();
/**
* Returns the namespace used for the generated resource pack.
*
* @return the namespace
* @since 1.15.2
*/
@NotNull String namespace();
/**
* Returns the type of resource pack generation (Folder, Zip, or None).
*
* @return the pack type
* @since 1.15.2
*/
@NotNull PackType packType();
/**
* Returns the location of the build folder for resource packs.
*
* @return the build folder path
* @since 1.15.2
*/
@NotNull String buildFolderLocation();
/**
* Checks if model trackers should follow the source entity's invisibility status.
*
* @return true to follow invisibility, false otherwise
* @since 1.15.2
*/
boolean followMobInvisibility();
/**
* Checks if Purpur's AFK API should be used.
*
* @return true to use Purpur AFK, false otherwise
* @since 1.15.2
*/
boolean usePurpurAfk();
/**
* Checks if version update notifications should be sent to OPs on join.
*
* @return true to send notifications, false otherwise
* @since 1.15.2
*/
boolean versionCheck();
/**
* Returns the default mount controller used for entities.
*
* @return the default mount controller
* @see kr.toxicity.model.api.mount.MountControllers
* @since 1.15.2
*/
@NotNull MountController defaultMountController();
/**
* Returns the interpolation frame time (lerp) in milliseconds.
*
* @return the lerp frame time
* @since 1.15.2
*/
int lerpFrameTime();
/**
* Checks if inventory swap packets should be cancelled for players with active models.
*
* @return true to cancel, false otherwise
* @since 1.15.2
*/
boolean cancelPlayerModelInventory();
/**
* Returns the delay in ticks before hiding a player's model after they become invisible.
*
* @return the hide delay
* @since 1.15.2
*/
long playerHideDelay();
/**
* Returns the threshold size for packet bundling.
*
* @return the packet bundling size
* @since 1.15.2
*/
int packetBundlingSize();
/**
* Checks if strict loading mode is enabled.
*
* Strict loading causes the platform to fail fast on model loading errors.
*
*
* @return true if strict loading is enabled, false otherwise
* @since 1.15.2
*/
boolean enableStrictLoading();
/**
* Enumerates the types of resource pack generation.
*
* @since 1.15.2
*/
enum PackType {
/**
* Generate the resource pack as a folder structure.
* @since 1.15.2
*/
FOLDER,
/**
* Generate the resource pack as a ZIP archive.
* @since 1.15.2
*/
ZIP,
/**
* Do not generate a resource pack.
* @since 1.15.2
*/
NONE
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/BetterModelEvaluator.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api;
import kr.toxicity.model.api.util.function.Float2FloatFunction;
import org.jetbrains.annotations.NotNull;
/**
* Evaluator
*/
public interface BetterModelEvaluator {
/**
* Compiles molang expression
* @param expression expression
* @return compiled function
*/
@NotNull Float2FloatFunction compile(@NotNull String expression);
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/BetterModelEventBus.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api;
import kr.toxicity.model.api.event.ModelEvent;
import kr.toxicity.model.api.event.ModelEventApplication;
import kr.toxicity.model.api.event.ModelEventListener;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import java.util.function.Consumer;
import java.util.function.Supplier;
/**
* A central event bus for handling model-related events.
*
* This interface allows subscribing to and publishing {@link ModelEvent}s.
* It serves as a decoupling mechanism between different parts of the engine.
*
*
* @since 2.0.0
*/
public interface BetterModelEventBus {
/**
* Subscribes a consumer to a specific event type.
*
* @param application the application that subscribes to the event
* @param eventClass the class of the event to subscribe to
* @param consumer the consumer to handle the event
* @param the type of the event
* @return a listener handle that can be used to unregister the subscription
* @since 2.0.0
*/
@NotNull
ModelEventListener subscribe(@NotNull ModelEventApplication application, @NotNull Class eventClass, @NotNull Consumer consumer);
/**
* Publishes an event to all registered subscribers.
*
* The event is created lazily using the provided supplier if there are subscribers.
*
*
* @param eventClass the class of the event
* @param eventSupplier a supplier that creates the event
* @param the type of the event
* @return the result of the event call
* @since 2.0.0
*/
@NotNull Result call(@NotNull Class extends T> eventClass, @NotNull Supplier eventSupplier);
/**
* Publishes an event to all registered subscribers.
*
* @param event the event to publish
* @return the result of the event call
* @since 2.0.0
*/
default @NotNull Result call(@NotNull ModelEvent event) {
return call(event.getClass(), () -> event);
}
/**
* Represents the outcome of an event publication.
*
* @since 2.0.0
*/
@RequiredArgsConstructor
enum Result {
/**
* The event was successfully processed by at least one subscriber.
* @since 2.0.0
*/
SUCCESS(true),
/**
* The event processing failed or was canceled.
* @since 2.0.0
*/
FAIL(false),
/**
* No handlers were registered for this event type.
* @since 2.0.0
*/
NO_EVENT_HANDLER(true)
;
private final boolean triggered;
/**
* Checks if the event was considered "triggered" (i.e., not canceled or failed).
*
* Note that {@link #NO_EVENT_HANDLER} is considered triggered as the operation wasn't blocked.
*
*
* @return true if triggered, false otherwise
* @since 2.0.0
*/
public boolean triggered() {
return triggered;
}
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/BetterModelLogger.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api;
import net.kyori.adventure.text.Component;
import org.jetbrains.annotations.NotNull;
/**
* BetterModel's logger
*/
public interface BetterModelLogger {
/**
* Infos messages
* @param message message
*/
void info(@NotNull Component... message);
/**
* Warns message
* @param message message
*/
void warn(@NotNull Component... message);
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/BetterModelPlatform.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api;
import kr.toxicity.model.api.event.ModelEventApplication;
import kr.toxicity.model.api.manager.*;
import kr.toxicity.model.api.nms.NMS;
import kr.toxicity.model.api.pack.PackResult;
import kr.toxicity.model.api.pack.PackZipper;
import kr.toxicity.model.api.platform.PlatformAdapter;
import kr.toxicity.model.api.scheduler.ModelScheduler;
import kr.toxicity.model.api.version.MinecraftVersion;
import lombok.RequiredArgsConstructor;
import net.kyori.adventure.audience.Audience;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.semver4j.Semver;
import java.io.File;
import java.io.InputStream;
import java.util.function.Consumer;
/**
* Represents the main platform interface for BetterModel.
*
* @see BetterModel
* @since 1.15.2
*/
public interface BetterModelPlatform extends ModelEventApplication {
/**
* Returns the data folder for the BetterModel plugin.
* This is where configuration files, data files, and other plugin-specific resources are stored.
*
* @return the data folder as a {@link File} object.
* @since 2.0.0
*/
@NotNull File dataFolder();
/**
* Returns the type of JAR file this platform is running on (e.g., SPIGOT, PAPER, FABRIC).
*
* @return the {@link JarType} enum representing the platform's JAR type.
* @since 2.0.0
*/
@NotNull JarType jarType();
/**
* Reloads the platform with default settings (console sender).
*
* @return the result of the reload operation
* @since 2.0.0
*/
default @NotNull ReloadResult reload() {
return reload(ReloadInfo.DEFAULT);
}
/**
* Reloads the platform, specifying the command sender who initiated it.
*
* @param sender the command sender
* @return the result of the reload operation
* @since 1.15.2
*/
default @NotNull ReloadResult reload(@NotNull Audience sender) {
return reload(ReloadInfo.builder().sender(sender).build());
}
/**
* Reloads the platform with specific reload information.
*
* @param info the reload configuration
* @return the result of the reload operation
* @since 1.15.2
*/
@NotNull ReloadResult reload(@NotNull ReloadInfo info);
/**
* Checks if the running version of BetterModel is a snapshot build.
*
* @return true if snapshot, false otherwise
* @since 1.15.2
*/
boolean isSnapshot();
/**
* Returns the platform's configuration manager.
*
* @return the configuration
* @since 1.15.2
*/
@NotNull BetterModelConfig config();
/**
* Returns the Minecraft version of the running server.
*
* @return the Minecraft version
* @since 1.15.2
*/
@NotNull MinecraftVersion version();
/**
* Returns the semantic version of the platform.
*
* @return the semantic version
* @since 1.15.2
*/
@NotNull Semver semver();
/**
* Returns the NMS (Net.Minecraft.Server) handler for version-specific operations.
*
* @return the NMS handler
* @since 1.15.2
*/
@NotNull NMS nms();
/**
* Returns the model manager.
*
* @return the model manager
* @since 1.15.2
*/
@NotNull ModelManager modelManager();
/**
* Returns the player manager.
*
* @return the player manager
* @since 1.15.2
*/
@NotNull PlayerManager playerManager();
/**
* Returns the script manager.
*
* @return the script manager
* @since 1.15.2
*/
@NotNull ScriptManager scriptManager();
/**
* Returns the skin manager.
*
* @return the skin manager
* @since 1.15.2
*/
@NotNull SkinManager skinManager();
/**
* Returns the profile manager.
*
* @return the profile manager
* @since 1.15.2
*/
@NotNull ProfileManager profileManager();
/**
* Returns the platform's scheduler.
*
* @return the scheduler
* @since 1.15.2
*/
@NotNull ModelScheduler scheduler();
/**
* Return the platform's adapter
* @return the adapter
*/
@NotNull PlatformAdapter adapter();
/**
* Registers a handler to be executed when a reload starts.
*
* @param consumer the handler, receiving the {@link PackZipper}
* @since 1.15.2
*/
void addReloadStartHandler(@NotNull Consumer consumer);
/**
* Registers a handler to be executed when a reload ends.
*
* @param consumer the handler, receiving the {@link ReloadResult}
* @since 1.15.2
*/
void addReloadEndHandler(@NotNull Consumer consumer);
/**
* Returns the platform's logger.
*
* @return the logger
* @since 1.15.2
*/
@NotNull BetterModelLogger logger();
/**
* Returns the expression evaluator.
*
* @return the evaluator
* @since 1.15.2
*/
@NotNull BetterModelEvaluator evaluator();
/**
* Returns the event bus.
*
* @return the event bus
* @since 2.0.0
*/
@NotNull BetterModelEventBus eventBus();
/**
* Retrieves a resource from the platform's JAR file.
*
* @param path the path to the resource
* @return an input stream for the resource, or null if not found
* @since 1.15.2
*/
@Nullable InputStream getResource(@NotNull String path);
/**
* Represents the outcome of a platform reload operation.
*
* @since 1.15.2
*/
sealed interface ReloadResult {
/**
* Indicates a successful reload.
*
* @param firstLoad true if this is the first load (startup), false otherwise
* @param assetsTime the time taken to reload assets in milliseconds
* @param packResult the result of the resource pack generation
* @since 1.15.2
*/
record Success(boolean firstLoad, long assetsTime, @NotNull PackResult packResult) implements ReloadResult {
/**
* Returns the time taken to generate the resource pack.
*
* @return the packing time in milliseconds
* @since 1.15.2
*/
public long packingTime() {
return packResult().time();
}
/**
* Returns the total time taken for the reload operation.
*
* @return the total time in milliseconds
* @since 1.15.2
*/
public long totalTime() {
return assetsTime + packingTime();
}
/**
* Returns the size of the generated resource pack.
*
* @return the size in bytes
* @since 1.15.2
*/
public long length() {
var dir = packResult.directory();
return dir != null && dir.isFile() ? dir.length() : packResult.stream().mapToLong(b -> b.bytes().length).sum();
}
}
/**
* Indicates that a reload is currently in progress.
* @since 1.15.2
*/
enum OnReload implements ReloadResult {
/**
* Singleton instance.
* @since 1.15.2
*/
INSTANCE
}
/**
* Indicates a failed reload.
*
* @param throwable the exception that caused the failure
* @since 1.15.2
*/
record Failure(@NotNull Throwable throwable) implements ReloadResult {
}
}
/**
* Represents the type of JAR file the platform is running on.
* This enum helps identify the specific server implementation (e.g., Spigot, Paper, Fabric).
*
* @since 2.0.0
*/
@RequiredArgsConstructor
enum JarType {
/**
* Indicates a Spigot-based server.
* @since 2.0.0
*/
SPIGOT("spigot"),
/**
* Indicates a Paper-based server.
* @since 2.0.0
*/
PAPER("paper"),
/**
* Indicates a Fabric-based server.
* @since 2.0.0
*/
FABRIC("fabric");
private final String raw;
/**
* Returns the raw string representation of the JAR type.
*
* @return the raw string (e.g., "spigot", "paper", "fabric")
* @since 2.0.0
*/
public String raw() {
return raw;
}
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/animation/AnimationIterator.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.animation;
import com.google.gson.annotations.SerializedName;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import java.util.Iterator;
/**
* An iterator for traversing animation keyframes.
*
* This interface supports different looping modes (play once, loop, hold on last)
* and allows resetting the iteration state.
*
*
* @param the type of keyframe (must implement {@link Timed})
* @since 1.15.2
*/
public sealed interface AnimationIterator extends Iterator {
/**
* Resets the iterator to its initial state.
* @since 1.15.2
*/
void clear();
/**
* Returns the type of this animation iterator.
*
* @return the animation type
* @since 1.15.2
*/
@NotNull Type type();
/**
* Defines the behavior of the animation iterator.
* @since 1.15.2
*/
@RequiredArgsConstructor
enum Type {
/**
* Plays the animation once and then stops.
* @since 1.15.2
*/
@SerializedName("once")
PLAY_ONCE {
@Override
public @NotNull AnimationIterator create(@NotNull TimedStorage keyframes) {
return new PlayOnce<>(keyframes);
}
},
/**
* Loops the animation continuously.
* @since 1.15.2
*/
@SerializedName("loop")
LOOP {
@Override
public @NotNull AnimationIterator create(@NotNull TimedStorage keyframes) {
return new Loop<>(keyframes);
}
},
/**
* Plays the animation once and holds the last frame.
* @since 1.15.2
*/
@SerializedName("hold")
HOLD_ON_LAST {
@Override
public @NotNull AnimationIterator create(@NotNull TimedStorage keyframes) {
return new HoldOnLast<>(keyframes);
}
}
;
/**
* Creates a new iterator for the given keyframes based on this type.
*
* @param keyframes the keyframes to iterate over
* @param the type of keyframe
* @return a new animation iterator
* @since 1.15.2
*/
public abstract @NotNull AnimationIterator create(@NotNull TimedStorage keyframes);
}
/**
* Implementation for {@link Type#PLAY_ONCE}.
*
* @param the type of keyframe
* @since 1.15.2
*/
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
final class PlayOnce implements AnimationIterator {
private final TimedStorage keyframe;
private int index = 0;
@Override
public void clear() {
index = Integer.MAX_VALUE;
}
@Override
public boolean hasNext() {
return index < keyframe.size();
}
@Override
@NotNull
public T next() {
return keyframe.get(index++);
}
@NotNull
@Override
public Type type() {
return Type.PLAY_ONCE;
}
}
/**
* Implementation for {@link Type#HOLD_ON_LAST}.
*
* @param the type of keyframe
* @since 1.15.2
*/
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
final class HoldOnLast implements AnimationIterator {
private final TimedStorage keyframe;
private int index = 0;
@Override
public void clear() {
index = 0;
}
@Override
public boolean hasNext() {
return true;
}
@Override
@NotNull
public T next() {
if (index >= keyframe.size()) return keyframe.getLast();
return keyframe.get(index++);
}
@NotNull
@Override
public Type type() {
return Type.HOLD_ON_LAST;
}
}
/**
* Implementation for {@link Type#LOOP}.
*
* @param the type of keyframe
* @since 1.15.2
*/
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
final class Loop implements AnimationIterator {
private final TimedStorage keyframe;
private int index = 0;
@Override
public void clear() {
index = 0;
}
@Override
public boolean hasNext() {
return true;
}
@Override
@NotNull
public T next() {
if (index >= keyframe.size()) index = 0;
return keyframe.get(index++);
}
@NotNull
@Override
public Type type() {
return Type.LOOP;
}
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/animation/AnimationKeyframe.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.animation;
import kr.toxicity.model.api.bone.BoneMovement;
import kr.toxicity.model.api.util.MathUtil;
import org.jetbrains.annotations.NotNull;
import org.joml.Vector3f;
import java.util.Arrays;
import static kr.toxicity.model.api.util.MathUtil.isNotZero;
/**
* Represents a collection of animation keyframes, optimized for efficient storage and access.
*
* This record stores an array of {@link AnimationProgress} objects, which define the state of a bone
* at specific time intervals. It implements {@link TimedStorage} for indexed access.
*
*
* @param progresses the array of animation progresses
* @since 2.0.0
*/
public record AnimationKeyframe(
@NotNull AnimationProgress[] progresses
) implements TimedStorage {
/**
* Creates a new builder for constructing an AnimationKeyframe.
*
* @param size the number of keyframes
* @param rotateGlobal whether rotation should be applied globally
* @return a new builder instance
* @since 2.0.0
*/
public static @NotNull Builder builder(int size, boolean rotateGlobal) {
return new Builder(size, rotateGlobal);
}
private record AnimationArray(
boolean rotateGlobal,
boolean[] skipInterpolation,
float[] times,
float[] position,
float[] scale,
float[] rotation
) {
AnimationArray(int size, boolean rotateGlobal) {
this(
rotateGlobal,
new boolean[size],
new float[size],
new float[size * 3],
new float[size * 3],
new float[size * 3]
);
}
}
/**
* Builder for {@link AnimationKeyframe}.
*
* This builder allows for efficient population of keyframe data using primitive arrays.
*
*
* @since 2.0.0
*/
public static final class Builder {
private final AnimationArray set;
private final AnimationProgress[] progresses;
private int index = 0;
private Builder(int size, boolean rotateGlobal) {
set = new AnimationArray(size, rotateGlobal);
progresses = new AnimationProgress[size];
}
/**
* Writes a keyframe data point.
*
* @param time the time of the keyframe
* @param position the position vector
* @param scale the scale vector
* @param rotation the rotation vector
* @param skipInterpolation whether to skip interpolation for this keyframe
* @since 2.0.0
*/
public void write(
float time,
@NotNull Vector3f position,
@NotNull Vector3f scale,
@NotNull Vector3f rotation,
boolean skipInterpolation
) {
var i = index++;
var x = i * 3;
var y = x + 1;
var z = x + 2;
set.times[i] = time;
set.position[x] = position.x;
set.position[y] = position.y;
set.position[z] = position.z;
set.scale[x] = scale.x + 1;
set.scale[y] = scale.y + 1;
set.scale[z] = scale.z + 1;
set.rotation[x] = rotation.x;
set.rotation[y] = rotation.y;
set.rotation[z] = rotation.z;
set.skipInterpolation[i] = skipInterpolation;
this.progresses[i] = isNotZero(position) || isNotZero(scale) || isNotZero(rotation) ? new ArrayProgress(set, i) : AnimationProgress.empty(time);
}
/**
* Builds the {@link AnimationKeyframe}.
*
* @return the created keyframe collection
* @since 2.0.0
*/
public @NotNull AnimationKeyframe build() {
return new AnimationKeyframe(progresses);
}
}
private record ArrayProgress(@NotNull AnimationArray array, int index) implements AnimationProgress {
@Override
public @NotNull BoneMovement animate(@NotNull BoneMovement movement, @NotNull BoneMovement dest) {
var destPos = movement.position().get(dest.position());
var destScl = movement.scale().get(dest.scale());
var destRot = movement.rotation().get(dest.rotation());
var destRawRot = movement.rawRotation().get(dest.rawRotation());
var position = array.position;
var scale = array.scale;
var rotation = array.rotation;
var x = index * 3;
var y = x + 1;
var z = x + 2;
destPos.add(position[x], position[y], position[z]);
destScl.mul(scale[x], scale[y], scale[z]);
MathUtil.toQuaternion(destRawRot.add(rotation[x], rotation[y], rotation[z]), destRot);
return dest;
}
@Override
public boolean skipInterpolation() {
return array.skipInterpolation[index];
}
@Override
public boolean globalRotation() {
return array.rotateGlobal;
}
@Override
public float time() {
return array.times[index];
}
}
@Override
public @NotNull AnimationProgress get(int i) {
return progresses[i];
}
@Override
public @NotNull AnimationProgress getLast() {
return get(progresses.length - 1);
}
@Override
public int size() {
return progresses.length;
}
/**
* Converts this keyframe collection to a storage of empty progresses.
*
* @return a new timed storage with empty progresses
* @since 2.0.0
*/
public @NotNull TimedStorage toEmpty() {
return TimedStorage.listOf(Arrays.stream(progresses)
.map(AnimationProgress::toEmpty)
.toList());
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/animation/AnimationModifier.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.animation;
import kr.toxicity.model.api.platform.PlatformPlayer;
import kr.toxicity.model.api.util.FunctionUtil;
import kr.toxicity.model.api.util.MathUtil;
import kr.toxicity.model.api.util.function.FloatSupplier;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.function.BooleanSupplier;
/**
* A modifier of animation.
* @param predicate predicate
* @param start start lerp
* @param end end lerp
* @param priority priority
* @param type animation type
* @param speed speed modifier
* @param override override
* @param player player
*/
public record AnimationModifier(
@Nullable BooleanSupplier predicate,
int start,
int end,
int priority,
@Nullable AnimationIterator.Type type,
@Nullable FloatSupplier speed,
@Nullable Boolean override,
@Nullable PlatformPlayer player
) {
/**
* Default modifier
*/
@NotNull
public static final AnimationModifier DEFAULT = builder().build();
/**
* Default with play once modifier
*/
public static final AnimationModifier DEFAULT_WITH_PLAY_ONCE = builder().type(AnimationIterator.Type.PLAY_ONCE).build();
/**
* Creates builder
* @return builder
*/
public static @NotNull Builder builder() {
return new Builder();
}
/**
* Makes this modifier as builder
* @return builder
*/
public @NotNull Builder toBuilder() {
return builder()
.predicate(predicate)
.start(start)
.end(end)
.type(type)
.speed(speed)
.override(override)
.player(player);
}
/**
* Builder
*/
public static final class Builder {
private BooleanSupplier predicate = null;
private int start = 1;
private int end = 0;
private int priority = 0;
private AnimationIterator.Type type = null;
private FloatSupplier speed = null;
private Boolean override = null;
private PlatformPlayer player = null;
/**
* Private initializer
*/
private Builder() {
}
/**
* Sets the predicate of this modifier
* @param predicate predicate
* @return self
*/
public @NotNull Builder predicate(@Nullable BooleanSupplier predicate) {
this.predicate = predicate == null ? null : FunctionUtil.throttleTickBoolean(predicate);
return this;
}
/**
* Sets the lerp-in time of this modifier
* @param start lerp-in time
* @return self
*/
public @NotNull Builder start(int start) {
this.start = start;
return this;
}
/**
* Sets the lerp-out time of this modifier
* @param end lerp-out time
* @return self
*/
public @NotNull Builder end(int end) {
this.end = end;
return this;
}
/**
* Sets the priority of this modifier
* @param priority priority
* @return self
*/
public @NotNull Builder priority(int priority) {
this.priority = priority;
return this;
}
/**
* Sets the animation type of this modifier
* @param type animation type
* @return self
*/
public @NotNull Builder type(@Nullable AnimationIterator.Type type) {
this.type = type;
return this;
}
/**
* Sets the speed modifier of this modifier
* @param speed speed
* @return self
*/
public @NotNull Builder speed(float speed) {
this.speed = toSupplier(speed);
return this;
}
/**
* Sets the speed modifier of this modifier
* @param speed speed modifier
* @return self
*/
public @NotNull Builder speed(@Nullable FloatSupplier speed) {
this.speed = speed == null ? null : FunctionUtil.throttleTickFloat(speed);
return this;
}
/**
* Sets the override flag of this modifier
* @param override override flag
* @return self
*/
public @NotNull Builder override(@Nullable Boolean override) {
this.override = override;
return this;
}
/**
* Sets the target player of this modifier
* @param player target player
* @return self
*/
public @NotNull Builder player(@Nullable PlatformPlayer player) {
this.player = player;
return this;
}
/**
* Merges non-default value with other modifier
* @param modifier modifier
* @return self
*/
public @NotNull Builder mergeNotDefault(@NotNull AnimationModifier modifier) {
if (modifier.predicate != null) predicate(modifier.predicate);
if (modifier.start >= 0) start(modifier.start);
if (modifier.end >= 0) end(modifier.end);
if (modifier.type != null) type(modifier.type);
if (modifier.speed != null) speed(modifier.speed);
if (modifier.override != null) override(modifier.override);
if (modifier.player != null) player(modifier.player);
return this;
}
/**
* Builds animation modifier
* @return build
*/
public @NotNull AnimationModifier build() {
return new AnimationModifier(
predicate,
start,
end,
priority,
type,
speed,
override,
player
);
}
}
/**
* Creates modifier
*
* @param start start time
* @param end end time
*/
public AnimationModifier(int start, int end) {
this(start, end, null, null);
}
/**
* Creates modifier
*
* @param start start time
* @param end end time
* @param speedValue speed value
*/
public AnimationModifier(int start, int end, float speedValue) {
this(start, end, null, FloatSupplier.of(speedValue));
}
/**
* Creates modifier
*
* @param start start time
* @param end end time
* @param supplier speed supplier
*/
public AnimationModifier(int start, int end, @Nullable FloatSupplier supplier) {
this(start, end, null, supplier);
}
/**
* Creates modifier
*
* @param start start time
* @param end end time
* @param type type
*/
public AnimationModifier(int start, int end, @Nullable AnimationIterator.Type type) {
this(start, end, type, null);
}
/**
* Creates modifier
*
* @param start start time
* @param end end time
* @param type type
* @param speed speed
*/
public AnimationModifier(int start, int end, @Nullable AnimationIterator.Type type, @Nullable FloatSupplier speed) {
this(null, start, end, type, speed);
}
/**
* Creates modifier
*
* @param predicate animation predicate
* @param start start time
* @param end end time
* @param type type
* @param speed speed
*/
public AnimationModifier(@Nullable BooleanSupplier predicate, int start, int end, @Nullable AnimationIterator.Type type, @Nullable FloatSupplier speed) {
this(predicate, start, end, 0, type, speed, null, null);
}
/**
* Gets modifier's type or default value
* @param defaultType default value
* @return modifier's type or default value
*/
public @NotNull AnimationIterator.Type type(@NotNull AnimationIterator.Type defaultType) {
return type != null ? type : defaultType;
}
/**
* Gets speed value
* @return speed value
*/
public float speedValue() {
return speed != null ? speed.getAsFloat() : 1F;
}
/**
* Gets predicate value
* @return predicate value
*/
public boolean predicateValue() {
return predicate == null || predicate.getAsBoolean();
}
/**
* Gets override
* @param original original value
* @return override
*/
public boolean override(boolean original) {
return override != null ? override : original;
}
private static @Nullable FloatSupplier toSupplier(float speed) {
return MathUtil.isSimilar(speed, 1F) ? null : FloatSupplier.of(speed);
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/animation/AnimationOverrideState.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.animation;
public enum AnimationOverrideState {
NOT_MATCHED,
MATCHED
;
public boolean shouldSkip() {
return this == NOT_MATCHED;
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/animation/AnimationProgress.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.animation;
import kr.toxicity.model.api.bone.BoneMovement;
import org.jetbrains.annotations.NotNull;
import java.util.List;
/**
* Represents the state of an animation at a specific keyframe.
*
* This interface defines how to apply the keyframe's transformation to a bone's movement.
*
*
* @since 2.0.0
*/
public interface AnimationProgress extends Timed {
/**
* An empty animation progress that applies no transformation.
* @since 2.0.0
*/
AnimationProgress EMPTY = empty(0);
/**
* Checks if interpolation should be skipped after this keyframe.
*
* @return true to skip interpolation, false otherwise
* @since 2.0.0
*/
boolean skipInterpolation();
/**
* Checks if the rotation in this keyframe should be applied globally.
*
* @return true for global rotation, false for local
* @since 2.0.0
*/
boolean globalRotation();
/**
* Creates an empty animation progress at a specific time.
*
* @param time the time of the keyframe
* @return an empty progress
* @since 2.0.0
*/
static @NotNull AnimationProgress empty(float time) {
return new EmptyProgress(time);
}
/**
* Converts this progress to empty progress at the same time.
*
* @return an empty progress
* @since 2.0.0
*/
default @NotNull AnimationProgress toEmpty() {
var time = time();
return time <= 0 ? EMPTY : empty(time);
}
/**
* Creates an empty timed storage with a start and end keyframe.
*
* @param time the duration of the empty animation
* @return the timed storage
* @since 2.0.0
*/
static @NotNull TimedStorage emptyStorage(float time) {
return TimedStorage.listOf(List.of(
EMPTY,
empty(time)
));
}
/**
* Applies this keyframe's animation to a bone's movement.
*
* @param movement the current bone movement
* @param dest the destination object to store the result
* @return the resulting bone movement
* @since 2.0.0
*/
@NotNull BoneMovement animate(@NotNull BoneMovement movement, @NotNull BoneMovement dest);
/**
* An implementation of {@link AnimationProgress} that represents an empty keyframe.
*
* @param time the time of the keyframe
* @since 2.0.0
*/
record EmptyProgress(float time) implements AnimationProgress {
@Override
public @NotNull BoneMovement animate(@NotNull BoneMovement movement, @NotNull BoneMovement dest) {
return dest.set(movement);
}
@Override
public @NotNull AnimationProgress toEmpty() {
return this;
}
@Override
public boolean skipInterpolation() {
return false;
}
@Override
public boolean globalRotation() {
return false;
}
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/animation/AnimationStateHandler.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.animation;
import kr.toxicity.model.api.tracker.Tracker;
import kr.toxicity.model.api.util.MathUtil;
import kr.toxicity.model.api.util.collection.PriorityMap;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Iterator;
import java.util.function.BiConsumer;
import java.util.function.BooleanSupplier;
/**
* Animation state handler
* @param timed value
*/
@RequiredArgsConstructor
@ApiStatus.Internal
public final class AnimationStateHandler {
private final T initialValue;
private final BiConsumer setConsumer;
private final PriorityMap animators = new PriorityMap<>();
@Getter
private int delay;
private volatile TreeIterator currentIterator = null;
private volatile T beforeKeyframe = null, afterKeyframe = null;
/**
* Checks this keyframe has been finished
* @return finished
*/
public boolean keyframeFinished() {
return delay <= 0;
}
/**
* Gets before keyframe
* @return before keyframe
*/
public T beforeKeyframe() {
return beforeKeyframe;
}
/**
* Gets after keyframe
* @return after keyframe
*/
public T afterKeyframe() {
return afterKeyframe;
}
/**
* Gets before keyframe
* @param defaultValue default value
* @return before keyframe
*/
@NotNull
public T beforeKeyframe(@NotNull T defaultValue) {
var value = beforeKeyframe;
return value != null ? value : defaultValue;
}
/**
* Gets after keyframe
* @param defaultValue default value
* @return after keyframe
*/
@NotNull
public T afterKeyframe(@NotNull T defaultValue) {
var value = afterKeyframe;
return value != null ? value : defaultValue;
}
/**
* Gets running animation
* @return animation
*/
public @Nullable RunningAnimation runningAnimation() {
var iterator = currentIterator;
return iterator != null ? iterator.animation : null;
}
/**
* Ticks this state handler
* @return keyframe has been shifted or not
*/
public boolean tick() {
return tick(() -> {});
}
/**
* Ticks this state handler
* @param ifEmpty callback if animator is empty
* @return keyframe has been shifted or not
*/
public boolean tick(@NotNull Runnable ifEmpty) {
delay--;
if (animators.isEmpty()) {
ifEmpty.run();
return false;
}
return shouldUpdateAnimation() && updateAnimation();
}
/**
* Gets the progress of current keyframe
* @return progress
*/
public float progress() {
var frame = frame();
return frame == 0 ? 0 : Math.clamp((float) delay / frame, 0F, 1F);
}
private boolean shouldUpdateAnimation() {
return (afterKeyframe != null && keyframeFinished()) || delay % Tracker.MINECRAFT_TICK_MULTIPLIER == 0;
}
private boolean updateAnimation() {
synchronized (animators) {
var iterator = animators.valueIterator();
while (iterator.hasNext()) {
var next = iterator.next();
if (!next.getAsBoolean()) continue;
if (currentIterator == null) {
if (updateKeyframe(iterator, next)) {
currentIterator = next;
return setAfterKeyframe(next.next());
}
} else if (currentIterator != next) {
if (updateKeyframe(iterator, next)) {
currentIterator.clear();
currentIterator = next;
return setAfterKeyframe(next.next());
}
} else if (keyframeFinished()) {
if (updateKeyframe(iterator, next)) {
return setAfterKeyframe(next.next());
}
} else {
return false;
}
}
}
return setAfterKeyframe(null);
}
private boolean updateKeyframe(@NotNull Iterator iterator, @NotNull TreeIterator next) {
if (!next.hasNext()) {
next.removeTask.run();
iterator.remove();
return false;
} else {
return true;
}
}
private boolean setAfterKeyframe(@Nullable T next) {
if (afterKeyframe == next) return false;
setConsumer.accept(
beforeKeyframe = afterKeyframe,
afterKeyframe = next
);
delay = Math.round(frame());
return true;
}
/**
* Adds animation
* @param name name
* @param iterator iterator
* @param modifier modifier
* @param removeTask remove task
*/
public void addAnimation(@NotNull String name, @NotNull AnimationIterator iterator, @NotNull AnimationModifier modifier, @NotNull Runnable removeTask) {
synchronized (animators) {
animators.put(name, new TreeIterator(name, iterator, modifier, removeTask), modifier.priority());
}
}
/**
* Replaces animation
* @param name name
* @param iterator iterator
* @param modifier modifier
*/
public void replaceAnimation(@NotNull String name, @NotNull AnimationIterator iterator, @NotNull AnimationModifier modifier) {
synchronized (animators) {
animators.replace(name, v -> new TreeIterator(name, iterator, v.modifier.toBuilder()
.mergeNotDefault(modifier)
.build(), v.removeTask));
}
}
/**
* Remove animation
* @param name name
* @return success
*/
public boolean stopAnimation(@NotNull String name) {
synchronized (animators) {
if (animators.remove(name) != null) {
return true;
}
}
return false;
}
/**
* Gets ticking frame of current keyframe
* @return ticking frame
*/
public float frame() {
return afterKeyframe != null ? 20 * Tracker.MINECRAFT_TICK_MULTIPLIER * (currentIterator.time + MathUtil.FRAME_EPSILON) : 0F;
}
private class TreeIterator implements BooleanSupplier {
private final RunningAnimation animation;
private final AnimationIterator iterator;
private final AnimationModifier modifier;
private final Runnable removeTask;
private final T previous;
private boolean started = false;
private boolean ended = false;
private float time = 0;
public TreeIterator(String name, AnimationIterator iterator, AnimationModifier modifier, Runnable removeTask) {
animation = new RunningAnimation(name, iterator.type());
this.iterator = iterator;
this.modifier = modifier;
this.removeTask = removeTask;
previous = afterKeyframe != null ? afterKeyframe : initialValue;
}
@Override
public boolean getAsBoolean() {
return modifier.predicateValue();
}
public boolean hasNext() {
return iterator.hasNext() || (modifier.end() > 0 && !ended);
}
public @NotNull T next() {
if (!started) {
started = true;
time = (float) modifier.start() / 20;
return iterator.next();
}
if (!iterator.hasNext()) {
ended = true;
time = (float) modifier.end() / 20;
return previous;
}
var nxt = iterator.next();
time = nxt.time() / modifier.speedValue();
return nxt;
}
public void clear() {
iterator.clear();
started = ended = !iterator.hasNext();
}
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/animation/RunningAnimation.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.animation;
import org.jetbrains.annotations.NotNull;
/**
* Running animation
* @param name name
* @param type type
*/
public record RunningAnimation(@NotNull String name, @NotNull AnimationIterator.Type type) {
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/animation/Timed.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.animation;
import kr.toxicity.model.api.util.MathUtil;
import org.jetbrains.annotations.NotNull;
/**
* Object with keyframe time
*/
public interface Timed extends Comparable {
default int compareTo(@NotNull Timed o) {
return MathUtil.FRAME_COMPARATOR.compare(time(), o.time());
}
/**
* Gets time
* @return time
*/
float time();
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/animation/TimedStorage.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.animation;
import org.jetbrains.annotations.NotNull;
import org.jspecify.annotations.NonNull;
import java.util.List;
/**
* A read-only storage for timed elements (keyframes), allowing indexed access.
*
* This interface abstracts the underlying data structure (e.g., List, Array) used to store animation frames.
*
*
* @param the type of timed element
* @since 2.0.0
*/
public interface TimedStorage {
/**
* Creates a TimedStorage backed by a List.
*
* @param list the list of elements
* @param the type of element
* @return a new TimedStorage
* @since 2.0.0
*/
@NotNull
static TimedStorage listOf(@NotNull List list) {
return new ListDelegate<>(list);
}
/**
* Retrieves the element at the specified index.
*
* @param index the index of the element
* @return the element
* @throws IndexOutOfBoundsException if the index is out of range
* @since 2.0.0
*/
@NotNull T get(int index);
/**
* Returns the number of elements in the storage.
*
* @return the size
* @since 2.0.0
*/
int size();
/**
* Retrieves the last element in the storage.
*
* @return the last element
* @throws java.util.NoSuchElementException if the storage is empty
* @since 2.0.0
*/
@NotNull T getLast();
/**
* A {@link TimedStorage} implementation that delegates to a {@link List}.
*
* @param list the backing list
* @param the type of element
* @since 2.0.0
*/
record ListDelegate(@NotNull List list) implements TimedStorage {
@Override
public @NonNull T get(int index) {
return list.get(index);
}
@Override
public int size() {
return list.size();
}
@Override
public @NonNull T getLast() {
return list.getLast();
}
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/animation/VectorPoint.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.animation;
import kr.toxicity.model.api.util.function.FloatFunction;
import kr.toxicity.model.api.util.interpolator.VectorInterpolator;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.joml.Vector3f;
/**
* Represents a keyframe point in an animation timeline.
*
* This record holds the value of a vector (position, rotation, or scale) at a specific time,
* along with interpolation information to create smooth transitions between keyframes.
*
*
* @param function a function to get the vector value, which may be dynamic (e.g., based on Molang expressions)
* @param time the time of this keyframe in seconds
* @param bezier the bezier curve configuration for interpolation, if applicable
* @param interpolator the interpolation method to use (e.g., linear, bezier, catmull-rom)
* @since 1.15.2
*/
public record VectorPoint(@NotNull FloatFunction function, float time, @NotNull BezierConfig bezier, @NotNull VectorInterpolator interpolator) implements Timed {
private static final Vector3f ZERO = new Vector3f();
/**
* An empty, default vector point at time 0 with linear interpolation.
* @since 1.15.2
*/
public static final VectorPoint EMPTY = new VectorPoint(
FloatFunction.of(ZERO),
0F,
new BezierConfig(null, null, null, null),
VectorInterpolator.LINEAR
);
/**
* Gets the vector value at this keyframe's specific time.
*
* @return the vector value
* @since 1.15.2
*/
public @NotNull Vector3f vector() {
return vector(time);
}
/**
* Gets the vector value at a specific time, evaluating the function if necessary.
*
* @param time the time to evaluate at
* @return the calculated vector
* @since 1.15.2
*/
public @NotNull Vector3f vector(float time) {
return function.apply(time);
}
/**
* Checks if the interpolation method for this point is continuous.
*
* @return true if continuous (e.g., linear), false if stepped
* @since 1.15.2
*/
public boolean isContinuous() {
return interpolator.isContinuous();
}
/**
* Configuration for bezier curve interpolation.
*
* @param leftTime the time offset for the incoming (left) handle
* @param leftValue the value offset for the incoming (left) handle
* @param rightTime the time offset for the outgoing (right) handle
* @param rightValue the value offset for the outgoing (right) handle
* @since 1.15.2
*/
public record BezierConfig(@Nullable Vector3f leftTime, @Nullable Vector3f leftValue, @Nullable Vector3f rightTime, @Nullable Vector3f rightValue) {
/**
* Gets the time offset for the incoming (left) handle.
* If null, returns a zero vector.
* @return the left time offset vector
* @since 1.15.2
*/
@Override
public @NotNull Vector3f leftTime() {
return leftTime != null ? leftTime : ZERO;
}
/**
* Gets the value offset for the incoming (left) handle.
* If null, returns a zero vector.
* @return the left value offset vector
* @since 1.15.2
*/
@Override
public @NotNull Vector3f leftValue() {
return leftValue != null ? leftValue : ZERO;
}
/**
* Gets the time offset for the outgoing (right) handle.
* If null, returns a zero vector.
* @return the right time offset vector
* @since 1.15.2
*/
@Override
public @NotNull Vector3f rightTime() {
return rightTime != null ? rightTime : ZERO;
}
/**
* Gets the value offset for the outgoing (right) handle.
* If null, returns a zero vector.
* @return the right value offset vector
* @since 1.15.2
*/
@Override
public @NotNull Vector3f rightValue() {
return rightValue != null ? rightValue : ZERO;
}
}
@Override
public boolean equals(Object o) {
if (!(o instanceof VectorPoint that)) return false;
return Float.compare(time, that.time) == 0;
}
@Override
public int hashCode() {
return Float.hashCode(time);
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/armor/ArmorItem.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.armor;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Armor item
* @param tint tint value
* @param type armor type
* @param trim trim
* @param palette palette
*/
public record ArmorItem(int tint, @NotNull String type, @Nullable String trim, @Nullable String palette) {
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/armor/PlayerArmor.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.armor;
import org.jetbrains.annotations.Nullable;
/**
* Player armor
*/
public interface PlayerArmor {
/**
* Empty armor
*/
PlayerArmor EMPTY = new PlayerArmor() {
@Override
public @Nullable ArmorItem helmet() {
return null;
}
@Override
public @Nullable ArmorItem chestplate() {
return null;
}
@Override
public @Nullable ArmorItem leggings() {
return null;
}
@Override
public @Nullable ArmorItem boots() {
return null;
}
};
/**
* Gets helmet
* @return helmet
*/
@Nullable ArmorItem helmet();
/**
* Gets chestplate
* @return chestplate
*/
@Nullable ArmorItem chestplate();
/**
* Gets leggings
* @return leggings
*/
@Nullable ArmorItem leggings();
/**
* Gets boots
* @return boots
*/
@Nullable ArmorItem boots();
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/bone/BoneEventDispatcher.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bone;
import kr.toxicity.model.api.nms.HitBoxListener;
import lombok.AllArgsConstructor;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
/**
* Dispatches events related to bone lifecycle and interaction.
*
* This class manages handlers for hitbox creation, state creation, and state removal.
* It allows for extending behavior by chaining dispatchers.
*
*
* @since 1.15.2
*/
public final class BoneEventDispatcher {
private final EventFunction builder = new EventFunction();
private EventFunction applier = builder;
/**
* Extends this dispatcher with another dispatcher's handlers.
*
* The handlers from the provided dispatcher will be executed before the handlers in this dispatcher.
*
*
* @param dispatcher the dispatcher to extend
* @throws UnsupportedOperationException if attempting to extend self
* @since 1.15.2
*/
public synchronized void extend(@NotNull BoneEventDispatcher dispatcher) {
if (dispatcher == this) throw new UnsupportedOperationException("cannot extend self");
applier = EventFunction.concat(dispatcher.applier, builder);
}
/**
* Registers a handler for hitbox creation.
*
* @param function the function to modify the hitbox listener builder
* @since 1.15.2
*/
public synchronized void handleCreateHitBox(@NotNull BiFunction function) {
var before = builder.createHitBox;
builder.createHitBox = (b, l) -> function.apply(b, before.apply(b, l));
}
/**
* Registers a handler for state creation (e.g., when a bone is initialized for a player).
*
* @param function the consumer to handle state creation
* @since 1.15.2
*/
public synchronized void handleStateCreate(@NotNull BiConsumer function) {
builder.stateCreate = builder.stateCreate.andThen(function);
}
/**
* Registers a handler for state removal (e.g., when a bone is removed for a player).
*
* @param function the consumer to handle state removal
* @since 1.15.2
*/
public synchronized void handleStateRemove(@NotNull BiConsumer function) {
builder.stateRemove = builder.stateRemove.andThen(function);
}
@NotNull HitBoxListener.Builder onCreateHitBox(@NotNull RenderedBone bone, @NotNull HitBoxListener.Builder builder) {
return applier.createHitBox.apply(bone, builder);
}
void onStateCreated(@NotNull RenderedBone bone, @NotNull UUID uuid) {
applier.stateCreate.accept(bone, uuid);
}
void onStateRemoved(@NotNull RenderedBone bone, @NotNull UUID uuid) {
applier.stateRemove.accept(bone, uuid);
}
@AllArgsConstructor
private static class EventFunction {
private BiFunction createHitBox;
private BiConsumer stateCreate;
private BiConsumer stateRemove;
EventFunction() {
this(
(_, l) -> l,
(_, _) -> {},
(_, _) -> {}
);
}
static @NotNull EventFunction concat(@NotNull EventFunction first, @NotNull EventFunction second) {
return new EventFunction(
(b, l) -> second.createHitBox.apply(b, first.createHitBox.apply(b, l)),
(b, u) -> {
first.stateCreate.accept(b, u);
second.stateCreate.accept(b, u);
},
(b, u) -> {
first.stateRemove.accept(b, u);
second.stateRemove.accept(b, u);
}
);
}
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/bone/BoneEventHandler.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bone;
import org.jetbrains.annotations.NotNull;
public interface BoneEventHandler {
@NotNull BoneEventDispatcher eventDispatcher();
default void extend(@NotNull BoneEventHandler eventHandler) {
eventDispatcher().extend(eventHandler.eventDispatcher());
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/bone/BoneIKSolver.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bone;
import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap;
import kr.toxicity.model.api.util.InterpolationUtil;
import kr.toxicity.model.api.util.MathUtil;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import java.util.Map;
import java.util.UUID;
import static kr.toxicity.model.api.util.CollectionUtil.newSequencedAddressingMap;
/**
* Bone IK solver
*/
@ApiStatus.Internal
@RequiredArgsConstructor
public final class BoneIKSolver {
private static final int MAX_IK_ITERATION = 20;
private static final Vector3f FROM_VECTOR = new Vector3f(0, -1, 0).normalize();
private final Map boneMap;
private final Object2ObjectLinkedOpenHashMap locators = newSequencedAddressingMap();
/**
* Adds some external locator to this solver
* @param ikSource nullable source
* @param ikTarget target bone
* @param locator locator bone
*/
public void addLocator(@Nullable UUID ikSource, @NotNull UUID ikTarget, @NotNull RenderedBone locator) {
var target = boneMap.get(ikTarget);
if (target == null) return;
var source = ikSource == null ? target.root : boneMap.getOrDefault(ikSource, target.root);
var chainArray = source.flatten()
.filter(bone -> !bone.flattenBones().contains(locator) && bone.flattenBones().contains(target))
.toArray(RenderedBone[]::new);
if (chainArray.length < 2) return;
locators.put(locator, new IKChain(chainArray));
}
/**
* Solves ik
*/
public void solve() {
solve(null);
}
/**
* Solves ik
* @param uuid player uuid
*/
public void solve(@Nullable UUID uuid) {
if (locators.isEmpty()) return;
locators.object2ObjectEntrySet().fastForEach(entry -> {
var locator = entry.getKey();
var value = entry.getValue();
fabrik(
value.movements(uuid),
value.invertedFirstRotation(uuid),
value.cache.lengths,
locator.state(uuid).after().position().get(value.cache.destination)
.add(locator.root.group.getPosition())
.sub(value.first().root.group.getPosition())
);
});
}
private record IKChain(@NotNull RenderedBone[] bones, @NotNull IKCache cache) {
private IKChain(@NotNull RenderedBone[] bones) {
this(bones, new IKCache(bones.length));
}
private @NotNull RenderedBone first() {
return bones[0];
}
private @NotNull Quaternionf invertedFirstRotation(@Nullable UUID uuid) {
return first().state(uuid).after().rotation().invert(cache.rotation);
}
private @NotNull BoneMovement[] movements(@Nullable UUID uuid) {
var movements = cache.movements;
for (int i = 0; i < bones.length; i++) {
movements[i] = bones[i].state(uuid).after();
}
return movements;
}
}
private record IKCache(@NotNull BoneMovement[] movements, float[] lengths, @NotNull Vector3f destination, @NotNull Quaternionf rotation) {
private IKCache(int length) {
this(new BoneMovement[length], new float[length - 1], new Vector3f(), new Quaternionf());
}
}
private static void fabrik(@NotNull BoneMovement[] bones, @NotNull Quaternionf firstRot, float[] lengths, @NotNull Vector3f target) {
var first = bones[0].position();
var last = bones[bones.length - 1].position();
var vecCache = new Vector3f();
var rootPos = first.get(vecCache);
for (int i = 0; i < bones.length - 1; i++) {
var before = bones[i];
var after = bones[i + 1];
lengths[i] = before.position().distance(after.position());
}
for (int iter = 0; iter < MAX_IK_ITERATION; iter++) {
// Forward
last.set(target);
for (int i = bones.length - 2; i >= 0; i--) {
var current = bones[i].position();
var next = bones[i + 1].position();
var dist = current.distanceSquared(next);
if (dist < MathUtil.VECTOR_COMPARISON_EPSILON_SQ) continue;
InterpolationUtil.lerp(next, current, lengths[i] / (float) Math.sqrt(dist), current);
}
// Backward
first.set(rootPos);
for (int i = 0; i < bones.length - 1; i++) {
var current = bones[i].position();
var next = bones[i + 1].position();
var dist = current.distanceSquared(next);
if (dist < MathUtil.VECTOR_COMPARISON_EPSILON_SQ) continue;
InterpolationUtil.lerp(current, next, lengths[i] / (float) Math.sqrt(dist), next);
}
// Check
if (last.distanceSquared(target) < MathUtil.VECTOR_COMPARISON_EPSILON_SQ) break;
}
var rotCache = new Quaternionf();
for (int i = 0; i < bones.length - 1; i++) {
var current = bones[i];
var next = bones[i + 1];
var dir = next.position().sub(current.position(), vecCache);
current.rotation().set(rotCache.identity().rotateTo(FROM_VECTOR, dir.normalize()).mul(firstRot).mul(current.rotation()));
}
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/bone/BoneItemMapper.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bone;
import kr.toxicity.model.api.data.renderer.RenderSource;
import kr.toxicity.model.api.entity.BaseEntity;
import kr.toxicity.model.api.platform.PlatformItemTransform;
import kr.toxicity.model.api.platform.PlatformPlayer;
import kr.toxicity.model.api.util.TransformedItemStack;
import org.jetbrains.annotations.NotNull;
import java.util.function.BiFunction;
import java.util.function.Function;
/**
* Item-mapper of bone
*/
public interface BoneItemMapper extends BiFunction {
@Override
@NotNull TransformedItemStack apply(@NotNull BoneRenderContext context, @NotNull TransformedItemStack transformedItemStack);
/**
* Empty
*/
BoneItemMapper EMPTY = new BoneItemMapper() {
@NotNull
@Override
public PlatformItemTransform transform() {
return PlatformItemTransform.FIXED;
}
@Override
@NotNull
public TransformedItemStack apply(@NotNull BoneRenderContext context, @NotNull TransformedItemStack transformedItemStack) {
return transformedItemStack;
}
};
/**
* Mapped if a render source is player
* @param transform transformation
* @param mapper mapper
* @return bone item mapper
*/
static @NotNull BoneItemMapper player(@NotNull PlatformItemTransform transform, @NotNull Function mapper) {
return new BoneItemMapper() {
private static final TransformedItemStack AIR = TransformedItemStack.empty();
@NotNull
@Override
public PlatformItemTransform transform() {
return transform;
}
@Override
public @NotNull TransformedItemStack apply(@NotNull BoneRenderContext context, @NotNull TransformedItemStack transformedItemStack) {
if (context.source() instanceof RenderSource.BasePlayer(PlatformPlayer player)) {
var get = mapper.apply(player);
return get == null ? AIR : get;
}
return transformedItemStack;
}
};
}
/**
* Mapped if a render source is entity
* @param transform transformation
* @param mapper mapper
* @return bone item mapper
*/
static @NotNull BoneItemMapper entity(@NotNull PlatformItemTransform transform, @NotNull Function mapper) {
return new BoneItemMapper() {
private static final TransformedItemStack AIR = TransformedItemStack.empty();
@NotNull
@Override
public PlatformItemTransform transform() {
return transform;
}
@Override
public @NotNull TransformedItemStack apply(@NotNull BoneRenderContext context, @NotNull TransformedItemStack transformedItemStack) {
if (context.source() instanceof RenderSource.Entity entity) {
var get = mapper.apply(entity.entity());
return get == null ? AIR : get;
}
return transformedItemStack;
}
};
}
/**
* Gets this mapper's display is fixed
* @return fixed
*/
default boolean fixed() {
return transform() == PlatformItemTransform.FIXED;
}
/**
* Gets item display transformation
* @return transformation
*/
@NotNull PlatformItemTransform transform();
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/bone/BoneMovement.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bone;
import kr.toxicity.model.api.util.InterpolationUtil;
import kr.toxicity.model.api.util.MathUtil;
import org.jetbrains.annotations.NotNull;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Represents the transformation state of a single bone, including its position, scale, and rotation.
*
* This record is used to calculate the final transformation of a bone after applying animations.
*
*
* @param position the local position of the bone
* @param scale the local scale of the bone
* @param rotation the final local rotation of the bone as a quaternion
* @param rawRotation the local rotation of the bone in Euler angles (degrees) before being converted to a quaternion
* @since 1.15.2
*/
public record BoneMovement(
@NotNull Vector3f position,
@NotNull Vector3f scale,
@NotNull Quaternionf rotation,
@NotNull Vector3f rawRotation
) {
/**
* Creates a new BoneMovement with default (identity) transformations.
* @since 1.15.2
*/
public BoneMovement() {
this(
new Vector3f(),
new Vector3f(1),
new Quaternionf(),
new Vector3f()
);
}
/**
* Copies the values from another BoneMovement into this one.
*
* @param movement the source movement
* @return this movement instance
* @since 1.15.2
*/
public @NotNull BoneMovement set(@NotNull BoneMovement movement) {
position.set(movement.position);
scale.set(movement.scale);
rotation.set(movement.rotation);
rawRotation.set(movement.rawRotation);
return this;
}
/**
* Linearly interpolates between this movement and another movement.
*
* @param to the target movement
* @param alpha the interpolation factor (0.0 to 1.0)
* @param dest the destination movement to store the result
* @return the destination movement
* @since 2.1.0
*/
public @NotNull BoneMovement lerp(@NotNull BoneMovement to, float alpha, @NotNull BoneMovement dest) {
InterpolationUtil.lerp(position, to.position, alpha, dest.position);
InterpolationUtil.lerp(scale, to.scale, alpha, dest.scale);
MathUtil.toQuaternion(InterpolationUtil.lerp(rawRotation, to.rawRotation, alpha, dest.rawRotation), dest.rotation);
return dest;
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/bone/BoneName.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bone;
import com.google.gson.JsonDeserializer;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Unmodifiable;
import java.util.Objects;
import java.util.Set;
/**
* A tagged name of some bone
* @param tags tags
* @param name name
* @param rawName original name
*/
public record BoneName(@NotNull @Unmodifiable Set tags, @NotNull String name, @NotNull String rawName) {
/**
* A JSON deserializer for parsing BoneName from a string.
* @since 2.0.1
*/
public static final JsonDeserializer PARSER = (json, _, _) -> BoneName.of(json.getAsString());
/**
* Internal constructor for BoneName.
*/
@ApiStatus.Internal
public BoneName {
}
/**
* Creates a new BoneName by parsing the raw name string.
* @param rawName the raw string to parse
* @since 2.0.1
* @return a parsed BoneName instance
*/
public static @NotNull BoneName of(@NotNull String rawName) {
return BoneTag.REGISTRY.parse(rawName);
}
/**
* Checks this name has some tags
* @param tags tags
* @return any match
*/
public boolean tagged(@NotNull BoneTag... tags) {
for (BoneTag boneTag : tags) {
if (this.tags.contains(boneTag)) return true;
}
return false;
}
/**
* Gets an item mapper of this bone name.
* @return item mapper
*/
public @NotNull BoneItemMapper toItemMapper() {
return tags.isEmpty() ? BoneItemMapper.EMPTY : tags.stream().map(BoneTag::itemMapper).filter(Objects::nonNull).findFirst().orElse(BoneItemMapper.EMPTY);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof BoneName boneName)) return false;
return rawName.equals(boneName.rawName);
}
@Override
public int hashCode() {
return rawName.hashCode();
}
@Override
public @NotNull String toString() {
return rawName;
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/bone/BonePosition.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bone;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.joml.Vector3f;
import java.util.UUID;
/**
* Represents the position and state of a bone in a model.
*
* @param globalOffset the global offset vector
* @param localOffset the local offset vector
* @param state the unique identifier of the current state, or null if none
* @since 2.1.0
*/
public record BonePosition(
@NotNull Vector3f globalOffset,
@NotNull Vector3f localOffset,
@Nullable UUID state
) {
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/bone/BoneRenderContext.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bone;
import kr.toxicity.model.api.BetterModel;
import kr.toxicity.model.api.data.renderer.RenderSource;
import kr.toxicity.model.api.skin.SkinData;
import org.jetbrains.annotations.NotNull;
/**
* Render item context
* @param source source
* @param skin skin
*/
public record BoneRenderContext(@NotNull RenderSource> source, @NotNull SkinData skin) {
/**
* Creates default context
* @param source source
*/
public BoneRenderContext(@NotNull RenderSource> source) {
this(source, BetterModel.platform().skinManager().fallback());
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/bone/BoneTag.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bone;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.List;
/**
* A tag of bone
*/
public interface BoneTag {
/**
* The default registry for bone tags.
* @since 2.0.1
*/
BoneTagRegistry REGISTRY = new BoneTagRegistry();
/**
* Gets tag name
* @return tag name
*/
@NotNull String name();
/**
* Gets an item mapper
* @return item mapper
*/
@Nullable BoneItemMapper itemMapper();
/**
* Gets a tag list like 'h', 'hi', 'b'
* @since 2.0.1
* @return tags
*/
@NotNull @Unmodifiable
List tags();
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/bone/BoneTagRegistry.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bone;
import it.unimi.dsi.fastutil.objects.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import static kr.toxicity.model.api.util.CollectionUtil.newAddressingMap;
/**
* Bone tag registry
*/
public final class BoneTagRegistry {
private static final String TAG_SPLITTER = "_";
private final Object2ObjectMap byName = newAddressingMap();
BoneTagRegistry() {
for (BoneTags value : BoneTags.values()) {
addTag(value);
}
}
/**
* Adds some tag to this registry
* @param tag tag
*/
public void addTag(@NotNull BoneTag tag) {
BoneTag checkDuplicate;
for (String s : tag.tags()) {
if ((checkDuplicate = byName.put(s, tag)) != null) throw new RuntimeException("Duplicated tags: " + tag.name() + " between " + checkDuplicate.name());
}
}
/**
* Gets a bone tag by its name wrapped in an Optional.
* @param tag tag name
* @return bone tag
* @since 1.15.2
*/
public @NotNull Optional byTagName(@NotNull String tag) {
return Optional.ofNullable(byTagNameOrNull(tag));
}
/**
* Gets a bone tag by its name.
* @param tag tag name
* @return bone tag or null
* @since 2.1.0
*/
public @Nullable BoneTag byTagNameOrNull(@NotNull String tag) {
return byName.get(tag);
}
/**
* Parses bone name by raw group name
* @param rawName raw name
* @return bone name
*/
public @NotNull BoneName parse(@NotNull String rawName) {
rawName = rawName.toLowerCase(Locale.ROOT);
var tagArray = rawName.split(TAG_SPLITTER);
if (tagArray.length < 2) return new BoneName(ObjectSets.emptySet(), rawName, rawName);
var tagList = List.of(tagArray);
var maxSize = tagList.size() - 1;
ObjectSet set = maxSize <= 4 ? new ObjectArraySet<>(maxSize) : new ObjectOpenHashSet<>(maxSize);
for (String s : tagList) {
var tag = byTagNameOrNull(s);
if (tag != null && set.size() < maxSize) set.add(tag);
else return new BoneName(
set.isEmpty() ? ObjectSets.emptySet() : ObjectSets.unmodifiable(set),
set.isEmpty() ? rawName : String.join(TAG_SPLITTER, tagList.subList(set.size(), tagList.size())),
rawName
);
}
return new BoneName(
ObjectSets.unmodifiable(set),
String.join(TAG_SPLITTER, tagList.subList(set.size(), tagList.size())),
rawName
);
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/bone/BoneTags.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bone;
import kr.toxicity.model.api.entity.BaseEntity;
import kr.toxicity.model.api.nms.Profiled;
import kr.toxicity.model.api.platform.PlatformItemTransform;
import kr.toxicity.model.api.player.PlayerLimb;
import kr.toxicity.model.api.util.TransformedItemStack;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.List;
/**
* Builtin tags
*/
public enum BoneTags implements BoneTag {
/**
* Follows entity's head rotation
*/
HEAD("h"),
/**
* Follows entity's head rotation
*/
HEAD_WITH_CHILDREN("hi"),
/**
* Creates a hitbox following this bone
*/
HITBOX("b", "ob"),
/**
* It can be used as a seat
*/
SEAT("p"),
/**
* It can be used as a seat but not controllable
*/
SUB_SEAT("sp"),
/**
* Nametag
*/
TAG("tag"),
/**
* Mob's nametag
*/
MOB_TAG("mtag"),
/**
* Player's nametag
*/
PLAYER_TAG("ptag"),
/**
* Glow
*/
GLOW("glow"),
/**
* Entity's item in left hand
*/
LEFT_ITEM(BoneItemMapper.entity(
PlatformItemTransform.THIRDPERSON_LEFTHAND,
BaseEntity::offHand
), "pli", "li"),
/**
* Entity's item in right hand
*/
RIGHT_ITEM(BoneItemMapper.entity(
PlatformItemTransform.THIRDPERSON_RIGHTHAND,
BaseEntity::mainHand
), "pri", "ri"),
/**
* Player head
*/
PLAYER_HEAD(PlayerLimb.HEAD.getItemMapper(), "ph"),
/**
* Player right arm
*/
PLAYER_RIGHT_ARM(PlayerLimb.RIGHT_ARM.getItemMapper(), "pra"),
/**
* Player right forearm
*/
PLAYER_RIGHT_FOREARM(PlayerLimb.RIGHT_FOREARM.getItemMapper(), "prfa"),
/**
* Player left arm
*/
PLAYER_LEFT_ARM(PlayerLimb.LEFT_ARM.getItemMapper(), "pla"),
/**
* Player left forearm
*/
PLAYER_LEFT_FOREARM(PlayerLimb.LEFT_FOREARM.getItemMapper(), "plfa"),
/**
* Player left hip
*/
PLAYER_HIP(PlayerLimb.HIP.getItemMapper(), "phip"),
/**
* Player left waist
*/
PLAYER_WAIST(PlayerLimb.WAIST.getItemMapper(), "pw"),
/**
* Player left chest
*/
PLAYER_CHEST(PlayerLimb.CHEST.getItemMapper(), "pc"),
/**
* Player right leg
*/
PLAYER_RIGHT_LEG(PlayerLimb.RIGHT_LEG.getItemMapper(), "prl"),
/**
* Player right foreleg
*/
PLAYER_RIGHT_FORELEG(PlayerLimb.RIGHT_FORELEG.getItemMapper(), "prfl"),
/**
* Player left leg
*/
PLAYER_LEFT_LEG(PlayerLimb.LEFT_LEG.getItemMapper(), "pll"),
/**
* Player left foreleg
*/
PLAYER_LEFT_FORELEG(PlayerLimb.LEFT_FORELEG.getItemMapper(), "plfl"),
/**
* Cape
*/
CAPE(new BoneItemMapper() {
@Override
public @NotNull TransformedItemStack apply(@NotNull BoneRenderContext context, @NotNull TransformedItemStack transformedItemStack) {
TransformedItemStack cape = null;
if (context.source() instanceof Profiled profiled && profiled.skinParts().isCapeEnabled()) {
cape = context.skin().cape(profiled.armors());
}
return cape != null ? cape : TransformedItemStack.empty();
}
@Override
public @NotNull PlatformItemTransform transform() {
return PlatformItemTransform.FIXED;
}
}, "cape")
;
BoneTags(@NotNull String... tags) {
this(null, tags);
}
BoneTags(@Nullable BoneItemMapper itemMapper, @NotNull String... tags) {
this.itemMapper = itemMapper;
this.tags = List.of(tags);
}
@Nullable
private final BoneItemMapper itemMapper;
@NotNull
private final List tags;
@Nullable
@Override
public BoneItemMapper itemMapper() {
return itemMapper;
}
@NotNull
@Unmodifiable
@Override
public List tags() {
return tags;
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/bone/RenderedBone.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.bone;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet;
import it.unimi.dsi.fastutil.objects.ObjectSortedSets;
import kr.toxicity.model.api.BetterModel;
import kr.toxicity.model.api.animation.*;
import kr.toxicity.model.api.data.blueprint.BlueprintAnimation;
import kr.toxicity.model.api.data.blueprint.BlueprintElement;
import kr.toxicity.model.api.data.blueprint.ModelBoundingBox;
import kr.toxicity.model.api.data.renderer.RenderSource;
import kr.toxicity.model.api.data.renderer.RendererGroup;
import kr.toxicity.model.api.entity.BaseEntity;
import kr.toxicity.model.api.nms.*;
import kr.toxicity.model.api.platform.PlatformItemStack;
import kr.toxicity.model.api.platform.PlatformLocation;
import kr.toxicity.model.api.platform.PlatformPlayer;
import kr.toxicity.model.api.tracker.ModelRotation;
import kr.toxicity.model.api.tracker.Tracker;
import kr.toxicity.model.api.util.*;
import kr.toxicity.model.api.util.collection.SingletonSequencedSet;
import kr.toxicity.model.api.util.function.BonePredicate;
import kr.toxicity.model.api.util.function.FloatConstantSupplier;
import kr.toxicity.model.api.util.function.FloatSupplier;
import kr.toxicity.model.api.util.lock.DuplexLock;
import lombok.Getter;
import lombok.Setter;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* A rendered item-display.
*/
public final class RenderedBone implements BoneEventHandler {
private static final int INITIAL_TINT_VALUE = 0xFFFFFF;
private static final Vector3f EMPTY_VECTOR = new Vector3f();
private static final BonePosition EMPTY_POSITION = new BonePosition(EMPTY_VECTOR, EMPTY_VECTOR, null);
@Getter
@NotNull
final RendererGroup group;
private final BoneMovement defaultFrame;
private volatile BoneRenderContext renderContext;
private final BoneEventDispatcher eventDispatcher = new BoneEventDispatcher();
@NotNull
@Getter
final RenderedBone root;
@Nullable
@Getter
final RenderedBone parent;
final RenderedBone[] children;
private volatile SequencedSet flattenBones;
private final Int2ObjectMap tintCacheMap = new Int2ObjectOpenHashMap<>();
@Getter
private final boolean dummyBone;
private final Object itemLock = new Object();
//Resource
@Getter
@Nullable
private final ModelDisplay display;
@Getter
@Nullable
private HitBox hitBox;
@Getter
@Nullable
private ModelNametag nametag;
//Item
@Getter
@Setter
private BoneItemMapper itemMapper;
private volatile int previousTint = INITIAL_TINT_VALUE, tint = INITIAL_TINT_VALUE;
private volatile TransformedItemStack itemStack;
//Animation
private final BoneStateHandler globalState;
private final Map perPlayerState = new ConcurrentHashMap<>();
private volatile ModelRotation rotation = ModelRotation.EMPTY;
private Supplier defaultPosition = FunctionUtil.asSupplier(EMPTY_VECTOR);
private FloatSupplier scale = FloatConstantSupplier.ONE;
private Function positionModifier = p -> p;
private Vector3f lastModifiedPosition = new Vector3f();
private Function localRotModifier = r -> r, globalRotModifier = r -> r;
private Quaternionf lastModifiedLocalRot = new Quaternionf(), lastModifiedGlobalRot = new Quaternionf();
/**
* Creates entity.
* @param group group
* @param parent parent entity
* @param context render context
* @param movement spawn movement
* @param childrenMapper mapper
*/
@ApiStatus.Internal
public RenderedBone(
@NotNull RendererGroup group,
@Nullable RenderedBone parent,
@NotNull BoneRenderContext context,
@NotNull BoneMovement movement,
@NotNull Function childrenMapper
) {
this.group = group;
this.parent = parent;
this.renderContext = context;
itemMapper = group.getItemMapper();
root = parent != null ? parent.root : this;
this.itemStack = itemMapper.apply(renderContext, group.getItemStack());
this.dummyBone = group.getItemStack().isAir() && itemMapper == BoneItemMapper.EMPTY;
defaultFrame = movement;
children = childrenMapper.apply(this);
if (!dummyBone) {
display = BetterModel.nms().create(context.source().location(), context.source() instanceof RenderSource.Entity ? -4096 : 0, d -> {
d.display(itemMapper.transform());
d.invisible(!group.getParent().visibility());
d.viewRange(EntityUtil.entityModelViewRadius());
applyItem(d);
});
} else display = null;
globalState = new BoneStateHandler(null, _ -> {});
}
public void locator(@NotNull BoneIKSolver solver) {
if (getGroup().getParent() instanceof BlueprintElement.NullObject nullObject) {
var ikTarget = nullObject.ikTarget();
if (ikTarget == null) return;
solver.addLocator(nullObject.ikSource(), ikTarget, this);
}
}
private @NotNull BoneStateHandler state(@Nullable PlatformPlayer player) {
return state(player != null ? player.uuid() : null);
}
@NotNull BoneStateHandler state(@Nullable UUID uuid) {
return uuid == null ? globalState : perPlayerState.getOrDefault(uuid, globalState);
}
private @NotNull BoneStateHandler getOrCreateState(@Nullable PlatformPlayer player) {
return getOrCreateState(player != null ? player.uuid() : null);
}
private @NotNull BoneStateHandler getOrCreateState(@Nullable UUID uuid) {
return uuid == null ? globalState : perPlayerState.computeIfAbsent(uuid, u -> {
eventDispatcher.onStateCreated(this, u);
return new BoneStateHandler(u, targetUUID -> eventDispatcher.onStateRemoved(this, targetUUID));
});
}
public @Nullable RunningAnimation runningAnimation() {
return globalState.state.runningAnimation();
}
@Override
public @NotNull BoneEventDispatcher eventDispatcher() {
return eventDispatcher;
}
public boolean updateItem(@NotNull Predicate predicate) {
return itemStack(predicate, itemMapper.apply(renderContext, itemStack));
}
public boolean updateItem(@NotNull BoneRenderContext context) {
synchronized (this) {
renderContext = context;
}
return updateItem(_ -> true);
}
/**
* Creates hit box.
* @param entity target entity
* @param predicate predicate
* @param listener hit box listener
* @return success
*/
public boolean createHitBox(@NotNull BaseEntity entity, @NotNull Predicate predicate, @Nullable HitBoxListener listener) {
if (predicate.test(this)) {
var previous = hitBox;
synchronized (this) {
if (previous != hitBox) return false;
var h = group.getHitBox();
if (h == null) h = ModelBoundingBox.MIN;
var l = eventDispatcher.onCreateHitBox(this, (listener != null ? listener : HitBoxListener.EMPTY).toBuilder()).build();
if (hitBox != null) hitBox.removeHitBox();
hitBox = BetterModel.nms().createHitBox(entity, this, h, group.getMountController(), l);
return hitBox != null;
}
}
return false;
}
/**
* Creates nametag
* @param predicate predicate
* @param consumer nametag consumer
* @return success
*/
public boolean createNametag(@NotNull Predicate predicate, @NotNull Consumer consumer) {
if (nametag == null && predicate.test(this)) {
synchronized (this) {
if (nametag != null) return false;
nametag = BetterModel.nms().createNametag(this, consumer);
}
return true;
}
return false;
}
/**
* Make item has enchantment or not
* @param predicate predicate
* @param enchant should enchant
* @return success or not
*/
public boolean enchant(@NotNull Predicate predicate, boolean enchant) {
return itemStack(predicate, itemStack.modify(i -> i.enchant(enchant)));
}
/**
* Sets the scale of this bone
* @param scale scale
*/
public void scale(@NotNull FloatSupplier scale) {
this.scale = scale;
}
/**
* Applies some function at display
* @param predicate predicate
* @param consumer consumer
* @return success or not
*/
public boolean applyAtDisplay(@NotNull Predicate predicate, @NotNull Consumer consumer) {
if (display != null && predicate.test(this)) {
consumer.accept(display);
return true;
}
return false;
}
/**
* Changes displayed item
* @param predicate predicate
* @param itemStack target item
* @return success
*/
public boolean itemStack(@NotNull Predicate predicate, @NotNull TransformedItemStack itemStack) {
if (this.itemStack != itemStack && predicate.test(this)) {
synchronized (itemLock) {
if (this.itemStack == itemStack) return false;
this.itemStack = itemStack;
if (display != null) display.invisible(itemStack.isAir());
tintCacheMap.clear();
return applyItem();
}
}
return false;
}
/**
* Adds local rot modifier.
* @param predicate predicate
* @param function animation consumer
* @return whether to success
*/
public synchronized boolean addLocalRotModifier(@NotNull Predicate predicate, @NotNull Function function) {
if (predicate.test(this)) {
localRotModifier = localRotModifier.andThen(function);
return true;
}
return false;
}
/**
* Adds global rot modifier.
* @param predicate predicate
* @param function animation consumer
* @return whether to success
*/
public synchronized boolean addGlobalRotModifier(@NotNull Predicate predicate, @NotNull Function function) {
if (predicate.test(this)) {
globalRotModifier = globalRotModifier.andThen(function);
return true;
}
return false;
}
/**
* Adds position modifier.
* @param predicate predicate
* @param function animation consumer
* @return whether to success
*/
public synchronized boolean addPositionModifier(@NotNull Predicate predicate, @NotNull Function function) {
if (predicate.test(this)) {
positionModifier = positionModifier.andThen(function);
return true;
}
return false;
}
public boolean rotate(@NotNull ModelRotation rotation, @NotNull PacketBundler bundler) {
this.rotation = rotation;
if (display != null) {
display.rotate(rotation, bundler);
return true;
}
return false;
}
public boolean tick() {
return globalState.tick();
}
public boolean tick(@NotNull UUID uuid) {
var get = perPlayerState.get(uuid);
return get != null && get.tick();
}
public void dirtyUpdate(@NotNull PacketBundler bundler) {
var d = display;
if (d != null) d.sendDirtyEntityData(bundler);
}
public void forceUpdate(boolean showItem, @NotNull PacketBundler bundler) {
var d = display;
if (d != null) d.sendEntityData(showItem, bundler);
}
public void forceUpdate(@NotNull PacketBundler bundler) {
var d = display;
if (d != null) d.sendEntityData(!d.invisible(), bundler);
}
public void sendTransformation(@Nullable UUID uuid, @NotNull AnimationBundler bundler) {
state(uuid).sendTransformation(bundler);
}
public void forceTransformation(@NotNull PacketBundler bundler) {
var d = globalState.transformer;
if (d != null) d.sendTransformation(bundler);
}
public int interpolationDuration() {
return globalState.interpolationDuration();
}
public @NotNull Vector3f worldPosition() {
return worldPosition(EMPTY_POSITION);
}
public @NotNull Vector3f worldPosition(@NotNull BonePosition position) {
return worldPosition(position, new BoneMovement());
}
public @NotNull Vector3f worldPosition(@NotNull BoneMovement cache) {
return worldPosition(EMPTY_POSITION, cache);
}
public @NotNull Vector3f worldPosition(@NotNull BonePosition position, @NotNull BoneMovement cache) {
return state(position.state()).worldPosition(position, cache);
}
public @NotNull Vector3f worldRotation() {
return worldRotation(null);
}
public @NotNull Vector3f worldRotation(@Nullable UUID uuid) {
return state(uuid).worldRotation();
}
public void defaultPosition(@NotNull Supplier movement) {
defaultPosition = movement;
}
private @NotNull Vector3f modifiedPosition(boolean preventModifierUpdate) {
return preventModifierUpdate ? lastModifiedPosition : (lastModifiedPosition = positionModifier.apply(lastModifiedPosition.set(EMPTY_VECTOR)));
}
private @NotNull Quaternionf modifiedLocalRot(boolean preventModifierUpdate) {
return preventModifierUpdate ? lastModifiedLocalRot : (lastModifiedLocalRot = localRotModifier.apply(lastModifiedLocalRot.identity()));
}
private @NotNull Quaternionf modifiedGlobalRot(boolean preventModifierUpdate) {
return preventModifierUpdate ? lastModifiedGlobalRot : (lastModifiedGlobalRot = globalRotModifier.apply(lastModifiedGlobalRot.identity()));
}
public boolean tint(@NotNull Predicate predicate) {
return tint(predicate, previousTint);
}
public boolean tint(@NotNull Predicate predicate, int tint) {
if (this.tint != tint && predicate.test(this)) {
synchronized (itemLock) {
if (this.tint == tint) return false;
this.previousTint = this.tint;
this.tint = tint;
return applyItem();
}
}
return false;
}
private boolean applyItem() {
if (display != null) {
applyItem(display);
return true;
}
return false;
}
private void applyItem(@NotNull ModelDisplay targetDisplay) {
targetDisplay.item(itemStack.isAir() ? itemStack.itemStack() : tintCacheMap.computeIfAbsent(tint, i -> BetterModel.nms().tint(itemStack.itemStack(), i)));
}
public void teleport(@NotNull PlatformLocation location, @NotNull PacketBundler bundler) {
if (display != null) display.teleport(location, bundler);
}
public void spawn(boolean hide, @NotNull PacketBundler bundler) {
if (display != null) display.spawn(!hide && !display.invisible(), bundler);
var transformer = globalState.transformer;
if (transformer != null) transformer.sendTransformation(bundler);
}
public boolean addAnimation(@NotNull AnimationOverrideState overrideState, @NotNull BlueprintAnimation animator, @NotNull AnimationModifier modifier, @NotNull Runnable removeTask) {
var get = animator.animator().get(name());
if (get == null && modifier.override(animator.override()) && overrideState.shouldSkip()) return false;
var type = modifier.type(animator.loop());
var iterator = get != null ? get.iterator(type) : animator.emptyIterator(type);
getOrCreateState(modifier.player()).state.addAnimation(animator.name(), iterator, modifier, removeTask);
return true;
}
public boolean replaceAnimation(@NotNull AnimationOverrideState overrideState, @NotNull String target, @NotNull BlueprintAnimation animator, @NotNull AnimationModifier modifier) {
var get = animator.animator().get(name());
if (get == null && modifier.override(animator.override()) && overrideState.shouldSkip()) return false;
var type = modifier.type(animator.loop());
var iterator = get != null ? get.iterator(type) : animator.emptyIterator(type);
state(modifier.player()).state.replaceAnimation(target, iterator, modifier);
return true;
}
/**
* Stops bone's animation
* @param filter filter
* @param name animation's name
* @param player player
*/
public boolean stopAnimation(@NotNull Predicate filter, @NotNull String name, @Nullable PlatformPlayer player) {
return filter.test(this) && state(player).state.stopAnimation(name);
}
/**
* Removes model's display
* @param bundler packet bundler
*/
public void remove(@NotNull PacketBundler bundler) {
if (display != null) display.remove(bundler);
if (nametag != null) nametag.remove(bundler);
}
public @NotNull Stream flatten() {
return flattenBones().stream();
}
@Unmodifiable
@NotNull
public SequencedSet flattenBones() {
SequencedSet set;
if ((set = flattenBones) != null) return set;
synchronized (this) {
if ((set = flattenBones) != null) return set;
return flattenBones = children.length == 0 ? SingletonSequencedSet.of(this) : Stream.concat(
Stream.of(this),
Arrays.stream(children).flatMap(RenderedBone::flatten)
).collect(Collectors.collectingAndThen(
Collectors.toCollection(ObjectLinkedOpenHashSet::new),
ObjectSortedSets::unmodifiable
));
}
}
public boolean matchTree(@NotNull BonePredicate predicate, @NotNull BiPredicate mapper) {
var parentResult = mapper.test(this, predicate);
var childPredicate = predicate.children(parentResult);
for (RenderedBone value : children) {
if (value.matchTree(childPredicate, mapper)) parentResult = true;
}
return parentResult;
}
public boolean matchAnimation(@NotNull AnimationOverrideState overrideState, @NotNull BiPredicate mapper) {
var parentResult = mapper.test(this, overrideState);
if (parentResult) overrideState = AnimationOverrideState.MATCHED;
for (RenderedBone value : children) {
if (value.matchAnimation(overrideState, mapper)) parentResult = true;
}
return parentResult;
}
@NotNull
public Vector3f hitBoxPosition() {
return hitBoxPosition(new BoneMovement());
}
@NotNull
public Vector3f hitBoxPosition(@NotNull BoneMovement cache) {
var box = getGroup().getHitBox();
if (box != null) return worldPosition(new BonePosition(EMPTY_VECTOR, group.getHitBoxPoint(), null), cache);
return worldPosition(cache);
}
public float hitBoxScale() {
return scale.getAsFloat();
}
@NotNull
public ModelRotation rotation() {
return rotation;
}
final class BoneStateHandler {
private final @Nullable UUID uuid;
private final Consumer consumer;
//States
private final AnimationStateHandler state;
private final BoneMovement before = new BoneMovement(), after = new BoneMovement(), current = new BoneMovement();
private final DisplayTransformer transformer = display != null ? display.createTransformer() : null;
//Flags
private boolean firstTick = true;
private boolean skipInterpolation = false;
private final AtomicBoolean updateAfter = new AtomicBoolean();
private final AtomicBoolean updateCurrent = new AtomicBoolean();
//Caches
private final Vector3f positionCache = new Vector3f(), scaleCache = new Vector3f();
private final Quaternionf localRotCache = new Quaternionf(), globalRotCache = new Quaternionf();
//Lock
private final DuplexLock lock = new DuplexLock();
private BoneStateHandler(@Nullable UUID uuid, @NotNull Consumer consumer) {
this.uuid = uuid;
this.consumer = consumer;
state = new AnimationStateHandler<>(
AnimationProgress.EMPTY,
(_, a) -> skipInterpolation = (a != null && a.skipInterpolation()) || (parent != null && parent.state(uuid).skipInterpolation)
);
}
@NotNull BoneMovement after() {
if (!updateAfter.compareAndSet(true, false)) return after;
var keyframe = state.afterKeyframe(AnimationProgress.EMPTY);
var preventModifierUpdate = interpolationDuration() < 1;
var def = keyframe.animate(defaultFrame, after);
if (parent != null) {
var p = parent.state(uuid).after();
MathUtil.fma(
def.position().rotate(p.rotation()),
p.scale(),
p.position()
).sub(parent.lastModifiedPosition)
.add(modifiedPosition(preventModifierUpdate));
def.scale().mul(p.scale());
def.rotation().set(parent.lastModifiedGlobalRot.invert(globalRotCache)
.mul(modifiedGlobalRot(preventModifierUpdate))
.mul((keyframe.globalRotation() ? localRotCache.identity() : p.rotation().div(parent.lastModifiedLocalRot, localRotCache)).mul(def.rotation()))
.mul(modifiedLocalRot(preventModifierUpdate))
);
} else {
def.position().add(modifiedPosition(preventModifierUpdate));
def.rotation().set(modifiedGlobalRot(preventModifierUpdate).get(globalRotCache)
.mul(def.rotation())
.mul(modifiedLocalRot(preventModifierUpdate)));
}
return def;
}
private boolean tick() {
var result = state.tick(() -> {
if (uuid != null) {
perPlayerState.remove(uuid);
consumer.accept(uuid);
}
}) || firstTick;
if (result && updateAfter.compareAndSet(false, true)) {
lock.accessToWriteLock(() -> before.set(current));
updateCurrent.set(true);
}
firstTick = false;
return result;
}
private float progress() {
return 1F - state.progress();
}
private int interpolationDuration() {
if (skipInterpolation) return 0;
var frame = state.frame() / (float) Tracker.MINECRAFT_TICK_MULTIPLIER;
return Math.round(frame + MathUtil.FLOAT_COMPARISON_EPSILON);
}
private void sendTransformation(@NotNull AnimationBundler bundler) {
if (!updateCurrent.compareAndSet(true, false)) return;
var after = after();
var movement = lock.accessToWriteLock(() -> current.set(after));
if (transformer == null) return;
var mul = scale.getAsFloat();
transformer.transform(
interpolationDuration(),
MathUtil.fma(
itemStack.offset().rotate(movement.rotation(), positionCache)
.add(movement.position())
.add(root.group.getPosition()),
mul,
itemStack.position()
).add(defaultPosition.get()),
movement.scale()
.mul(itemStack.scale(), scaleCache)
.mul(mul)
.max(EMPTY_VECTOR),
movement.rotation(),
bundler
);
}
private @NotNull Vector3f worldPosition(@NotNull BonePosition position, @NotNull BoneMovement cache) {
var progress = progress();
var interpolated = lock.accessToReadLock(() -> before.lerp(current, progress, cache));
return MathUtil.fma(
interpolated.position()
.add(itemStack.offset())
.add(position.localOffset())
.rotate(interpolated.rotation()),
interpolated.scale(),
position.globalOffset()
)
.add(root.getGroup().getPosition())
.mul(scale.getAsFloat())
.rotateX(-rotation.radianX())
.rotateY(-rotation.radianY());
}
private @NotNull Vector3f worldRotation() {
var progress = progress();
return lock.accessToReadLock(() -> InterpolationUtil.lerp(before.rawRotation(), current.rawRotation(), progress));
}
}
public @NotNull BoneName name() {
return getGroup().name();
}
public @NotNull UUID uuid() {
return getGroup().uuid();
}
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (!(obj instanceof RenderedBone bone)) return false;
return uuid().equals(bone.uuid());
}
@Override
public int hashCode() {
return uuid().hashCode();
}
@Override
public String toString() {
return name().toString();
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/config/DebugConfig.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.config;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Unmodifiable;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
/**
* Debug config
* @param options options
*/
public record DebugConfig(@NotNull @Unmodifiable Set options) {
/**
* Debug option
*/
@RequiredArgsConstructor
public enum DebugOption {
/**
* Debug stack trace of exception
*/
EXCEPTION("exception"),
/**
* Debug hit-box entity
*/
HITBOX("hitbox"),
/**
* Debug packing resource pack
*/
PACK("pack"),
/**
* Debug tracker thread
*/
TRACKER("tracker")
;
private final String config;
}
/**
* Checks this config has this option
* @param option option
* @return has or not
*/
public boolean has(@NotNull DebugOption option) {
return options.contains(option);
}
/**
* Default config
*/
public static final DebugConfig DEFAULT = new DebugConfig(Collections.emptySet());
/**
* Creates config from YAML
* @param predicate predicate
* @return config
*/
public static @NotNull DebugConfig from(@NotNull Predicate predicate) {
return new DebugConfig(Collections.unmodifiableSet(Arrays.stream(DebugOption.values())
.filter(o -> predicate.test(o.config))
.collect(Collectors.toCollection(() -> EnumSet.noneOf(DebugOption.class)))));
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/config/IndicatorConfig.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.config;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Unmodifiable;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
/**
* Indicator config
* @param options options
*/
public record IndicatorConfig(@NotNull @Unmodifiable Set options) {
/**
* Indicator option
*/
@RequiredArgsConstructor
public enum IndicatorOption {
/**
* Progress bar
*/
PROGRESS_BAR("progress_bar"),
;
private final String config;
}
/**
* Default config
*/
public static final IndicatorConfig DEFAULT = new IndicatorConfig(Collections.emptySet());
/**
* Creates config from YAML
* @param predicate predicate
* @return config
*/
public static @NotNull IndicatorConfig from(@NotNull Predicate predicate) {
return new IndicatorConfig(Collections.unmodifiableSet(Arrays.stream(IndicatorOption.values())
.filter(o -> predicate.test(o.config))
.collect(Collectors.toCollection(() -> EnumSet.noneOf(IndicatorOption.class)))));
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/config/ModuleConfig.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.config;
import org.jetbrains.annotations.NotNull;
import java.util.function.Predicate;
/**
* Module config
* @param model creates model
* @param playerAnimation create player animation
*/
public record ModuleConfig(
boolean model,
boolean playerAnimation
) {
/**
* Default config
*/
public static final ModuleConfig DEFAULT = new ModuleConfig(
true,
true
);
/**
* Creates config from YAML
* @param predicate predicate
* @return config
*/
public static @NotNull ModuleConfig from(@NotNull Predicate predicate) {
return new ModuleConfig(
predicate.test("model"),
predicate.test("player-animation")
);
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/config/PackConfig.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.config;
import org.jetbrains.annotations.NotNull;
import java.util.function.Predicate;
/**
* Pack config
* @param generateModernModel generate modern model
* @param generateLegacyModel generate legacy model
* @param useObfuscation use obfuscation
*/
public record PackConfig(
boolean generateModernModel,
boolean generateLegacyModel,
boolean useObfuscation
) {
/**
* Default config
*/
public static final PackConfig DEFAULT = new PackConfig(true, true, false);
/**
* Creates config from YAML
* @param predicate predicate
* @return config
*/
public static @NotNull PackConfig from(@NotNull Predicate predicate) {
return new PackConfig(
predicate.test("generate-modern-model"),
predicate.test("generate-legacy-model"),
predicate.test("use-obfuscation")
);
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/Float2.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data;
import com.google.gson.JsonDeserializer;
import org.jetbrains.annotations.NotNull;
import org.joml.Vector2f;
/**
* A simple record representing two float values.
*
* @param x the x value
* @param y the y value
* @since 3.0.0
*/
public record Float2(
float x,
float y
) {
/**
* A GSON deserializer for {@link Float2}.
* @since 3.0.0
*/
public static final JsonDeserializer PARSER = (json, _, _) -> {
var array = json.getAsJsonArray();
return new Float2(
array.get(0).getAsFloat(),
array.get(1).getAsFloat()
);
};
/**
* Converts this record to a {@link Vector2f}.
*
* @return a new vector instance
* @since 3.0.0
*/
public @NotNull Vector2f toVector() {
return new Vector2f(x, y);
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/Float3.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data;
import com.google.gson.JsonArray;
import com.google.gson.JsonDeserializer;
import kr.toxicity.model.api.util.MathUtil;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* A three float value (origin, rotation)
* @param x x
* @param y y
* @param z z
*/
@ApiStatus.Internal
public record Float3(
float x,
float y,
float z
) {
/**
* Creates floats
* @param value scala
*/
public Float3(float value) {
this(value, value, value);
}
/**
* Center
*/
public static final Float3 CENTER = new Float3(8, 8, 8);
/**
* Zero
*/
public static final Float3 ZERO = new Float3(0, 0, 0);
public static final Float3 MESH_TRIANGLE_FROM = new Float3(-8, 0, 0);
public static final Float3 MESH_TRIANGLE_TO = new Float3(0, 8, 0);
/**
* Parser
*/
public static final JsonDeserializer PARSER = (json, _, _) -> {
var array = json.getAsJsonArray();
return new Float3(
array.get(0).getAsFloat(),
array.get(1).getAsFloat(),
array.get(2).getAsFloat()
);
};
/**
* Adds other floats.
* @param other other floats
* @return new floats
*/
public @NotNull Float3 plus(@NotNull Float3 other) {
return new Float3(
x + other.x,
y + other.y,
z + other.z
);
}
/**
* Converts zxy euler to xyz euler (Minecraft)
* @return new float
*/
public @NotNull Float3 convertToMinecraftDegree() {
var vec = MathUtil.toXYZEuler(toVector());
return new Float3(vec.x, vec.y, vec.z);
}
/**
* Rotates this float
* @param quaternionf rotation
* @return new float
*/
public @NotNull Float3 rotate(@NotNull Quaternionf quaternionf) {
var vec = toVector().rotate(quaternionf);
return new Float3(vec.x, vec.y, vec.z);
}
/**
* Subtracts other floats.
* @param other other floats
* @return new floats
*/
public @NotNull Float3 minus(@NotNull Float3 other) {
return new Float3(
x - other.x,
y - other.y,
z - other.z
);
}
/**
* Converts item model scale to block scale
* @return block
*/
public @NotNull Float3 toBlockScale() {
return div(MathUtil.MODEL_TO_BLOCK_MULTIPLIER);
}
/**
* Multiplies floats.
* @param value multiplier
* @return new floats
*/
public @NotNull Float3 times(float value) {
return new Float3(
x * value,
y * value,
z * value
);
}
/**
* Divides floats.
* @param value multiplier
* @return new floats
*/
public @NotNull Float3 div(float value) {
return new Float3(
x / value,
y / value,
z / value
);
}
/**
* Inverts XZ
* @return new floats
*/
public @NotNull Float3 invertXZ() {
return new Float3(
-x,
y,
-z
);
}
/**
* Converts floats to JSON array.
* @return json array
*/
public @NotNull JsonArray toJson() {
var array = new JsonArray(3);
array.add(x);
array.add(y);
array.add(z);
return array;
}
public @NotNull Quaternionf toQuaternionZYX() {
return new Quaternionf()
.rotateZYX(
z * MathUtil.DEGREES_TO_RADIANS,
y * MathUtil.DEGREES_TO_RADIANS,
x * MathUtil.DEGREES_TO_RADIANS
);
}
public @NotNull Quaternionf toQuaternionXYZ() {
return new Quaternionf()
.rotateXYZ(
x * MathUtil.DEGREES_TO_RADIANS,
y * MathUtil.DEGREES_TO_RADIANS,
z * MathUtil.DEGREES_TO_RADIANS
);
}
/**
* Converts floats to vector.
* @return vector
*/
public @NotNull Vector3f toVector() {
return new Vector3f(x, y, z);
}
@Override
public int hashCode() {
var hash = 31;
var value = 1;
value = value * hash + MathUtil.similarHashCode(x);
value = value * hash + MathUtil.similarHashCode(y);
value = value * hash + MathUtil.similarHashCode(z);
return value;
}
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (!(obj instanceof Float3(float x1, float y1, float z1))) return false;
return MathUtil.isSimilar(x, x1)
&& MathUtil.isSimilar(y, y1)
&& MathUtil.isSimilar(z, z1);
}
@Override
public @NotNull String toString() {
return toJson().toString();
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/Float4.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data;
import com.google.gson.JsonArray;
import com.google.gson.JsonDeserializer;
import kr.toxicity.model.api.data.raw.ModelResolution;
import kr.toxicity.model.api.util.MathUtil;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
/**
* A four float values (uv)
* @param dx from-x
* @param dz from-z
* @param tx to-x
* @param tz to-z
*/
@ApiStatus.Internal
public record Float4(
float dx,
float dz,
float tx,
float tz
) {
/**
* Parser
*/
public static final JsonDeserializer PARSER = (json, _, _) -> {
var array = json.getAsJsonArray();
return new Float4(
array.get(0).getAsFloat(),
array.get(1).getAsFloat(),
array.get(2).getAsFloat(),
array.get(3).getAsFloat()
);
};
public static final Float4 MAX_UV = new Float4(0, 0, 16, 16);
/**
* Divides floats by resolution.
* @param resolution model resolution
* @return new floats
*/
public @NotNull Float4 div(@NotNull ModelResolution resolution) {
return div((float) resolution.width() / MathUtil.MODEL_TO_BLOCK_MULTIPLIER, (float) resolution.height() / MathUtil.MODEL_TO_BLOCK_MULTIPLIER);
}
/**
* Divides floats by width, height
* @param width width
* @param height height
* @return new floats
*/
public @NotNull Float4 div(float width, float height) {
return new Float4(
dx / width,
dz / height,
tx / width,
tz / height
);
}
/**
* Checks validity of this uv
* @return is valid
*/
public boolean isValid() {
return dx >= 0 && dx <= 16
&& dz >= 0 && dz <= 16
&& tx >= 0 && tx <= 16
&& tz >= 0 && tz <= 16;
}
/**
* Converts floats to JSON array.
* @return json array
*/
public @NotNull JsonArray toJson() {
var array = new JsonArray(4);
array.add(dx);
array.add(dz);
array.add(tx);
array.add(tz);
return array;
}
@Override
public int hashCode() {
var hash = 31;
var value = 1;
value = value * hash + MathUtil.similarHashCode(dx);
value = value * hash + MathUtil.similarHashCode(dz);
value = value * hash + MathUtil.similarHashCode(tx);
value = value * hash + MathUtil.similarHashCode(tz);
return value;
}
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (!(obj instanceof Float4(float dx1, float dz1, float tx1, float tz1))) return false;
return MathUtil.isSimilar(dx, dx1)
&& MathUtil.isSimilar(dz, dz1)
&& MathUtil.isSimilar(tx, tx1)
&& MathUtil.isSimilar(tz, tz1);
}
@Override
public @NotNull String toString() {
return toJson().toString();
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/ModelAsset.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data;
import com.google.gson.JsonParseException;
import kr.toxicity.model.api.data.raw.ModelData;
import kr.toxicity.model.api.data.raw.ModelLoadResult;
import kr.toxicity.model.api.util.PackUtil;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Represents a raw model asset that can be loaded into the engine.
*
* This record encapsulates the source of the model data (e.g., a file or stream), its name, and metadata.
* It provides methods to load and parse the model data into a usable format.
*
*
* @param rawName the original raw name or path of the asset
* @param name the sanitized, pack-compliant name of the asset
* @param sizeAssume the estimated size of the asset in bytes (0 if unknown)
* @param supplier a supplier for the input stream containing the model data
* @since 2.0.0
*/
public record ModelAsset(
@NotNull String rawName,
@NotNull String name,
long sizeAssume,
@NotNull StreamSupplier supplier
) implements Comparable {
/**
* Internal constructor for ModelAsset.
*/
@ApiStatus.Internal
public ModelAsset {
}
/**
* Creates a new ModelAsset from a name and byte array.
*
* @param name the name of the asset
* @param bytes the byte array containing the model data
* @return the created asset
* @since 2.0.0
*/
public static @NotNull ModelAsset of(@NotNull String name, byte[] bytes) {
return of(name, bytes.length, () -> new ByteArrayInputStream(bytes));
}
/**
* Creates a new ModelAsset from a name and stream supplier.
*
* @param name the name of the asset
* @param supplier the stream supplier
* @return the created asset
* @since 2.0.0
*/
public static @NotNull ModelAsset of(@NotNull String name, @NotNull StreamSupplier supplier) {
return of(name, 0, supplier); // Unknown size
}
/**
* Creates a new ModelAsset from a name, stream supplier, and estimated size.
*
* @param name the name of the asset
* @param sizeAssume the estimated size in bytes
* @param supplier the stream supplier
* @return the created asset
* @since 2.0.0
*/
public static @NotNull ModelAsset of(@NotNull String name, long sizeAssume, @NotNull StreamSupplier supplier) {
PackUtil.assertPackName(name);
return new ModelAsset(name, name, sizeAssume, supplier);
}
/**
* Creates a new ModelAsset from a file.
*
* @param file the source file
* @return the created asset
* @since 2.0.0
*/
public static @NotNull ModelAsset of(@NotNull File file) {
return new ModelAsset(file.getPath(), nameWithoutExtension(file.getName()), file.length(), () -> new FileInputStream(file));
}
/**
* Creates a new ModelAsset from a path.
*
* @param path the source path
* @return the created asset
* @throws RuntimeException if an I/O error occurs
* @since 2.0.0
*/
public static @NotNull ModelAsset of(@NotNull Path path) {
try {
return new ModelAsset(path.toString(), nameWithoutExtension(path.getFileName().toString()), Files.size(path), () -> Files.newInputStream(path));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private static @NotNull String nameWithoutExtension(@NotNull String name) {
var index = name.lastIndexOf('.');
return PackUtil.toPackName(index > 0 ? name.substring(0, index) : name);
}
/**
* Loads and parses the model data from this asset.
*
* @return the result of the load operation
* @throws RuntimeException if an I/O or parsing error occurs
* @since 2.0.0
*/
public @NotNull ModelLoadResult toResult() {
try (
var stream = supplier.get();
var reader = new InputStreamReader(stream, StandardCharsets.UTF_8)
) {
var result = ModelData.GSON.fromJson(reader, ModelData.class);
result.assertSupported();
return result.loadBlueprint(name);
} catch (IOException e) {
throw new RuntimeException("Unable to load this asset: " + this, e);
} catch (JsonParseException e) {
throw new RuntimeException("Unable to parse this json asset: " + this, e);
}
}
@Override
public int compareTo(@NotNull ModelAsset o) {
return name.compareTo(o.name);
}
@Override
public boolean equals(Object o) {
if (!(o instanceof ModelAsset that)) return false;
return name.equals(that.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
@Override
public @NotNull String toString() {
return rawName;
}
/**
* A functional interface for supplying an input stream.
*
* @since 2.0.0
*/
public interface StreamSupplier {
/**
* Gets the input stream.
*
* @return the input stream
* @throws IOException if an I/O error occurs
* @since 2.0.0
*/
@NotNull InputStream get() throws IOException;
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/blueprint/AnimationGenerator.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.blueprint;
import it.unimi.dsi.fastutil.floats.*;
import kr.toxicity.model.api.animation.VectorPoint;
import kr.toxicity.model.api.bone.BoneName;
import kr.toxicity.model.api.util.InterpolationUtil;
import kr.toxicity.model.api.util.MathUtil;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.joml.Vector3f;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Stream;
import static kr.toxicity.model.api.util.CollectionUtil.*;
/**
* Generates animation data by interpolating keyframes and calculating bone movements.
*
* This class processes raw animation points and generates smooth transitions for position, rotation, and scale.
* It handles the creation of intermediate frames to ensure fluid motion, especially for rotations.
*
*
* @since 1.15.2
*/
@ApiStatus.Internal
public final class AnimationGenerator {
private static final Vector3f EMPTY = new Vector3f();
private final Map pointMap;
private final List trees;
/**
* Creates a map of blueprint animators from the provided animation data.
*
* This method calculates all necessary interpolation frames and builds the final animation structures for each bone.
*
*
* @param length the total length of the animation in seconds
* @param children the list of root blueprint elements (bones)
* @param pointMap a map containing raw animation data for each bone
* @return a map of generated blueprint animators keyed by bone name
* @since 1.15.2
*/
public static @NotNull Map createMovements(
float length,
@NotNull List children,
@NotNull Map pointMap
) {
var floatSet = mapFloat(pointMap.values()
.stream()
.flatMap(BlueprintAnimator.AnimatorData::allPoints), VectorPoint::time, () -> new FloatAVLTreeSet(MathUtil.FRAME_COMPARATOR));
floatSet.add(0F);
floatSet.add(length);
InterpolationUtil.insertLerpFrame(floatSet);
var generator = new AnimationGenerator(pointMap, children);
generator.interpolateRotation(floatSet);
generator.interpolateStep(floatSet);
return mapValue(pointMap, v -> new BlueprintAnimator(
v.name(),
InterpolationUtil.buildAnimation(
v.position(),
v.rotation(),
v.scale(),
v.rotationGlobal(),
floatSet
)
));
}
private AnimationGenerator(
@NotNull Map pointMap,
@NotNull List children
) {
this.pointMap = pointMap;
trees = filterIsInstance(children, BlueprintElement.Group.class)
.map(g -> new AnimationTree(g, pointMap.get(g.name())))
.flatMap(AnimationTree::flatten)
.toList();
}
private float firstTime = 0F;
private float secondTime = 0F;
/**
* Inserts additional keyframes to smooth out large rotations.
*
* This ensures that rotations larger than 90 degrees between frames are broken down into smaller steps.
*
*
* @param floats the set of keyframe times to update
* @since 1.15.2
*/
public void interpolateRotation(@NotNull FloatSortedSet floats) {
var iterator = new FloatArrayList(floats).iterator();
var time = 0.05F;
while (iterator.hasNext()) {
firstTime = secondTime;
secondTime = iterator.nextFloat();
if (secondTime - firstTime <= 0) continue;
var minus = trees.stream()
.mapToDouble(t -> t.tree(firstTime, secondTime, BlueprintAnimator.AnimatorData::rotation))
.max()
.orElse(0);
var length = (float) Math.ceil(minus / 90);
if (length < 2) continue;
var addTime = Math.max(
InterpolationUtil.lerp(0, secondTime - firstTime, 1F / length),
time
);
for (float f = 1; f < length; f++) {
if (secondTime - addTime < time + MathUtil.FRAME_EPSILON) continue;
floats.add(firstTime + f * addTime);
}
}
}
/**
* Inserts keyframes for step interpolation (non-continuous transitions).
*
* @param floats the set of keyframe times to update
* @since 1.15.2
*/
public void interpolateStep(@NotNull FloatSortedSet floats) {
trees.stream()
.map(tree -> tree.data)
.filter(Objects::nonNull)
.forEach(data -> {
interpolateStep(floats, data.position());
interpolateStep(floats, data.rotation());
interpolateStep(floats, data.scale());
});
}
private void interpolateStep(@NotNull FloatSortedSet floats, @NotNull List points) {
if (points.size() < 2) return;
for (int i = 1; i < points.size(); i++) {
var before = points.get(i - 1);
if (before.isContinuous()) continue;
var time = points.get(i).time() - 0.05F;
if (time < 0 || time - before.time() < 0) continue;
floats.add(time);
}
}
private class AnimationTree {
private final AnimationTree parent;
private final List children;
private final BlueprintAnimator.AnimatorData data;
private int searchCache = 0;
private final Float2ObjectMap valueCache = new Float2ObjectOpenHashMap<>();
AnimationTree(@NotNull BlueprintElement.Group group, @Nullable BlueprintAnimator.AnimatorData data) {
this(null, group, data);
}
AnimationTree(
@Nullable AnimationTree parent,
@NotNull BlueprintElement.Group group,
@Nullable BlueprintAnimator.AnimatorData data
) {
this.parent = parent;
this.data = data;
children = filterIsInstance(group.children(), BlueprintElement.Group.class)
.map(g -> new AnimationTree(this, g, pointMap.get(g.name())))
.toList();
}
@NotNull
Stream flatten() {
return children.isEmpty() ? Stream.of(this) : Stream.concat(
Stream.of(this),
children.stream().flatMap(AnimationTree::flatten)
);
}
private float tree(float first, float second, @NotNull Function> mapper) {
var value = data != null ? mapper.apply(data) : Collections.emptyList();
return findTree(first, second, value).length();
}
private @NotNull Vector3f findTree(float first, float second, @NotNull List target) {
var get = find(first, second, target);
return parent != null ? parent.findTree(first, second, target).add(get) : get;
}
private @NotNull Vector3f find(float first, float second, @NotNull List target) {
return find(second, target).sub(find(first, target), new Vector3f());
}
private @NotNull Vector3f find(float time, @NotNull List target) {
return valueCache.computeIfAbsent(time, _ -> {
if (target.size() <= 1) return EMPTY;
var i = searchCache;
for (; i < target.size(); i++) {
if (target.get(i).time() >= time) break;
}
searchCache = i;
if (i == 0) return EMPTY;
if (i == target.size()) return EMPTY;
var first = target.get(i - 1);
var second = target.get(i);
var t1 = first.time();
var t2 = second.time();
var a = InterpolationUtil.alpha(t1, t2, time);
return second.time() == time ? second.vector() : InterpolationUtil.lerp(
first.vector(InterpolationUtil.lerp(t1, t2, a)),
second.vector(),
a
);
});
}
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/blueprint/BlueprintAnimation.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.blueprint;
import kr.toxicity.model.api.animation.AnimationIterator;
import kr.toxicity.model.api.animation.AnimationModifier;
import kr.toxicity.model.api.animation.AnimationProgress;
import kr.toxicity.model.api.animation.TimedStorage;
import kr.toxicity.model.api.bone.BoneName;
import kr.toxicity.model.api.script.BlueprintScript;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.Map;
/**
* Represents a complete, processed animation for a model.
*
* This record contains all the necessary data to play an animation, including keyframes for each bone,
* loop settings, and associated scripts.
*
*
* @param name the name of the animation
* @param loop the default loop mode
* @param length the length of the animation in seconds
* @param override whether this animation overrides others
* @param animator a map of animators for each bone
* @param script the script associated with this animation, if any
* @param emptyAnimator a list of empty movements, used as a fallback or for initialization
* @since 1.15.2
*/
public record BlueprintAnimation(
@NotNull String name,
@NotNull AnimationIterator.Type loop,
float length,
boolean override,
@NotNull @Unmodifiable Map animator,
@Nullable BlueprintScript script,
@NotNull TimedStorage emptyAnimator
) {
/**
* Retrieves the script for this animation, considering the provided modifier.
*
* If the modifier overrides the animation or specifies a player, the script may be suppressed.
*
*
* @param modifier the animation modifier
* @return the script, or null if suppressed
* @since 1.15.2
*/
public @Nullable BlueprintScript script(@NotNull AnimationModifier modifier) {
return modifier.override(override) || modifier.player() != null ? null : script;
}
/**
* Creates an iterator for the empty animation sequence.
*
* @param type the loop type
* @return an animation iterator
* @since 1.15.2
*/
public @NotNull AnimationIterator emptyIterator(@NotNull AnimationIterator.Type type) {
return type.create(emptyAnimator);
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/blueprint/BlueprintAnimator.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.blueprint;
import kr.toxicity.model.api.animation.AnimationIterator;
import kr.toxicity.model.api.animation.AnimationKeyframe;
import kr.toxicity.model.api.animation.AnimationProgress;
import kr.toxicity.model.api.animation.VectorPoint;
import kr.toxicity.model.api.bone.BoneName;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.stream.Stream;
/**
* Represents the processed animation data for a single bone within a model blueprint.
*
* This record holds the sequence of keyframes that define the bone's movement over time.
*
*
* @param name the name of the bone this animator applies to
* @param keyframe a list of animation movements representing the keyframes
* @since 1.15.2
*/
public record BlueprintAnimator(
@NotNull BoneName name,
@NotNull AnimationKeyframe keyframe
) {
/**
* Holds the raw, separated animation data points for a bone before final processing.
*
* @param name the name of the bone
* @param position a list of position keyframes
* @param scale a list of scale keyframes
* @param rotation a list of rotation keyframes
* @param rotationGlobal whether the rotation is applied globally
* @since 1.15.2
*/
public record AnimatorData(
@NotNull BoneName name,
@NotNull List position,
@NotNull List scale,
@NotNull List rotation,
boolean rotationGlobal
) {
/**
* Returns a stream containing all keyframe points (position, scale, and rotation).
*
* @return a stream of all vector points
* @since 1.15.2
*/
public @NotNull Stream allPoints() {
return Stream.concat(
Stream.concat(
position.stream(),
scale.stream()
),
rotation.stream()
);
}
}
/**
* Creates an iterator for the keyframes based on a specified loop type.
*
* @param type the loop type (e.g., play_once, loop)
* @return an animation iterator
* @since 1.15.2
*/
public @NotNull AnimationIterator iterator(@NotNull AnimationIterator.Type type) {
return type.create(keyframe);
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/blueprint/BlueprintElement.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.blueprint;
import com.google.gson.JsonObject;
import io.github.toxicity188.javamesh.MeshBuilder;
import io.github.toxicity188.javamesh.MeshPoint;
import io.github.toxicity188.javamesh.MeshShape;
import kr.toxicity.model.api.bone.BoneName;
import kr.toxicity.model.api.bone.BoneTags;
import kr.toxicity.model.api.data.Float2;
import kr.toxicity.model.api.data.Float3;
import kr.toxicity.model.api.data.raw.ModelFace;
import kr.toxicity.model.api.pack.PackObfuscator;
import kr.toxicity.model.api.util.MathUtil;
import kr.toxicity.model.api.util.PackUtil;
import kr.toxicity.model.api.util.json.JsonObjectBuilder;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import org.joml.Quaternionf;
import java.util.*;
import java.util.stream.Stream;
import static kr.toxicity.model.api.util.CollectionUtil.*;
/**
* Represents a processed element within a model blueprint.
*
* This is a sealed interface with implementations for different types of elements like bones, groups, cubes, and locators.
*
*
* @since 1.15.2
*/
public sealed interface BlueprintElement {
String MESH_TRIANGLE_SINGLE = "mesh_triangle_single";
String MESH_TRIANGLE_DUPLEX = "mesh_triangle_duplex";
String MESH_PIXEL = "mesh_pixel";
/**
* Represents an element that acts as a bone in the model's armature.
*
* @since 1.15.2
*/
sealed interface Bone extends BlueprintElement {
/**
* Returns the UUID of the bone.
*
* @return the UUID
* @since 1.15.2
*/
@NotNull UUID uuid();
/**
* Returns the name of the bone.
*
* @return the bone name
* @since 1.15.2
*/
@NotNull BoneName name();
/**
* Returns the origin (pivot point) of the bone.
*
* @return the origin
* @since 1.15.2
*/
@NotNull Float3 origin();
}
/**
* Returns the rotation of the element.
*
* @return the rotation, defaulting to zero
* @since 1.15.2
*/
default @NotNull Float3 rotation() {
return Float3.ZERO;
}
/**
* Checks if the element is visible.
*
* @return true if visible, false otherwise
* @since 1.15.2
*/
default boolean visibility() {
return false;
}
/**
* Represents a group of elements, forming a bone in the hierarchy.
*
* @param uuid the UUID of the group
* @param name the name of the group/bone
* @param origin the pivot point of the group
* @param rotation the rotation of the group
* @param children the list of child elements
* @param visibility whether the group is visible
* @since 1.15.2
*/
record Group(
@NotNull UUID uuid,
@NotNull BoneName name,
@NotNull Float3 origin,
@NotNull Float3 rotation,
@NotNull List children,
boolean visibility
) implements Bone {
/**
* Returns the origin with inverted X and Z axes.
*
* @return the inverted origin
* @since 1.15.2
*/
@Override
@NotNull
public Float3 origin() {
return origin.invertXZ();
}
private @NotNull String jsonName(@NotNull BlueprintLoadContext context) {
return PackUtil.toPackName(context.name() + "_" + name.rawName());
}
/**
* Builds the JSON representation for legacy clients (1.21.3 or under).
*
* @param obfuscator the obfuscator for model and texture names
* @param context the load context
* @return the generated blueprint JSON, or null if not applicable
* @since 1.15.2
*/
public @Nullable BlueprintJson buildLegacyJson(
@NotNull PackObfuscator.Pair obfuscator,
@NotNull BlueprintLoadContext context
) {
return buildJson(-2, 1, scale(), obfuscator, context, Float3.ZERO, filterIsInstance(children, Cube.class).filter(element -> MathUtil.checkValidDegree(element.identifierDegree())));
}
/**
* Builds the JSON representation for modern clients.
*
* @param obfuscator the obfuscator for model and texture names
* @param context the load context
* @return a list of generated blueprint JSONs, or null if not applicable
* @since 1.15.2
*/
@Nullable
@Unmodifiable
public List buildModernJson(
@NotNull PackObfuscator.Pair obfuscator,
@NotNull BlueprintLoadContext context
) {
var scale = scale();
var list = mapIndexed(
group(
filterIsInstance(children, Cube.class),
Cube::identifierDegree
),
(i, entry) -> buildJson(0, i + 1, scale, obfuscator, context, entry.getKey(), entry.getValue().stream())
).filter(Objects::nonNull)
.toList();
return list.isEmpty() ? null : list;
}
/**
* Builds the JSON representation for a mesh-based item model.
*
* @param context the load context
* @return the generated mesh JSON, or null if no meshes are present
* @since 3.0.0
*/
public @Nullable JsonObject buildMeshItemModel(
@NotNull BlueprintLoadContext context
) {
var scale = 1F / scale();
var meshes = filterIsInstance(children, Mesh.class).toList();
if (meshes.isEmpty()) return null;
var builder = MeshBuilder.of(context.triangleName())
.matrixModifier(mat -> mat.scale(scale))
.image(context.imageByIndex());
meshes.forEach(mesh -> builder.load(mesh.toShape(origin)));
return builder.toJson();
}
private @Nullable BlueprintJson buildJson(
int tint,
int number,
float scale,
@NotNull PackObfuscator.Pair obfuscator,
@NotNull BlueprintLoadContext context,
@NotNull Float3 identifier,
@NotNull Stream cubes
) {
var cubeElement = cubes
.filter(Cube::hasTexture)
.toList();
var selectedTextures = cubeElement.stream()
.flatMapToInt(tex -> tex.faces().textureIndex())
.distinct()
.sorted()
.mapToObj(i -> Map.entry(Integer.toString(i), context.texture(i).packNamespace(obfuscator.textures())))
.toList();
if (selectedTextures.isEmpty()) return null;
return new BlueprintJson(obfuscator.models().obfuscate(jsonName(context) + "_" + number), () -> JsonObjectBuilder.builder()
.jsonObject("textures", textures -> textures
.stringProperties(selectedTextures)
.property("particle", selectedTextures.getFirst().getValue()))
.jsonArray("elements", mapToJson(cubeElement, cube -> cube.buildJson(tint, scale, context, this, identifier)))
.jsonObject("display", display -> display.jsonObject("fixed", fixed -> {
if (!identifier.equals(Float3.ZERO)) {
fixed.jsonArray("rotation", identifier.convertToMinecraftDegree().toJson());
}
}))
.build());
}
/**
* Calculates the required scale for the cubes in this group.
*
* @return the scale factor
* @since 1.15.2
*/
public float scale() {
return (float) Math.max(filterIsInstance(children, Cube.class)
.mapToDouble(e -> e.max(origin) / 16F)
.max()
.orElse(1F), 1F);
}
/**
* Calculates the bounding box for this group to be used as a hitbox.
*
* @return the named bounding box, or null if no cubes are present
* @since 1.15.2
*/
public @Nullable ModelBoundingBox hitBox() {
return filterIsInstance(children, Cube.class)
.map(element -> {
var from = element.from()
.minus(origin)
.toBlockScale();
var to = element.to()
.minus(origin)
.toBlockScale();
return ModelBoundingBox.of(
from.x(),
from.y(),
from.z(),
to.x(),
to.y(),
to.z()
).invert();
})
.max(Comparator.comparingDouble(ModelBoundingBox::length))
.orElse(null);
}
}
/**
* Represents a locator element, used as a named attachment point.
*
* @param uuid the UUID of the locator
* @param name the name of the locator
* @param origin the position of the locator
* @since 1.15.2
*/
record Locator(
@NotNull UUID uuid,
@NotNull BoneName name,
@NotNull Float3 origin
) implements Bone {
/**
* Returns the origin with inverted X and Z axes.
*
* @return the inverted origin
* @since 1.15.2
*/
@Override
@NotNull
public Float3 origin() {
return origin.invertXZ();
}
}
/**
* Represents a camera element (currently a placeholder).
*
* @param uuid the UUID of the camera
* @since 1.15.2
*/
record Camera(
@NotNull UUID uuid
) implements BlueprintElement {
}
/**
* Represents a null object, often used for IK or as a simple bone.
*
* @param uuid the UUID of the null object
* @param name the name of the null object
* @param ikTarget the UUID of the IK target bone
* @param ikSource the UUID of the IK source bone
* @param origin the position of the null object
* @since 1.15.2
*/
record NullObject(
@NotNull UUID uuid,
@NotNull BoneName name,
@Nullable UUID ikTarget,
@Nullable UUID ikSource,
@NotNull Float3 origin
) implements Bone {
/**
* Returns the origin with inverted X and Z axes.
*
* @return the inverted origin
* @since 1.15.2
*/
@Override
@NotNull
public Float3 origin() {
return origin.invertXZ();
}
}
/**
* Represents a cube element, the basic building block of a model.
*
* @param name the name of the cube
* @param from the starting coordinate (min corner)
* @param to the ending coordinate (max corner)
* @param inflate the inflation value
* @param rotation the rotation of the cube
* @param origin the pivot point of the cube
* @param faces the UV mapping for the faces
* @param lightEmission the light emission level (1-15 or null if 0)
* @param visibility whether the cube is visible
* @since 1.15.2
*/
record Cube(
@NotNull String name,
@NotNull Float3 from,
@NotNull Float3 to,
float inflate,
@NotNull Float3 rotation,
@NotNull Float3 origin,
@Nullable ModelFace faces,
@Nullable Integer lightEmission,
boolean visibility
) implements BlueprintElement {
private @NotNull Float3 identifierDegree() {
return MathUtil.identifier(rotation());
}
private static @NotNull Float3 centralize(@NotNull Float3 target, @NotNull Float3 groupOrigin, float scale) {
return target.minus(groupOrigin).div(scale);
}
private static @NotNull Float3 deltaPosition(@NotNull Float3 target, @NotNull Quaternionf quaternionf) {
return target.rotate(quaternionf).minus(target);
}
private @NotNull JsonObject buildJson(
int tint,
float scale,
@NotNull BlueprintLoadContext parent,
@NotNull BlueprintElement.Group group,
@NotNull Float3 identifier
) {
var qua = identifier.toQuaternionZYX().invert();
var centerOrigin = centralize(origin(), group.origin, scale);
var groupDelta = deltaPosition(centerOrigin, qua);
var inflate = new Float3(inflate() / scale);
return JsonObjectBuilder.builder()
.property("light_emission", group.name.tagged(BoneTags.GLOW) ? Integer.valueOf(15) : lightEmission)
.jsonArray("from", centralize(from(), group.origin, scale)
.plus(groupDelta)
.plus(Float3.CENTER)
.minus(inflate)
.toJson())
.jsonArray("to", centralize(to(), group.origin, scale)
.plus(groupDelta)
.plus(Float3.CENTER)
.plus(inflate)
.toJson())
.jsonObject("faces", faces().toJson(parent, tint))
.jsonObject("rotation", Optional.of(rotation().minus(identifier))
.filter(r -> !Float3.ZERO.equals(r))
.map(rot -> {
var rotation = getRotation(rot);
rotation.add("origin", centerOrigin
.plus(groupDelta)
.plus(Float3.CENTER)
.toJson());
return rotation;
})
.orElse(null))
.build();
}
/**
* Calculates the maximum distance from the origin to any corner of the cube.
*
* @param origin the reference origin
* @return the maximum length
* @since 1.15.2
*/
public float max(@NotNull Float3 origin) {
var f = from().minus(origin);
var t = to().minus(origin);
var max = 0F;
max = Math.max(max, Math.abs(f.x()));
max = Math.max(max, Math.abs(f.y()));
max = Math.max(max, Math.abs(f.z()));
max = Math.max(max, Math.abs(t.x()));
max = Math.max(max, Math.abs(t.y()));
max = Math.max(max, Math.abs(t.z()));
return max;
}
@Override
public @NotNull ModelFace faces() {
return Objects.requireNonNull(faces);
}
/**
* Checks if this cube has any textures defined.
*
* @return true if it has textures, false otherwise
* @since 1.15.2
*/
public boolean hasTexture() {
return faces != null && faces.hasTexture();
}
private @NotNull JsonObject getRotation(@NotNull Float3 rot) {
var rotation = new JsonObject();
if (Math.abs(rot.x()) > 0) {
rotation.addProperty("angle", rot.x());
rotation.addProperty("axis", "x");
} else if (Math.abs(rot.y()) > 0) {
rotation.addProperty("angle", rot.y());
rotation.addProperty("axis", "y");
} else if (Math.abs(rot.z()) > 0) {
rotation.addProperty("angle", rot.z());
rotation.addProperty("axis", "z");
}
return rotation;
}
}
/**
* Represents a mesh element, allowing for complex geometry beyond simple cubes.
*
* @param origin the pivot point of the mesh
* @param rotation the rotation of the mesh
* @param faces the list of faces forming the mesh
* @param visibility whether the mesh is visible
* @since 3.0.0
*/
record Mesh(
@NotNull Float3 origin,
@NotNull Float3 rotation,
@NotNull List faces,
boolean visibility
) implements BlueprintElement {
/**
* Converts this mesh into a list of {@link MeshShape} for rendering.
*
* @param parentOrigin the origin of the parent bone
* @return an unmodifiable list of mesh shapes
* @since 3.0.0
*/
@NotNull
@Unmodifiable
public List toShape(@NotNull Float3 parentOrigin) {
var deltaOrigin = origin().minus(parentOrigin).toVector();
var pointRotation = rotation().toQuaternionXYZ();
return faces.stream()
.map(face -> new MeshShape(
face.points.stream()
.map(p -> new MeshPoint(
p.vertices.toVector()
.rotate(pointRotation)
.add(deltaOrigin)
.mul(-1F, 1F, -1F)
.div(MathUtil.MODEL_TO_BLOCK_MULTIPLIER),
p.uv.toVector()
.div(MathUtil.MODEL_TO_BLOCK_MULTIPLIER)
))
.toList(),
Integer.toString(face.texture)
))
.toList();
}
/**
* Represents a single face of a mesh.
*
* @param points the vertices and UV coordinates of the face
* @param texture the index of the texture used by this face
* @since 3.0.0
*/
public record Face(@NotNull @Unmodifiable List points, int texture) {}
/**
* Represents a single point (vertex) in a mesh face.
*
* @param vertices the 3D coordinates of the vertex
* @param uv the 2D UV coordinates for texture mapping
* @since 3.0.0
*/
public record Point(@NotNull Float3 vertices, @NotNull Float2 uv) {}
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/blueprint/BlueprintImage.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.blueprint;
import com.google.gson.JsonObject;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Represents an image file to be generated as part of the resource pack.
*
* This record holds the image's name, its binary content, and an optional .mcmeta file for animations.
*
*
* @param name the name of the image file (including extension)
* @param image the binary content of the image
* @param mcmeta the JSON object for the .mcmeta file, if any
* @since 1.15.2
*/
public record BlueprintImage(@NotNull String name, byte[] image, @Nullable JsonObject mcmeta) {
/**
* Returns the estimated size of the image in bytes.
*
* @return the image size
* @since 1.15.2
*/
public long estimatedSize() {
return image.length;
}
/**
* Returns the name of the image file with a .png extension.
*
* @return the png file name
* @since 2.0.1
*/
public @NotNull String pngName() {
return name + ".png";
}
/**
* Returns the name of the metadata file associated with the png.
*
* @return the mcmeta file name
* @since 2.0.1
*/
public @NotNull String mcmetaName() {
return pngName() + ".mcmeta";
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/blueprint/BlueprintJson.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.blueprint;
import com.google.gson.JsonElement;
import org.jetbrains.annotations.NotNull;
import java.util.function.Supplier;
/**
* Represents a JSON file to be generated as part of the resource pack.
*
* This record holds the file name and a supplier for the JSON content.
*
*
* @param name the name of the JSON file (without extension)
* @param element a supplier that provides the JSON content
* @since 1.15.2
*/
public record BlueprintJson(
@NotNull String name,
@NotNull Supplier element
) {
/**
* Returns the name of the JSON file with a .json extension.
*
* @return the JSON file name
* @since 2.0.1
*/
public @NotNull String jsonName() {
return name + ".json";
}
/**
* Builds and returns the JSON content by invoking the supplier.
*
* @since 2.0.1
* @return the generated JSON element
*/
public @NotNull JsonElement buildJson() {
return element.get();
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/blueprint/BlueprintLoadContext.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2026 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.blueprint;
import io.github.toxicity188.javamesh.MeshImage;
import io.github.toxicity188.javamesh.MeshTriangleName;
import kr.toxicity.model.api.BetterModel;
import kr.toxicity.model.api.data.raw.ModelResolution;
import kr.toxicity.model.api.pack.PackObfuscator;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Unmodifiable;
import javax.imageio.ImageIO;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
/**
* A context class for loading blueprints.
*
* @since 3.0.0
*/
@ApiStatus.Internal
public final class BlueprintLoadContext {
private final String name;
private final ModelResolution resolution;
private final TextureRef[] textureRefs;
private final boolean canBeRendered;
private volatile Map imageRefMap;
private final MeshTriangleName triangleName = new MeshTriangleName(
BetterModel.config().namespace() + ":" + BlueprintElement.MESH_TRIANGLE_SINGLE,
BetterModel.config().namespace() + ":" + BlueprintElement.MESH_TRIANGLE_DUPLEX
);
BlueprintLoadContext(
@NotNull String name,
@NotNull ModelResolution resolution,
@NotNull List textures
) {
this.name = name;
this.resolution = resolution;
this.textureRefs = new TextureRef[textures.size()];
var i = 0;
var canBeRendered = false;
for (BlueprintTexture texture : textures) {
canBeRendered |= texture.canBeRendered();
this.textureRefs[i++] = new TextureRef(texture);
}
this.canBeRendered = canBeRendered;
}
/**
* Gets the name of the blueprint.
*
* @return the name
* @since 3.0.0
*/
public @NotNull String name() {
return name;
}
/**
* Gets the mesh triangle name.
*
* @return the triangle name
* @since 3.0.0
*/
public @NotNull MeshTriangleName triangleName() {
return triangleName;
}
/**
* Gets the model resolution.
*
* @return the resolution
* @since 3.0.0
*/
public @NotNull ModelResolution resolution() {
return resolution;
}
/**
* Gets a texture by its index.
*
* @param index the index
* @return the texture
* @since 3.0.0
*/
public @NotNull BlueprintTexture texture(int index) {
return Objects.requireNonNull(textureRefs[index]).texture();
}
@NotNull
@Unmodifiable
Map imageByIndex() {
Map map;
if ((map = imageRefMap) != null) return map;
synchronized (this) {
if ((map = imageRefMap) != null) return map;
return imageRefMap = Collections.unmodifiableMap(new AbstractMap<>() {
@Override
public MeshImage get(Object key) {
var get = textureRefs[Integer.parseInt(key.toString())];
return get != null ? get.image() : null;
}
@Override
@Unmodifiable
public @NotNull Set> entrySet() {
return IntStream.range(0, textureRefs.length)
.mapToObj(i -> Map.entry(Integer.toString(i), textureRefs[i].image()))
.collect(Collectors.toUnmodifiableSet());
}
});
}
}
/**
* Returns whether this context can be rendered.
*
* @return true if renderable
* @since 3.0.0
*/
public boolean canBeRendered() {
return canBeRendered;
}
/**
* Builds a stream of blueprint images using the provided obfuscator.
*
* @param obfuscator the obfuscator
* @return a stream of images
* @since 3.0.0
*/
@NotNull
public Stream buildImage(@NotNull PackObfuscator obfuscator) {
if (!canBeRendered()) return Stream.empty();
return Arrays.stream(textureRefs)
.filter(TextureRef::canBeRendered)
.map(ref -> new BlueprintImage(
ref.texture.packName(obfuscator),
ref.texture.image(),
ref.texture.isAnimatedTexture() ? ref.texture.toMcmeta() : null)
);
}
private static final class TextureRef {
private final BlueprintTexture texture;
private final AtomicBoolean referenced = new AtomicBoolean();
private volatile MeshImage image;
private TextureRef(BlueprintTexture texture) {
this.texture = texture;
}
public boolean canBeRendered() {
return referenced.get() && texture.canBeRendered();
}
public @NotNull BlueprintTexture texture() {
referenced.set(true);
return texture;
}
public @NotNull MeshImage image() {
MeshImage img;
if ((img = image) != null) return img;
synchronized (this) {
if ((img = image) != null) return img;
try (var input = new ByteArrayInputStream(texture.image())) {
return image = MeshImage.from(ImageIO.read(input));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/blueprint/BlueprintTexture.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.blueprint;
import com.google.gson.JsonObject;
import kr.toxicity.model.api.BetterModel;
import kr.toxicity.model.api.data.raw.ModelResolution;
import kr.toxicity.model.api.pack.PackObfuscator;
import kr.toxicity.model.api.util.json.JsonObjectBuilder;
import org.jetbrains.annotations.NotNull;
/**
* Represents a processed texture in a model blueprint.
*
* This record holds the texture's name, binary image data, dimensions, and rendering properties.
*
*
* @param name the internal name of the texture
* @param image the binary content of the texture image
* @param width the original width of the texture in pixels
* @param height the original height of the texture in pixels
* @param uvWidth the UV width of the texture, if specified
* @param uvHeight the UV height of the texture, if specified
* @param canBeRendered whether this texture should be included in the resource pack
* @param frameTime the frame time of the texture
* @param frameInterpolate the interpolation flag of the texture
* @since 1.15.2
*/
public record BlueprintTexture(
@NotNull String name,
byte[] image,
int width,
int height,
int uvWidth,
int uvHeight,
boolean canBeRendered,
int frameTime,
boolean frameInterpolate
) {
/**
* Checks if this texture is an animated texture (a texture atlas for animation).
*
* @return true if it is an animated texture, false otherwise
* @since 1.15.2
*/
public boolean isAnimatedTexture() {
if (hasUVSize()) {
var h = (float) height / uvHeight;
var w = (float) width / uvWidth;
return h > w;
} else {
return height > 0 && width > 0 && height / width > 1;
}
}
/**
* Generates the .mcmeta file content for this texture if it is animated.
*
* @return the JSON object for the .mcmeta file
* @since 1.15.2
*/
public @NotNull JsonObject toMcmeta() {
return JsonObjectBuilder.builder()
.jsonObject("animation", animation -> {
animation.property("interpolate", frameInterpolate());
animation.property("frametime", frameTime());
})
.build();
}
/**
* Generates the pack-compliant file name for this texture.
*
* @param obfuscator the obfuscator to use for the name
* @return the obfuscated file name
* @since 1.15.2
*/
public @NotNull String packName(@NotNull PackObfuscator obfuscator) {
return obfuscator.obfuscate(name());
}
/**
* Generates the full resource pack namespace path for this texture.
*
* @param obfuscator the obfuscator to use for the name
* @return the texture's namespace path
* @since 1.15.2
*/
public @NotNull String packNamespace(@NotNull PackObfuscator obfuscator) {
return BetterModel.config().namespace() + ":item/" + packName(obfuscator);
}
/**
* Checks if this texture has a specific UV size defined.
*
* @return true if UV width and height are specified, false otherwise
* @since 1.15.2
*/
public boolean hasUVSize() {
return uvWidth > 0 && uvHeight > 0;
}
/**
* Returns the effective resolution for this texture's UV mapping.
*
* @param resolution the parent model's resolution
* @return the UV resolution, or the parent resolution if not specified
* @since 1.15.2
*/
public @NotNull ModelResolution resolution(@NotNull ModelResolution resolution) {
if (!hasUVSize()) return resolution;
return resolution.width() == width && resolution.height() == height ? resolution : new ModelResolution(uvWidth, uvHeight);
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/blueprint/ModelBlueprint.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.blueprint;
import kr.toxicity.model.api.data.raw.ModelResolution;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Map;
/**
* Represents a fully processed model blueprint, ready for generation and rendering.
*
* This record contains all the necessary data derived from a raw model file, including
* textures, structural elements (bones/cubes), and animations.
*
*
* @param name the name of the model
* @param resolution the texture resolution of the model
* @param textures the list of textures used by the model
* @param elements the hierarchical list of model elements (bones)
* @param animations a map of animations available for this model
* @since 1.15.2
*/
@ApiStatus.Internal
public record ModelBlueprint(
@NotNull String name,
@NotNull ModelResolution resolution,
@NotNull List textures,
@NotNull List elements,
@NotNull Map animations
) {
/**
* Creates a new load context for this blueprint.
*
* @since 3.0.0
* @return a new blueprint load context
*/
public @NotNull BlueprintLoadContext context() {
return new BlueprintLoadContext(
name(),
resolution(),
textures()
);
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/blueprint/ModelBoundingBox.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.blueprint;
import org.jetbrains.annotations.NotNull;
import org.joml.Quaterniond;
import org.joml.Vector3d;
import org.joml.Vector3f;
/**
* Represents an axis-aligned bounding box (AABB) for a model part.
*
* This record defines the spatial extent of a model element or group, used for hitboxes and collision detection.
*
*
* @param minX the minimum X coordinate
* @param minY the minimum Y coordinate
* @param minZ the minimum Z coordinate
* @param maxX the maximum X coordinate
* @param maxY the maximum Y coordinate
* @param maxZ the maximum Z coordinate
* @since 1.15.2
*/
public record ModelBoundingBox(
double minX,
double minY,
double minZ,
double maxX,
double maxY,
double maxZ
) {
/**
* A minimal bounding box size (0.1 x 0.1 x 0.1).
* @since 1.15.2
*/
public static final ModelBoundingBox MIN = of(0.1, 0.1, 0.1);
/**
* Creates a bounding box from two corner vectors.
*
* @param min the minimum corner vector
* @param max the maximum corner vector
* @return the bounding box
* @since 1.15.2
*/
public static @NotNull ModelBoundingBox of(@NotNull Vector3d min, @NotNull Vector3d max) {
return of(
min.x,
min.y,
min.z,
max.x,
max.y,
max.z
);
}
/**
* Creates a bounding box centered at the origin with the given dimensions.
*
* @param x the width (X-axis)
* @param y the height (Y-axis)
* @param z the depth (Z-axis)
* @return the centered bounding box
* @since 1.15.2
*/
public static @NotNull ModelBoundingBox of(double x, double y, double z) {
return of(
-x / 2,
-y / 2,
-z / 2,
x / 2,
y / 2,
z / 2
);
}
/**
* Creates a bounding box from explicit min/max coordinates.
*
* This method automatically ensures that min values are less than or equal to max values.
*
*
* @param minX the first X coordinate
* @param minY the first Y coordinate
* @param minZ the first Z coordinate
* @param maxX the second X coordinate
* @param maxY the second Y coordinate
* @param maxZ the second Z coordinate
* @return the normalized bounding box
* @since 1.15.2
*/
public static @NotNull ModelBoundingBox of(
double minX,
double minY,
double minZ,
double maxX,
double maxY,
double maxZ
) {
return new ModelBoundingBox(
Math.min(minX, maxX),
Math.min(minY, maxY),
Math.min(minZ, maxZ),
Math.max(minX, maxX),
Math.max(minY, maxY),
Math.max(minZ, maxZ)
);
}
/**
* Returns the width of the bounding box (X-axis extent).
*
* @return the width
* @since 1.15.2
*/
public double x() {
return maxX - minX;
}
/**
* Returns the height of the bounding box (Y-axis extent).
*
* @return the height
* @since 1.15.2
*/
public double y() {
return maxY - minY;
}
/**
* Returns the Y coordinate of the center of the bounding box.
*
* @return the center Y
* @since 1.15.2
*/
public double centerY() {
return (maxY + minY) / 2;
}
/**
* Returns the depth of the bounding box (Z-axis extent).
*
* @return the depth
* @since 1.15.2
*/
public double z() {
return maxZ - minZ;
}
/**
* Returns the center point of the bounding box as a vector.
*
* @return the center vector
* @since 1.15.2
*/
public @NotNull Vector3f centerPoint() {
return new Vector3f(
(float) (minX + maxX),
(float) (minY + maxY),
(float) (minZ + maxZ)
).div(2F);
}
/**
* Scales the bounding box by a uniform factor.
*
* @param scale the scale factor
* @return the scaled bounding box
* @since 1.15.2
*/
public @NotNull ModelBoundingBox times(double scale) {
return of(
minX * scale,
minY * scale,
minZ * scale,
maxX * scale,
maxY * scale,
maxZ * scale
);
}
/**
* Returns a new bounding box with the same dimensions but centered at the origin (0,0,0).
*
* @return the centered bounding box
* @since 1.15.2
*/
public @NotNull ModelBoundingBox center() {
var center = centerPoint();
return of(
minX - center.x,
minY - center.y,
minZ - center.z,
maxX - center.x,
maxY - center.y,
maxZ - center.z
);
}
/**
* Inverts the X and Z coordinates of the bounding box.
*
* @return the inverted bounding box
* @since 1.15.2
*/
public @NotNull ModelBoundingBox invert() {
return of(
-minX,
minY,
-minZ,
-maxX,
maxY,
-maxZ
);
}
/**
* Rotates the bounding box around its center.
*
* @param quaterniond the rotation quaternion
* @return the rotated bounding box
* @since 1.15.2
*/
public @NotNull ModelBoundingBox rotate(@NotNull Quaterniond quaterniond) {
var centerVec = centerPoint();
return of(
min().sub(centerVec).rotate(quaterniond).add(centerVec),
max().sub(centerVec).rotate(quaterniond).add(centerVec)
);
}
/**
* Returns the minimum corner as a vector.
*
* @return the min vector
* @since 1.15.2
*/
public @NotNull Vector3d min() {
return new Vector3d(minX, minY, minZ);
}
/**
* Returns the maximum corner as a vector.
*
* @return the max vector
* @since 1.15.2
*/
public @NotNull Vector3d max() {
return new Vector3d(maxX, maxY, maxZ);
}
/**
* Calculates the diagonal length in the XZ plane.
*
* @return the XZ diagonal length
* @since 1.15.2
*/
public double lengthZX() {
return Math.sqrt(Math.pow(x(), 2) + Math.pow(z(), 2));
}
/**
* Calculates the full diagonal length of the bounding box.
*
* @return the diagonal length
* @since 1.15.2
*/
public double length() {
return Math.sqrt(Math.pow(x(), 2) + Math.pow(y(), 2) + Math.pow(z(), 2));
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/KeyframeChannel.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.ApiStatus;
/**
* Represents the type of property a keyframe affects.
*
* @since 1.15.2
*/
@ApiStatus.Internal
public enum KeyframeChannel {
/**
* Affects the position of a bone.
* @since 1.15.2
*/
@SerializedName("position")
POSITION,
/**
* Affects the rotation of a bone.
* @since 1.15.2
*/
@SerializedName("rotation")
ROTATION,
/**
* Affects the scale of a bone.
* @since 1.15.2
*/
@SerializedName("scale")
SCALE,
/**
* Represents a timeline for sound or particle effects.
* @since 1.15.2
*/
@SerializedName("timeline")
TIMELINE,
/**
* Represents a sound effect.
* @since 1.15.2
*/
@SerializedName("sound")
SOUND,
/**
* Represents a particle effect.
* @since 1.15.2
*/
@SerializedName("particle")
PARTICLE,
/**
* Represents an unknown or unsupported channel.
* @since 1.15.2
*/
NOT_FOUND
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelAnimation.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import it.unimi.dsi.fastutil.objects.ObjectAVLTreeSet;
import kr.toxicity.model.api.BetterModel;
import kr.toxicity.model.api.animation.AnimationIterator;
import kr.toxicity.model.api.animation.AnimationProgress;
import kr.toxicity.model.api.animation.VectorPoint;
import kr.toxicity.model.api.data.blueprint.AnimationGenerator;
import kr.toxicity.model.api.data.blueprint.BlueprintAnimation;
import kr.toxicity.model.api.data.blueprint.BlueprintAnimator;
import kr.toxicity.model.api.data.blueprint.BlueprintElement;
import kr.toxicity.model.api.script.AnimationScript;
import kr.toxicity.model.api.script.BlueprintScript;
import kr.toxicity.model.api.script.TimeScript;
import kr.toxicity.model.api.util.InterpolationUtil;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import static kr.toxicity.model.api.util.CollectionUtil.associate;
/**
* Represents a raw animation definition from a model file.
*
* This record holds the properties of an animation, such as its name, length, loop mode,
* and the animators that define the keyframes for each bone group.
*
*
* @param name the name of the animation
* @param loop the loop mode (e.g., play_once, loop, hold)
* @param override whether this animation should override others
* @param uuid the unique identifier of the animation
* @param length the total length of the animation in seconds
* @param animators a map of animators, keyed by the UUID of the bone group they affect
* @since 1.15.2
*/
@ApiStatus.Internal
public record ModelAnimation(
@NotNull String name,
@Nullable AnimationIterator.Type loop,
boolean override,
@NotNull String uuid,
float length,
@Nullable Map animators
) {
/**
* Converts this raw animation data into a processed {@link BlueprintAnimation}.
*
* @param context the model loading context
* @param children the list of root blueprint elements
* @return the blueprint animation
* @since 1.15.2
*/
public @NotNull BlueprintAnimation toBlueprint(
@NotNull ModelLoadContext context,
@NotNull List children
) {
var animators = AnimationGenerator.createMovements(length(), children, associate(
animators().entrySet().stream()
.filter(e -> context.availableUUIDs.contains(e.getKey()))
.map(Map.Entry::getValue)
.filter(ModelAnimator::isAvailable)
.map(a -> buildAnimationData(context, a)),
BlueprintAnimator.AnimatorData::name
));
return new BlueprintAnimation(
name(),
loop(),
length(),
override(),
animators,
Optional.ofNullable(animators().get("effects"))
.filter(ModelAnimator::isNotEmpty)
.map(a -> toScript(a, context.placeholder))
.orElseGet(() -> BlueprintScript.fromEmpty(this)),
animators.isEmpty() ? AnimationProgress.emptyStorage(length()) : animators.values()
.iterator()
.next()
.keyframe()
.toEmpty()
);
}
private @NotNull BlueprintScript toScript(@NotNull ModelAnimator animator, @NotNull ModelPlaceholder placeholder) {
var set = new ObjectAVLTreeSet();
set.add(TimeScript.EMPTY);
set.add(TimeScript.EMPTY.time(length()));
animator.stream()
.filter(f -> f.point().hasScript())
.map(d -> AnimationScript.of(Arrays.stream(placeholder.parseVariable(d.point().script()).split("\n"))
.map(BetterModel.platform().scriptManager()::build)
.filter(Objects::nonNull)
.toList())
.time(d.time()))
.forEach(set::add);
var array = new TimeScript[set.size()];
var before = 0F;
var i = 0;
for (TimeScript timeScript : set) {
var t = timeScript.time();
array[i++] = timeScript.time(InterpolationUtil.roundTime(t - before));
before = t;
}
return new BlueprintScript(
name(),
loop(),
length(),
List.of(array)
);
}
/**
* Returns the loop mode of the animation.
*
* @return the loop mode, defaulting to {@link AnimationIterator.Type#PLAY_ONCE} if null
* @since 1.15.2
*/
@Override
public @NotNull AnimationIterator.Type loop() {
return loop != null ? loop : AnimationIterator.Type.PLAY_ONCE;
}
/**
* Returns the map of animators for this animation.
*
* @return the animators, or an empty map if null
* @since 1.15.2
*/
@Override
@NotNull
public Map animators() {
return animators != null ? animators : Collections.emptyMap();
}
@NotNull
private BlueprintAnimator.AnimatorData buildAnimationData(@NotNull ModelLoadContext context, @NotNull ModelAnimator animator) {
var position = new ArrayList();
var rotation = new ArrayList();
var scale = new ArrayList();
var version = context.meta.formatVersion();
animator.stream().filter(keyframe -> keyframe.time() <= length()).forEach(keyframe -> {
switch (keyframe.channel()) {
case POSITION -> position.add(keyframe.point(context, version::convertAnimationPosition));
case ROTATION -> rotation.add(keyframe.point(context, version::convertAnimationRotation));
case SCALE -> scale.add(keyframe.point(context, version::convertAnimationScale));
}
});
return new BlueprintAnimator.AnimatorData(
animator.name(),
position,
scale,
rotation,
animator.rotationGlobal()
);
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelAnimator.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import com.google.gson.annotations.SerializedName;
import kr.toxicity.model.api.bone.BoneName;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;
/**
* Represents a raw animator from a model file, containing a set of keyframes for a specific bone group.
*
* @param name the name of the bone group this animator affects
* @param keyframes the list of keyframes
* @param _rotationGlobal whether the rotation is applied globally (true) or locally (false/null)
* @since 1.15.2
*/
@ApiStatus.Internal
public record ModelAnimator(
@Nullable BoneName name,
@Nullable List keyframes,
@Nullable @SerializedName("rotation_global") Boolean _rotationGlobal
) {
/**
* Checks if the rotation should be applied globally.
*
* @return true if rotation is global, false otherwise
* @since 1.15.2
*/
public boolean rotationGlobal() {
return Boolean.TRUE.equals(_rotationGlobal);
}
/**
* Checks if this animator is valid and has keyframes.
*
* @return true if available, false otherwise
* @since 1.15.2
*/
public boolean isAvailable() {
return name != null && isNotEmpty();
}
/**
* Checks if this animator contains any keyframes.
*
* @return true if not empty, false otherwise
* @since 1.15.2
*/
public boolean isNotEmpty() {
return !keyframes().isEmpty();
}
/**
* Returns the name of the bone group this animator affects.
*
* @return the name of the bone group
*/
@Override
public @NotNull BoneName name() {
return Objects.requireNonNull(name);
}
/**
* Returns the list of keyframes for this animator. If no keyframes are present, an empty list is returned.
*
* @return the list of keyframes
*/
@Override
public @NotNull List keyframes() {
return keyframes != null ? keyframes : Collections.emptyList();
}
/**
* Returns a sorted stream of valid keyframes.
*
* @return the stream of keyframes
* @since 1.15.2
*/
public @NotNull Stream stream() {
return keyframes().stream()
.filter(ModelKeyframe::hasPoint)
.sorted();
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelData.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.annotations.SerializedName;
import kr.toxicity.model.api.BetterModel;
import kr.toxicity.model.api.bone.BoneName;
import kr.toxicity.model.api.data.Float2;
import kr.toxicity.model.api.data.Float3;
import kr.toxicity.model.api.data.Float4;
import kr.toxicity.model.api.data.blueprint.BlueprintAnimation;
import kr.toxicity.model.api.data.blueprint.ModelBlueprint;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collections;
import java.util.List;
import static kr.toxicity.model.api.util.CollectionUtil.*;
/**
* Represents the raw data structure of a model file, typically parsed from a .bbmodel JSON file.
*
* This record holds all the top-level components of a BlockBench model, including metadata, elements, textures, and animations.
* It serves as the initial data container before being processed into a {@link ModelBlueprint}.
*
*
* @param meta the metadata of the model
* @param resolution the texture resolution
* @param elements the list of cube/mesh elements
* @param outliner the hierarchical structure of the model
* @param textures the list of textures used in the model
* @param animations the list of animations
* @param groups the list of groups (used in BlockBench 5.0.0+)
* @param placeholder the animation variable placeholders
* @since 1.15.2
*/
@ApiStatus.Internal
public record ModelData(
@NotNull ModelMeta meta,
@NotNull ModelResolution resolution,
@NotNull List elements,
@NotNull List outliner,
@NotNull List textures,
@Nullable List animations,
@Nullable List groups,
@Nullable @SerializedName("animation_variable_placeholders") ModelPlaceholder placeholder
) {
/**
* The GSON parser configured for deserializing model data.
* @since 1.15.2
*/
public static final Gson GSON = new GsonBuilder()
.registerTypeAdapter(Float2.class, Float2.PARSER)
.registerTypeAdapter(Float3.class, Float3.PARSER)
.registerTypeAdapter(Float4.class, Float4.PARSER)
.registerTypeAdapter(BoneName.class, BoneName.PARSER)
.registerTypeAdapter(ModelMeta.class, ModelMeta.PARSER)
.registerTypeAdapter(ModelOutliner.class, ModelOutliner.PARSER)
.registerTypeAdapter(ModelPlaceholder.class, ModelPlaceholder.PARSER)
.registerTypeAdapter(ModelElement.class, ModelElement.PARSER)
.create();
/**
* Converts this raw model data into a processed {@link ModelBlueprint}.
*
* @param name the name to assign to the blueprint
* @return the result of the loading process, containing the blueprint and any errors
* @since 1.15.2
*/
public @NotNull ModelLoadResult loadBlueprint(@NotNull String name) {
return loadBlueprint(name, BetterModel.config().enableStrictLoading());
}
/**
* Converts this raw model data into a processed {@link ModelBlueprint} with a specific loading mode.
*
* @param name the name to assign to the blueprint
* @param strict whether to use strict loading mode (fail on unsupported features)
* @return the result of the loading process, containing the blueprint and any errors
* @since 1.15.2
*/
public @NotNull ModelLoadResult loadBlueprint(@NotNull String name, boolean strict) {
var context = new ModelLoadContext(
name,
placeholder(),
meta(),
associate(elements(), ModelElement::uuid),
associate(groups(), ModelGroup::uuid),
mapToSet(outliner().stream().flatMap(ModelOutliner::flatten), ModelOutliner::uuid),
strict
);
var group = mapToList(outliner(), outliner -> outliner.toBlueprint(context));
return new ModelLoadResult(
new ModelBlueprint(
context.name,
resolution(),
mapToList(textures(), texture -> texture.toBlueprint(context)),
group,
associate(animations().stream().map(raw -> raw.toBlueprint(context, group)), BlueprintAnimation::name)
),
context.errors
);
}
/**
* Asserts that the model does not contain any unsupported element types.
*
* @throws RuntimeException if an unsupported element is found
* @since 1.15.2
*/
public void assertSupported() {
elements().stream()
.filter(e -> !e.isSupported())
.findFirst()
.ifPresent(e -> {
throw new RuntimeException("This model file has unsupported element type: " + e.type());
});
}
/**
* Returns the animation variable placeholders, or an empty placeholder if none are defined.
*
* @return the animation variable placeholders
* @since 1.15.2
*/
@Override
public @NotNull ModelPlaceholder placeholder() {
return placeholder != null ? placeholder : ModelPlaceholder.EMPTY;
}
/**
* Returns the list of animations, or an empty list if none are defined.
*
* @return the list of animations
* @since 1.15.2
*/
@Override
@NotNull
public List animations() {
return animations != null ? animations : Collections.emptyList();
}
/**
* Returns the list of groups, or an empty list if none are defined.
*
* @return the list of groups
* @since 1.15.2
*/
@Override
public @NotNull List groups() {
return groups != null ? groups : Collections.emptyList();
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelDatapoint.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import com.google.gson.JsonPrimitive;
import kr.toxicity.model.api.BetterModel;
import kr.toxicity.model.api.util.function.Float2FloatConstantFunction;
import kr.toxicity.model.api.util.function.Float2FloatFunction;
import kr.toxicity.model.api.util.function.FloatFunction;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.joml.Vector3f;
import java.util.Objects;
/**
* Represents a single data point within a keyframe, which can be a static value or a Molang script.
*
* This record holds the raw JSON values for x, y, and z coordinates, or a script string.
*
*
* @param x the x-coordinate value or script
* @param y the y-coordinate value or script
* @param z the z-coordinate value or script
* @param script the script string (used for sound/particle effects)
* @since 1.15.2
*/
@ApiStatus.Internal
public record ModelDatapoint(
@Nullable JsonPrimitive x,
@Nullable JsonPrimitive y,
@Nullable JsonPrimitive z,
@Nullable String script
) {
/**
* Checks if this data point contains a script.
*
* @return true if a script is present, false otherwise
* @since 1.15.2
*/
public boolean hasScript() {
return script != null;
}
/**
* Returns the script string.
*
* @return the script
* @throws NullPointerException if no script is present
* @since 1.15.2
*/
@Override
public @NotNull String script() {
return Objects.requireNonNull(script);
}
/**
* Converts this data point into a function that returns a {@link Vector3f}.
*
* If the data point contains static values, it returns a constant function.
* If it contains Molang expressions, it compiles them into a function that evaluates the expressions.
*
*
* @param context the model loading context
* @return a function to get the vector value
* @since 1.15.2
*/
public @NotNull FloatFunction toFunction(@NotNull ModelLoadContext context) {
var xb = build(x, context);
var yb = build(y, context);
var zb = build(z, context);
if (xb instanceof Float2FloatConstantFunction(float xc)
&& yb instanceof Float2FloatConstantFunction(float yc)
&& zb instanceof Float2FloatConstantFunction(float zc)
) {
return FloatFunction.of(new Vector3f(xc, yc, zc));
} else {
return f -> new Vector3f(
xb.applyAsFloat(f),
yb.applyAsFloat(f),
zb.applyAsFloat(f)
);
}
}
private static @NotNull Float2FloatFunction build(@Nullable JsonPrimitive primitive, @NotNull ModelLoadContext context) {
if (primitive == null) return Float2FloatFunction.ZERO;
if (primitive.isNumber()) return Float2FloatFunction.of(primitive.getAsFloat());
var string = primitive.getAsString().trim();
if (string.isEmpty()) return Float2FloatFunction.ZERO;
try {
return Float2FloatFunction.of(Float.parseFloat(string));
} catch (NumberFormatException ignored) {
return context.trySupply(
() -> BetterModel.platform().evaluator().compile(context.placeholder.parseVariable(string)),
error -> new ModelLoadContext.Fallback<>(
Float2FloatFunction.ZERO,
"Cannot parse this datapoint: " + primitive + ", reason: " + error.getMessage()
)
);
}
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelElement.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import com.google.gson.JsonDeserializer;
import com.google.gson.annotations.SerializedName;
import kr.toxicity.model.api.bone.BoneName;
import kr.toxicity.model.api.data.Float2;
import kr.toxicity.model.api.data.Float3;
import kr.toxicity.model.api.data.blueprint.BlueprintElement;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
/**
* Represents a raw element within a model file.
*
* This interface is a sealed type that permits specific implementations for different element types
* found in BlockBench models, such as cubes, locators, null objects, and cameras.
*
*
* @since 1.15.2
*/
@ApiStatus.Internal
public sealed interface ModelElement {
/**
* The type identifier for a null object element.
* @since 2.2.1
*/
String NULL_OBJECT = "null_object";
/**
* The type identifier for a locator element.
* @since 2.2.1
*/
String LOCATOR = "locator";
/**
* The type identifier for a camera element.
* @since 2.2.1
*/
String CAMERA = "camera";
/**
* The type identifier for a cube element.
* @since 2.2.1
*/
String CUBE = "cube";
/**
* The type identifier for a mesh element.
* @since 3.0.0
*/
String MESH = "mesh";
/**
* A JSON deserializer that automatically dispatches to the correct {@link ModelElement} implementation based on the "type" field.
* @since 1.15.2
*/
JsonDeserializer PARSER = (json, _, context) -> {
var t = json.getAsJsonObject().getAsJsonPrimitive("type");
var select = t != null ? t.getAsString() : CUBE;
return switch (select) {
case NULL_OBJECT -> context.deserialize(json, NullObject.class);
case LOCATOR -> context.deserialize(json, Locator.class);
case CAMERA -> context.deserialize(json, Camera.class);
case CUBE -> context.deserialize(json, Cube.class);
case MESH -> context.deserialize(json, Mesh.class);
default -> new Unsupported(select);
};
};
/**
* Returns the unique identifier (UUID) of this element.
*
* @return the UUID string
* @since 1.15.2
*/
@NotNull String uuid();
/**
* Returns the type identifier of this element (e.g., "cube", "locator").
*
* @return the type string
* @since 1.15.2
*/
@NotNull String type();
/**
* Converts this raw element into a processed {@link BlueprintElement}.
*
* @return the blueprint element
* @since 1.15.2
*/
@NotNull BlueprintElement toBlueprint();
/**
* Checks if this element type is supported by the engine.
*
* @return true if supported, false otherwise
* @since 1.15.2
*/
default boolean isSupported() {
return true;
}
/**
* Represents a locator element, used for positioning attachments or particles.
*
* @param name the name of the locator
* @param uuid the UUID of the locator
* @param position the position of the locator
* @since 1.15.2
*/
record Locator(
@NotNull String name,
@NotNull String uuid,
@Nullable Float3 position
) implements ModelElement {
@Override
public @NotNull String type() {
return LOCATOR;
}
/**
* Returns the position of the locator.
*
* @return the position, or {@link Float3#ZERO} if not specified
* @since 1.15.2
*/
@Override
public @NotNull Float3 position() {
return position != null ? position : Float3.ZERO;
}
@Override
public @NotNull BlueprintElement toBlueprint() {
return new BlueprintElement.Locator(
UUID.fromString(uuid),
BoneName.of(name()),
position()
);
}
}
/**
* Represents a camera element (currently used as a placeholder).
*
* @param uuid the UUID of the camera
* @since 1.15.2
*/
record Camera(
@NotNull String uuid
) implements ModelElement {
@Override
public @NotNull String type() {
return CAMERA;
}
@Override
public @NotNull BlueprintElement toBlueprint() {
return new BlueprintElement.Camera(
UUID.fromString(uuid)
);
}
}
/**
* Represents a null object, often used for grouping or IK targets.
*
* @param name the name of the null object
* @param uuid the UUID of the null object
* @param ikTarget the UUID of the IK target, if any
* @param ikSource the UUID of the IK source, if any
* @param position the position of the null object
* @since 1.15.2
*/
record NullObject(
@NotNull String name,
@NotNull String uuid,
@Nullable @SerializedName("ik_target") String ikTarget,
@Nullable @SerializedName("ik_source") String ikSource,
@Nullable Float3 position
) implements ModelElement {
@Override
public @NotNull String type() {
return NULL_OBJECT;
}
/**
* Returns the position of the null object.
*
* @return the position, or {@link Float3#ZERO} if not specified
* @since 1.15.2
*/
@Override
public @NotNull Float3 position() {
return position != null ? position : Float3.ZERO;
}
@Override
public @NotNull BlueprintElement toBlueprint() {
return new BlueprintElement.NullObject(
UUID.fromString(uuid),
BoneName.of(name()),
Optional.ofNullable(ikTarget())
.filter(str -> !str.isEmpty())
.map(UUID::fromString)
.orElse(null),
Optional.ofNullable(ikSource())
.filter(str -> !str.isEmpty())
.map(UUID::fromString)
.orElse(null),
position()
);
}
}
/**
* Represents an unsupported element type.
*
* @param type the unsupported type string
* @since 1.15.2
*/
record Unsupported(@NotNull String type) implements ModelElement {
@Override
public @NotNull String uuid() {
throw new UnsupportedOperationException(type());
}
@Override
public boolean isSupported() {
return false;
}
@Override
public @NotNull BlueprintElement toBlueprint() {
throw new UnsupportedOperationException(type());
}
}
/**
* Represents a standard cube element.
*
* @param name the name of the cube
* @param uuid the UUID of the cube
* @param from the starting coordinate (min corner)
* @param to the ending coordinate (max corner)
* @param inflate the inflation value (size increase)
* @param rotation the rotation of the cube
* @param origin the pivot point (origin) of the cube
* @param faces the UV mapping for the faces
* @param lightEmission the light emission level (0-15)
* @param _visibility the visibility state (null means visible)
* @since 1.15.2
*/
record Cube(
@NotNull String name,
@NotNull String uuid,
@Nullable Float3 from,
@Nullable Float3 to,
float inflate,
@Nullable Float3 rotation,
@NotNull Float3 origin,
@Nullable ModelFace faces,
@SerializedName("light_emission") int lightEmission,
@SerializedName("visibility") @Nullable Boolean _visibility
) implements ModelElement {
@Override
public @NotNull String type() {
return CUBE;
}
/**
* Returns the starting coordinate (min corner).
*
* @return the from vector, or {@link Float3#ZERO} if not specified
* @since 1.15.2
*/
@Override
public @NotNull Float3 from() {
return from != null ? from : Float3.ZERO;
}
/**
* Returns the ending coordinate (max corner).
*
* @return the to vector, or {@link Float3#ZERO} if not specified
* @since 1.15.2
*/
@Override
public @NotNull Float3 to() {
return to != null ? to : Float3.ZERO;
}
/**
* Checks if the cube is visible.
*
* @return true if visible, false otherwise
* @since 1.15.2
*/
public boolean visibility() {
return !Boolean.FALSE.equals(_visibility);
}
/**
* Returns the rotation of the cube.
*
* @return the rotation, or {@link Float3#ZERO} if not specified
* @since 1.15.2
*/
@Override
public @NotNull Float3 rotation() {
return rotation != null ? rotation : Float3.ZERO;
}
/**
* Returns the light emission level of the cube.
*
* @return the light emission level (0-15)
* @since 2.1.0
*/
@Override
public int lightEmission() {
return name().toLowerCase().contains("glow") ? 15 : lightEmission;
}
@Override
public @NotNull BlueprintElement toBlueprint() {
return new BlueprintElement.Cube(
name(),
from(),
to(),
inflate(),
rotation(),
origin(),
faces(),
Optional.of(lightEmission()).filter(i -> i > 0).orElse(null),
visibility()
);
}
}
/**
* Represents a mesh element, allowing for complex geometry beyond simple cubes.
*
* @param uuid the UUID of the mesh
* @param origin the pivot point (origin) of the mesh
* @param rotation the rotation of the mesh
* @param vertices a map of vertex identifiers to their 3D positions
* @param faces a map of face identifiers to their face data
* @param _visibility the visibility state (null means visible)
* @since 3.0.0
*/
record Mesh(
@NotNull String uuid,
@Nullable Float3 origin,
@Nullable Float3 rotation,
@NotNull Map vertices,
@NotNull Map faces,
@SerializedName("visibility") @Nullable Boolean _visibility
) implements ModelElement {
/**
* Returns the pivot point (origin) of the mesh.
*
* @return the origin vector, or {@link Float3#ZERO} if not specified
* @since 3.0.0
*/
@Override
public @NotNull Float3 origin() {
return origin != null ? origin : Float3.ZERO;
}
/**
* Returns the rotation of the mesh.
*
* @return the rotation vector, or {@link Float3#ZERO} if not specified
* @since 3.0.0
*/
@Override
public @NotNull Float3 rotation() {
return rotation != null ? rotation : Float3.ZERO;
}
/**
* Returns the type identifier of this element.
*
* @return {@link #MESH}
* @since 3.0.0
*/
@Override
public @NotNull String type() {
return MESH;
}
public boolean visibility() {
return !Boolean.FALSE.equals(_visibility);
}
@Override
public @NotNull BlueprintElement toBlueprint() {
return new BlueprintElement.Mesh(
origin(),
rotation(),
faces.values()
.stream()
.map(face -> new BlueprintElement.Mesh.Face(
face.vertices.stream()
.map(n -> new BlueprintElement.Mesh.Point(
Objects.requireNonNull(vertices.get(n)),
Objects.requireNonNull(face.uv.get(n))
))
.toList(),
face.texture
))
.toList(),
visibility()
);
}
/**
* Represents a single face of a mesh.
*
* @param uv a map of vertex identifiers to their UV coordinates
* @param vertices a set of vertex identifiers that form this face
* @param texture the index of the texture used by this face
* @since 3.0.0
*/
public record Face(@NotNull Map uv, @NotNull Set vertices, int texture) {}
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelFace.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import com.google.gson.JsonObject;
import kr.toxicity.model.api.data.blueprint.BlueprintLoadContext;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.stream.IntStream;
/**
* Represents the UV mappings for all six faces of a cube element.
*
* @param north the UV mapping for the north face
* @param east the UV mapping for the east face
* @param south the UV mapping for the south face
* @param west the UV mapping for the west face
* @param up the UV mapping for the up face
* @param down the UV mapping for the down face
* @since 1.15.2
*/
@ApiStatus.Internal
public record ModelFace(
@NotNull ModelUV north,
@NotNull ModelUV east,
@NotNull ModelUV south,
@NotNull ModelUV west,
@NotNull ModelUV up,
@NotNull ModelUV down
) {
/**
* Converts the face UV data to a JSON object for the Minecraft model file.
*
* Only faces with a defined texture will be included in the output.
*
*
* @param parent the parent model blueprint, used for texture resolution
* @param tint the tint index to apply
* @return the generated JSON object
* @since 1.15.2
*/
public @NotNull JsonObject toJson(@NotNull BlueprintLoadContext parent, int tint) {
var object = new JsonObject();
JsonObject add;
if ((add = north.toJson(parent, tint)) != null) object.add("north", add);
if ((add = east.toJson(parent, tint)) != null) object.add("east", add);
if ((add = south.toJson(parent, tint)) != null) object.add("south", add);
if ((add = west.toJson(parent, tint)) != null) object.add("west", add);
if ((add = up.toJson(parent, tint)) != null) object.add("up", add);
if ((add = down.toJson(parent, tint)) != null) object.add("down", add);
return object;
}
/**
* Checks if any face has a texture defined.
*
* @return true if at least one face has a texture, false otherwise
* @since 1.15.2
*/
public boolean hasTexture() {
return north.hasTexture()
|| east.hasTexture()
|| south.hasTexture()
|| west.hasTexture()
|| up.hasTexture()
|| down.hasTexture();
}
public @NotNull IntStream textureIndex() {
var builder = IntStream.builder();
if (north.hasTexture()) builder.add(north.textureIndex());
if (east.hasTexture()) builder.add(east.textureIndex());
if (south.hasTexture()) builder.add(south.textureIndex());
if (west.hasTexture()) builder.add(west.textureIndex());
if (up.hasTexture()) builder.add(up.textureIndex());
if (down.hasTexture()) builder.add(down.textureIndex());
return builder.build();
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelGroup.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import com.google.gson.annotations.SerializedName;
import kr.toxicity.model.api.data.Float3;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Represents a group definition in the raw model data.
*
* Groups are used to organize elements and other groups hierarchically.
* This record corresponds to the group structure found in newer BlockBench versions (>= 5.0.0).
*
*
* @param name the name of the group
* @param uuid the unique identifier of the group
* @param origin the pivot point (origin) of the group
* @param rotation the rotation of the group
* @param lightEmission the light emission level (0-15)
* @param _visibility the visibility state of the group (null means visible)
* @since 1.15.2
*/
public record ModelGroup(
@NotNull String name,
@NotNull String uuid,
@Nullable Float3 origin,
@Nullable Float3 rotation,
@SerializedName("light_emission") int lightEmission,
@Nullable @SerializedName("visibility") Boolean _visibility
) {
/**
* Returns the origin (pivot point) of the group.
*
* @return the origin, or {@link Float3#ZERO} if not specified
* @since 1.15.2
*/
@Override
@NotNull
public Float3 origin() {
return origin != null ? origin : Float3.ZERO;
}
/**
* Returns the rotation of the group.
*
* @return the rotation, or {@link Float3#ZERO} if not specified
* @since 1.15.2
*/
@Override
@NotNull
public Float3 rotation() {
return rotation != null ? rotation : Float3.ZERO;
}
/**
* Checks if the group is visible.
*
* @return true if visible, false otherwise
* @since 1.15.2
*/
public boolean visibility() {
return !Boolean.FALSE.equals(_visibility);
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelKeyframe.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import com.google.gson.annotations.SerializedName;
import kr.toxicity.model.api.animation.Timed;
import kr.toxicity.model.api.animation.VectorPoint;
import kr.toxicity.model.api.data.Float3;
import kr.toxicity.model.api.util.interpolator.VectorInterpolator;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.joml.Vector3f;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
/**
* Represents a single keyframe in an animation timeline.
*
* A keyframe defines the state of a bone (position, rotation, or scale) at a specific time,
* along with interpolation data (linear, catmull-rom, bezier) to smooth transitions.
*
*
* @param channel the channel this keyframe affects (position, rotation, scale)
* @param dataPoints the list of data points (values) for this keyframe
* @param bezierLeftTime the time offset for the left bezier handle
* @param bezierLeftValue the value offset for the left bezier handle
* @param bezierRightTime the time offset for the right bezier handle
* @param bezierRightValue the value offset for the right bezier handle
* @param interpolation the interpolation type (e.g., linear, catmullrom, bezier)
* @param time the time of the keyframe in seconds
* @since 1.15.2
*/
@ApiStatus.Internal
public record ModelKeyframe(
@Nullable KeyframeChannel channel,
@SerializedName("data_points") @NotNull List dataPoints,
@SerializedName("bezier_left_time") @Nullable Float3 bezierLeftTime,
@SerializedName("bezier_left_value") @Nullable Float3 bezierLeftValue,
@SerializedName("bezier_right_time") @Nullable Float3 bezierRightTime,
@SerializedName("bezier_right_value") @Nullable Float3 bezierRightValue,
@Nullable VectorInterpolator interpolation,
float time
) implements Timed {
/**
* Checks if this keyframe contains any data points.
*
* @return true if data points exist, false otherwise
* @since 1.15.2
*/
public boolean hasPoint() {
return !dataPoints.isEmpty();
}
/**
* Returns the first data point in the list.
*
* @return the first data point
* @throws java.util.NoSuchElementException if the list is empty
* @since 1.15.2
*/
public @NotNull ModelDatapoint point() {
return dataPoints.getFirst();
}
/**
* Converts this keyframe into a processed {@link VectorPoint}.
*
* @param context the model loading context
* @param function a transformation function to apply to the vector values (e.g., coordinate conversion)
* @return the vector point
* @since 1.15.2
*/
public @NotNull VectorPoint point(@NotNull ModelLoadContext context, @NotNull Function function) {
return new VectorPoint(
point().toFunction(context).map(function).memoize(),
time(),
new VectorPoint.BezierConfig(
Optional.ofNullable(bezierLeftTime).map(Float3::toVector).orElse(null),
Optional.ofNullable(bezierLeftValue).map(Float3::toVector).map(function).orElse(null),
Optional.ofNullable(bezierRightTime).map(Float3::toVector).orElse(null),
Optional.ofNullable(bezierRightValue).map(Float3::toVector).map(function).orElse(null)
),
interpolation()
);
}
/**
* Returns the interpolation type for this keyframe.
*
* @return the interpolation type, defaulting to {@link VectorInterpolator#LINEAR} if null
* @since 1.15.2
*/
@Override
public @NotNull VectorInterpolator interpolation() {
return interpolation != null ? interpolation : VectorInterpolator.LINEAR;
}
/**
* Returns the channel this keyframe affects.
*
* @return the channel, defaulting to {@link KeyframeChannel#NOT_FOUND} if null
* @since 1.15.2
*/
@Override
public @NotNull KeyframeChannel channel() {
return channel != null ? channel : KeyframeChannel.NOT_FOUND;
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelLoadContext.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.*;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Holds the context and state during the model loading process.
*
* This class provides access to all parts of the raw model data and accumulates errors
* that occur during processing. It also controls the loading mode (strict or lenient).
*
*
* @since 1.15.2
*/
@RequiredArgsConstructor
@ApiStatus.Internal
public final class ModelLoadContext {
final @NotNull String name;
final @NotNull ModelPlaceholder placeholder;
final @NotNull ModelMeta meta;
final @NotNull Map elements;
final @NotNull Map groups;
final @NotNull Set availableUUIDs;
private final boolean strict;
private final List _errors = new ArrayList<>();
final List errors = Collections.unmodifiableList(_errors);
/**
* Tries to execute a supplier, catching exceptions in lenient mode.
*
* @param supplier the supplier to execute
* @param fallbackFunction a function to provide a fallback value and error message on exception
* @param the return type
* @return the result of the supplier or the fallback value
* @since 1.15.2
*/
@NotNull T trySupply(@NotNull Supplier supplier, @NotNull Function> fallbackFunction) {
if (strict) return supplier.get();
try {
return supplier.get();
} catch (Exception e) {
var fallback = fallbackFunction.apply(e);
_errors.add(fallback.message);
return fallback.value;
}
}
/**
* Represents a fallback value and an associated error message.
*
* @param value the fallback value
* @param message the error message
* @param the type of the value
* @since 1.15.2
*/
record Fallback(@NotNull T value, @NotNull String message) {}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelLoadResult.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import kr.toxicity.model.api.data.blueprint.ModelBlueprint;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Unmodifiable;
import java.util.List;
/**
* Represents the result of loading and processing a raw model file.
*
* This record contains the successfully created {@link ModelBlueprint} and a list of any errors
* or warnings that occurred during the loading process.
*
*
* @param blueprint the processed model blueprint
* @param errors a list of error or warning messages generated during loading
* @since 1.15.2
*/
public record ModelLoadResult(@NotNull ModelBlueprint blueprint, @NotNull @Unmodifiable List errors) {
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelMeta.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import com.google.gson.JsonDeserializer;
import kr.toxicity.model.api.util.MathUtil;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.joml.Vector3f;
import org.semver4j.Semver;
import java.util.Arrays;
import java.util.Objects;
/**
* Represents metadata about the model file, specifically the format version.
*
* This record is used to handle differences in coordinate systems and animation data between different BlockBench versions.
*
*
* @param formatVersion the detected format version of the model file
* @since 1.15.2
*/
public record ModelMeta(
@NotNull FormatVersion formatVersion
) {
/**
* A JSON deserializer for parsing {@link ModelMeta} from the "meta" object in a .bbmodel file.
* @since 1.15.2
*/
public static final JsonDeserializer PARSER = (json, _, _) -> new ModelMeta(
FormatVersion.find(Objects.requireNonNull(Semver.coerce(json.getAsJsonObject().getAsJsonPrimitive("format_version").getAsString())).getMajor())
);
/**
* Enumerates supported BlockBench format versions and their specific coordinate conversions.
* @since 1.15.2
*/
@RequiredArgsConstructor
public enum FormatVersion {
/**
* BlockBench version 5.0.0 and later.
* @since 1.15.2
*/
BLOCKBENCH_5(5) {
@Override
public @NotNull Vector3f convertAnimationRotation(@NotNull Vector3f vector) {
vector.x = -vector.x;
vector.z = -vector.z;
return vector;
}
@Override
public @NotNull Vector3f convertAnimationPosition(@NotNull Vector3f vector) {
vector.x = -vector.x;
vector.z = -vector.z;
return vector.div(MathUtil.MODEL_TO_BLOCK_MULTIPLIER);
}
},
/**
* Legacy BlockBench versions (pre-5.0.0).
* @since 1.15.2
*/
BLOCKBENCH_LEGACY(0) {
@Override
public @NotNull Vector3f convertAnimationRotation(@NotNull Vector3f vector) {
vector.y = -vector.y;
vector.z = -vector.z;
return vector;
}
@Override
public @NotNull Vector3f convertAnimationPosition(@NotNull Vector3f vector) {
vector.z = -vector.z;
return vector.div(MathUtil.MODEL_TO_BLOCK_MULTIPLIER);
}
},
;
private final int major;
/**
* Finds the appropriate format version based on the major version number.
*
* @param major the major version number
* @return the matching format version
* @throws java.util.NoSuchElementException if no matching version is found
* @since 1.15.2
*/
public static @NotNull FormatVersion find(int major) {
return Arrays.stream(values())
.filter(v -> v.major <= major)
.findFirst()
.orElseThrow();
}
/**
* Converts animation rotation values to the engine's coordinate system.
*
* @param vector the raw rotation vector
* @return the converted rotation vector
* @since 1.15.2
*/
public abstract @NotNull Vector3f convertAnimationRotation(@NotNull Vector3f vector);
/**
* Converts animation position values to the engine's coordinate system.
*
* @param vector the raw position vector
* @return the converted position vector
* @since 1.15.2
*/
public abstract @NotNull Vector3f convertAnimationPosition(@NotNull Vector3f vector);
/**
* Converts animation scale values to the engine's format (relative to 1.0).
*
* @param vector the raw scale vector
* @return the converted scale vector
* @since 1.15.2
*/
public @NotNull Vector3f convertAnimationScale(@NotNull Vector3f vector) {
vector.sub(1F, 1F, 1F);
return vector;
}
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelOutliner.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import com.google.gson.JsonDeserializer;
import kr.toxicity.model.api.bone.BoneName;
import kr.toxicity.model.api.data.blueprint.BlueprintElement;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Unmodifiable;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Stream;
import static kr.toxicity.model.api.util.CollectionUtil.filterIsInstance;
import static kr.toxicity.model.api.util.CollectionUtil.mapToList;
/**
* Represents the hierarchical structure (outliner) of a model.
*
* The outliner defines the parent-child relationships between groups and elements (cubes, locators, etc.).
* It can be either a direct reference to an element (UUID string) or a tree node (Group) containing children.
*
*
* @since 1.15.2
*/
@ApiStatus.Internal
public sealed interface ModelOutliner {
/**
* A JSON deserializer that parses the outliner structure.
*
* It distinguishes between leaf nodes (UUID strings) and branch nodes (Groups with children).
*
* @since 1.15.2
*/
JsonDeserializer PARSER = (json, _, context) -> {
if (json.isJsonPrimitive()) return new Reference(json.getAsString());
else if (json.isJsonObject()) {
var children = json.getAsJsonObject().getAsJsonArray("children");
return new Tree(
context.deserialize(json, ModelGroup.class),
children.asList()
.stream()
.map(child -> (ModelOutliner) context.deserialize(child, ModelOutliner.class))
.toList()
);
}
else throw new RuntimeException();
};
/**
* Converts this outliner node into a processed {@link BlueprintElement}.
*
* @param context the model loading context
* @return the blueprint element
* @since 1.15.2
*/
@NotNull BlueprintElement toBlueprint(@NotNull ModelLoadContext context);
/**
* Flattens the outliner tree into a stream of all nodes.
*
* @return a stream of all outliner nodes
* @since 1.15.2
*/
@NotNull Stream flatten();
/**
* Returns the UUID of this outliner node.
*
* @return the UUID string
* @since 1.15.2
*/
@NotNull String uuid();
/**
* Represents a leaf node in the outliner, referencing a specific element by UUID.
*
* @param uuid the UUID of the referenced element
* @since 1.15.2
*/
record Reference(@NotNull String uuid) implements ModelOutliner {
@Override
public @NotNull BlueprintElement toBlueprint(@NotNull ModelLoadContext context) {
return Objects.requireNonNull(context.elements.get(uuid())).toBlueprint();
}
@Override
public @NotNull Stream flatten() {
return Stream.of(this);
}
}
/**
* Represents a branch node (Group) in the outliner, containing child nodes.
*
* @param group the group definition
* @param children the list of child outliner nodes
* @since 1.15.2
*/
record Tree(
@NotNull ModelGroup group,
@NotNull @Unmodifiable List children
) implements ModelOutliner {
@Override
public @NotNull BlueprintElement toBlueprint(@NotNull ModelLoadContext context) {
var child = mapToList(children, c -> c.toBlueprint(context));
var filtered = filterIsInstance(child, BlueprintElement.Cube.class).toList();
var selectedGroup = context.groups.getOrDefault(uuid(), group);
return new BlueprintElement.Group(
UUID.fromString(selectedGroup.uuid()),
BoneName.of(selectedGroup.name()),
selectedGroup.origin(),
selectedGroup.rotation().invertXZ(),
child,
filtered.isEmpty() ? selectedGroup.visibility() : filtered.stream().anyMatch(BlueprintElement.Cube::visibility)
);
}
@Override
public @NotNull Stream flatten() {
return children.isEmpty() ? Stream.of(this) : Stream.concat(
Stream.of(this),
children.stream().flatMap(ModelOutliner::flatten)
);
}
@Override
public @NotNull String uuid() {
return group.uuid();
}
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelPlaceholder.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2025 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import com.google.gson.JsonDeserializer;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Unmodifiable;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
import static kr.toxicity.model.api.util.CollectionUtil.associate;
/**
* Represents animation variable placeholders defined in a model file.
*
* These placeholders allow for defining reusable values or expressions that can be referenced within Molang scripts in animations.
*
*
* @param variables a map of placeholder variable names to their string values
* @since 1.15.2
*/
public record ModelPlaceholder(
@NotNull @Unmodifiable Map variables
) {
/**
* An empty placeholder instance with no variables.
* @since 1.15.2
*/
public static final ModelPlaceholder EMPTY = new ModelPlaceholder(Collections.emptyMap());
/**
* A JSON deserializer for parsing placeholders from a multi-line string.
* @since 1.15.2
*/
public static final JsonDeserializer PARSER = (json, _, _) -> new ModelPlaceholder(associate(
Arrays.stream(json.getAsString().split("\n"))
.map(line -> line.split("=", 2))
.filter(array -> array.length == 2),
array -> array[0].trim(),
array -> array[1].trim()
));
/**
* Replaces all placeholder variables in a given expression with their corresponding values.
*
* @param expression the expression containing placeholders
* @return the expression with placeholders substituted
* @since 1.15.2
*/
public @NotNull String parseVariable(@NotNull String expression) {
for (var entry : variables.entrySet()) {
expression = expression.replace(entry.getKey(), entry.getValue());
}
return expression;
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelResolution.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import org.jetbrains.annotations.ApiStatus;
/**
* Represents the texture resolution of a model.
*
* @param width the width of the texture in pixels
* @param height the height of the texture in pixels
* @since 1.15.2
*/
@ApiStatus.Internal
public record ModelResolution(
int width,
int height
) {
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelTexture.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import com.google.gson.annotations.SerializedName;
import kr.toxicity.model.api.data.blueprint.BlueprintTexture;
import kr.toxicity.model.api.util.PackUtil;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.Base64;
/**
* Represents a raw texture definition from a model file.
*
* This record contains the texture's metadata and its content encoded as a Base64 string.
*
*
* @param name the name of the texture file (e.g., "texture.png")
* @param source the Base64-encoded content of the texture image
* @param width the width of the texture in pixels
* @param height the height of the texture in pixels
* @param uvWidth the UV width of the texture
* @param uvHeight the UV height of the texture
* @param frameTime the frame time of the texture
* @param frameInterpolate the interpolation flag of the texture
* @since 1.15.2
*/
@ApiStatus.Internal
public record ModelTexture(
@NotNull String name,
@NotNull String source,
int width,
int height,
@SerializedName("uv_width") int uvWidth,
@SerializedName("uv_height") int uvHeight,
@SerializedName("frame_time") int frameTime,
@SerializedName("frame_interpolate") boolean frameInterpolate
) {
/**
* Converts this raw texture into a processed {@link BlueprintTexture}.
*
* This method decodes the Base64 source, generates a pack-compliant name, and determines if the texture should be included in the pack.
*
*
* @param context the model loading context
* @return the blueprint texture
* @since 1.15.2
*/
public @NotNull BlueprintTexture toBlueprint(@NotNull ModelLoadContext context) {
var name = nameWithoutExtension();
return new BlueprintTexture(
PackUtil.toPackName(name.startsWith("global_") ? name : context.name + "_" + name),
Base64.getDecoder().decode(source().substring(source().indexOf(',') + 1)),
width(),
height(),
uvWidth(),
uvHeight(),
!name.startsWith("-"),
frameTime(),
frameInterpolate()
);
}
/**
* Returns the texture name without its file extension.
*
* @return the name without extension
* @since 1.15.2
*/
public @NotNull String nameWithoutExtension() {
var name = name();
var nameIndex = name.lastIndexOf('.');
return nameIndex >= 0 ? name.substring(0, nameIndex) : name;
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/raw/ModelUV.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.raw;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import kr.toxicity.model.api.data.Float4;
import kr.toxicity.model.api.data.blueprint.BlueprintLoadContext;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
/**
* Represents the UV mapping data for a model face.
*
* This record holds the UV coordinates, rotation, and texture index for a specific face of a model element.
*
*
* @param uv the UV coordinates as a {@link Float4} (u1, v1, u2, v2)
* @param rotation the rotation of the UV map in degrees (0, 90, 180, 270)
* @param texture the JSON element representing the texture index, can be null
* @since 1.15.2
*/
public record ModelUV(
@NotNull Float4 uv,
int rotation,
@Nullable JsonElement texture
) {
/**
* Checks if this UV mapping has a valid texture index.
*
* @return true if a texture is defined, false otherwise
* @since 1.15.2
*/
public boolean hasTexture() {
return texture != null && texture.isJsonPrimitive() && texture.getAsJsonPrimitive().isNumber();
}
/**
* Returns the texture index associated with this UV mapping.
*
* @return the texture index
* @throws NullPointerException if no texture is defined
* @since 1.15.2
*/
public int textureIndex() {
return Objects.requireNonNull(texture).getAsInt();
}
/**
* Converts this UV data to a JSON object for the Minecraft model file.
*
* @param context the blueprint context, used for texture resolution
* @param tint the tint index to apply
* @return the generated JSON object
* @since 1.15.2
*/
public @Nullable JsonObject toJson(@NotNull BlueprintLoadContext context, int tint) {
if (!hasTexture()) return null;
var div = uv.div(context.texture(textureIndex()).resolution(context.resolution()));
if (!div.isValid()) return null;
var object = new JsonObject();
object.add("uv", div.toJson());
if (rotation != 0) object.addProperty("rotation", rotation);
object.addProperty("tintindex", tint);
object.addProperty("texture", "#" + texture);
return object;
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/renderer/ModelRenderer.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.renderer;
import kr.toxicity.model.api.bone.BoneName;
import kr.toxicity.model.api.bone.RenderedBone;
import kr.toxicity.model.api.data.blueprint.BlueprintAnimation;
import kr.toxicity.model.api.entity.BaseEntity;
import kr.toxicity.model.api.platform.PlatformEntity;
import kr.toxicity.model.api.platform.PlatformLocation;
import kr.toxicity.model.api.profile.ModelProfile;
import kr.toxicity.model.api.tracker.DummyTracker;
import kr.toxicity.model.api.tracker.EntityTracker;
import kr.toxicity.model.api.tracker.TrackerModifier;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.SequencedMap;
import java.util.function.Consumer;
import java.util.stream.Stream;
/**
* A blueprint renderer.
*
* @param name name
* @param type type
* @param rendererGroups group map
* @param animations animations
*/
public record ModelRenderer(
@NotNull String name,
@NotNull Type type,
@NotNull @Unmodifiable SequencedMap rendererGroups,
@NotNull @Unmodifiable Map animations
) {
/**
* Gets a renderer group by tree
*
* @param name part name
* @return group or null
*/
public @Nullable RendererGroup groupByTree(@NotNull BoneName name) {
return groupByTree0(rendererGroups, name);
}
private static @Nullable RendererGroup groupByTree0(@NotNull Map map, @NotNull BoneName name) {
if (map.isEmpty()) return null;
var get = map.get(name);
if (get != null) return get;
else return map.values()
.stream()
.map(g -> groupByTree0(g.getChildren(), name))
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}
/**
* Gets flatten groups.
* @return flatten groups
*/
public @NotNull Stream flatten() {
return rendererGroups.values().stream().flatMap(RendererGroup::flatten);
}
/**
* Gets blueprint animation by name
*
* @param name name
* @return optional animation
*/
public @NotNull Optional animation(@NotNull String name) {
return Optional.ofNullable(animations().get(name));
}
//----- Dummy -----
/**
* Creates tracker by location
*
* @param location location
* @return empty tracker
*/
public @NotNull DummyTracker create(@NotNull PlatformLocation location) {
return create(location, TrackerModifier.DEFAULT);
}
/**
* Creates tracker by location
*
* @param location location
* @param modifier modifier
* @return empty tracker
*/
public @NotNull DummyTracker create(@NotNull PlatformLocation location, @NotNull TrackerModifier modifier) {
return create(location, modifier, _ -> {
});
}
/**
* Creates tracker by location
*
* @param location location
* @param modifier modifier
* @param preUpdateConsumer task on pre-update
* @return empty tracker
*/
public @NotNull DummyTracker create(@NotNull PlatformLocation location, @NotNull TrackerModifier modifier, @NotNull Consumer preUpdateConsumer) {
var source = RenderSource.of(location);
return source.create(
pipeline(source),
modifier,
preUpdateConsumer
);
}
/**
* Creates tracker by location and completed profile
*
* @param location location
* @param profile profile
* @return empty tracker
*/
public @NotNull DummyTracker create(@NotNull PlatformLocation location, @NotNull ModelProfile profile) {
return create(location, profile.asUncompleted());
}
/**
* Creates tracker by location and completed profile
*
* @param location location
* @param profile profile
* @param modifier modifier
* @return empty tracker
*/
public @NotNull DummyTracker create(@NotNull PlatformLocation location, ModelProfile profile, @NotNull TrackerModifier modifier) {
return create(location, profile.asUncompleted(), modifier);
}
/**
* Creates tracker by location and completed profile
*
* @param location location
* @param profile profile
* @param modifier modifier
* @param preUpdateConsumer task on pre-update
* @return empty tracker
*/
public @NotNull DummyTracker create(@NotNull PlatformLocation location, @NotNull ModelProfile profile, @NotNull TrackerModifier modifier, @NotNull Consumer preUpdateConsumer) {
return create(location, profile.asUncompleted(), modifier, preUpdateConsumer);
}
/**
* Creates tracker by location and uncompleted profile
*
* @param location location
* @param profile profile
* @return empty tracker
*/
public @NotNull DummyTracker create(@NotNull PlatformLocation location, @NotNull ModelProfile.Uncompleted profile) {
return create(location, profile, TrackerModifier.DEFAULT);
}
/**
* Creates tracker by location and uncompleted profile
*
* @param location location
* @param profile profile
* @param modifier modifier
* @return empty tracker
*/
public @NotNull DummyTracker create(@NotNull PlatformLocation location, ModelProfile.Uncompleted profile, @NotNull TrackerModifier modifier) {
return create(location, profile, modifier, _ -> {
});
}
/**
* Creates tracker by location and uncompleted profile
*
* @param location location
* @param profile profile
* @param modifier modifier
* @param preUpdateConsumer task on pre-update
* @return empty tracker
*/
public @NotNull DummyTracker create(@NotNull PlatformLocation location, @NotNull ModelProfile.Uncompleted profile, @NotNull TrackerModifier modifier, @NotNull Consumer preUpdateConsumer) {
var source = RenderSource.of(location, profile);
return source.create(
pipeline(source),
modifier,
preUpdateConsumer
);
}
//----- Entity -----
/**
* Creates tracker by entity
*
* @param entity entity
* @return entity tracker
*/
public @NotNull EntityTracker create(@NotNull PlatformEntity entity) {
return create(BaseEntity.of(entity));
}
/**
* Creates tracker by entity
*
* @param entity entity
* @param modifier modifier
* @return entity tracker
*/
public @NotNull EntityTracker create(@NotNull PlatformEntity entity, @NotNull TrackerModifier modifier) {
return create(BaseEntity.of(entity), modifier);
}
/**
* Creates tracker by entity
*
* @param entity entity
* @param modifier modifier
* @param preUpdateConsumer task on pre-update
* @return entity tracker
*/
public @NotNull EntityTracker create(@NotNull PlatformEntity entity, @NotNull TrackerModifier modifier, @NotNull Consumer preUpdateConsumer) {
return create(BaseEntity.of(entity), modifier, preUpdateConsumer);
}
/**
* Creates tracker by entity and uncompleted profile
*
* @param entity entity
* @param profile profile
* @return entity tracker
*/
public @NotNull EntityTracker create(@NotNull PlatformEntity entity, @NotNull ModelProfile.Uncompleted profile) {
return create(BaseEntity.of(entity), profile);
}
/**
* Creates tracker by entity and uncompleted profile
*
* @param entity entity
* @param profile profile
* @param modifier modifier
* @return entity tracker
*/
public @NotNull EntityTracker create(@NotNull PlatformEntity entity, @NotNull ModelProfile.Uncompleted profile, @NotNull TrackerModifier modifier) {
return create(BaseEntity.of(entity), profile, modifier);
}
/**
* Creates tracker by entity and uncompleted profile
*
* @param entity entity
* @param profile skin
* @param modifier modifier
* @param preUpdateConsumer task on pre-update
* @return entity tracker
*/
public @NotNull EntityTracker create(@NotNull PlatformEntity entity, @NotNull ModelProfile.Uncompleted profile, @NotNull TrackerModifier modifier, @NotNull Consumer preUpdateConsumer) {
return create(BaseEntity.of(entity), profile, modifier, preUpdateConsumer);
}
/**
* Gets or creates tracker by entity
*
* @param entity entity
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull PlatformEntity entity) {
return getOrCreate(BaseEntity.of(entity));
}
/**
* Gets or creates tracker by entity
*
* @param entity entity
* @param modifier modifier
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull PlatformEntity entity, @NotNull TrackerModifier modifier) {
return getOrCreate(BaseEntity.of(entity), modifier);
}
/**
* Gets or creates tracker by entity
*
* @param entity entity
* @param modifier modifier
* @param preUpdateConsumer task on pre-update
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull PlatformEntity entity, @NotNull TrackerModifier modifier, @NotNull Consumer preUpdateConsumer) {
return getOrCreate(BaseEntity.of(entity), modifier, preUpdateConsumer);
}
/**
* Gets or creates tracker by entity and completed profile
*
* @param entity entity
* @param profile profile
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull PlatformEntity entity, @NotNull ModelProfile profile) {
return getOrCreate(entity, profile.asUncompleted());
}
/**
* Gets or creates tracker by entity and completed profile
*
* @param entity entity
* @param profile profile
* @param modifier modifier
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull PlatformEntity entity, @NotNull ModelProfile profile, @NotNull TrackerModifier modifier) {
return getOrCreate(entity, profile.asUncompleted(), modifier);
}
/**
* Gets or creates tracker by entity and completed profile
*
* @param entity entity
* @param profile skin
* @param modifier modifier
* @param preUpdateConsumer task on pre-update
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull PlatformEntity entity, @NotNull ModelProfile profile, @NotNull TrackerModifier modifier, @NotNull Consumer preUpdateConsumer) {
return getOrCreate(entity, profile.asUncompleted(), modifier, preUpdateConsumer);
}
/**
* Gets or creates tracker by entity and uncompleted profile
*
* @param entity entity
* @param profile profile
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull PlatformEntity entity, @NotNull ModelProfile.Uncompleted profile) {
return getOrCreate(BaseEntity.of(entity), profile);
}
/**
* Gets or creates tracker by entity and uncompleted profile
*
* @param entity entity
* @param profile profile
* @param modifier modifier
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull PlatformEntity entity, @NotNull ModelProfile.Uncompleted profile, @NotNull TrackerModifier modifier) {
return getOrCreate(BaseEntity.of(entity), profile, modifier);
}
/**
* Gets or creates tracker by entity and uncompleted profile
*
* @param entity entity
* @param profile skin
* @param modifier modifier
* @param preUpdateConsumer task on pre-update
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull PlatformEntity entity, @NotNull ModelProfile.Uncompleted profile, @NotNull TrackerModifier modifier, @NotNull Consumer preUpdateConsumer) {
return getOrCreate(BaseEntity.of(entity), profile, modifier, preUpdateConsumer);
}
/**
* Creates tracker by entity
*
* @param entity entity
* @return entity tracker
*/
public @NotNull EntityTracker create(@NotNull BaseEntity entity) {
return create(entity, TrackerModifier.DEFAULT);
}
/**
* Creates tracker by entity
*
* @param entity entity
* @param modifier modifier
* @return entity tracker
*/
public @NotNull EntityTracker create(@NotNull BaseEntity entity, @NotNull TrackerModifier modifier) {
return create(entity, modifier, _ -> {
});
}
/**
* Creates tracker by entity
*
* @param entity entity
* @param modifier modifier
* @param preUpdateConsumer task on pre-update
* @return entity tracker
*/
public @NotNull EntityTracker create(@NotNull BaseEntity entity, @NotNull TrackerModifier modifier, @NotNull Consumer preUpdateConsumer) {
var source = RenderSource.of(entity);
return source.create(
pipeline(source),
modifier,
preUpdateConsumer
);
}
/**
* Creates tracker by entity and completed profile
*
* @param entity entity
* @param profile profile
* @return entity tracker
*/
public @NotNull EntityTracker create(@NotNull BaseEntity entity, @NotNull ModelProfile profile) {
return create(entity, profile.asUncompleted());
}
/**
* Creates tracker by entity and completed profile
*
* @param entity entity
* @param profile profile
* @param modifier modifier
* @return entity tracker
*/
public @NotNull EntityTracker create(@NotNull BaseEntity entity, @NotNull ModelProfile profile, @NotNull TrackerModifier modifier) {
return create(entity, profile.asUncompleted(), modifier);
}
/**
* Creates tracker by entity and completed profile
*
* @param entity entity
* @param profile profile
* @param modifier modifier
* @param preUpdateConsumer task on pre-update
* @return entity tracker
*/
public @NotNull EntityTracker create(@NotNull BaseEntity entity, @NotNull ModelProfile profile, @NotNull TrackerModifier modifier, @NotNull Consumer preUpdateConsumer) {
return create(entity, profile.asUncompleted(), modifier, preUpdateConsumer);
}
/**
* Creates tracker by entity and uncompleted profile
*
* @param entity entity
* @param profile profile
* @return entity tracker
*/
public @NotNull EntityTracker create(@NotNull BaseEntity entity, @NotNull ModelProfile.Uncompleted profile) {
return create(entity, profile, TrackerModifier.DEFAULT);
}
/**
* Creates tracker by entity and uncompleted profile
*
* @param entity entity
* @param profile profile
* @param modifier modifier
* @return entity tracker
*/
public @NotNull EntityTracker create(@NotNull BaseEntity entity, @NotNull ModelProfile.Uncompleted profile, @NotNull TrackerModifier modifier) {
return create(entity, profile, modifier, _ -> {
});
}
/**
* Creates tracker by entity and uncompleted profile
*
* @param entity entity
* @param profile profile
* @param modifier modifier
* @param preUpdateConsumer task on pre-update
* @return entity tracker
*/
public @NotNull EntityTracker create(@NotNull BaseEntity entity, @NotNull ModelProfile.Uncompleted profile, @NotNull TrackerModifier modifier, @NotNull Consumer preUpdateConsumer) {
var source = RenderSource.of(entity, profile);
return source.create(
pipeline(source),
modifier,
preUpdateConsumer
);
}
/**
* Gets or creates tracker by entity
*
* @param entity entity
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull BaseEntity entity) {
return getOrCreate(entity, TrackerModifier.DEFAULT);
}
/**
* Gets or creates tracker by entity
*
* @param entity entity
* @param modifier modifier
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull BaseEntity entity, @NotNull TrackerModifier modifier) {
return getOrCreate(entity, modifier, _ -> {
});
}
/**
* Gets or creates tracker by entity
*
* @param entity entity
* @param modifier modifier
* @param preUpdateConsumer task on pre-update
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull BaseEntity entity, @NotNull TrackerModifier modifier, @NotNull Consumer preUpdateConsumer) {
var source = RenderSource.of(entity);
return source.getOrCreate(
name(),
() -> pipeline(source),
modifier,
preUpdateConsumer
);
}
/**
* Gets or creates tracker by entity and completed profile
*
* @param entity entity
* @param profile profile
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull BaseEntity entity, @NotNull ModelProfile profile) {
return getOrCreate(entity, profile.asUncompleted());
}
/**
* Gets or creates tracker by entity and completed profile
*
* @param entity entity
* @param profile profile
* @param modifier modifier
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull BaseEntity entity, ModelProfile profile, @NotNull TrackerModifier modifier) {
return getOrCreate(entity, profile.asUncompleted(), modifier);
}
/**
* Gets or creates tracker by entity and completed profile
*
* @param entity entity
* @param profile profile
* @param modifier modifier
* @param preUpdateConsumer task on pre-update
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull BaseEntity entity, @NotNull ModelProfile profile, @NotNull TrackerModifier modifier, @NotNull Consumer preUpdateConsumer) {
return getOrCreate(entity, profile.asUncompleted(), modifier, preUpdateConsumer);
}
/**
* Gets or creates tracker by entity and uncompleted profile
*
* @param entity entity
* @param profile profile
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull BaseEntity entity, @NotNull ModelProfile.Uncompleted profile) {
return getOrCreate(entity, profile, TrackerModifier.DEFAULT);
}
/**
* Gets or creates tracker by entity and uncompleted profile
*
* @param entity entity
* @param profile profile
* @param modifier modifier
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull BaseEntity entity, ModelProfile.Uncompleted profile, @NotNull TrackerModifier modifier) {
return getOrCreate(entity, profile, modifier, _ -> {
});
}
/**
* Gets or creates tracker by entity and uncompleted profile
*
* @param entity entity
* @param profile profile
* @param modifier modifier
* @param preUpdateConsumer task on pre-update
* @return entity tracker
*/
public @NotNull EntityTracker getOrCreate(@NotNull BaseEntity entity, @NotNull ModelProfile.Uncompleted profile, @NotNull TrackerModifier modifier, @NotNull Consumer preUpdateConsumer) {
var source = RenderSource.of(entity, profile);
return source.getOrCreate(
name(),
() -> pipeline(source),
modifier,
preUpdateConsumer
);
}
private @NotNull RenderPipeline pipeline(@NotNull RenderSource> source) {
return new RenderPipeline(
this,
source,
rendererGroups.values().stream().map(value -> value.create(source)).toArray(RenderedBone[]::new)
);
}
/**
* Renderer type
*/
@RequiredArgsConstructor
@Getter
public enum Type {
/**
* General
*/
GENERAL(true),
/**
* Player
*/
PLAYER(false)
;
private final boolean canBeSaved;
}
}
================================================
FILE: api/src/main/java/kr/toxicity/model/api/data/renderer/RenderPipeline.java
================================================
/*
* This source file is part of BetterModel.
* Copyright (c) 2024 toxicity188
* Licensed under the MIT License.
* See LICENSE.md file for full license text.
*/
package kr.toxicity.model.api.data.renderer;
import kr.toxicity.model.api.BetterModel;
import kr.toxicity.model.api.animation.AnimationOverrideState;
import kr.toxicity.model.api.animation.RunningAnimation;
import kr.toxicity.model.api.bone.*;
import kr.toxicity.model.api.nms.AnimationBundler;
import kr.toxicity.model.api.nms.HitBox;
import kr.toxicity.model.api.nms.PacketBundler;
import kr.toxicity.model.api.nms.PlayerChannelHandler;
import kr.toxicity.model.api.platform.PlatformPlayer;
import kr.toxicity.model.api.tracker.ModelRotation;
import kr.toxicity.model.api.util.FunctionUtil;
import kr.toxicity.model.api.util.function.BonePredicate;
import kr.toxicity.model.api.util.function.FloatSupplier;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiPredicate;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Stream;
import static kr.toxicity.model.api.util.CollectionUtil.associate;
import static kr.toxicity.model.api.util.CollectionUtil.associateSequenced;
/**
* Represents the rendering pipeline for a specific model instance.
*
* This class manages the hierarchy of {@link RenderedBone}s, handles player visibility and packet bundling,
* and coordinates animation updates and inverse kinematics (IK) solving.
*
*
* @since 1.15.2
*/
public final class RenderPipeline implements BoneEventHandler, Iterable {
@Getter
private final ModelRenderer parent;
@Getter
private final RenderSource> source;
private final RenderedBone[] bones;
private final RenderedBone[] flattenBones;
private final SequencedMap byIdMap;
private final int displayAmount;
private final Map playerMap = new ConcurrentHashMap<>();
private final Set