dependencies = actor.sable$getConnectionDependencies();
+
+ if (dependencies == null) continue;
+
+ for (final SubLevel dependency : dependencies) {
+ final SubLevel serverDependency = dependency;
+
+ if (!visited.contains(serverDependency)) {
+ frontier.add(serverDependency);
+ }
+ }
+ }
+ }
+
+ return visited;
+ }
+
+ private static class EntityRot {
+
+ private float xRot;
+ private float yRot;
+ private float yHeadRot;
+
+ public void apply(final Entity entity) {
+ entity.setXRot(this.xRot);
+ entity.setYRot(this.yRot);
+ entity.setYHeadRot(this.yHeadRot);
+ }
+
+ public void copy(final Entity entity) {
+ this.xRot = entity.getXRot();
+ this.yRot = entity.getYRot();
+ this.yHeadRot = entity.getYHeadRot();
+ }
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/block/BlockEntitySubLevelActor.java b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockEntitySubLevelActor.java
new file mode 100644
index 0000000..0a9e759
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockEntitySubLevelActor.java
@@ -0,0 +1,56 @@
+package dev.ryanhcode.sable.api.block;
+
+import dev.ryanhcode.sable.api.physics.handle.RigidBodyHandle;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import dev.ryanhcode.sable.sublevel.SubLevel;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * An interface for sub-classes of {@link net.minecraft.world.level.block.entity.BlockEntity} to implement behaviour
+ * when mounted on a sub-level.
+ */
+public interface BlockEntitySubLevelActor {
+
+ /**
+ * Called once per server game tick when this actor is on a {@link SubLevel}
+ */
+ default void sable$tick(final ServerSubLevel subLevel) {}
+
+ /**
+ * Called once per **physics** tick when this actor is on a {@link SubLevel}.
+ * There may be multiple physics ticks per tick.
+ *
+ * @param subLevel the sub-level this block entity is on
+ * @param timeStep the time this physics tick is stepping
+ */
+ default void sable$physicsTick(final ServerSubLevel subLevel, final RigidBodyHandle handle, final double timeStep) {}
+
+ /**
+ * Returns the loading dependencies this block-entity has on other sub-levels.
+ * Loading dependencies are used to unload and load a group of sub-levels together.
+ * By default, loading dependencies are assumed from the connection dependencies.
+ *
+ * Note that this may be called after chunks have been un-loaded, and as such, direct level access
+ * should not be done to fetch the dependencies.
+ *
+ * @return a collection of loading dependencies on other loaded sub-levels, or null for none
+ */
+ @Nullable
+ default Iterable<@NotNull SubLevel> sable$getLoadingDependencies() {
+ return this.sable$getConnectionDependencies();
+ }
+
+ /**
+ * Returns the connections this block-entity has on other sub-levels.
+ * Connections are used to dictate sub-levels that should be treated as one by many systems.
+ *
+ * Note that this may be called after chunks have been un-loaded, and as such, direct level access
+ * should not be done to fetch the dependencies.
+ * @return a collection of connection dependencies on other loaded sub-levels, or null for none
+ */
+ @Nullable
+ default Iterable<@NotNull SubLevel> sable$getConnectionDependencies() {
+ return null;
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/block/BlockEntitySubLevelReactionWheel.java b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockEntitySubLevelReactionWheel.java
new file mode 100644
index 0000000..ae99628
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockEntitySubLevelReactionWheel.java
@@ -0,0 +1,26 @@
+package dev.ryanhcode.sable.api.block;
+
+import net.minecraft.world.level.block.state.BlockState;
+import org.joml.Vector3d;
+import dev.ryanhcode.sable.physics.config.block_properties.PhysicsBlockPropertyTypes;
+
+/**
+ * An interface for sub-classes of {@link net.minecraft.world.level.block.entity.BlockEntity} to provide angular momentum
+ * when mounted on a sub-level.
+ */
+public interface BlockEntitySubLevelReactionWheel {
+ /**
+ * Get the angular velocity of this reaction wheel, in radians per second.
+ * The total angular momentum given to the sublevel is this velocity scaled by {@link dev.ryanhcode.sable.physics.config.block_properties.PhysicsBlockPropertyTypes#INERTIA}
+ * and by {@link dev.ryanhcode.sable.physics.config.block_properties.PhysicsBlockPropertyTypes#MASS}.
+ *
+ * @param angularVelocity Angular velocity to be set, using {@link org.joml.Vector3d#set(double, double, double)} or similar
+ */
+ void sable$getAngularVelocity(Vector3d angularVelocity);
+
+ /**
+ * The default block state getter for block entities
+ * @return The block state for this block entity
+ */
+ BlockState getBlockState();
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/block/BlockSubLevelAssemblyListener.java b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockSubLevelAssemblyListener.java
new file mode 100644
index 0000000..f9f58a7
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockSubLevelAssemblyListener.java
@@ -0,0 +1,41 @@
+package dev.ryanhcode.sable.api.block;
+
+import dev.ryanhcode.sable.api.SubLevelAssemblyHelper;
+import net.minecraft.core.BlockPos;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.block.state.BlockState;
+
+/**
+ * An interface for sub-classes of {@link net.minecraft.world.level.block.Block} to implement that indicates the
+ * {@link SubLevelAssemblyHelper} should notify the block any time it is "moved" as a part of sub-level assembly.
+ */
+public interface BlockSubLevelAssemblyListener {
+
+ /**
+ * Called before the {@link SubLevelAssemblyHelper} has moved a block of state newState from oldPos to newPos.
+ *
+ * @param originLevel the level the block will be moved from
+ * @param resultingLevel the level the block will be moved to
+ * @param newState the new block state
+ * @param oldPos the old block position
+ * @param newPos the new block position
+ */
+ default void beforeMove(final ServerLevel originLevel, final ServerLevel resultingLevel, final BlockState newState, final BlockPos oldPos, final BlockPos newPos) {
+
+ }
+
+
+
+ /**
+ * Called after the {@link SubLevelAssemblyHelper} has moved a block of state newState from oldPos to newPos.
+ * At this point in time during the move, the old block has not been removed.
+ *
+ * @param originLevel the level the block was moved from
+ * @param resultingLevel the level the block was moved to
+ * @param newState the new block state
+ * @param oldPos the old block position
+ * @param newPos the new block position
+ */
+ void afterMove(ServerLevel originLevel, ServerLevel resultingLevel, BlockState newState, BlockPos oldPos, BlockPos newPos);
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/block/BlockSubLevelCollisionShape.java b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockSubLevelCollisionShape.java
new file mode 100644
index 0000000..12b0ea8
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockSubLevelCollisionShape.java
@@ -0,0 +1,22 @@
+package dev.ryanhcode.sable.api.block;
+
+import net.minecraft.world.level.BlockGetter;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.phys.shapes.VoxelShape;
+
+/**
+ * Interface for sub-classes of {@link net.minecraft.world.level.block.Block} to implement to specify a separate
+ * collision shape for sub-level physics.
+ */
+public interface BlockSubLevelCollisionShape {
+
+ /**
+ * Gets the collision shape that will be baked for a given block-state of this block.
+ *
+ * @param blockGetter the blockGetter to bake the collision shape for
+ * @param state the block state to bake the collision shape for
+ * @return the collision shape that should be used for this block state
+ */
+ VoxelShape getSubLevelCollisionShape(final BlockGetter blockGetter, final BlockState state);
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/block/BlockSubLevelCustomCenterOfMass.java b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockSubLevelCustomCenterOfMass.java
new file mode 100644
index 0000000..5aef82f
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockSubLevelCustomCenterOfMass.java
@@ -0,0 +1,22 @@
+package dev.ryanhcode.sable.api.block;
+
+import net.minecraft.world.level.BlockGetter;
+import net.minecraft.world.level.block.state.BlockState;
+import org.joml.Vector3dc;
+
+/**
+ * Interface for sub-classes of {@link net.minecraft.world.level.block.Block} to implement to specify a custom center
+ * of mass for sub-level physics.
+ */
+public interface BlockSubLevelCustomCenterOfMass {
+
+ /**
+ * Gets the center of mass that will be baked for a given block-state of this block.
+ *
+ * @param blockGetter the blockGetter to bake the center of mass for
+ * @param state the block state to bake the center of mass for
+ * @return the center of mass relative to the lower corner of the block
+ */
+ Vector3dc getCenterOfMass(final BlockGetter blockGetter, final BlockState state);
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/block/BlockSubLevelDynamicCollider.java b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockSubLevelDynamicCollider.java
new file mode 100644
index 0000000..993b006
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockSubLevelDynamicCollider.java
@@ -0,0 +1,13 @@
+package dev.ryanhcode.sable.api.block;
+
+import dev.ryanhcode.sable.api.physics.collider.VoxelColliderData;
+
+/**
+ * An interface for sub-classes of {@link net.minecraft.world.level.block.Block} to implement that indicates they
+ * have a dynamic collider. Dynamic colliders will be significantly more performance
+ */
+public interface BlockSubLevelDynamicCollider {
+
+ void buildBoxes(VoxelColliderData data);
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/block/BlockSubLevelLiftProvider.java b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockSubLevelLiftProvider.java
new file mode 100644
index 0000000..0233bb3
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockSubLevelLiftProvider.java
@@ -0,0 +1,244 @@
+package dev.ryanhcode.sable.api.block;
+
+import dev.ryanhcode.sable.companion.math.Pose3d;
+import dev.ryanhcode.sable.physics.config.dimension_physics.DimensionPhysicsData;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import dev.ryanhcode.sable.sublevel.SubLevel;
+import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.phys.Vec3;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+
+public interface BlockSubLevelLiftProvider {
+
+ Direction[] DIRECTIONS = Direction.values();
+
+ // memory optimization
+ Vector3d LIFT_FORCE = new Vector3d();
+ Vector3d LIFT_POS = new Vector3d();
+ Vector3d LIFT_NORMAL = new Vector3d();
+
+ Vector3d LIFT_VELO = new Vector3d();
+ Vector3d DRAG = new Vector3d();
+ Vector3d TEMP = new Vector3d();
+
+ /**
+ * Resets the vectors to their identity.
+ */
+ static void resetVectors() {
+ LIFT_VELO.set(0, 0, 0);
+ LIFT_POS.set(0, 0, 0);
+ LIFT_FORCE.set(0, 0, 0);
+ LIFT_NORMAL.set(0, 0, 0);
+ DRAG.set(0, 0, 0);
+ }
+
+ static List groupLiftProviders(final Collection liftProviders) {
+ final List groups = new ObjectArrayList<>();
+ final Set positions = new ObjectOpenHashSet<>(liftProviders.size());
+
+ for (final LiftProviderContext liftProvider : liftProviders) {
+ positions.add(liftProvider.pos);
+ }
+
+ while (!positions.isEmpty()) {
+ // run a flood-fill
+ final Set groupBlocks = new ObjectOpenHashSet<>();
+ final List toVisit = new ObjectArrayList<>();
+
+ toVisit.add(positions.iterator().next());
+
+ while (!toVisit.isEmpty()) {
+ final BlockPos pos = toVisit.removeLast();
+
+ if (groupBlocks.contains(pos)) {
+ continue;
+ }
+
+ groupBlocks.add(pos);
+ positions.remove(pos);
+
+ for (final Direction direction : DIRECTIONS) {
+ final BlockPos offsetPos = pos.relative(direction);
+
+ if (positions.contains(offsetPos)) {
+ toVisit.add(offsetPos);
+ }
+ }
+ }
+
+ groups.add(new LiftProviderGroup(groupBlocks));
+ }
+
+ return groups;
+ }
+
+ /**
+ * @param state The current blockstate of this lift provider
+ * @return The normal of this lift provider
+ */
+ @NotNull
+ Direction sable$getNormal(BlockState state);
+
+ /**
+ * Adjust {@link BlockSubLevelLiftProvider#sable$getDirectionlessDragScalar()} if this value is changed
+ * @return How effective this lift provider is at producing drag parallel to the normal.
+ */
+ default float sable$getParallelDragScalar() {
+ return 0.75F;
+ }
+
+ /**
+ * {@code parallelDragScalar = k1, liftScalar = k2 }
+ * Should be at minimum {@code (-k1 + sqrt(k1^2 + k2^2)) / 2} to prevent exponential velocity gain.
+ * @return How effective this lift provider is at producing directionless drag.
+ */
+ default float sable$getDirectionlessDragScalar() {
+ return 0.06888202261f; // (-0.75 + sqrt(0.75^2 + 0.475^2)) / 2
+ }
+
+ /**
+ * Adjust {@link BlockSubLevelLiftProvider#sable$getDirectionlessDragScalar()} if this value is changed
+ * @return How effective this lift provider is at producing lift.
+ */
+ default float sable$getLiftScalar() {
+ return 0.475f;
+ }
+
+ /**
+ * Called once per **physics** tick when this LiftProvider is on a {@link SubLevel}.
+ * There may be multiple physics ticks per tick.
+ *
+ * @param ctx The in world context of this lift provider.
+ * @param subLevel The sub-level this lift provider is on
+ * @param localPose The pose of the contraption this lift provider is in, if any
+ * @param timeStep The time step between physics ticks
+ * @param linearVelocity The linear velocity of the data
+ * @param angularVelocity The angular velocity of the data
+ * @param linearImpulse Mutable vector to sum the linear impulse
+ * @param angularImpulse Mutable vector to sum the angular impulse
+ */
+ default void sable$contributeLiftAndDrag(final LiftProviderContext ctx, final ServerSubLevel subLevel,
+ @NotNull final Pose3d localPose, final double timeStep,
+ final Vector3dc linearVelocity, final Vector3dc angularVelocity,
+ final Vector3d linearImpulse, final Vector3d angularImpulse,
+ @Nullable final LiftProviderGroup group) {
+ resetVectors();
+ LIFT_NORMAL.set(ctx.dir.x(), ctx.dir.y(), ctx.dir.z());
+ LIFT_POS.set(ctx.pos.getX() + 0.5, ctx.pos.getY() + 0.5, ctx.pos.getZ() + 0.5);
+
+ if (localPose != null) {
+ localPose.transformNormal(LIFT_NORMAL);
+ localPose.transformPosition(LIFT_POS);
+ }
+
+ final Pose3d pose = subLevel.logicalPose();
+ final double pressure = DimensionPhysicsData.getAirPressure(subLevel.getLevel(), pose.transformPosition(LIFT_POS, TEMP));
+
+ // transform VELO to be the local velocity at the center of the block
+ // TEMP = transformed POS
+ // VELO = linVel + angVel cross TEMP
+ // VELO = inv transformed VELO
+ pose.transformPosition(LIFT_POS, TEMP).sub(pose.position());
+ LIFT_VELO.set(linearVelocity).add(angularVelocity.cross(TEMP, TEMP));
+ pose.transformNormalInverse(LIFT_VELO);
+
+ LIFT_FORCE.zero();
+
+ if (this.sable$getParallelDragScalar() > 0) {
+ // DRAG = NORMAL * (NORMAL dot VELO)
+ // FORCE = DRAG * scalars
+ final double dragStrength = LIFT_NORMAL.dot(LIFT_VELO) * this.sable$getParallelDragScalar() * pressure * timeStep;
+ final Vector3d parallelDrag = LIFT_NORMAL.mul(dragStrength, DRAG);
+ LIFT_FORCE.add(parallelDrag);
+
+ if (group != null) {
+ group.totalDrag.sub(parallelDrag);
+ group.dragCenter.fma(Math.abs(dragStrength), LIFT_POS);
+ group.totalDragStrength += Math.abs(dragStrength);
+ }
+ }
+
+ if (this.sable$getDirectionlessDragScalar() > 0) {
+ // TEMP = VELO * scalars
+ // FORCE += TEMP
+ final double dragStrength = this.sable$getDirectionlessDragScalar() * pressure * timeStep;
+ final Vector3d directionlessDrag = LIFT_VELO.mul(dragStrength, TEMP);
+ LIFT_FORCE.add(directionlessDrag);
+
+ if (group != null) {
+ group.totalDrag.sub(directionlessDrag);
+ group.dragCenter.fma(directionlessDrag.length(), LIFT_POS);
+ group.totalDragStrength += directionlessDrag.length();
+ }
+ }
+
+ if (this.sable$getLiftScalar() > 0) {
+ // TEMP = VELO - DRAG
+ // TEMP = NORMAL * |TEMP| * scalars
+ // FORCE += TEMP
+ final double liftStrength = LIFT_VELO.sub(DRAG, TEMP).length() * this.sable$getLiftScalar() * pressure * timeStep;
+ final Vector3d lift = LIFT_NORMAL.mul(liftStrength, TEMP);
+ LIFT_FORCE.add(lift);
+
+ if (group != null) {
+ group.totalLift.sub(lift);
+ group.liftCenter.fma(Math.abs(liftStrength), LIFT_POS);
+ group.totalLiftStrength += liftStrength;
+ }
+ }
+
+ // why is this all negative of what it should be?
+ linearImpulse.sub(LIFT_FORCE);
+ LIFT_POS.sub(subLevel.getMassTracker().getCenterOfMass(), TEMP);
+ angularImpulse.sub(TEMP.cross(LIFT_FORCE));
+ resetVectors();
+ }
+
+ record LiftProviderContext(BlockPos pos, BlockState state, Vec3 dir) {
+ }
+
+ final class LiftProviderGroup {
+ private final Set positions;
+ private final Vector3d totalLift = new Vector3d();
+ private final Vector3d liftCenter = new Vector3d();
+ private final Vector3d totalDrag = new Vector3d();
+ private final Vector3d dragCenter = new Vector3d();
+ public double totalLiftStrength;
+ public double totalDragStrength;
+
+ public LiftProviderGroup(final Set positions) {
+ this.positions = positions;
+ }
+
+ public Set positions() {
+ return this.positions;
+ }
+
+ public Vector3d totalLift() {
+ return this.totalLift;
+ }
+
+ public Vector3d liftCenter() {
+ return this.liftCenter;
+ }
+
+ public Vector3d totalDrag() {
+ return this.totalDrag;
+ }
+
+ public Vector3d dragCenter() {
+ return this.dragCenter;
+ }
+ }
+}
\ No newline at end of file
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/block/BlockWithSubLevelCollisionCallback.java b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockWithSubLevelCollisionCallback.java
new file mode 100644
index 0000000..b929f86
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/block/BlockWithSubLevelCollisionCallback.java
@@ -0,0 +1,42 @@
+package dev.ryanhcode.sable.api.block;
+
+import dev.ryanhcode.sable.api.physics.callback.BlockSubLevelCollisionCallback;
+import dev.ryanhcode.sable.mixinterface.block_properties.BlockStateExtension;
+import dev.ryanhcode.sable.physics.callback.FragileBlockCallback;
+import dev.ryanhcode.sable.physics.config.block_properties.PhysicsBlockPropertyTypes;
+import net.minecraft.world.level.block.state.BlockState;
+
+/**
+ * Interface for sub-classes of {@link net.minecraft.world.level.block.Block} to implement for physics collision callbacks.
+ */
+public interface BlockWithSubLevelCollisionCallback {
+
+ /**
+ * Gets the collision callback a given block state should have
+ * @param state the block state to check
+ * @return the block collision callback that should be used for that state
+ */
+ static BlockSubLevelCollisionCallback sable$getCallback(final BlockState state) {
+ if (state.getBlock() instanceof final BlockWithSubLevelCollisionCallback blockCollisionCallback) {
+ return blockCollisionCallback.sable$getCallback();
+ }
+
+ if (((BlockStateExtension) state).sable$getProperty(PhysicsBlockPropertyTypes.FRAGILE.get())) {
+ return FragileBlockCallback.INSTANCE;
+ }
+
+ return null;
+ }
+
+ /**
+ * Checks if a block state should have a collision callback used
+ * @param state the block state to check
+ * @return if the block state should have collision callbacks used
+ */
+ static boolean hasCallback(final BlockState state) {
+ return sable$getCallback(state) != null;
+ }
+
+ BlockSubLevelCollisionCallback sable$getCallback();
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/block/propeller/BlockEntityPropeller.java b/common/src/main/java/dev/ryanhcode/sable/api/block/propeller/BlockEntityPropeller.java
new file mode 100644
index 0000000..b278d6c
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/block/propeller/BlockEntityPropeller.java
@@ -0,0 +1,74 @@
+package dev.ryanhcode.sable.api.block.propeller;
+
+import dev.ryanhcode.sable.Sable;
+import dev.ryanhcode.sable.companion.math.JOMLConversion;
+import dev.ryanhcode.sable.physics.config.dimension_physics.DimensionPhysicsData;
+import dev.ryanhcode.sable.sublevel.SubLevel;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.world.level.Level;
+import org.joml.Vector3d;
+
+/**
+ * Spinny spin spin, woosh woosh!
+ */
+public interface BlockEntityPropeller {
+
+ /**
+ * @return the direction of the propeller
+ */
+ Direction getBlockDirection();
+
+ /**
+ * @return airflow in units of [m/s]
+ */
+ double getAirflow();
+
+ /**
+ * @return thrust in [pN]
+ */
+ double getThrust();
+
+ /**
+ * @return if the propeller is active / thrust should be computed
+ */
+ boolean isActive();
+
+ /**
+ * @return the thrust scaled by -1 * airflow scaling * air pressure
+ */
+ default double getScaledThrust() {
+ return -this.getThrust() * this.getAirflowScaling() * this.getCurrentAirPressure();
+ }
+
+ default double getCurrentAirPressure() {
+ final Level level = this.getLevel();
+ return DimensionPhysicsData.getAirPressure(level, Sable.HELPER.projectOutOfSubLevel(level, JOMLConversion.toJOML(this.getBlockPos().getCenter())));
+ }
+
+ default double getAirflowScaling() {
+ final double airflow = this.getAirflow();
+
+ if (Math.abs(airflow) <= 0.001) {
+ return 1.0;
+ }
+
+ final Level level = this.getLevel();
+ final Vector3d pos = JOMLConversion.toJOML(this.getBlockPos().getCenter());
+ final SubLevel subLevel = Sable.HELPER.getContaining(level, this.getBlockPos());
+
+ if (subLevel == null) {
+ return 1.0;
+ }
+
+ final Vector3d velocity = Sable.HELPER.getVelocity(level, subLevel, pos, new Vector3d());
+ final Vector3d thrustDirection = subLevel.logicalPose().transformNormal(JOMLConversion.atLowerCornerOf(this.getBlockDirection().getNormal()));
+
+ return Math.clamp((airflow + velocity.dot(thrustDirection.x, thrustDirection.y, thrustDirection.z)) / airflow, 0, 1);
+ }
+
+ Level getLevel();
+
+ BlockPos getBlockPos();
+}
+
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/block/propeller/BlockEntitySubLevelPropellerActor.java b/common/src/main/java/dev/ryanhcode/sable/api/block/propeller/BlockEntitySubLevelPropellerActor.java
new file mode 100644
index 0000000..cf663aa
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/block/propeller/BlockEntitySubLevelPropellerActor.java
@@ -0,0 +1,39 @@
+package dev.ryanhcode.sable.api.block.propeller;
+
+import dev.ryanhcode.sable.api.block.BlockEntitySubLevelActor;
+import dev.ryanhcode.sable.api.physics.force.ForceGroups;
+import dev.ryanhcode.sable.api.physics.force.QueuedForceGroup;
+import dev.ryanhcode.sable.api.physics.handle.RigidBodyHandle;
+import dev.ryanhcode.sable.companion.math.JOMLConversion;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import net.minecraft.world.phys.Vec3;
+import org.joml.Vector3d;
+
+public interface BlockEntitySubLevelPropellerActor extends BlockEntitySubLevelActor {
+
+ Vector3d THRUST_VECTOR = new Vector3d();
+ Vector3d THRUST_POSITION = new Vector3d();
+
+ BlockEntityPropeller getPropeller();
+
+ @Override
+ default void sable$physicsTick(final ServerSubLevel subLevel, final RigidBodyHandle handle, final double timeStep) {
+ final BlockEntityPropeller prop = this.getPropeller();
+
+ if (prop.isActive()) {
+ final Vec3 thrustDirection = Vec3.atLowerCornerOf(prop.getBlockDirection().getNormal());
+ this.applyForces(subLevel, thrustDirection, timeStep);
+ }
+ }
+
+ default void applyForces(final ServerSubLevel subLevel, final Vec3 thrustDirection, final double timeStep) {
+ final BlockEntityPropeller prop = this. getPropeller();
+ final Vec3 thrust = thrustDirection.scale(prop.getScaledThrust() * timeStep);
+
+ THRUST_POSITION.set(JOMLConversion.atCenterOf(prop.getBlockPos()));
+ THRUST_VECTOR.set(thrust.x, thrust.y, thrust.z);
+
+ final QueuedForceGroup forceGroup = subLevel.getOrCreateQueuedForceGroup(ForceGroups.PROPULSION.get());
+ forceGroup.applyAndRecordPointForce(new Vector3d(THRUST_POSITION), new Vector3d(THRUST_VECTOR));
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/command/SableCommandHelper.java b/common/src/main/java/dev/ryanhcode/sable/api/command/SableCommandHelper.java
new file mode 100644
index 0000000..fb4141e
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/command/SableCommandHelper.java
@@ -0,0 +1,136 @@
+package dev.ryanhcode.sable.api.command;
+
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
+import dev.ryanhcode.sable.api.physics.PhysicsPipeline;
+import dev.ryanhcode.sable.api.sublevel.ServerSubLevelContainer;
+import dev.ryanhcode.sable.api.sublevel.SubLevelContainer;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import dev.ryanhcode.sable.sublevel.SubLevel;
+import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerLevel;
+
+import java.util.Collection;
+
+public class SableCommandHelper {
+
+ private static final SimpleCommandExceptionType MISSING_SUBLEVEL_CONTAINER
+ = new SimpleCommandExceptionType(Component.translatable("commands.sable.helper.missing_sub_level_container"));
+ private static final SimpleCommandExceptionType MISSING_PHYSICS_SYSTEM
+ = new SimpleCommandExceptionType(Component.translatable("commands.sable.helper.missing_physics_system"));
+ public static final SimpleCommandExceptionType ERROR_NO_SUB_LEVELS_FOUND = new SimpleCommandExceptionType(Component.translatable("commands.sable.fail.no_sub_levels"));
+ public static final SimpleCommandExceptionType ERROR_NOT_INSIDE_SUB_LEVEL = new SimpleCommandExceptionType(Component.translatable("commands.sable.fail.not_inside_sub_level"));
+ public static final SimpleCommandExceptionType ERROR_NO_AXIS_FOR_ROTATION = new SimpleCommandExceptionType(Component.translatable("commands.sable.fail.no_axis_for_rotation"));
+ public static final SimpleCommandExceptionType ERROR_NO_SUB_LEVELS_MODIFIED = new SimpleCommandExceptionType(Component.translatable("commands.sable.fail.unmodified"));
+ public static final SimpleCommandExceptionType ERROR_SUB_LEVEL_UNNAMED = new SimpleCommandExceptionType(Component.translatable("commands.sable.sub_level.get_name.failure_unnamed"));
+
+ // Component utilities related to sub-levels
+
+ /**
+ * Returns a formatted component, with one of the arguments describing the subLevels parameter, being either:
+ *
+ * - The name of the data or "sub-level" if there is only one sub-level
+ * - The number of sub-levels in the collection if there are multiple
+ *
+ *
+ * @param translationKey The translation key to use
+ * @param subLevels The collection of sub-levels to describe
+ * @param subLevelsDescriptionIndex The index of the sub-levels description in the args array
+ * @param additionalArguments The additional arguments to pass to the translation key
+ */
+ public static Component getResultComponentForSublevelCollection(final String translationKey, final Collection subLevels,
+ final int subLevelsDescriptionIndex, final Object... additionalArguments) {
+ final boolean isPlural = subLevels.size() != 1;
+
+ // Varargs of an Object type don't handle arrays so it has to be manually collected
+ final Object[] translationArguments = new Object[additionalArguments.length + 1];
+ System.arraycopy(additionalArguments, 0, translationArguments, 1, additionalArguments.length);
+
+ if (isPlural) {
+ translationArguments[0] = Component.translatable("commands.sable.sub_levels", subLevels.size());
+ } else {
+ final SubLevel subLevel = subLevels.iterator().next();
+ final Object name = subLevel.getName() == null ? Component.translatable("commands.sable.sub_level") : subLevel.getName();
+ translationArguments[0] = name;
+ }
+
+ if (subLevelsDescriptionIndex != 0) {
+ final Object swap = translationArguments[subLevelsDescriptionIndex];
+ translationArguments[subLevelsDescriptionIndex] = translationArguments[0];
+ translationArguments[0] = swap;
+ }
+
+ return Component.translatable(translationKey, translationArguments);
+ }
+
+ /**
+ * Sends a formatted component, where the specified translation key is given a description of the sub-levels collection
+ * See {@link SableCommandHelper#getResultComponentForSublevelCollection} for more info about the description
+ * Functionally an overload of {@link SableCommandHelper#getResultComponentForSublevelCollection}, but with a different name due to the Object varargs.
+ * @param translationKey The translation key to use
+ * @param context The command context to send the message to
+ * @param subLevels The collection of sub-levels to describe
+ * @param additionalArguments The additional arguments to pass to the translation key
+ */
+ public static void sendSuccessDescribingSubLevels(final String translationKey, final CommandContext context, final Collection subLevels,
+ final Object... additionalArguments) {
+ sendSuccessDescribingSubLevelsAtIndex(translationKey, context, subLevels, 0, additionalArguments);
+ }
+
+ /**
+ * Sends a formatted component, where the specified translation key is given a description of the sub-levels collection, in the index specified
+ * See {@link SableCommandHelper#getResultComponentForSublevelCollection} for more info about the description
+ * @param translationKey The translation key to use
+ * @param context The command context to send the message to
+ * @param subLevels The collection of sub-levels to describe
+ * @param subLevelsDescriptionIndex The index of the sub-levels description in the args array
+ * @param additionalArguments The additional arguments to pass to the translation key
+ */
+ public static void sendSuccessDescribingSubLevelsAtIndex(final String translationKey, final CommandContext context, final Collection subLevels,
+ final int subLevelsDescriptionIndex, final Object... additionalArguments) {
+ context.getSource().sendSuccess(
+ () -> getResultComponentForSublevelCollection(translationKey, subLevels, subLevelsDescriptionIndex, additionalArguments),
+ true
+ );
+ }
+
+ //Requires with a command exception
+
+ public static ServerSubLevelContainer requireSubLevelContainer(final CommandContext context) throws CommandSyntaxException {
+ return requireSubLevelContainer(context.getSource());
+ }
+
+ public static ServerSubLevelContainer requireSubLevelContainer(final CommandSourceStack source) throws CommandSyntaxException {
+ final ServerLevel level = source.getLevel();
+ return requireNotNull(SubLevelContainer.getContainer(level), MISSING_SUBLEVEL_CONTAINER);
+ }
+
+ public static SubLevelPhysicsSystem requireSubLevelPhysicsSystem(final ServerSubLevelContainer subLevelContainer) throws CommandSyntaxException {
+ return requireNotNull(subLevelContainer.physicsSystem(), MISSING_PHYSICS_SYSTEM);
+ }
+
+ //Overloads from context only
+
+ public static SubLevelPhysicsSystem requireSubLevelPhysicsSystem(final CommandContext context) throws CommandSyntaxException {
+ return requireSubLevelPhysicsSystem(
+ requireSubLevelContainer(context)
+ );
+ }
+
+ public static PhysicsPipeline requireSubLevelPhysicsPipeline(final CommandContext context) throws CommandSyntaxException {
+ return requireSubLevelPhysicsSystem(context).getPipeline();
+ }
+
+ //
+
+ public static T requireNotNull(final T value, final SimpleCommandExceptionType message) throws CommandSyntaxException {
+ if (value == null) {
+ throw message.create();
+ }
+ return value;
+ }
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/command/SubLevelArgumentType.java b/common/src/main/java/dev/ryanhcode/sable/api/command/SubLevelArgumentType.java
new file mode 100644
index 0000000..cd82145
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/command/SubLevelArgumentType.java
@@ -0,0 +1,303 @@
+package dev.ryanhcode.sable.api.command;
+
+import com.google.gson.JsonObject;
+import com.mojang.brigadier.Message;
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.arguments.ArgumentType;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
+import com.mojang.brigadier.suggestion.Suggestions;
+import com.mojang.brigadier.suggestion.SuggestionsBuilder;
+import dev.ryanhcode.sable.command.argument.SubLevelSelector;
+import dev.ryanhcode.sable.command.argument.SubLevelSelectorModifierType;
+import dev.ryanhcode.sable.command.argument.SubLevelSelectorType;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import it.unimi.dsi.fastutil.Pair;
+import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import it.unimi.dsi.fastutil.objects.ObjectList;
+import net.minecraft.commands.CommandBuildContext;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.commands.synchronization.ArgumentTypeInfo;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Function;
+
+public class SubLevelArgumentType implements ArgumentType {
+
+ public static final Function> NO_SUGGESTIONS = SuggestionsBuilder::buildFuture;
+ private static final SimpleCommandExceptionType ERROR_SINGLE_SUB_LEVEL_REQUIRED =
+ new SimpleCommandExceptionType(Component.translatable("argument.sable.single_sub_level_required"));
+ private static final SimpleCommandExceptionType ERROR_INVALID_SUBLEVEL =
+ new SimpleCommandExceptionType(Component.translatable("argument.sable.sub_level.invalid"));
+ private static final SimpleCommandExceptionType UNEXPECTED_END_OF_INPUT =
+ new SimpleCommandExceptionType(Component.translatable("argument.sable.unexpected_end_of_input"));
+ private static final String STATIC_WORLD = "static_world";
+ private static final Collection EXAMPLES = Arrays.stream(SubLevelSelectorType.values())
+ .map(type -> "@" + type.getChar()).toList();
+ private static Function> suggestions = NO_SUGGESTIONS;
+ private final boolean allowStaticLevel;
+ private final boolean allowMultiple;
+
+ public SubLevelArgumentType(final boolean allowStaticLevel, final boolean allowMultiple) {
+ this.allowStaticLevel = allowStaticLevel;
+ this.allowMultiple = allowMultiple;
+ }
+
+ public static Collection getSubLevels(final CommandContext ctx, final String name) throws CommandSyntaxException {
+ return ctx.getArgument(name, SubLevelSelector.class).getSubLevels(ctx.getSource());
+ }
+
+ public static ServerSubLevel getSingleSubLevel(final CommandContext ctx, final String name) throws CommandSyntaxException {
+ final Collection subLevels = ctx.getArgument(name, SubLevelSelector.class).getSubLevels(ctx.getSource());
+ if (subLevels.size() > 1) {
+ throw ERROR_SINGLE_SUB_LEVEL_REQUIRED.create();
+ }
+
+ if (subLevels.isEmpty()) {
+ throw SableCommandHelper.ERROR_NO_SUB_LEVELS_FOUND.create();
+ }
+
+ return subLevels.stream().findFirst().orElseThrow();
+ }
+
+ public static SubLevelArgumentType singleSubLevel() {
+ return new SubLevelArgumentType(false, false);
+ }
+
+ public static SubLevelArgumentType subLevels() {
+ return new SubLevelArgumentType(false, true);
+ }
+
+ public static SubLevelArgumentType subLevelsOrLevel() {
+ return new SubLevelArgumentType(true, true);
+ }
+
+ private static @NotNull List> parseSelectorArguments(final StringReader reader) throws CommandSyntaxException {
+ final List> modifiers = new ObjectArrayList<>();
+ setSuggestions(reader, "[");
+
+ final List> permittedPreEntryToken = new ArrayList<>(SubLevelSelectorModifierType.getAllNamesWithTooltip()
+ .stream().map(s -> Pair.of(s.first() + "=", s.second())).toList());
+ permittedPreEntryToken.add(Pair.of("]", null));
+ boolean isFirstEntry = true;
+
+ if (reader.canRead() && reader.peek() == '[') {
+ reader.skip();
+
+ setSuggestionsWithTooltip(reader, permittedPreEntryToken);
+ while (reader.canRead() && reader.peek() != ']') {
+ if (reader.peek() == ',') {
+ reader.skip();
+ }
+ setSuggestionsWithTooltip(reader, permittedPreEntryToken);
+
+ final String propertyName = readUntilEndOrCharacter(reader, '=');
+
+ if (!reader.canRead() || reader.peek() != '=') {
+ throw UNEXPECTED_END_OF_INPUT.createWithContext(reader);
+ }
+ reader.skip();
+
+ final SubLevelSelectorModifierType modifierType = SubLevelSelectorModifierType.getModifier(propertyName, reader);
+ if (modifierType == null) {
+ throw UNEXPECTED_END_OF_INPUT.createWithContext(reader);
+ }
+ final SubLevelSelectorModifierType.Modifier modifier = modifierType.getParser().parse(reader);
+ modifiers.add(Pair.of(modifierType, modifier));
+
+ setSuggestionsWithTooltip(reader, permittedPreEntryToken);
+ if (isFirstEntry) {
+ permittedPreEntryToken.add(Pair.of(",", null));
+ isFirstEntry = false;
+ }
+ }
+
+ if (reader.canRead() && reader.peek() == ']') {
+ reader.skip();
+ } else {
+ throw UNEXPECTED_END_OF_INPUT.createWithContext(reader);
+ }
+ }
+
+ return modifiers;
+ }
+
+ public static void setSuggestions(final StringReader reader, final String... suggested) {
+ setSuggestions(reader, Arrays.asList(suggested));
+ }
+
+ public static void setSuggestions(final StringReader reader, final List suggested) {
+ setSuggestionsWithTooltip(reader, suggested.stream().map(s -> Pair.of(s, (Message) null)).toList());
+ }
+
+ @SafeVarargs
+ public static void setSuggestionsWithTooltip(final StringReader reader, final Pair... suggested) {
+ setSuggestionsWithTooltip(reader, Arrays.asList(suggested));
+ }
+
+ public static void setSuggestionsWithTooltip(final StringReader reader, final List> suggested) {
+ final int cursor = reader.getCursor();
+ suggestions = builder -> {
+ final SuggestionsBuilder nextSuggestion = builder.createOffset(cursor);
+ for (final Pair suggestion : suggested) {
+ if (!suggestion.first().startsWith(builder.getInput().substring(cursor))) {
+ continue;
+ }
+ if (suggestion.second() != null) {
+ nextSuggestion.suggest(suggestion.first(), suggestion.second());
+ } else {
+ nextSuggestion.suggest(suggestion.first());
+ }
+ }
+ return nextSuggestion.buildFuture();
+ };
+ }
+
+ public static String readUntilEndOrCharacter(final StringReader reader, final char character) throws CommandSyntaxException {
+ final StringBuilder builder = new StringBuilder();
+ while (reader.canRead() && reader.peek() != character) {
+ builder.append(reader.read());
+ }
+ if (builder.isEmpty()) {
+ throw UNEXPECTED_END_OF_INPUT.create();
+ }
+ return builder.toString();
+ }
+
+ @Override
+ public SubLevelSelector parse(final StringReader reader) throws CommandSyntaxException {
+ final ObjectList> allowedSelectors = new ObjectArrayList<>();
+ if (this.allowStaticLevel) {
+ allowedSelectors.add(Pair.of(STATIC_WORLD, Component.translatable("argument.sable.body.static_world")));
+ }
+ for (final SubLevelSelectorType selector : SubLevelSelectorType.values()) {
+ allowedSelectors.add(Pair.of("@" + selector.getChar(), selector.getTooltip()));
+ }
+ setSuggestionsWithTooltip(reader, allowedSelectors);
+
+ if (this.allowStaticLevel && reader.canRead(STATIC_WORLD.length()) && reader.peek() == STATIC_WORLD.charAt(0)) {
+ final String staticWorld = reader.readString();
+
+ if (!staticWorld.equals(STATIC_WORLD)) {
+ throw ERROR_INVALID_SUBLEVEL.create();
+ }
+
+ return new SubLevelSelector(null, new ObjectArrayList<>());
+ }
+
+ if (!reader.canRead()) {
+ throw ERROR_INVALID_SUBLEVEL.create();
+ }
+
+ final char firstChar = reader.read();
+
+ if (!reader.canRead() || firstChar != '@') {
+ throw ERROR_INVALID_SUBLEVEL.create();
+ }
+
+ if (!reader.canRead()) {
+ throw ERROR_INVALID_SUBLEVEL.create();
+ }
+
+ final SubLevelSelectorType selectorType = SubLevelSelectorType.of(reader.read());
+ if (selectorType == null) {
+ throw ERROR_INVALID_SUBLEVEL.create();
+ }
+
+ int maximumResults = Integer.MAX_VALUE;
+
+ if (selectorType.single()) {
+ maximumResults = 1;
+ }
+
+ final List> modifiers = parseSelectorArguments(reader);
+
+ for (final Pair modifierPair : modifiers) {
+ maximumResults = Math.min(maximumResults, modifierPair.second().getMaxResults());
+ }
+
+ // If we don't allow multiple sub-levels and we have more than one, throw a fit
+ if (maximumResults > 1 && !this.allowMultiple) {
+ throw ERROR_SINGLE_SUB_LEVEL_REQUIRED.create();
+ }
+
+ return new SubLevelSelector(selectorType, modifiers);
+ }
+
+ @Override
+ public CompletableFuture listSuggestions(final CommandContext pContext, final SuggestionsBuilder builder) {
+ final StringReader stringreader = new StringReader(builder.getInput());
+ stringreader.setCursor(builder.getStart());
+ suggestions = NO_SUGGESTIONS;
+ try {
+ this.parse(stringreader);
+ } catch (final CommandSyntaxException ignored) {
+ }
+ return suggestions.apply(builder);
+ }
+
+ @Override
+ public Collection getExamples() {
+ return EXAMPLES;
+ }
+
+ public static class Info implements ArgumentTypeInfo {
+ private static final byte FLAG_MULTIPLE = 1;
+ private static final byte FLAG_STATIC_ALLOWED = 2;
+
+ public void serializeToNetwork(final SubLevelArgumentType.Info.Template template, final FriendlyByteBuf byteBuf) {
+ int serialized = 0;
+ if (template.allowMultiple) {
+ serialized |= FLAG_MULTIPLE;
+ }
+
+ if (template.allowStaticLevel) {
+ serialized |= FLAG_STATIC_ALLOWED;
+ }
+
+ byteBuf.writeByte(serialized);
+ }
+
+ public SubLevelArgumentType.Info.Template deserializeFromNetwork(final FriendlyByteBuf arg) {
+ final byte serialized = arg.readByte();
+ return new SubLevelArgumentType.Info.Template((serialized & FLAG_MULTIPLE) != 0, (serialized & FLAG_STATIC_ALLOWED) != 0);
+ }
+
+ public void serializeToJson(final SubLevelArgumentType.Info.Template arg, final JsonObject jsonObject) {
+ jsonObject.addProperty("amount", arg.allowMultiple ? "single" : "multiple");
+ jsonObject.addProperty("type", arg.allowStaticLevel ? "players" : "entities");
+ }
+
+ public SubLevelArgumentType.Info.Template unpack(final SubLevelArgumentType arg) {
+ return new Template(arg.allowMultiple, arg.allowStaticLevel);
+ }
+
+ public final class Template implements ArgumentTypeInfo.Template {
+ final boolean allowMultiple;
+ final boolean allowStaticLevel;
+
+ Template(final boolean allowMultiple, final boolean allowStaticLevel) {
+ this.allowMultiple = allowMultiple;
+ this.allowStaticLevel = allowStaticLevel;
+ }
+
+ public SubLevelArgumentType instantiate(final CommandBuildContext commandBuildContext) {
+ return new SubLevelArgumentType(this.allowStaticLevel, this.allowMultiple);
+ }
+
+ public ArgumentTypeInfo type() {
+ return SubLevelArgumentType.Info.this;
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/entity/EntitySubLevelUtil.java b/common/src/main/java/dev/ryanhcode/sable/api/entity/EntitySubLevelUtil.java
new file mode 100644
index 0000000..a5b9313
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/entity/EntitySubLevelUtil.java
@@ -0,0 +1,99 @@
+package dev.ryanhcode.sable.api.entity;
+
+import dev.ryanhcode.sable.Sable;
+import dev.ryanhcode.sable.companion.math.JOMLConversion;
+import dev.ryanhcode.sable.index.SableTags;
+import dev.ryanhcode.sable.sublevel.SubLevel;
+import net.minecraft.commands.arguments.EntityAnchorArgument;
+import net.minecraft.util.Mth;
+import net.minecraft.world.entity.Entity;
+import net.minecraft.world.entity.item.FallingBlockEntity;
+import net.minecraft.world.entity.projectile.AbstractArrow;
+import net.minecraft.world.entity.projectile.AbstractHurtingProjectile;
+import net.minecraft.world.phys.Vec3;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Quaterniondc;
+import org.joml.Vector3d;
+
+/**
+ * Utility for operations regarding entities and sub-levels
+ */
+public class EntitySubLevelUtil {
+
+ /**
+ * Sets the old pos of an entity for no apparent movement, taking their tracking sub-level
+ * into account.
+ *
+ * @param entity the entity to set the old pos of
+ */
+ public static void setOldPosNoMovement(final Entity entity) {
+ final SubLevel trackingSubLevel = Sable.HELPER.getTrackingSubLevel(entity);
+
+ if (trackingSubLevel != null) {
+ final Vec3 entityPos = entity.position();
+ final Vec3 oldPos = trackingSubLevel.lastPose().transformPosition(trackingSubLevel.logicalPose().transformPositionInverse(entityPos));
+
+ entity.xOld = oldPos.x;
+ entity.xo = oldPos.x;
+ entity.yOld = oldPos.y;
+ entity.yo = oldPos.y;
+ entity.zOld = oldPos.z;
+ entity.zo = oldPos.z;
+ } else {
+ entity.xOld = entity.getX();
+ entity.xo = entity.getX();
+ entity.yOld = entity.getY();
+ entity.yo = entity.getY();
+ entity.zOld = entity.getZ();
+ entity.zo = entity.getZ();
+ }
+ }
+
+ /**
+ * Kicks an entity out of a sub-level, including velocity and position.
+ *
+ * @param subLevel The sub-level to kick the entity out of
+ * @param entity The entity to kick
+ */
+ public static void kickEntity(final SubLevel subLevel, final Entity entity) {
+ final Vector3d subLevelGainedVelo = new Vector3d();
+ if (entity instanceof final AbstractHurtingProjectile ahp && ahp.accelerationPower == 0) {
+ Sable.HELPER.getVelocity(entity.level(), JOMLConversion.toJOML(entity.position()), subLevelGainedVelo);
+ }
+
+ // convert from m/s to m/t
+ subLevelGainedVelo.mul(1.0 / 20.0);
+
+ final Vec3 pos = entity.position();
+ Vec3 anchor = Vec3.ZERO;
+
+ if (entity instanceof FallingBlockEntity) {
+ anchor = new Vec3(0.0, entity.getBbHeight() / 2.0, 0.0);
+ }
+
+ entity.moveTo(subLevel.logicalPose().transformPosition(pos.add(anchor)).subtract(anchor));
+ entity.setDeltaMovement(subLevel.logicalPose().transformNormal(entity.getDeltaMovement()).add(subLevelGainedVelo.x, subLevelGainedVelo.y, subLevelGainedVelo.z));
+ entity.lookAt(EntityAnchorArgument.Anchor.FEET, subLevel.logicalPose().transformNormal(entity.getLookAngle()).add(entity.position()));
+
+ // Arrows use an incorrect Y and X rotation
+ if (entity instanceof AbstractArrow) {
+ final Vec3 deltaMovement = entity.getDeltaMovement();
+ final double horizontal = deltaMovement.horizontalDistance();
+ entity.setYRot((float) (Mth.atan2(deltaMovement.x, deltaMovement.z) * 180.0 / (float) Math.PI));
+ entity.setXRot((float) (Mth.atan2(deltaMovement.y, horizontal) * 180.0 / (float) Math.PI));
+ }
+ }
+
+ public static boolean shouldKick(final Entity entity) {
+ return !entity.getType().is(SableTags.RETAIN_IN_SUB_LEVEL);
+ }
+
+ @Nullable
+ public static Quaterniondc getCustomEntityOrientation(final Entity entity, final float partialTicks) {
+ return null;
+ }
+
+ public static boolean hasCustomEntityOrientation(final Entity entity) {
+ return false;
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/event/SablePostPhysicsTickEvent.java b/common/src/main/java/dev/ryanhcode/sable/api/event/SablePostPhysicsTickEvent.java
new file mode 100644
index 0000000..b542050
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/event/SablePostPhysicsTickEvent.java
@@ -0,0 +1,23 @@
+package dev.ryanhcode.sable.api.event;
+
+import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem;
+
+/**
+ * Fired when Sable's {@link SubLevelPhysicsSystem} is complete with a physics tick.
+ *
+ * Note that multiple physics ticks are completed per game tick, based on the amount of configured sub-steps.
+ * Logic that needs to influence the physics world should occur on the physics tick, and not the game tick
+ * due to this reason.
+ */
+@FunctionalInterface
+public interface SablePostPhysicsTickEvent {
+
+ /**
+ * Fired when Sable's {@link dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem} is complete with a physics tick.
+ *
+ * @param physicsSystem the physics system running the physics tick
+ * @param timeStep the time step of this physics tick [s]
+ */
+ void postPhysicsTick(SubLevelPhysicsSystem physicsSystem, double timeStep);
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/event/SablePrePhysicsTickEvent.java b/common/src/main/java/dev/ryanhcode/sable/api/event/SablePrePhysicsTickEvent.java
new file mode 100644
index 0000000..80da5bd
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/event/SablePrePhysicsTickEvent.java
@@ -0,0 +1,23 @@
+package dev.ryanhcode.sable.api.event;
+
+import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem;
+
+/**
+ * Fired when Sable's {@link dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem} is ticking physics.
+ *
+ * Note that multiple physics ticks are completed per game tick, based on the amount of configured sub-steps.
+ * Logic that needs to influence the physics world should occur on the physics tick, and not the game tick
+ * due to this reason.
+ */
+@FunctionalInterface
+public interface SablePrePhysicsTickEvent {
+
+ /**
+ * Fired when Sable's {@link dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem} is ticking physics.
+ *
+ * @param physicsSystem the physics system running the physics tick
+ * @param timeStep the time step of this physics tick [s]
+ */
+ void prePhysicsTick(SubLevelPhysicsSystem physicsSystem, double timeStep);
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/event/SableSubLevelContainerReadyEvent.java b/common/src/main/java/dev/ryanhcode/sable/api/event/SableSubLevelContainerReadyEvent.java
new file mode 100644
index 0000000..281542f
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/event/SableSubLevelContainerReadyEvent.java
@@ -0,0 +1,20 @@
+package dev.ryanhcode.sable.api.event;
+
+import dev.ryanhcode.sable.api.sublevel.SubLevelContainer;
+import net.minecraft.world.level.Level;
+
+/**
+ * Fired when Sable has finished initialization for a level and its sub-level container is ready to use.
+ */
+@FunctionalInterface
+public interface SableSubLevelContainerReadyEvent {
+
+ /**
+ * Called when a sub-level container is ready to use.
+ *
+ * @param level The level instance
+ * @param container The sub-level container that is ready
+ */
+ void onSubLevelContainerReady(Level level, SubLevelContainer container);
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/math/LevelReusedVectors.java b/common/src/main/java/dev/ryanhcode/sable/api/math/LevelReusedVectors.java
new file mode 100644
index 0000000..b15031f
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/math/LevelReusedVectors.java
@@ -0,0 +1,119 @@
+package dev.ryanhcode.sable.api.math;
+
+
+import dev.ryanhcode.sable.companion.math.BoundingBox3d;
+import dev.ryanhcode.sable.companion.math.Pose3d;
+import net.minecraft.core.BlockPos;
+import net.minecraft.world.phys.AABB;
+import net.minecraft.world.phys.shapes.Shapes;
+import net.minecraft.world.phys.shapes.VoxelShape;
+import org.joml.*;
+
+/**
+ * Class containing mutability optimization vectors for OBB SAT calculations
+ */
+public class LevelReusedVectors {
+ public final VoxelShape SCAFFOLDING_TOP = Shapes.create(new AABB(0, 15 / 16f, 0, 1f, 1f, 1f));
+
+ public final Vector3d tempVert6 = new Vector3d();
+ public final Vector3d tempVert5 = new Vector3d();
+ public final Vector3d tempVert4 = new Vector3d();
+ public final Vector3d tempVert3 = new Vector3d();
+ public final Vector3d tempVert2 = new Vector3d();
+ public final Vector3d tempVert1 = new Vector3d();
+
+ public final Vector3dc zero = new Vector3d();
+ public final Vector2d proj1 = new Vector2d();
+ public final Vector2d proj2 = new Vector2d();
+ public final Vector3d oppo = new Vector3d();
+ public final Vector3d obbARight = new Vector3d();
+ public final Vector3d obbAForward = new Vector3d();
+ public final Vector3d obbAUp = new Vector3d();
+ public final Vector3d obbBRight = new Vector3d();
+ public final Vector3d obbBForward = new Vector3d();
+ public final Vector3d obbBUp = new Vector3d();
+ public final Vector3d checker = new Vector3d();
+ public final BlockPos.MutableBlockPos minPos = new BlockPos.MutableBlockPos();
+ public final BlockPos.MutableBlockPos maxPos = new BlockPos.MutableBlockPos();
+ public final BlockPos.MutableBlockPos maxBlockPos = new BlockPos.MutableBlockPos();
+ public final BlockPos.MutableBlockPos offsetPos = new BlockPos.MutableBlockPos();
+ public final BoundingBox3d fullContextBounds = new BoundingBox3d();
+ public final BoundingBox3d rotatedContextBounds = new BoundingBox3d();
+ public final BoundingBox3d considerationBounds = new BoundingBox3d();
+ public final BoundingBox3d localBounds = new BoundingBox3d();
+ public final BoundingBox3d localBounds2 = new BoundingBox3d();
+ public final Vector3d collisionMotion = new Vector3d();
+ public final Vector3d velocityMotion = new Vector3d();
+ public final Vector3d entityBoundsCenter = new Vector3d();
+ public final Vector3d stepHeightEntityBoundsCenter = new Vector3d();
+ public final Vector3d lastStepTestMTV = new Vector3d();
+ public final Vector3d entityPosition = new Vector3d();
+ public final Vector3d posMinusCenter = new Vector3d();
+ public final Vector3d trackingPosition = new Vector3d();
+ public final Pose3d lastPose = new Pose3d();
+ public final Pose3d lastSubLevelPose = new Pose3d();
+ public final Pose3d subLevelPose = new Pose3d();
+ public final Matrix4d bakedMatrix = new Matrix4d();
+ public final Vector3d mtv = new Vector3d();
+ public final Vector3d normalizedMtv = new Vector3d();
+ public final Vector3d localMtv = new Vector3d();
+ public final Vector3d existingDeltaMovement = new Vector3d();
+ public final Vector3d maxMTV = new Vector3d();
+ public final BoundingBox3d maxAABB = new BoundingBox3d();
+ public final Vector3d center = new Vector3d();
+ public final BoundingBox3d offsetAABB = new BoundingBox3d();
+ public final BoundingBox3d compressedMinAABB = new BoundingBox3d();
+ public final BoundingBox3d compressedOffsetAABB = new BoundingBox3d();
+ public final BoundingBox3d intersection = new BoundingBox3d();
+
+ public final Quaterniond entityBoxOrientation = new Quaterniond();
+ public final Quaterniond entityCustomOrientation = new Quaterniond();
+ public final Vector3d tempEyePosition = new Vector3d();
+ public final Vector3d anchorRelativePosition = new Vector3d();
+
+ public final Vector3d[] a = {
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d(),
+
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d()
+ };
+ public final Vector3d[] b = {
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d(),
+
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d()
+ };
+ public final Vector3d[] checks = {
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d(),
+
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d(),
+
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d(),
+
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d()
+ };
+ protected final Vector3d tempmin = new Vector3d();
+ protected final Vector3d tempmax = new Vector3d();
+ public final Vector3d entityUpDirection = new Vector3d();
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/math/OrientedBoundingBox3d.java b/common/src/main/java/dev/ryanhcode/sable/api/math/OrientedBoundingBox3d.java
new file mode 100644
index 0000000..a6ab28c
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/math/OrientedBoundingBox3d.java
@@ -0,0 +1,327 @@
+package dev.ryanhcode.sable.api.math;
+
+import org.jetbrains.annotations.NotNull;
+import org.joml.*;
+
+import java.lang.Math;
+import java.util.Objects;
+
+/**
+ * Represents an oriented bounding box with extents, orientation, and positioning.
+ * The box is expected to be centered on the position.
+ */
+public class OrientedBoundingBox3d {
+ public static final Vector3dc RIGHT = new Vector3d(1, 0, 0);
+ public static final Vector3dc UP = new Vector3d(0, 1, 0);
+ public static final Vector3dc FORWARD = new Vector3d(0, 0, 1);
+
+ private final Vector3d position = new Vector3d();
+ private final Vector3d dimensions = new Vector3d();
+ private final Quaterniond orientation = new Quaterniond();
+ private final LevelReusedVectors sink;
+
+
+ /**
+ * Creates a new oriented bounding box.
+ */
+ public OrientedBoundingBox3d(@NotNull final LevelReusedVectors sink) {
+ this.sink = sink;
+ }
+
+ /**
+ * Creates a new oriented bounding box.
+ *
+ * @param position The center in global space
+ * @param dimensions The total dimensions
+ * @param orientation The unit quaternion rotation
+ */
+ public OrientedBoundingBox3d(@NotNull final Vector3dc position,
+ @NotNull final Vector3dc dimensions,
+ @NotNull final Quaterniondc orientation,
+ @NotNull final LevelReusedVectors sink) {
+ this.position.set(position);
+ this.dimensions.set(dimensions);
+ this.orientation.set(orientation);
+ this.sink = sink;
+ }
+
+ /**
+ * Creates a new oriented bounding box.
+ *
+ * @param x The center X in global space
+ * @param y The center Y in global space
+ * @param z The center Z in global space
+ * @param sizeX The total dimensions in the x-axis
+ * @param sizeY The total dimensions in the y-axis
+ * @param sizeZ The total dimensions in the z-axis
+ * @param orientation The unit quaternion rotation
+ */
+ public OrientedBoundingBox3d(final double x,
+ final double y,
+ final double z,
+ final double sizeX,
+ final double sizeY,
+ final double sizeZ,
+ @NotNull final Quaterniondc orientation,
+ @NotNull final LevelReusedVectors sink) {
+ this.position.set(x, y, z);
+ this.dimensions.set(sizeX, sizeY, sizeZ);
+ this.orientation.set(orientation);
+ this.sink = sink;
+ }
+
+ public void set(final Vector3dc position, final Vector3dc dimensions, final Quaterniondc orientation) {
+ this.position.set(position);
+ this.dimensions.set(dimensions);
+ this.orientation.set(orientation);
+ }
+
+ public OrientedBoundingBox3d setPosition(final Vector3dc position) {
+ this.position.set(position);
+ return this;
+ }
+
+ public OrientedBoundingBox3d setDimensions(final Vector3dc dimensions) {
+ this.dimensions.set(dimensions);
+ return this;
+ }
+
+ public OrientedBoundingBox3d setOrientation(final Quaterniondc orientation) {
+ this.orientation.set(orientation);
+ return this;
+ }
+
+ public Quaterniond getOrientation() {
+ return this.orientation;
+ }
+
+ public Vector3d getPosition() {
+ return this.position;
+ }
+
+ public Vector3d getDimensions() {
+ return this.dimensions;
+ }
+
+ /**
+ * Computes all global vertices of this box.
+ */
+ public Vector3d @NotNull [] vertices(final Vector3d[] result) {
+ this.dimensions.mul(0.5, this.sink.tempmin);
+ this.dimensions.mul(-0.5, this.sink.tempmax);
+
+ this.orientation.transform(this.sink.tempmin, result[0]).add(this.position);
+ this.orientation.transform(this.sink.tempVert1.set(this.sink.tempmax.x, this.sink.tempmin.y, this.sink.tempmin.z), result[1]).add(this.position);
+ this.orientation.transform(this.sink.tempVert4.set(this.sink.tempmin.x, this.sink.tempmin.y, this.sink.tempmax.z), result[4]).add(this.position);
+ this.orientation.transform(this.sink.tempVert5.set(this.sink.tempmax.x, this.sink.tempmin.y, this.sink.tempmax.z), result[5]).add(this.position);
+ this.orientation.transform(this.sink.tempVert3.set(this.sink.tempmax.x, this.sink.tempmax.y, this.sink.tempmin.z), result[3]).add(this.position);
+ this.orientation.transform(this.sink.tempVert2.set(this.sink.tempmin.x, this.sink.tempmax.y, this.sink.tempmin.z), result[2]).add(this.position);
+ this.orientation.transform(this.sink.tempVert6.set(this.sink.tempmin.x, this.sink.tempmax.y, this.sink.tempmax.z), result[6]).add(this.position);
+ this.orientation.transform(this.sink.tempmax, result[7]).add(this.position);
+
+ return result;
+ }
+
+
+ /**
+ * Rotates a vector from local space in this OBB to global space.
+ */
+ public Vector3d rotate(@NotNull final Vector3d vec) {
+ return this.orientation.transform(vec);
+ }
+
+ /**
+ * Checks if two intervals intersect.
+ */
+ private static boolean doesOverlap(@NotNull final Vector2d a, @NotNull final Vector2d b) {
+ return a.x <= b.y && a.y >= b.x;
+ }
+
+ /**
+ * @return The overlap of the two intervals.
+ */
+ public static double getOverlap(@NotNull final Vector2d a, @NotNull final Vector2d b) {
+ if (!OrientedBoundingBox3d.doesOverlap(a, b)) {
+ return 0.f;
+ }
+
+ return Math.min(a.y, b.y) - Math.max(a.x, b.x);
+ }
+
+ /**
+ * Computes the MTV, or Minimum Translation Vector between the vertices of two OBBs.
+ */
+ public static @NotNull Vector3d sat(@NotNull final OrientedBoundingBox3d obbA, @NotNull final OrientedBoundingBox3d obbB) {
+ return sat(obbA, obbB, new Vector3d());
+ }
+
+ /**
+ * Computes the MTV, or Minimum Translation Vector between the vertices of two OBBs.
+ */
+ public static @NotNull Vector3d sat(@NotNull final OrientedBoundingBox3d obbA, @NotNull final OrientedBoundingBox3d obbB, @NotNull final Vector3d dest) {
+ Objects.requireNonNull(obbA, "obbA");
+ Objects.requireNonNull(obbB, "obbB");
+ Objects.requireNonNull(dest, "dest");
+
+ final LevelReusedVectors context = obbA.sink;
+
+ final Vector3d[] verticesA = obbA.vertices(context.a);
+ final Vector3d[] verticesB = obbB.vertices(context.b);
+
+ final Vector3d checker = obbA.position.sub(obbB.position, obbA.sink.checker).normalize();
+
+ final Vector3d aRight = obbA.rotate(context.obbARight.set(OrientedBoundingBox3d.RIGHT));
+ final Vector3d aUp = obbA.rotate(context.obbAUp.set(OrientedBoundingBox3d.UP));
+ final Vector3d aForward = obbA.rotate(context.obbAForward.set(OrientedBoundingBox3d.FORWARD));
+
+ final Vector3d bRight = obbB.rotate(context.obbBRight.set(OrientedBoundingBox3d.RIGHT));
+ final Vector3d bUp = obbB.rotate(context.obbBUp.set(OrientedBoundingBox3d.UP));
+ final Vector3d bForward = obbB.rotate(context.obbBForward.set(OrientedBoundingBox3d.FORWARD));
+
+ final Vector3d mtv = dest.set(Double.MAX_VALUE);
+
+ OrientedBoundingBox3d.genChecks(aRight, aUp, aForward, bRight, bUp, bForward, context.checks);
+
+ double minOverlap = Double.MAX_VALUE;
+
+ for (final Vector3d check : context.checks) {
+ if (check.lengthSquared() <= 0) {
+ continue;
+ }
+
+ check.normalize();
+
+ OrientedBoundingBox3d.checkSeparation(verticesA, check, context.proj1);
+ OrientedBoundingBox3d.checkSeparation(verticesB, check, context.proj2);
+
+ if (check.dot(checker) > 0) {
+ check.mul(-1.0);
+ }
+
+ final double overlap = OrientedBoundingBox3d.getOverlap(context.proj1, context.proj2);
+
+ if (overlap == 0.f) { // shapes are not overlapping
+ return dest.zero();
+ } else {
+ if (overlap < minOverlap) {
+ minOverlap = overlap;
+ mtv.set(check.mul(minOverlap));
+ }
+ }
+ }
+
+ final boolean facingOpposite = obbA.position.sub(obbB.position, context.oppo).dot(mtv) < 0;
+
+ if (facingOpposite) {
+ mtv.mul(-1);
+ }
+
+ return mtv;
+ }
+
+ public static Vector3d[] genChecks(final Vector3d aRight, final Vector3d aUp, final Vector3d aForward, final Vector3d bRight, final Vector3d bUp, final Vector3d bForward, final Vector3d[] checks) {
+ checks[0].set(aRight);
+ checks[1].set(aUp);
+ checks[2].set(aForward);
+ checks[3].set(bRight);
+ checks[4].set(bUp);
+ checks[5].set(bForward);
+ aRight.cross(bRight, checks[6]);
+ aRight.cross(bUp, checks[7]);
+ aRight.cross(bForward, checks[8]);
+ aUp.cross(bRight, checks[9]);
+ aUp.cross(bUp, checks[10]);
+ aUp.cross(bForward, checks[11]);
+ aForward.cross(bRight, checks[12]);
+ aForward.cross(bUp, checks[13]);
+ aForward.cross(bForward, checks[14]);
+
+ return checks;
+ }
+
+ public static Vector3dc satToleranced(final OrientedBoundingBox3d entityOBB, final OrientedBoundingBox3d obbB, final double tolerance) {
+ Objects.requireNonNull(entityOBB, "entityOBB");
+ Objects.requireNonNull(obbB, "obbB");
+
+ final LevelReusedVectors context = entityOBB.sink;
+
+ final Vector3d[] verticesA = entityOBB.vertices(context.a);
+ final Vector3d[] verticesB = obbB.vertices(context.b);
+
+ final Vector3d checker = entityOBB.position.sub(obbB.position, new Vector3d()).normalize();
+
+ final Vector3d aRight = entityOBB.rotate(context.obbARight.set(OrientedBoundingBox3d.RIGHT));
+ final Vector3d aUp = entityOBB.rotate(context.obbAUp.set(OrientedBoundingBox3d.UP));
+ final Vector3d aForward = entityOBB.rotate(context.obbAForward.set(OrientedBoundingBox3d.FORWARD));
+
+ final Vector3d bRight = obbB.rotate(context.obbBRight.set(OrientedBoundingBox3d.RIGHT));
+ final Vector3d bUp = obbB.rotate(context.obbBUp.set(OrientedBoundingBox3d.UP));
+ final Vector3d bForward = obbB.rotate(context.obbBForward.set(OrientedBoundingBox3d.FORWARD));
+
+ Vector3d mtv = new Vector3d(Double.MAX_VALUE);
+
+ OrientedBoundingBox3d.genChecks(aRight, aUp, aForward, bRight, bUp, bForward, context.checks);
+
+ double minOverlap = Double.MAX_VALUE;
+
+
+ int i = 0;
+ for (final Vector3d check : context.checks) {
+ if (check.lengthSquared() <= 0) {
+ continue;
+ }
+
+ check.normalize();
+
+ OrientedBoundingBox3d.checkSeparation(verticesA, check, context.proj1);
+ OrientedBoundingBox3d.checkSeparation(verticesB, check, context.proj2);
+
+ if (check.dot(checker) > 0) {
+ check.mul(-1.0);
+ }
+
+ final double overlap = OrientedBoundingBox3d.getOverlap(context.proj1, context.proj2);
+
+ if (overlap == 0.f) { // shapes are not overlapping
+ return context.zero;
+ } else {
+ if (overlap - (i == 14 ? 0.1 : 0.0) < minOverlap) {
+ minOverlap = overlap;
+ mtv = check.mul(minOverlap);
+ }
+ }
+ i++;
+ }
+
+
+ final boolean facingOpposite = entityOBB.position.sub(obbB.position, context.oppo).dot(mtv) < 0;
+
+ if (facingOpposite) {
+ mtv.mul(-1);
+ }
+
+ return mtv;
+ }
+
+ /**
+ * Check separation along an axis for Separating Axis Theorem.
+ *
+ * @return a 2d vector with the first component representing minimum and second component maximum
+ */
+ public static @NotNull Vector2d checkSeparation(final Vector3d @NotNull [] self, @NotNull final Vector3d axis, final Vector2d result) {
+ if (axis.lengthSquared() <= 0.0) {
+ return result.set(0, 0);
+ }
+
+ double min = Double.MAX_VALUE;
+ double max = -Double.MAX_VALUE;
+
+ for (final Vector3d vec : self) {
+ final double dot = vec.dot(axis);
+ min = Math.min(dot, min);
+ max = Math.max(dot, max);
+ }
+
+ return result.set(min, max);
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/particle/ParticleSubLevelKickable.java b/common/src/main/java/dev/ryanhcode/sable/api/particle/ParticleSubLevelKickable.java
new file mode 100644
index 0000000..f25dcff
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/particle/ParticleSubLevelKickable.java
@@ -0,0 +1,22 @@
+package dev.ryanhcode.sable.api.particle;
+
+import dev.ryanhcode.sable.api.math.OrientedBoundingBox3d;
+import org.joml.Vector3dc;
+
+/**
+ * An interface for sub-classes of {@link net.minecraft.client.particle.Particle} to implement that indicates whether
+ * or not the particle should ever be kicked from the tracking sub-level
+ */
+public interface ParticleSubLevelKickable {
+ default boolean sable$shouldCareAboutIntersectingSubLevels() {
+ return true;
+ }
+
+ boolean sable$shouldKickFromTracking();
+
+ boolean sable$shouldCollideWithTrackingSubLevel();
+
+ default Vector3dc sable$getUpDirection() {
+ return OrientedBoundingBox3d.UP;
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/PhysicsPipeline.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/PhysicsPipeline.java
new file mode 100644
index 0000000..c51e8b3
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/PhysicsPipeline.java
@@ -0,0 +1,242 @@
+package dev.ryanhcode.sable.api.physics;
+
+import dev.ryanhcode.sable.api.physics.constraint.PhysicsConstraintConfiguration;
+import dev.ryanhcode.sable.api.physics.constraint.PhysicsConstraintHandle;
+import dev.ryanhcode.sable.api.physics.object.box.BoxHandle;
+import dev.ryanhcode.sable.api.physics.object.box.BoxPhysicsObject;
+import dev.ryanhcode.sable.api.physics.object.rope.RopeHandle;
+import dev.ryanhcode.sable.api.physics.object.rope.RopePhysicsObject;
+import dev.ryanhcode.sable.api.sublevel.KinematicContraption;
+import dev.ryanhcode.sable.companion.math.Pose3d;
+import dev.ryanhcode.sable.companion.math.Pose3dc;
+import dev.ryanhcode.sable.physics.config.PhysicsConfigData;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import dev.ryanhcode.sable.sublevel.SubLevel;
+import net.minecraft.core.SectionPos;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.chunk.LevelChunkSection;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Quaterniondc;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+
+/**
+ * An abstracted physics engine & pipeline for handling {@link dev.ryanhcode.sable.sublevel.SubLevel} physics calculations.
+ */
+public interface PhysicsPipeline {
+
+ /**
+ * Initializes the physics pipeline.
+ *
+ * @param gravity the gravity vector
+ * @param universalDrag the universal drag to apply to all bodies
+ */
+ void init(Vector3dc gravity, double universalDrag);
+
+ /**
+ * Disposes all resources used by the physics pipeline.
+ */
+ void dispose();
+
+ /**
+ * Sets up for the physics ticking with a time step of {@code 1.0 / 20.0} seconds.
+ */
+ void prePhysicsTicks();
+
+ /**
+ * Runs a physics substep with a time step of {@code 1.0 / 20.0 / substeps} seconds.
+ *
+ * @param timeStep the time step of this physics substep ({@code 1.0 / 20.0 / substeps}) [s]
+ */
+ void physicsTick(double timeStep);
+
+ /**
+ * Called after all physics substeps have been run, to finalize the physics tick.
+ */
+ void postPhysicsTicks();
+
+ /**
+ * Runs a tick to update any separate data tracking / logic, even if physics is currently paused
+ */
+ void tick();
+
+ /**
+ * Adds a {@link ServerSubLevel} to the physics pipeline.
+ */
+ void add(ServerSubLevel subLevel, Pose3dc pose);
+
+ /**
+ * Removes a {@link SubLevel} from the physics pipeline.
+ */
+ void remove(ServerSubLevel subLevel);
+
+ /**
+ * Adds a kinematic contraption to the scene
+ */
+ void add(KinematicContraption contraption);
+
+ /**
+ * Removes a kinematic contraption from the scene
+ */
+ void remove(KinematicContraption contraption);
+
+ /**
+ * Queries the physics pipeline for the current pose of a {@link SubLevel}.
+ *
+ * @param subLevel the sub-level to query
+ * @param dest the pose to write into
+ * @return the pose of the sub-level stored in dest
+ */
+ @ApiStatus.OverrideOnly
+ Pose3d readPose(ServerSubLevel subLevel, Pose3d dest);
+
+ /**
+ * Adds a rope to the physics pipeline
+ */
+ @ApiStatus.OverrideOnly
+ RopeHandle addRope(RopePhysicsObject rope);
+
+ /**
+ * Adds a box to the physics pipeline
+ */
+ BoxHandle addBox(BoxPhysicsObject boxPhysicsObject);
+
+ /**
+ * Handles the addition of a chunk to the physics context
+ *
+ * @param x the section x position
+ * @param y the section y position
+ * @param z the section z position
+ */
+ void handleChunkSectionAddition(LevelChunkSection chunk, int x, int y, int z, boolean uploadDataIfGlobal);
+
+ /**
+ * Handles the removal of a chunk section from the physics context
+ *
+ * @param x the section x position
+ * @param y the section y position
+ * @param z the section z position
+ */
+ void handleChunkSectionRemoval(int x, int y, int z);
+
+ /**
+ * Handles the change of a block (from oldState to newState) in a chunk at chunk-relative position x, y, z.
+ * Only called server-side.
+ *
+ * @param x chunk-relative x position
+ * @param z chunk-relative z position
+ * @param y chunk-relative y position
+ */
+ void handleBlockChange(SectionPos sectionPos, LevelChunkSection chunk, int x, int y, int z, BlockState oldState, BlockState newState);
+
+ /**
+ * Called to re-upload center of mass, mass properties, and local bounds to the physics pipeline
+ */
+ default void onStatsChanged(@NotNull final ServerSubLevel serverSubLevel) {
+
+ }
+
+ /**
+ * Teleports the physics pipeline body to a given position.
+ *
+ * @param body the physics pipeline body to teleport
+ * @param position the new position to teleport to
+ * @param orientation the new orientation to teleport to
+ */
+ void teleport(PhysicsPipelineBody body, Vector3dc position, Quaterniondc orientation);
+
+ /**
+ * Adds a force at a given world position to a data containing the position
+ *
+ * @param body the physics pipeline body to apply the force to
+ * @param position the plot position to apply the force at [m]
+ * @param force the force to apply [N]
+ */
+ void applyImpulse(PhysicsPipelineBody body, Vector3dc position, Vector3dc force);
+
+ /**
+ * Adds a local force and torque
+ *
+ * @param body the body to apply the force to
+ * @param force the local force to apply [N]
+ * @param torque the local torque to apply [Nm]
+ * @param wakeUp if the physics pipeline body should be woken if it is sleeping
+ */
+ void applyLinearAndAngularImpulse(PhysicsPipelineBody body, Vector3dc force, Vector3dc torque, boolean wakeUp);
+
+ /**
+ * Adds linear and angular velocities to a physics pipeline body
+ *
+ * @param body the physics pipeline body to apply the velocities to
+ * @param linearVelocity the linear velocity to apply [m/s]
+ * @param angularVelocity the angular velocity to apply [rad/s]
+ */
+ default void addLinearAndAngularVelocity(final PhysicsPipelineBody body, final Vector3dc linearVelocity, final Vector3dc angularVelocity) {
+
+ }
+
+ /**
+ * Resets the velocity of a physics pipeline body
+ *
+ * @param body the physics pipeline body to reset the velocity of
+ */
+ default void resetVelocity(final PhysicsPipelineBody body) {
+ this.addLinearAndAngularVelocity(body, this.getLinearVelocity(body, new Vector3d()).negate(), this.getAngularVelocity(body, new Vector3d()).negate());
+ }
+
+ /**
+ * Gets the linear velocity of a physics pipeline body
+ *
+ * @param body the physics pipeline body to get the linear velocity from
+ * @return the global linear velocity of the body from the physics engine, stored in dest [m/s]
+ */
+ default Vector3d getLinearVelocity(final PhysicsPipelineBody body, final Vector3d dest) {
+ return dest.zero();
+ }
+
+ /**
+ * Gets the angular velocity of a physics pipeline body
+ *
+ * @param body the physics pipeline body to get the angular velocity from
+ * @return the global angular velocity of the body from the physics engine, stored in dest [rad/s]
+ */
+ default Vector3d getAngularVelocity(final PhysicsPipelineBody body, final Vector3d dest) {
+ return dest.zero();
+ }
+
+ /**
+ * "Wakes up" a physics pipeline body, indicating environmental or other changes have occurred that should resume physics if idled or sleeping
+ *
+ * @param body the physics pipeline body to wake up
+ */
+ void wakeUp(PhysicsPipelineBody body);
+
+ /**
+ * Adds a constraint to the engine, returning its handle
+ *
+ * @param sublevelA the first sub-level to constrain, or null to constrain the second sub-level to the world
+ * @param sublevelB the second sub-level to constrain, or null to constrain the first sub-level to the world
+ * @param configuration the configuration of the constraint
+ */
+ default T addConstraint(@Nullable final ServerSubLevel sublevelA, @Nullable final ServerSubLevel sublevelB, final PhysicsConstraintConfiguration configuration) {
+ throw new UnsupportedOperationException("Not implemented");
+ }
+
+ /**
+ * Updates the config of the physics engine from a data object
+ *
+ * @param data the data to update from
+ */
+ @ApiStatus.OverrideOnly
+ default void updateConfigFrom(final PhysicsConfigData data) {
+ throw new UnsupportedOperationException("Not implemented");
+ }
+
+ /**
+ * @return the next runtime ID for a collider / sub-level
+ */
+ int getNextRuntimeID();
+
+}
\ No newline at end of file
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/PhysicsPipelineBody.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/PhysicsPipelineBody.java
new file mode 100644
index 0000000..6813b8d
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/PhysicsPipelineBody.java
@@ -0,0 +1,26 @@
+package dev.ryanhcode.sable.api.physics;
+
+import dev.ryanhcode.sable.api.physics.mass.MassData;
+
+/**
+ * A rigid-body tracked by a {@link PhysicsPipeline}
+ */
+public interface PhysicsPipelineBody {
+
+ int NULL_RUNTIME_ID = -1;
+
+ /**
+ * The runtime integer ID tracked by the {@link PhysicsPipeline}
+ */
+ int getRuntimeId();
+
+ /**
+ * @return the mass data for this physics body
+ */
+ MassData getMassTracker();
+
+ /**
+ * @return if this body has been removed by the pipeline
+ */
+ boolean isRemoved();
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/callback/BlockSubLevelCollisionCallback.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/callback/BlockSubLevelCollisionCallback.java
new file mode 100644
index 0000000..cc2c88c
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/callback/BlockSubLevelCollisionCallback.java
@@ -0,0 +1,38 @@
+package dev.ryanhcode.sable.api.physics.callback;
+
+import dev.ryanhcode.sable.companion.math.JOMLConversion;
+import net.minecraft.core.BlockPos;
+import org.jetbrains.annotations.ApiStatus;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+
+public interface BlockSubLevelCollisionCallback {
+
+ /**
+ * Called when a collision occurs between two blocks, from JNI / pipeline implementations
+ *
+ * @return tangent motion
+ */
+ @ApiStatus.Internal
+ @SuppressWarnings("unused")
+ default double[] onCollision(final int x,
+ final int y,
+ final int z,
+ final double x1,
+ final double y1,
+ final double z1,
+ final double impactVelocity) {
+ final CollisionResult result = this.sable$onCollision(new BlockPos(x, y, z), new Vector3d(x1, y1, z1), impactVelocity);
+ final Vector3dc motion = result.tangentMotion;
+
+ // TODO: this is stupid and moronic to pass through the removal as a double lmao, let's not do that in the future
+ return new double[]{motion.x(), motion.y(), motion.z(), result.removeCollision ? 1.0 : 0.0};
+ }
+
+ CollisionResult sable$onCollision(BlockPos blockPos, Vector3d pos, double impactVelocity);
+
+ record CollisionResult(Vector3dc tangentMotion, boolean removeCollision) {
+ public static final CollisionResult NONE = new CollisionResult(JOMLConversion.ZERO, false);
+ }
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/collider/SableCollisionContext.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/collider/SableCollisionContext.java
new file mode 100644
index 0000000..dd3e5c1
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/collider/SableCollisionContext.java
@@ -0,0 +1,17 @@
+package dev.ryanhcode.sable.api.physics.collider;
+
+import dev.ryanhcode.sable.physics.impl.SableCollisionContextImpl;
+import net.minecraft.world.phys.shapes.CollisionContext;
+
+/**
+ * Context used for getting collision shapes for sable physics pipelines.
+ */
+public interface SableCollisionContext extends CollisionContext {
+
+ /**
+ * @return The collision context to use for getting shapes
+ */
+ static SableCollisionContext get() {
+ return SableCollisionContextImpl.INSTANCE;
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/collider/VoxelColliderData.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/collider/VoxelColliderData.java
new file mode 100644
index 0000000..3521073
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/collider/VoxelColliderData.java
@@ -0,0 +1,19 @@
+package dev.ryanhcode.sable.api.physics.collider;
+
+import org.joml.Vector3dc;
+
+public interface VoxelColliderData {
+ /**
+ * Adds a collision box to the block physics data entry.
+ * Coordinates are expected to be within a single voxel space of the block, 0-1.
+ *
+ * @param min the minimum corner of the box
+ * @param max the maximum corner of the box
+ */
+ void addBox(Vector3dc min, Vector3dc max);
+
+ /**
+ * Clears all collision boxes
+ */
+ void clearBoxes();
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/ConstraintJointAxis.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/ConstraintJointAxis.java
new file mode 100644
index 0000000..1e88d77
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/ConstraintJointAxis.java
@@ -0,0 +1,17 @@
+package dev.ryanhcode.sable.api.physics.constraint;
+
+/**
+ * All degrees of freedom that joint motors and locks can be imposed on
+ */
+public enum ConstraintJointAxis {
+ LINEAR_X,
+ LINEAR_Y,
+ LINEAR_Z,
+ ANGULAR_X,
+ ANGULAR_Y,
+ ANGULAR_Z;
+
+ public static final ConstraintJointAxis[] ALL = values();
+ public static final ConstraintJointAxis[] LINEAR = {LINEAR_X, LINEAR_Y, LINEAR_Z};
+ public static final ConstraintJointAxis[] ANGULAR = {ANGULAR_X, ANGULAR_Y, ANGULAR_Z};
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/PhysicsConstraintConfiguration.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/PhysicsConstraintConfiguration.java
new file mode 100644
index 0000000..b004f05
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/PhysicsConstraintConfiguration.java
@@ -0,0 +1,9 @@
+package dev.ryanhcode.sable.api.physics.constraint;
+
+/**
+ * A configuration for a physics constraint.
+ * @param the type of constraint handle this configuration produces
+ */
+public interface PhysicsConstraintConfiguration {
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/PhysicsConstraintHandle.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/PhysicsConstraintHandle.java
new file mode 100644
index 0000000..26b97a1
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/PhysicsConstraintHandle.java
@@ -0,0 +1,43 @@
+package dev.ryanhcode.sable.api.physics.constraint;
+
+import org.joml.Vector3d;
+
+/**
+ * An active constraint tracked by the physics world.
+ * Must be kept track of to be removed.
+ */
+public interface PhysicsConstraintHandle {
+
+ /**
+ * Gets the latest global linear and angular joint impulses from the solver
+ */
+ void getJointImpulses(Vector3d linearImpulseDest, Vector3d angularImpulseDest);
+
+ /**
+ * Sets if contacts are enabled between the two bodies in the constraint
+ */
+ void setContactsEnabled(boolean enabled);
+
+ /**
+ * Adds / sets a motor on this joint
+ *
+ * @param axis The axis on which the motor operates
+ * @param target The target position along that axis [m | rad]
+ * @param stiffness How stiff the motor should act, or P in the PD controller
+ * @param damping How much damping the motor should have, or D in the PD controller
+ * @param hasMaxForce If the motor should have a force limit
+ * @param maxForce The maximum force the motor can apply
+ */
+ void setMotor(ConstraintJointAxis axis, double target, double stiffness, double damping, boolean hasMaxForce, double maxForce);
+
+ /**
+ * Removes the constraint from the active physics engine
+ */
+ void remove();
+
+ /**
+ * @return if the constraint is still valid, and has not been removed by the engine
+ */
+ boolean isValid();
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/fixed/FixedConstraintConfiguration.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/fixed/FixedConstraintConfiguration.java
new file mode 100644
index 0000000..edbe0ab
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/fixed/FixedConstraintConfiguration.java
@@ -0,0 +1,16 @@
+package dev.ryanhcode.sable.api.physics.constraint.fixed;
+
+import dev.ryanhcode.sable.api.physics.constraint.PhysicsConstraintConfiguration;
+import org.joml.Quaterniondc;
+import org.joml.Vector3dc;
+
+/**
+ * A configuration for a fixed constraint, with both bodies locked to eachother.
+ *
+ * @param pos1 the position in world space assumed to be inside the plot of the first sub-level (ex. a block position).
+ * @param pos2 the position in world space assumed to be inside the plot of the second sub-level (ex. a block position).
+ * @param orientation the local orientation of the second body from the first. Motor axes will be relative to this frame
+ */
+public record FixedConstraintConfiguration(Vector3dc pos1, Vector3dc pos2, Quaterniondc orientation) implements PhysicsConstraintConfiguration {
+
+}
\ No newline at end of file
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/fixed/FixedConstraintHandle.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/fixed/FixedConstraintHandle.java
new file mode 100644
index 0000000..a1f7eb0
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/fixed/FixedConstraintHandle.java
@@ -0,0 +1,10 @@
+package dev.ryanhcode.sable.api.physics.constraint.fixed;
+
+import dev.ryanhcode.sable.api.physics.constraint.PhysicsConstraintHandle;
+
+/**
+ * A fixed constraint between two bodies
+ */
+public interface FixedConstraintHandle extends PhysicsConstraintHandle {
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/free/FreeConstraintConfiguration.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/free/FreeConstraintConfiguration.java
new file mode 100644
index 0000000..6ffb943
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/free/FreeConstraintConfiguration.java
@@ -0,0 +1,16 @@
+package dev.ryanhcode.sable.api.physics.constraint.free;
+
+import dev.ryanhcode.sable.api.physics.constraint.PhysicsConstraintConfiguration;
+import org.joml.Quaterniondc;
+import org.joml.Vector3dc;
+
+/**
+ * A configuration for a free constraint, which imposes no locks
+ *
+ * @param pos1 the position in world space assumed to be inside the plot of the first sub-level (ex. a block position).
+ * @param pos2 the position in world space assumed to be inside the plot of the second sub-level (ex. a block position).
+ * @param orientation the local orientation of the second body from the first. Motor axes will be relative to this frame
+ */
+public record FreeConstraintConfiguration(Vector3dc pos1, Vector3dc pos2, Quaterniondc orientation) implements PhysicsConstraintConfiguration {
+
+}
\ No newline at end of file
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/free/FreeConstraintHandle.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/free/FreeConstraintHandle.java
new file mode 100644
index 0000000..2d224e3
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/free/FreeConstraintHandle.java
@@ -0,0 +1,10 @@
+package dev.ryanhcode.sable.api.physics.constraint.free;
+
+import dev.ryanhcode.sable.api.physics.constraint.PhysicsConstraintHandle;
+
+/**
+ * A free constraint between two bodies
+ */
+public interface FreeConstraintHandle extends PhysicsConstraintHandle {
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/generic/GenericConstraintConfiguration.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/generic/GenericConstraintConfiguration.java
new file mode 100644
index 0000000..5615040
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/generic/GenericConstraintConfiguration.java
@@ -0,0 +1,32 @@
+package dev.ryanhcode.sable.api.physics.constraint.generic;
+
+import dev.ryanhcode.sable.api.physics.constraint.ConstraintJointAxis;
+import dev.ryanhcode.sable.api.physics.constraint.PhysicsConstraintConfiguration;
+import org.joml.Quaterniondc;
+import org.joml.Vector3dc;
+
+import java.util.EnumSet;
+import java.util.Set;
+
+/**
+ * A configuration for a generic constraint, with per-axis hard locks and re-anchorable local frames.
+ *
+ * @param pos1 the position in world space assumed to be inside the plot of the first sub-level (ex. a block position).
+ * @param pos2 the position in world space assumed to be inside the plot of the second sub-level (ex. a block position).
+ * @param orientation1 the local orientation of the joint frame on the first sub-level.
+ * @param orientation2 the local orientation of the joint frame on the second sub-level.
+ * @param lockedAxes the set of axes hard-locked by the solver; empty matches a free constraint.
+ * @since 1.1.0
+ */
+public record GenericConstraintConfiguration(
+ Vector3dc pos1,
+ Vector3dc pos2,
+ Quaterniondc orientation1,
+ Quaterniondc orientation2,
+ Set lockedAxes
+) implements PhysicsConstraintConfiguration {
+
+ public GenericConstraintConfiguration(final Vector3dc pos1, final Vector3dc pos2, final Quaterniondc orientation1, final Quaterniondc orientation2) {
+ this(pos1, pos2, orientation1, orientation2, EnumSet.noneOf(ConstraintJointAxis.class));
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/generic/GenericConstraintHandle.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/generic/GenericConstraintHandle.java
new file mode 100644
index 0000000..5c714c8
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/generic/GenericConstraintHandle.java
@@ -0,0 +1,29 @@
+package dev.ryanhcode.sable.api.physics.constraint.generic;
+
+import dev.ryanhcode.sable.api.physics.constraint.PhysicsConstraintHandle;
+import org.joml.Quaterniondc;
+import org.joml.Vector3dc;
+
+/**
+ * A generic constraint between two bodies.
+ *
+ * @since 1.1.0
+ */
+public interface GenericConstraintHandle extends PhysicsConstraintHandle {
+
+ /**
+ * Sets the local frame on the first body.
+ *
+ * @param localPosition the local anchor position
+ * @param localRotation the local frame orientation
+ */
+ void setFrame1(Vector3dc localPosition, Quaterniondc localRotation);
+
+ /**
+ * Sets the local frame on the second body.
+ *
+ * @param localPosition the local anchor position
+ * @param localRotation the local frame orientation
+ */
+ void setFrame2(Vector3dc localPosition, Quaterniondc localRotation);
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/rotary/RotaryConstraintConfiguration.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/rotary/RotaryConstraintConfiguration.java
new file mode 100644
index 0000000..70d6e4c
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/rotary/RotaryConstraintConfiguration.java
@@ -0,0 +1,15 @@
+package dev.ryanhcode.sable.api.physics.constraint.rotary;
+
+import dev.ryanhcode.sable.api.physics.constraint.PhysicsConstraintConfiguration;
+import org.joml.Vector3dc;
+
+/**
+ * A configuration for a rotary joint constraint, with a single angular DOF.
+ * @param pos1 the position in world space assumed to be inside the plot of the first sub-level (ex. a block position).
+ * @param pos2 the position in world space assumed to be inside the plot of the second sub-level (ex. a block positino).
+ * @param normal1 the local normal of the joint on the first sub-level.
+ * @param normal2 the local normal of the joint on the second sub-level.
+ */
+public record RotaryConstraintConfiguration(Vector3dc pos1, Vector3dc pos2, Vector3dc normal1, Vector3dc normal2) implements PhysicsConstraintConfiguration {
+
+}
\ No newline at end of file
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/rotary/RotaryConstraintHandle.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/rotary/RotaryConstraintHandle.java
new file mode 100644
index 0000000..fb0c5b4
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/constraint/rotary/RotaryConstraintHandle.java
@@ -0,0 +1,26 @@
+package dev.ryanhcode.sable.api.physics.constraint.rotary;
+
+import dev.ryanhcode.sable.api.physics.constraint.ConstraintJointAxis;
+import dev.ryanhcode.sable.api.physics.constraint.PhysicsConstraintHandle;
+
+/**
+ * A constraint handle for a rotary / motor constraint between two bodies
+ */
+public interface RotaryConstraintHandle extends PhysicsConstraintHandle {
+
+ ConstraintJointAxis DEFAULT_AXIS = ConstraintJointAxis.ANGULAR_X;
+
+ /**
+ * Sets the servo coefficients for this rotary constraint.
+ *
+ * @param angle the target angle [radians]
+ * @param stiffness the stiffness of the servo
+ * @param damping the damping of the servo
+ * @deprecated use {@link #setMotor(ConstraintJointAxis, double, double, double, boolean, double)} instead with {@link RotaryConstraintHandle#DEFAULT_AXIS}.
+ */
+ @Deprecated(forRemoval = true)
+ default void setServoCoefficients(final double angle, final double stiffness, final double damping) {
+ this.setMotor(DEFAULT_AXIS, angle, stiffness, damping, false, 0.0);
+ }
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/force/ForceGroup.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/force/ForceGroup.java
new file mode 100644
index 0000000..e2c7b57
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/force/ForceGroup.java
@@ -0,0 +1,16 @@
+package dev.ryanhcode.sable.api.physics.force;
+
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A grouping of forces, for queued force totals & display to the user for making contraptions
+ *
+ * @param name the name of the force group
+ * @param description the description of the force group
+ * @param color the RGB color of the force group
+ * @param defaultDisplayed if the force group should be default displayed in GUIs
+ */
+public record ForceGroup(@NotNull Component name, @Nullable Component description, int color, boolean defaultDisplayed) {
+}
\ No newline at end of file
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/force/ForceGroups.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/force/ForceGroups.java
new file mode 100644
index 0000000..2d004f5
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/force/ForceGroups.java
@@ -0,0 +1,42 @@
+package dev.ryanhcode.sable.api.physics.force;
+
+import dev.ryanhcode.sable.Sable;
+import foundry.veil.platform.registry.RegistrationProvider;
+import foundry.veil.platform.registry.RegistryObject;
+import net.minecraft.core.Registry;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceKey;
+
+/**
+ * All default force groups
+ */
+public class ForceGroups {
+ public static final ResourceKey> REGISTRY_KEY = ResourceKey.createRegistryKey(Sable.sablePath("force_groups"));
+ private static final RegistrationProvider VANILLA_PROVIDER;
+ public static final Registry REGISTRY;
+
+ static {
+ VANILLA_PROVIDER = RegistrationProvider.get(REGISTRY_KEY, Sable.MOD_ID);
+ REGISTRY = VANILLA_PROVIDER.asVanillaRegistry();
+ }
+
+ public static final RegistryObject GRAVITY = VANILLA_PROVIDER.register(Sable.sablePath("gravity"), () -> new ForceGroup(Component.translatable("force_group.sable.gravity"), null, 0x216e55, false));
+ public static final RegistryObject DRAG = VANILLA_PROVIDER.register(Sable.sablePath("drag"), () -> new ForceGroup(Component.translatable("force_group.sable.drag"), null, 0x834f31, false));
+ public static final RegistryObject LEVITATION = VANILLA_PROVIDER.register(Sable.sablePath("levitation"), () -> new ForceGroup(Component.translatable("force_group.sable.levitation"), null, 0x734480, true));
+ public static final RegistryObject BALLOON_LIFT = VANILLA_PROVIDER.register(Sable.sablePath("balloon_lift"), () -> new ForceGroup(Component.translatable("force_group.sable.balloon_lift"), null, 0xd2643e, true));
+ public static final RegistryObject PROPULSION = VANILLA_PROVIDER.register(Sable.sablePath("propulsion"), () -> new ForceGroup(Component.translatable("force_group.sable.propulsion"), null, 0x5a7c9f, true));
+ public static final RegistryObject LIFT = VANILLA_PROVIDER.register(Sable.sablePath("lift"), () -> new ForceGroup(Component.translatable("force_group.sable.lift"), null, 0x8cb6c6, true));
+ public static final RegistryObject MAGNETIC_FORCE = VANILLA_PROVIDER.register(Sable.sablePath("magnetic_force"), () -> new ForceGroup(Component.translatable("force_group.sable.magnetic_force"), null, 0xe05343, false));
+
+ public static void register() {
+ // no-op
+ }
+
+ /**
+ *
+ * The count of registered force groups
+ */
+ public static int count() {
+ return REGISTRY.size();
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/force/ForceTotal.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/force/ForceTotal.java
new file mode 100644
index 0000000..47e587d
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/force/ForceTotal.java
@@ -0,0 +1,148 @@
+package dev.ryanhcode.sable.api.physics.force;
+
+import dev.ryanhcode.sable.api.physics.handle.RigidBodyHandle;
+import dev.ryanhcode.sable.api.physics.mass.MassData;
+import dev.ryanhcode.sable.api.physics.mass.MassTracker;
+import dev.ryanhcode.sable.companion.math.JOMLConversion;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import net.minecraft.world.phys.Vec3;
+import org.jetbrains.annotations.ApiStatus;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+
+/**
+ * Utility class for applying forces to {@link RigidBodyHandle rigid-body handles}
+ */
+public class ForceTotal {
+
+ private final Vector3d temp = new Vector3d();
+ private final Vector3d lastLocalForce = new Vector3d();
+ private final Vector3d lastLocalTorque = new Vector3d();
+ private final Vector3d localForce = new Vector3d();
+ private final Vector3d localTorque = new Vector3d();
+
+ @ApiStatus.Internal
+ public void applyForces(final RigidBodyHandle handle) {
+ final boolean forceChanged = this.localForce.distanceSquared( this.lastLocalForce) > 1e-5;
+ final boolean torqueChanged = this.localTorque.distanceSquared(this.lastLocalTorque) > 1e-5;
+
+ final boolean wakeUp = forceChanged || torqueChanged;
+ handle.applyLinearAndAngularImpulse(this.localForce, this.localTorque, wakeUp);
+
+ this.lastLocalForce.set(this.localForce);
+ this.lastLocalTorque.set(this.localTorque);
+
+ this.localForce.set(0.0, 0.0, 0.0);
+ this.localTorque.set(0.0, 0.0, 0.0);
+ }
+
+ /**
+ * Resets the current force total
+ */
+ public void reset() {
+ this.localForce.set(0.0, 0.0, 0.0);
+ this.localTorque.set(0.0, 0.0, 0.0);
+ }
+
+ /**
+ * Applies another force total to this one
+ *
+ * @param other the other force total to apply
+ */
+ public void applyForceTotal(final ForceTotal other) {
+ this.localForce.add(other.localForce);
+ this.localTorque.add(other.localTorque);
+ }
+
+ /**
+ * Adds to both local linear and angular momenta
+ *
+ * @param impulse the local impulse to apply [N]
+ * @param torque the local torque to apply [Nm]
+ */
+ public void applyLinearAndAngularImpulse(final Vector3dc impulse, final Vector3dc torque) {
+ this.localForce.add(impulse);
+ this.localTorque.add(torque);
+ }
+
+ /**
+ * Adds to local linear momenta
+ *
+ * @param impulse the local impulse to apply [N]
+ */
+ public void applyLinearImpulse(final Vector3dc impulse) {
+ this.applyLinearAndAngularImpulse(impulse, JOMLConversion.ZERO);
+ }
+
+ /**
+ * Adds to local angular momenta
+ *
+ * @param impulse the local impulse to apply [N]
+ */
+ public void applyAngularImpulse(final Vector3dc impulse) {
+ this.applyLinearAndAngularImpulse(JOMLConversion.ZERO, impulse);
+ }
+
+ /**
+ * Adds to local angular momenta
+ *
+ * @param torque the local torque to apply [Nm]
+ */
+ public void applyTorqueImpulse(final Vector3dc torque) {
+ this.applyAngularImpulse(torque);
+ }
+
+ /**
+ * Adds a momenta impulse at a given world position to a data containing the position
+ *
+ * @param position the position inside the plot to apply the force at [m]
+ * @param force the local impulse to apply [N]
+ */
+ public void applyImpulseAtPoint(final MassData massTracker, final Vector3dc position, final Vector3dc force) {
+ this.localForce.add(force);
+ position.sub(massTracker.getCenterOfMass(), this.temp);
+ this.localTorque.add(this.temp.cross(force));
+ }
+
+ /**
+ * Adds a momenta impulse at a given world position to a data containing the position
+ *
+ * @param position the position inside the plot to apply the force at [m]
+ * @param force the local impulse to apply [N]
+ */
+ public void applyImpulseAtPoint(final ServerSubLevel massTracker, final Vector3dc position, final Vector3dc force) {
+ this.applyImpulseAtPoint(
+ massTracker.getMassTracker(),
+ position,
+ force
+ );
+ }
+
+ /**
+ * @return the current totalled local force
+ */
+ public Vector3d getLocalForce() {
+ return this.localForce;
+ }
+
+ /**
+ * @return the current totalled local torque
+ */
+ public Vector3d getLocalTorque() {
+ return this.localTorque;
+ }
+
+ /**
+ * Adds a momenta impulse at a given world position to a data containing the position
+ *
+ * @param position the position inside the plot to apply the force at [m]
+ * @param force the local impulse to apply [N]
+ */
+ public void applyImpulseAtPoint(final MassTracker massTracker, final Vec3 position, final Vec3 force) {
+ this.applyImpulseAtPoint(
+ massTracker,
+ JOMLConversion.toJOML(position),
+ JOMLConversion.toJOML(force)
+ );
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/force/QueuedForceGroup.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/force/QueuedForceGroup.java
new file mode 100644
index 0000000..3cf9e6b
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/force/QueuedForceGroup.java
@@ -0,0 +1,50 @@
+package dev.ryanhcode.sable.api.physics.force;
+
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import org.joml.Vector3dc;
+
+import java.util.List;
+
+/**
+ * A grouping of applied point forces, alongside a force total to be applied.
+ */
+public class QueuedForceGroup {
+ private final List appliedForces = new ObjectArrayList<>();
+ private final ForceTotal forceTotal = new ForceTotal();
+ private final ServerSubLevel subLevel;
+
+ public QueuedForceGroup(final ServerSubLevel serverSubLevel) {
+ this.subLevel = serverSubLevel;
+ }
+
+ public ForceTotal getForceTotal() {
+ return this.forceTotal;
+ }
+
+ public void applyAndRecordPointForce(final Vector3dc point, final Vector3dc force) {
+ this.forceTotal.applyImpulseAtPoint(this.subLevel.getMassTracker(), point, force);
+ this.recordPointForce(point, force);
+ }
+ public void recordPointForce(final Vector3dc point, final Vector3dc force) {
+ if (!this.subLevel.isTrackingIndividualQueuedForces()) {
+ return;
+ }
+
+ if (force.lengthSquared() > 0.001 * 0.001) {
+ this.appliedForces.add(new PointForce(point, force));
+ }
+ }
+
+ public List getRecordedPointForces() {
+ return this.appliedForces;
+ }
+
+ public void reset() {
+ this.forceTotal.reset();
+ this.appliedForces.clear();
+ }
+
+ public record PointForce(Vector3dc point, Vector3dc force) {
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/handle/RigidBodyHandle.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/handle/RigidBodyHandle.java
new file mode 100644
index 0000000..3b6ff97
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/handle/RigidBodyHandle.java
@@ -0,0 +1,208 @@
+package dev.ryanhcode.sable.api.physics.handle;
+
+import dev.ryanhcode.sable.api.physics.PhysicsPipelineBody;
+import dev.ryanhcode.sable.api.physics.force.ForceTotal;
+import dev.ryanhcode.sable.api.sublevel.ServerSubLevelContainer;
+import dev.ryanhcode.sable.api.sublevel.SubLevelContainer;
+import dev.ryanhcode.sable.companion.math.JOMLConversion;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.phys.Vec3;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.Contract;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Quaterniondc;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+
+/**
+ * A handle for easy access to physics-related operations on a {@link dev.ryanhcode.sable.sublevel.ServerSubLevel}.
+ */
+public class RigidBodyHandle {
+ private final PhysicsPipelineBody body;
+ private final SubLevelPhysicsSystem physicsSystem;
+
+ /**
+ * Obtains a handle for a given physics body.
+ *
+ * @param level the level to obtain the handle for
+ * @param body the sub-level to obtain the handle for
+ */
+ @Contract("_,_ -> _")
+ public static @Nullable RigidBodyHandle of(final ServerLevel level, final PhysicsPipelineBody body) {
+ final ServerSubLevelContainer container = SubLevelContainer.getContainer(level);
+
+ if (container == null) {
+ return null;
+ }
+ final SubLevelPhysicsSystem physicsSystem = container.physicsSystem();
+
+ return new RigidBodyHandle(body, physicsSystem);
+ }
+
+ /**
+ * Obtains a handle for a given server sub-level.
+ *
+ * If the physics system is already available in-scope or this is being called in bulk, the handle should be obtained
+ * through {@link SubLevelPhysicsSystem#getPhysicsHandle(ServerSubLevel)}.
+ *
+ * @param subLevel the sub-level to obtain the handle for
+ */
+ @Contract("_ -> new")
+ public static @Nullable RigidBodyHandle of(final ServerSubLevel subLevel) {
+ final ServerLevel level = subLevel.getLevel();
+ final ServerSubLevelContainer container = SubLevelContainer.getContainer(level);
+
+ if (container == null) {
+ return null;
+ }
+
+ final SubLevelPhysicsSystem physicsSystem = container.physicsSystem();
+
+ return physicsSystem.getPhysicsHandle(subLevel);
+ }
+
+ @ApiStatus.Internal
+ public RigidBodyHandle(final PhysicsPipelineBody body, final SubLevelPhysicsSystem physicsSystem) {
+ this.body = body;
+ this.physicsSystem = physicsSystem;
+ }
+
+ /**
+ * Adds a momenta impulse at a given world position to a data containing the position
+ *
+ * @param position the position inside the plot to apply the force at [m]
+ * @param force the local impulse to apply [N]
+ */
+ public void applyImpulseAtPoint(final Vector3dc position, final Vector3dc force) {
+ this.physicsSystem.getPipeline().applyImpulse(this.body, position, force);
+ }
+
+ /**
+ * Adds a momenta impulse at a given world position to a data containing the position
+ *
+ * @param position the position inside the plot to apply the force at [m]
+ * @param force the local impulse to apply [N]
+ */
+ public void applyImpulseAtPoint(final Vec3 position, final Vec3 force) {
+ this.physicsSystem.getPipeline().applyImpulse(this.body, JOMLConversion.toJOML(position), JOMLConversion.toJOML(force));
+ }
+
+ /**
+ * Adds to both local linear and angular momenta
+ *
+ * @param impulse the local impulse to apply [N]
+ * @param torque the local torque to apply [Nm]
+ */
+ public void applyLinearAndAngularImpulse(final Vector3dc impulse, final Vector3dc torque) {
+ this.applyLinearAndAngularImpulse(impulse, torque, true);
+ }
+
+ /**
+ * Adds to both local linear and angular momenta
+ *
+ * @param impulse the local impulse to apply [N]
+ * @param torque the local torque to apply [Nm]
+ */
+ public void applyLinearAndAngularImpulse(final Vector3dc impulse, final Vector3dc torque, final boolean wakeUp) {
+ this.physicsSystem.getPipeline().applyLinearAndAngularImpulse(this.body, impulse, torque, wakeUp);
+ }
+
+ /**
+ * Adds to local linear momenta
+ *
+ * @param impulse the local impulse to apply [N]
+ */
+ public void applyLinearImpulse(final Vector3dc impulse) {
+ this.applyLinearAndAngularImpulse(impulse, JOMLConversion.ZERO);
+ }
+
+ /**
+ * Adds to local angular momenta
+ *
+ * @param impulse the local impulse to apply [N]
+ */
+ public void applyAngularImpulse(final Vector3dc impulse) {
+ this.applyLinearAndAngularImpulse(JOMLConversion.ZERO, impulse);
+ }
+
+ /**
+ * Adds to local angular momenta
+ *
+ * @param torque the local torque to apply [Nm]
+ */
+ public void applyTorqueImpulse(final Vector3dc torque) {
+ this.applyAngularImpulse(torque);
+ }
+
+ /**
+ * @return the global linear velocity of the body from the physics engine [m/s]
+ * @deprecated Use {@link RigidBodyHandle#getLinearVelocity(Vector3d)} instead.
+ */
+ @Deprecated
+ public Vector3dc getLinearVelocity() {
+ return this.physicsSystem.getPipeline().getLinearVelocity(this.body, new Vector3d());
+ }
+
+ /**
+ * @return the global angular velocity of the body from the physics engine [rad/s]
+ */
+ @Deprecated
+ public Vector3dc getAngularVelocity() {
+ return this.physicsSystem.getPipeline().getAngularVelocity(this.body, new Vector3d());
+ }
+
+ /**
+ * @param dest the destination vector to store the result in
+ * @return the global linear velocity of the body from the physics engine, stored in dest [m/s]
+ */
+ public Vector3d getLinearVelocity(final Vector3d dest) {
+ return this.physicsSystem.getPipeline().getLinearVelocity(this.body, dest);
+ }
+
+ /**
+ * @param dest the destination vector to store the result in
+ * @return the global angular velocity of the body from the physics engine, stored in dest [rad/s]
+ */
+ public Vector3d getAngularVelocity(final Vector3d dest) {
+ return this.physicsSystem.getPipeline().getAngularVelocity(this.body, dest);
+ }
+
+ /**
+ * Applies forces from a force applicator to this body.
+ * If the forces have not changed significantly since the last time the force total was used, the rigid-body will not be woken up.
+ */
+ public void applyForcesAndReset(final ForceTotal forceTotal) {
+ forceTotal.applyForces(this);
+ }
+
+ /**
+ * Adds linear and angular velocities
+ *
+ * @param linearVelocity the linear velocity to apply [m/s]
+ * @param angularVelocity the angular velocity to apply [rad/s]
+ */
+ public void addLinearAndAngularVelocity(final Vector3dc linearVelocity, final Vector3dc angularVelocity) {
+ this.physicsSystem.getPipeline().addLinearAndAngularVelocity(this.body, linearVelocity, angularVelocity);
+ }
+
+ /**
+ * Teleports the physics pipeline body to a given position.
+ *
+ * @param position the new position to teleport to
+ * @param orientation the new orientation to teleport to
+ */
+ public void teleport(final Vector3dc position, final Quaterniondc orientation) {
+ this.physicsSystem.getPipeline().teleport(this.body, position, orientation);
+ }
+
+ /**
+ * Checks if this handle is valid.
+ *
+ * @return true if the handle is alid
+ */
+ public boolean isValid() {
+ return !this.body.isRemoved();
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/mass/MassData.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/mass/MassData.java
new file mode 100644
index 0000000..ba4c8fa
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/mass/MassData.java
@@ -0,0 +1,53 @@
+package dev.ryanhcode.sable.api.physics.mass;
+
+import org.jetbrains.annotations.Nullable;
+import org.joml.Matrix3dc;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+
+public interface MassData {
+
+ /**
+ * @return total mass [kpg]
+ */
+ double getMass();
+
+ /**
+ * @return total inverse mass [1/kpg]
+ */
+ double getInverseMass();
+
+ /**
+ * @return inertia tensor in local space [kpg*m^2]
+ */
+ Matrix3dc getInertiaTensor();
+
+ /**
+ * @return inverse inertia tensor in local space [1/(kpg*m^2)]
+ */
+ Matrix3dc getInverseInertiaTensor();
+
+ /**
+ * @return the nullable location of the center-of-mass
+ */
+ @Nullable
+ Vector3dc getCenterOfMass();
+
+ default boolean isInvalid() {
+ return this.getMass() <= 0.0;
+ }
+
+ /**
+ * @param position the position to check the normal mass at, assumed to be in the plot
+ * @param direction the direction to check the normal mass along, local to the plot
+ * @return the normal mass, or effective mass at the plot position and direction
+ */
+ default double getInverseNormalMass(final Vector3dc position, final Vector3dc direction) {
+ final Vector3d comLocalPos = position.sub(this.getCenterOfMass(), new Vector3d());
+ final Vector3d normalizedDirection = direction.normalize(new Vector3d());
+ final Vector3d cross = comLocalPos.cross(normalizedDirection, new Vector3d());
+
+ return cross.dot(this.getInverseInertiaTensor().transform(cross, new Vector3d()))
+ + this.getInverseMass();
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/mass/MassTracker.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/mass/MassTracker.java
new file mode 100644
index 0000000..e0b26a0
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/mass/MassTracker.java
@@ -0,0 +1,270 @@
+package dev.ryanhcode.sable.api.physics.mass;
+
+import dev.ryanhcode.sable.api.block.BlockSubLevelCustomCenterOfMass;
+import dev.ryanhcode.sable.companion.math.BoundingBox3ic;
+import dev.ryanhcode.sable.companion.math.JOMLConversion;
+import dev.ryanhcode.sable.physics.chunk.VoxelNeighborhoodState;
+import dev.ryanhcode.sable.physics.config.block_properties.PhysicsBlockPropertyHelper;
+import dev.ryanhcode.sable.util.SableMathUtils;
+import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
+import net.minecraft.core.BlockPos;
+import net.minecraft.world.level.BlockGetter;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.phys.AABB;
+import net.minecraft.world.phys.Vec3;
+import net.minecraft.world.phys.shapes.VoxelShape;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Matrix3d;
+import org.joml.Matrix3dc;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+
+import java.util.function.BiFunction;
+
+/**
+ * Tracks the mass / inertia tensor of a structure
+ */
+public class MassTracker implements MassData {
+ private static final AABB UNIT_BOUNDS = new AABB(0, 0, 0, 1, 1, 1);
+
+ /**
+ * The memoized internal center of masses for block-states
+ */
+ public static BiFunction BLOCK_CENTER_OF_MASS = new BiFunction<>() {
+ private final Int2ObjectOpenHashMap cache = new Int2ObjectOpenHashMap<>();
+
+ @Override
+ public Vector3dc apply(final BlockGetter blockGetter, final BlockState state) {
+ return this.cache.computeIfAbsent(state.hashCode(), x -> {
+ if (state.isAir()) {
+ return JOMLConversion.HALF;
+ }
+
+ if (state.getBlock() instanceof final BlockSubLevelCustomCenterOfMass customCenterOfMass) {
+ return customCenterOfMass.getCenterOfMass(blockGetter, state);
+ }
+
+ final VoxelShape shape = state.getCollisionShape(blockGetter, BlockPos.ZERO);
+
+ if (shape.isEmpty()) {
+ return JOMLConversion.HALF;
+ }
+
+ if (state.isCollisionShapeFullBlock(blockGetter, BlockPos.ZERO)) {
+ return JOMLConversion.HALF;
+ }
+
+ final AABB bounds = shape.bounds().intersect(UNIT_BOUNDS);
+ return JOMLConversion.toJOML(bounds.getCenter());
+ });
+ }
+ };
+
+ private static final Matrix3d BLOCK_INERTIA = new Matrix3d();
+ /**
+ * The mass of the sub-level [kpg]
+ */
+ private double mass;
+ /**
+ * The inertia tensor of the sub-level [kgm^2]
+ */
+ private Matrix3d inertiaTensor;
+ /**
+ * 1 / mass of the sub-level [1 / kpg]
+ */
+ private double inverseMass;
+ /**
+ * 1 / inertia tensor of the sub-level [1 / kgm^2]
+ */
+ private Matrix3d inverseInertiaTensor;
+ /**
+ * The center of mass of the sub-level [m]
+ */
+ private @Nullable Vector3d centerOfMass;
+
+ /**
+ * The data to track the mass of
+ */
+ public MassTracker() {
+ this.mass = 0.0;
+ this.centerOfMass = null;
+ this.inertiaTensor = new Matrix3d().zero();
+ this.inverseInertiaTensor = new Matrix3d().zero();
+ }
+
+ /**
+ * Creates a new mass tracker for a sub-level.
+ */
+ public static MassTracker build(final BlockGetter blockGetter, final BoundingBox3ic bounds) {
+ double mass = 0.0;
+ final Vector3d centerOfMass = new Vector3d();
+ final Matrix3d inertiaTensor = new Matrix3d().zero();
+
+ final BlockPos.MutableBlockPos blockPos = new BlockPos.MutableBlockPos();
+ final Vector3d blockCenter = new Vector3d();
+ int blockCount = 0;
+
+ for (int x = bounds.minX(); x <= bounds.maxX(); x++) {
+ for (int y = bounds.minY(); y <= bounds.maxY(); y++) {
+ for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) {
+ final BlockState state = blockGetter.getBlockState(blockPos.set(x, y, z));
+
+ if (!VoxelNeighborhoodState.isSolid(blockGetter, blockPos, state)) {
+ continue;
+ }
+
+ final double blockMass = PhysicsBlockPropertyHelper.getMass(blockGetter, blockPos, state);
+ blockCenter.set(x, y, z)
+ .add(BLOCK_CENTER_OF_MASS.apply(blockGetter, state));
+
+ mass += blockMass;
+ centerOfMass.fma(blockMass, blockCenter);
+ blockCount++;
+ }
+ }
+ }
+
+ if (blockCount == 0) {
+ final MassTracker tracker = new MassTracker();
+ tracker.mass = 0.0;
+ tracker.centerOfMass = null;
+ tracker.inertiaTensor = new Matrix3d().zero();
+ tracker.inverseInertiaTensor = new Matrix3d().zero();
+ return tracker;
+ }
+
+ centerOfMass.div(mass);
+
+ for (int x = bounds.minX(); x <= bounds.maxX(); x++) {
+ for (int y = bounds.minY(); y <= bounds.maxY(); y++) {
+ for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) {
+ final BlockState state = blockGetter.getBlockState(blockPos.set(x, y, z));
+
+ if (!VoxelNeighborhoodState.isSolid(blockGetter, blockPos, state)) {
+ continue;
+ }
+
+ blockCenter.set(x, y, z)
+ .add(BLOCK_CENTER_OF_MASS.apply(blockGetter, state));
+
+ final double blockMass = PhysicsBlockPropertyHelper.getMass(blockGetter, blockPos, state);
+ final Vec3 blockInertia = PhysicsBlockPropertyHelper.getInertia(blockGetter, blockPos, state);
+ final Vector3d r = blockCenter.sub(centerOfMass);
+
+ MassTracker.addBlockInertia(r, blockMass, inertiaTensor, blockInertia);
+ }
+ }
+ }
+
+ final Matrix3d inverseInertiaTensor = new Matrix3d(inertiaTensor).invert();
+ final double inverseMass = 1.0 / mass;
+
+ final MassTracker tracker = new MassTracker();
+
+ tracker.centerOfMass = centerOfMass;
+ tracker.mass = mass;
+ tracker.inverseMass = inverseMass;
+ tracker.inertiaTensor = inertiaTensor;
+ tracker.inverseInertiaTensor = inverseInertiaTensor;
+
+ return tracker;
+ }
+
+ private static Matrix3d addBlockInertia(final Vector3d blockPos, final double blockMass, final Matrix3d dest, final @Nullable Vec3 blockInertia) {
+ if (blockInertia == null) {
+ // block doesn't specify inertia, we assume it to be a cube
+ BLOCK_INERTIA.identity().scale(blockMass / 6.0);
+ } else {
+ // block specifies inertia, we use it as diagonals
+ BLOCK_INERTIA.identity();
+ BLOCK_INERTIA.m00 = blockInertia.x * blockMass;
+ BLOCK_INERTIA.m11 = blockInertia.y * blockMass;
+ BLOCK_INERTIA.m22 = blockInertia.z * blockMass;
+ }
+
+ dest.add(BLOCK_INERTIA);
+ SableMathUtils.fmaInertiaTensor(blockPos, blockMass, dest);
+ return dest;
+ }
+
+ /**
+ * Adds the mass of a 1x1x1 cube to the sub-level.
+ * Negative mass is equivalent to the removal of a block.
+ *
+ * @param blockPos The position of the block
+ * @param blockMass The mass of the block [kpg]
+ */
+ public void addBlockMass(final BlockGetter blockGetter, final BlockState state, final BlockPos blockPos, final double blockMass, final @Nullable Vec3 blockInertia) {
+ final double oldMass = this.mass;
+ final double newMass = oldMass + blockMass;
+
+ final Vector3d blockCenter = new Vector3d(blockPos.getX(), blockPos.getY(), blockPos.getZ())
+ .add(BLOCK_CENTER_OF_MASS.apply(blockGetter, state));
+
+ if (this.centerOfMass == null) {
+ this.centerOfMass = new Vector3d(blockCenter);
+ }
+
+ final Vector3d blockCenterFromCOM = new Vector3d(blockCenter).sub(this.centerOfMass);
+
+ addBlockInertia(blockCenterFromCOM, blockMass, this.inertiaTensor, blockInertia);
+ this.mass = newMass;
+ this.inverseMass = 1.0 / newMass;
+
+ this.moveCenterOfMass(new Vector3d(this.centerOfMass).mul(oldMass).add(blockCenter.mul(blockMass)).div(newMass));
+ }
+
+ /**
+ * Moves the center of mass to a new position.
+ *
+ * @param newCenterOfMass The new center of mass
+ */
+ public void moveCenterOfMass(final Vector3d newCenterOfMass) {
+ final Vector3d diff = new Vector3d(newCenterOfMass).sub(this.centerOfMass);
+ final Matrix3d outerProduct = new Matrix3d(
+ diff.x * diff.x,
+ diff.y * diff.x,
+ diff.z * diff.x,
+
+ diff.x * diff.y,
+ diff.y * diff.y,
+ diff.z * diff.y,
+
+ diff.x * diff.z,
+ diff.y * diff.z,
+ diff.z * diff.z
+ );
+
+ final Matrix3d inertia = new Matrix3d().scale(diff.lengthSquared()).sub(outerProduct).scale(this.mass);
+
+ this.inertiaTensor.sub(inertia);
+ this.inverseInertiaTensor = new Matrix3d(this.inertiaTensor).invert();
+ this.centerOfMass.set(newCenterOfMass);
+ }
+
+
+ @Override
+ public double getInverseMass() {
+ return this.inverseMass;
+ }
+
+ @Override
+ public Matrix3dc getInverseInertiaTensor() {
+ return this.inverseInertiaTensor;
+ }
+
+ @Override
+ public Matrix3dc getInertiaTensor() {
+ return this.inertiaTensor;
+ }
+
+ @Override
+ public double getMass() {
+ return this.mass;
+ }
+
+ @Override
+ public Vector3dc getCenterOfMass() {
+ return this.centerOfMass;
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/mass/MergedMassTracker.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/mass/MergedMassTracker.java
new file mode 100644
index 0000000..7ef2f05
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/mass/MergedMassTracker.java
@@ -0,0 +1,175 @@
+package dev.ryanhcode.sable.api.physics.mass;
+
+import dev.ryanhcode.sable.companion.math.Pose3d;
+import dev.ryanhcode.sable.api.sublevel.KinematicContraption;
+import dev.ryanhcode.sable.api.sublevel.ServerSubLevelContainer;
+import dev.ryanhcode.sable.api.sublevel.SubLevelContainer;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem;
+import dev.ryanhcode.sable.util.SableMathUtils;
+import net.minecraft.server.level.ServerLevel;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.joml.*;
+
+import java.util.Collection;
+import java.util.Objects;
+
+public class MergedMassTracker implements MassData {
+ private final MassTracker selfTracker;
+ /**
+ * The sub-level to track the merged mass of
+ */
+ private final ServerSubLevel subLevel;
+ /**
+ * The merged mass of the sub-level, including contraptions [kpg]
+ */
+ private double mass;
+ /**
+ * The merged inertia tensor of the sub-level, including contraptions [kgm^2]
+ */
+ private final Matrix3d inertiaTensor = new Matrix3d().zero();
+ /**
+ * 1 / merged mass of the sub-level, including contraptions [1 / kpg]
+ */
+ private double inverseMass;
+ /**
+ * 1 / merged inertia tensor of the sub-level, including contraptions [1 / kgm^2]
+ */
+ private final Matrix3d inverseInertiaTensor = new Matrix3d().zero();
+ /**
+ * The merged center of mass of the sub-level, including contraptions [m]
+ */
+ private @Nullable Vector3d centerOfMass;
+
+ private double lastMass;
+ private @Nullable Vector3d lastCenterOfMass;
+ private @Nullable Matrix3d lastInertiaTensor;
+
+ public MergedMassTracker(@NotNull final ServerSubLevel subLevel, final MassTracker selfTracker) {
+ this.subLevel = subLevel;
+ this.selfTracker = selfTracker;
+ }
+
+ /**
+ * Updates the merged mass properties of this sub-level, and merges the contraption mass trackers into this one
+ */
+ public void update(final float partialPhysicsTick) {
+ if (this.selfTracker.getCenterOfMass() == null) {
+ return;
+ }
+
+ final Collection contraptions = this.subLevel.getPlot().getContraptions();
+
+ this.mass = this.selfTracker.getMass();
+ this.centerOfMass = this.selfTracker.getCenterOfMass().mul(this.getMass(), new Vector3d());
+
+ for (final KinematicContraption contraption : contraptions) {
+ final MassTracker contraptionMassData = contraption.sable$getMassTracker();
+ this.mass = this.getMass() + contraptionMassData.getMass();
+ this.centerOfMass.fma(contraptionMassData.getMass(), contraption.sable$getPosition(partialPhysicsTick));
+ }
+
+ this.centerOfMass.mul(1 / this.getMass());
+
+ this.inertiaTensor.set(this.selfTracker.getInertiaTensor());
+ final Vector3d localShift = this.centerOfMass.sub(this.selfTracker.getCenterOfMass(), new Vector3d());
+
+ // nudge inertia tensor
+ SableMathUtils.fmaInertiaTensor(localShift, this.selfTracker.getMass(), this.inertiaTensor);
+
+ for (final KinematicContraption contraption : contraptions) {
+ final MassTracker contraptionMassData = contraption.sable$getMassTracker();
+
+ final Vector3d localPos = contraption.sable$getPosition(partialPhysicsTick).sub(this.centerOfMass, new Vector3d());
+ SableMathUtils.fmaInertiaTensor(localPos, contraptionMassData.getMass(), this.inertiaTensor);
+
+ final Quaterniond contraptionOrientation = contraption.sable$getOrientation(partialPhysicsTick);
+
+ // Q * (I * (Q^-1 * v))
+ final Matrix3d localInertiaTensor = new Matrix3d()
+ .rotateLocal(contraptionOrientation.conjugate(new Quaterniond()))
+ .mulLocal(contraptionMassData.getInertiaTensor())
+ .rotateLocal(contraptionOrientation);
+
+ this.inertiaTensor.add(localInertiaTensor);
+ }
+
+ this.inverseMass = 1.0 / this.mass;
+ this.inertiaTensor.invert(this.inverseInertiaTensor);
+
+ this.uploadData();
+ this.setPreviousValues();
+ }
+
+ private void uploadData() {
+ if (this.centerOfMass != null && (this.mass != this.lastMass ||
+ !Objects.equals(this.lastCenterOfMass, this.centerOfMass) ||
+ !Objects.equals(this.lastInertiaTensor, this.inertiaTensor))) {
+ if (this.lastCenterOfMass == null || this.lastInertiaTensor == null) {
+ this.lastCenterOfMass = new Vector3d(this.centerOfMass);
+ this.lastInertiaTensor = new Matrix3d(this.inertiaTensor);
+ }
+
+ final ServerLevel level = this.subLevel.getLevel();
+ final ServerSubLevelContainer container = SubLevelContainer.getContainer(level);
+ final SubLevelPhysicsSystem physicsSystem = container.physicsSystem();
+
+ final Vector3d movement = this.centerOfMass.sub(this.lastCenterOfMass, new Vector3d());
+
+ physicsSystem.updatePose(this.subLevel);
+ final Pose3d pose = this.subLevel.logicalPose();
+ physicsSystem.getPipeline().teleport(this.subLevel, pose.position().add(pose.orientation().transform(movement)), pose.orientation());
+ pose.rotationPoint().set(this.centerOfMass);
+ physicsSystem.getPipeline().onStatsChanged(this.subLevel);
+ }
+ }
+
+ private void setPreviousValues() {
+ if (this.centerOfMass == null) {
+ this.lastCenterOfMass = null;
+ this.lastInertiaTensor = null;
+ } else {
+ if (this.lastCenterOfMass == null) {
+ this.lastCenterOfMass = new Vector3d();
+ this.lastInertiaTensor = new Matrix3d().zero();
+ }
+ this.lastCenterOfMass.set(this.centerOfMass);
+ this.lastInertiaTensor.set(this.inertiaTensor);
+ }
+
+ this.lastMass = this.mass;
+ }
+
+ @Override
+ public double getInverseMass() {
+ return this.inverseMass;
+ }
+
+ @Override
+ public Matrix3dc getInverseInertiaTensor() {
+ return this.inverseInertiaTensor;
+ }
+
+ @Override
+ public Matrix3dc getInertiaTensor() {
+ return this.inertiaTensor;
+ }
+
+ @Override
+ public double getMass() {
+ return this.mass;
+ }
+
+ @Override
+ public Vector3dc getCenterOfMass() {
+ return this.centerOfMass;
+ }
+
+ /**
+ * @return the mass tracker for just the sub-level, not including merged masses
+ */
+ public MassTracker getSelfMassTracker() {
+ return this.selfTracker;
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/object/ArbitraryPhysicsObject.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/object/ArbitraryPhysicsObject.java
new file mode 100644
index 0000000..fe9d1f4
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/object/ArbitraryPhysicsObject.java
@@ -0,0 +1,41 @@
+package dev.ryanhcode.sable.api.physics.object;
+
+import dev.ryanhcode.sable.companion.math.BoundingBox3d;
+import dev.ryanhcode.sable.sublevel.storage.holding.SubLevelHoldingChunkMap;
+import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem;
+import net.minecraft.world.level.ChunkPos;
+
+/**
+ * An arbitrary physics object in a {@link dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem}
+ */
+public interface ArbitraryPhysicsObject {
+
+ /**
+ * Gathers the bounding box that the arbitrary physics object requires to be loaded.
+ * Chunk sections intersecting this bounding box will be synced to the physics engine.
+ *
+ * @param dest the destination the bounding box should be written into
+ */
+ void getBoundingBox(final BoundingBox3d dest);
+
+ /**
+ * Called upon the physics object entering unloaded chunks
+ */
+ void onUnloaded(SubLevelHoldingChunkMap holdingChunkMap, ChunkPos chunkPos);
+
+ /**
+ * Called upon the physics object being removed from the system through means other than unloading
+ */
+ void onRemoved();
+
+ /**
+ * Called upon the physics object being added to the world
+ */
+ void onAddition(final SubLevelPhysicsSystem physicsSystem);
+
+ /**
+ * Called to wake up the physics object when nearby blocks were modified
+ */
+ void wakeUp();
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/object/box/BoxHandle.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/object/box/BoxHandle.java
new file mode 100644
index 0000000..7b19d2f
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/object/box/BoxHandle.java
@@ -0,0 +1,33 @@
+package dev.ryanhcode.sable.api.physics.object.box;
+
+import dev.ryanhcode.sable.companion.math.Pose3d;
+import org.jetbrains.annotations.ApiStatus;
+
+/**
+ * A handle to an active box in the physics engine.
+ *
+ * @see BoxPhysicsObject
+ */
+public interface BoxHandle {
+
+ /**
+ * Queries the pose of the box from the physics engine
+ */
+ @ApiStatus.OverrideOnly
+ void readPose(Pose3d dest);
+
+ /**
+ * Removes the box from the physics pipeline
+ */
+ void remove();
+
+ /**
+ * Wakes up the box
+ */
+ void wakeUp();
+
+ /**
+ * @return the runtime ID of the box
+ */
+ int getRuntimeId();
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/object/box/BoxPhysicsObject.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/object/box/BoxPhysicsObject.java
new file mode 100644
index 0000000..ac4ead3
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/object/box/BoxPhysicsObject.java
@@ -0,0 +1,178 @@
+package dev.ryanhcode.sable.api.physics.object.box;
+
+import dev.ryanhcode.sable.api.physics.PhysicsPipelineBody;
+import dev.ryanhcode.sable.api.physics.mass.MassData;
+import dev.ryanhcode.sable.api.physics.object.ArbitraryPhysicsObject;
+import dev.ryanhcode.sable.companion.math.BoundingBox3d;
+import dev.ryanhcode.sable.companion.math.JOMLConversion;
+import dev.ryanhcode.sable.companion.math.Pose3d;
+import dev.ryanhcode.sable.companion.math.Pose3dc;
+import dev.ryanhcode.sable.sublevel.storage.holding.SubLevelHoldingChunkMap;
+import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem;
+import net.minecraft.world.level.ChunkPos;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Matrix3d;
+import org.joml.Matrix3dc;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+
+/**
+ * A box cuboid physics object. Some may say.
+ */
+public class BoxPhysicsObject implements ArbitraryPhysicsObject, PhysicsPipelineBody {
+
+ protected BoxHandle handle;
+ private final Pose3d pose = new Pose3d();
+ private final Vector3d halfExtents = new Vector3d();
+ private final double mass;
+ private boolean active = false;
+
+ /**
+ * Constructs a box physics object
+ * @param pose the pose where the rotation point and scale are ignored
+ * @param halfExtents the half-extents of the box
+ * @param mass the mass of the box
+ */
+ public BoxPhysicsObject(final Pose3dc pose, final Vector3dc halfExtents, final double mass) {
+ this.pose.set(pose);
+ this.halfExtents.set(halfExtents);
+ this.mass = mass;
+ }
+
+ /**
+ * Gathers the bounding box that the arbitrary physics object requires to be loaded.
+ * Chunk sections intersecting this bounding box will be synced to the physics engine.
+ *
+ * @param dest the destination the bounding box should be written into
+ */
+ @Override
+ public void getBoundingBox(final BoundingBox3d dest) {
+ final double max = this.halfExtents.get(this.halfExtents.maxComponent());
+
+ final Vector3d center = this.pose.position();
+ dest.set(center.x, center.y, center.z, center.x, center.y, center.z);
+ dest.expand(max * 1.7321);
+ }
+
+ /**
+ * Updates the pose of the box
+ */
+ public void updatePose() {
+ this.handle.readPose(this.pose);
+ }
+
+ /**
+ * Called upon the physics object entering unloaded chunks
+ */
+ @Override
+ public void onUnloaded(final SubLevelHoldingChunkMap holdingChunkMap, final ChunkPos chunkPos) {
+ this.remove();
+ }
+
+ /**
+ * Called upon the physics object being removed from the system through means other than unloading
+ */
+ @Override
+ public void onRemoved() {
+ this.remove();
+ }
+
+ protected void remove() {
+ this.active = false;
+ this.handle.remove();
+ this.handle = null;
+ }
+
+ /**
+ * Called upon the physics object being added to the world
+ */
+ @Override
+ public void onAddition(final SubLevelPhysicsSystem physicsSystem) {
+ this.active = true;
+ this.handle = physicsSystem.getPipeline().addBox(this);
+ }
+
+ /**
+ * Called to wake up the physics object when nearby blocks were modified
+ */
+ @Override
+ public void wakeUp() {
+ this.handle.wakeUp();
+ }
+
+ /**
+ * @return the last updated pose
+ */
+ public Pose3dc getPose() {
+ return this.pose;
+ }
+
+ /**
+ * @return the half extents of the cube
+ */
+ public Vector3dc getHalfExtents() {
+ return this.halfExtents;
+ }
+
+ /**
+ * @return the mass of the cube
+ */
+ public double getMass() {
+ return this.mass;
+ }
+
+ public boolean isActive() {
+ return this.active;
+ }
+
+ @Override
+ public int getRuntimeId() {
+ if (this.handle == null) {
+ return PhysicsPipelineBody.NULL_RUNTIME_ID;
+ }
+ return this.handle.getRuntimeId();
+ }
+
+ @Override
+ public MassData getMassTracker() {
+ return new BoxMassData();
+ }
+
+ @Override
+ public boolean isRemoved() {
+ return !this.active;
+ }
+
+ /**
+ * Mass data for a box physics object
+ */
+ private class BoxMassData implements MassData {
+ private final Matrix3dc inertia = new Matrix3d().scale(BoxPhysicsObject.this.mass / 6.0);
+ private final Matrix3dc inverseInertia = this.inertia.invert(new Matrix3d());
+
+ @Override
+ public double getMass() {
+ return BoxPhysicsObject.this.mass;
+ }
+
+ @Override
+ public double getInverseMass() {
+ return 1.0 / BoxPhysicsObject.this.mass;
+ }
+
+ @Override
+ public Matrix3dc getInertiaTensor() {
+ return this.inertia;
+ }
+
+ @Override
+ public Matrix3dc getInverseInertiaTensor() {
+ return this.inverseInertia;
+ }
+
+ @Override
+ public @Nullable Vector3dc getCenterOfMass() {
+ return JOMLConversion.ZERO;
+ }
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/object/rope/RopeHandle.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/object/rope/RopeHandle.java
new file mode 100644
index 0000000..6facfdf
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/object/rope/RopeHandle.java
@@ -0,0 +1,61 @@
+package dev.ryanhcode.sable.api.physics.object.rope;
+
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import org.jetbrains.annotations.ApiStatus;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+
+import java.util.List;
+
+/**
+ * A handle to an active rope in the physics engine.
+ *
+ * @see RopePhysicsObject
+ */
+public interface RopeHandle {
+
+ /**
+ * Queries the points of the rope from the physics engine
+ */
+ @ApiStatus.OverrideOnly
+ void readPose(List dest);
+
+ /**
+ * Removes the rope from the physics pipeline
+ */
+ void remove();
+
+ /**
+ * Sets the extension constraint length of the first segment
+ */
+ void setFirstSegmentLength(double length);
+
+ /**
+ * Removes the point at the beginning of the rope
+ */
+ void removeFirstPoint();
+
+ /**
+ * Adds a point to the beginning of the rope
+ */
+ void addPoint(final Vector3dc position);
+
+ /**
+ * Sets an attachment
+ */
+ void setAttachment(final AttachmentPoint attachmentPoint, final Vector3dc location, final ServerSubLevel subLevel);
+
+ /**
+ * Wakes up the rope
+ */
+ void wakeUp();
+
+ /**
+ * Rope attachment points
+ */
+ enum AttachmentPoint {
+ START,
+ END
+ }
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/physics/object/rope/RopePhysicsObject.java b/common/src/main/java/dev/ryanhcode/sable/api/physics/object/rope/RopePhysicsObject.java
new file mode 100644
index 0000000..94d2bc0
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/physics/object/rope/RopePhysicsObject.java
@@ -0,0 +1,166 @@
+package dev.ryanhcode.sable.api.physics.object.rope;
+
+import dev.ryanhcode.sable.companion.math.BoundingBox3d;
+import dev.ryanhcode.sable.api.physics.object.ArbitraryPhysicsObject;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import dev.ryanhcode.sable.sublevel.storage.holding.SubLevelHoldingChunkMap;
+import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem;
+import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import it.unimi.dsi.fastutil.objects.ObjectList;
+import it.unimi.dsi.fastutil.objects.ObjectLists;
+import net.minecraft.world.level.ChunkPos;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+
+import java.util.Collection;
+
+/**
+ * A rope made of points. Some may say.
+ */
+public class RopePhysicsObject implements ArbitraryPhysicsObject {
+ protected final ObjectList points;
+ protected final ObjectList pointsView;
+ protected final double collisionRadius;
+ protected boolean active;
+ protected RopeHandle handle;
+
+ protected Vector3dc startAttachmentLocation = null;
+ protected ServerSubLevel startAttachmentSubLevel = null;
+
+ public RopePhysicsObject(final Collection points, final double collisionRadius) {
+ this.points = new ObjectArrayList<>(points);
+ this.pointsView = ObjectLists.unmodifiable(this.points);
+ this.collisionRadius = collisionRadius;
+ this.active = false;
+ }
+
+ /**
+ * Gathers the bounding box that the arbitrary physics object requires to be loaded.
+ * Chunk sections intersecting this bounding box will be synced to the physics engine.
+ *
+ * @param dest the destination the bounding box should be written into
+ */
+ @Override
+ public void getBoundingBox(final BoundingBox3d dest) {
+ final Vector3d first = this.points.getFirst();
+ dest.set(first.x, first.y, first.z, first.x, first.y, first.z);
+ for (final Vector3d point : this.points) {
+ dest.expandTo(point.x - this.collisionRadius, point.y - this.collisionRadius, point.z - this.collisionRadius);
+ dest.expandTo(point.x + this.collisionRadius, point.y + this.collisionRadius, point.z + this.collisionRadius);
+ }
+ }
+
+ public double getCollisionRadius() {
+ return this.collisionRadius;
+ }
+
+ /**
+ * @return A view of all points
+ */
+ public ObjectList getPoints() {
+ return this.pointsView;
+ }
+
+ /**
+ * Updates the points of the rope
+ */
+ public void updatePose() {
+ this.handle.readPose(this.points);
+ }
+
+ /**
+ * Sets the extension constraint length of the first segment
+ */
+ public void setFirstSegmentLength(final double length) {
+ this.handle.setFirstSegmentLength(length);
+ }
+
+ /**
+ * Removes the point at the beginning of the rope
+ */
+ public void removeFirstPoint() {
+ this.points.removeFirst();
+
+ if (this.isActive()) {
+ this.handle.removeFirstPoint();
+ }
+
+ if (this.startAttachmentLocation != null) {
+ this.setAttachment(RopeHandle.AttachmentPoint.START, this.startAttachmentLocation, this.startAttachmentSubLevel);
+ }
+ }
+
+ /**
+ * Adds a point to the beginning of the rope
+ */
+ public void addPoint(final Vector3dc position) {
+ this.points.addFirst(new Vector3d(position));
+
+ if (this.isActive()) {
+ this.handle.addPoint(position);
+ }
+
+ if (this.startAttachmentLocation != null) {
+ this.setAttachment(RopeHandle.AttachmentPoint.START, this.startAttachmentLocation, this.startAttachmentSubLevel);
+ }
+ }
+
+ /**
+ * Sets an attachment
+ */
+ public void setAttachment(final RopeHandle.AttachmentPoint attachmentPoint, final Vector3dc location, final ServerSubLevel subLevel) {
+ if (attachmentPoint == RopeHandle.AttachmentPoint.START) {
+ this.startAttachmentSubLevel = subLevel;
+ this.startAttachmentLocation = new Vector3d(location);
+ }
+
+ if (this.isActive()) {
+ this.handle.setAttachment(attachmentPoint, location, subLevel);
+ }
+ }
+
+ /**
+ * Called upon the physics object entering unloaded chnks
+ */
+ @Override
+ public void onUnloaded(final SubLevelHoldingChunkMap holdingChunkMap, final ChunkPos chunkPos) {
+ this.remove();
+ }
+
+ /**
+ * Called upon the physics object being removed from the system through means other than unloading
+ */
+ @Override
+ public void onRemoved() {
+ this.remove();
+ }
+
+ protected void remove() {
+ this.active = false;
+ this.handle.remove();
+ this.handle = null;
+ }
+
+ /**
+ * Called upon the physics object being added to the world
+ */
+ @Override
+ public void onAddition(final SubLevelPhysicsSystem physicsSystem) {
+ this.active = true;
+ this.handle = physicsSystem.getPipeline().addRope(this);
+ }
+
+ /**
+ * Called to wake up the physics object when nearby blocks were modified
+ */
+ @Override
+ public void wakeUp() {
+ if (this.isActive()) {
+ this.handle.wakeUp();
+ }
+ }
+
+ public boolean isActive() {
+ return this.active;
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/schematic/SubLevelSchematicSerializationContext.java b/common/src/main/java/dev/ryanhcode/sable/api/schematic/SubLevelSchematicSerializationContext.java
new file mode 100644
index 0000000..0881a1e
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/schematic/SubLevelSchematicSerializationContext.java
@@ -0,0 +1,95 @@
+package dev.ryanhcode.sable.api.schematic;
+
+import dev.ryanhcode.sable.companion.math.BoundingBox3i;
+import dev.ryanhcode.sable.sublevel.SubLevel;
+import io.netty.util.concurrent.FastThreadLocal;
+import it.unimi.dsi.fastutil.Function;
+import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
+import net.minecraft.core.BlockPos;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Quaterniondc;
+import org.joml.Vector3dc;
+
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * A global context for sub-levels being serialized/unserialized to/from schematics.
+ * Block-entities and pieces of content that rely on sub-level dependencies are encouraged
+ * to serialize differently using this context when being saved to schematics.
+ */
+public class SubLevelSchematicSerializationContext {
+ private static final FastThreadLocal THREAD_LOCAL = new FastThreadLocal<>();
+ private final Map mappings = new Object2ObjectOpenHashMap<>();
+ private Function placeTransform;
+ private Function setupTransform;
+ private final Type type;
+ private final BoundingBox3i boundingBox;
+
+ public SubLevelSchematicSerializationContext(final Type type, final BoundingBox3i boundingBox) {
+ this.type = type;
+ this.boundingBox = boundingBox;
+ }
+
+ public Type getType() {
+ return this.type;
+ }
+
+ public BoundingBox3i getBoundingBox() {
+ return this.boundingBox;
+ }
+
+ public static SubLevelSchematicSerializationContext getCurrentContext() {
+ return THREAD_LOCAL.get();
+ }
+
+ @ApiStatus.Internal
+ public static void setCurrentContext(@Nullable final SubLevelSchematicSerializationContext context) {
+ THREAD_LOCAL.set(context);
+ }
+
+ public Function getPlaceTransform() {
+ return this.placeTransform;
+ }
+
+ public Function getSetupTransform() {
+ return this.setupTransform;
+ }
+
+ @ApiStatus.Internal
+ public void setPlaceTransform(final Function transform) {
+ this.placeTransform = transform;
+ }
+
+
+ @ApiStatus.Internal
+ public void setSetupTransform(final Function transform) {
+ this.setupTransform = transform;
+ }
+
+ @Nullable
+ public SchematicMapping getMapping(final SubLevel subLevel) {
+ return this.mappings.get(subLevel.getUniqueId());
+ }
+
+ @Nullable
+ public SchematicMapping getMapping(final UUID uuid) {
+ return this.mappings.get(uuid);
+ }
+
+ @ApiStatus.Internal
+ public Map getMappings() {
+ return this.mappings;
+ }
+
+ public record SchematicMapping(Vector3dc newCorner, Quaterniondc newOrientation, UUID newUUID,
+ Function transform) {
+ }
+
+ public enum Type {
+ PLACE,
+ SAVE
+ }
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/ClientSubLevelContainer.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/ClientSubLevelContainer.java
new file mode 100644
index 0000000..75d2bdd
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/ClientSubLevelContainer.java
@@ -0,0 +1,107 @@
+package dev.ryanhcode.sable.api.sublevel;
+
+
+import dev.ryanhcode.sable.companion.math.Pose3d;
+import dev.ryanhcode.sable.network.client.ClientSableInterpolationState;
+import dev.ryanhcode.sable.sublevel.ClientSubLevel;
+import dev.ryanhcode.sable.sublevel.SubLevel;
+import net.minecraft.client.multiplayer.ClientLevel;
+import net.minecraft.world.level.Level;
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.BitSet;
+import java.util.List;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+/**
+ * Holds all sub-levels and plots in a {@link ClientLevel}
+ */
+public class ClientSubLevelContainer extends SubLevelContainer {
+ private final ClientSableInterpolationState interpolation = new ClientSableInterpolationState();
+
+ /**
+ * Temp lighting scene IDs for flywheel
+ */
+ private final BitSet lightingSceneIds;
+
+ /**
+ * Creates a new sub-level container with the given side length and plot size.
+ *
+ * @param level the level of the plotgrid
+ * @param logSideLength the log_2 of the amount of chunks in the side of the plotgrid
+ * @param logPlotSize the log_2 of the amount of chunks in the side of a plot
+ * @param originX the X coordinate in plots of the origin of the plotgrid
+ * @param originZ the Z coordinate in plots of the origin of the plotgrid
+ */
+ public ClientSubLevelContainer(final Level level, final int logSideLength, final int logPlotSize, final int originX, final int originZ) {
+ super(level, logSideLength, logPlotSize, originX, originZ);
+ this.lightingSceneIds = new BitSet(this.subLevels.length);
+ }
+
+ @Override
+ protected SubLevel createSubLevel(final int globalPlotX, final int globalPlotZ, final Pose3d pose, final UUID uuid) {
+ final ClientSubLevel subLevel = new ClientSubLevel(this.getLevel(), globalPlotX, globalPlotZ, pose);
+ subLevel.setUniqueId(uuid);
+ return subLevel;
+ }
+
+ /**
+ * Called every tick for the plotgrid.
+ */
+ @Override
+ public void tick() {
+ this.interpolation.tick();
+ super.tick();
+ }
+
+ @ApiStatus.Internal
+ public void addDebugInfo(final Consumer consumer) {
+ consumer.accept("Sub-Levels: " + this.getAllSubLevels().size());
+ this.interpolation.addDebugInfo(consumer);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public List getAllSubLevels() {
+ return (List) super.getAllSubLevels();
+ }
+
+ /**
+ * @return the level of the plotgrid.
+ */
+ @Override
+ public ClientLevel getLevel() {
+ return (ClientLevel) super.getLevel();
+ }
+
+ public ClientSableInterpolationState getInterpolation() {
+ return this.interpolation;
+ }
+
+ /**
+ * Gets the lighting scene ID for a sub-level.
+ */
+ public int getLightingSceneId(final ClientSubLevel subLevel) {
+ synchronized (this.lightingSceneIds) {
+ if (subLevel.getLightingSceneId() >= 0) {
+ return subLevel.getLightingSceneId();
+ }
+
+ for (int i = 0; i < this.lightingSceneIds.size(); i++) {
+ if (!this.lightingSceneIds.get(i)) {
+ this.lightingSceneIds.set(i);
+ subLevel.setLightingSceneId(i + 1);
+ return subLevel.getLightingSceneId();
+ }
+ }
+
+ throw new IllegalStateException("Out of lighting scene ids, uh oh!");
+ }
+ }
+
+ @ApiStatus.Internal
+ public void freeLightingScene(final int lightingSceneId) {
+ this.lightingSceneIds.clear(lightingSceneId - 1);
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/KinematicContraption.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/KinematicContraption.java
new file mode 100644
index 0000000..a589cc4
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/KinematicContraption.java
@@ -0,0 +1,45 @@
+package dev.ryanhcode.sable.api.sublevel;
+
+import dev.ryanhcode.sable.api.block.BlockSubLevelLiftProvider;
+import dev.ryanhcode.sable.api.physics.mass.MassTracker;
+import dev.ryanhcode.sable.companion.math.BoundingBox3i;
+import dev.ryanhcode.sable.companion.math.JOMLConversion;
+import dev.ryanhcode.sable.companion.math.Pose3d;
+import dev.ryanhcode.sable.physics.floating_block.FloatingClusterContainer;
+import net.minecraft.core.BlockPos;
+import net.minecraft.world.level.BlockGetter;
+import org.joml.Quaterniond;
+import org.joml.Vector3dc;
+
+import java.util.Map;
+
+public interface KinematicContraption {
+
+ void sable$getLocalBounds(final BoundingBox3i bounds);
+ BlockGetter sable$blockGetter();
+ MassTracker sable$getMassTracker();
+ Vector3dc sable$getPosition(double partialTick);
+ Quaterniond sable$getOrientation(double partialTick);
+ Map sable$liftProviders();
+ FloatingClusterContainer sable$getFloatingClusterContainer();
+
+ boolean sable$shouldCollide();
+
+ boolean sable$isValid();
+
+ default Vector3dc sable$getPosition() {
+ return this.sable$getPosition(1.0f);
+ }
+
+ default Quaterniond sable$getOrientation() {
+ return this.sable$getOrientation(1.0f);
+ }
+
+ default Pose3d sable$getLocalPose(final Pose3d dest, final double partialTick) {
+ dest.rotationPoint().set(this.sable$getMassTracker().getCenterOfMass());
+ dest.position().set(this.sable$getPosition(partialTick));
+ dest.orientation().set(this.sable$getOrientation(partialTick));
+ dest.scale().set(JOMLConversion.ONE);
+ return dest;
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/ServerSubLevelContainer.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/ServerSubLevelContainer.java
new file mode 100644
index 0000000..aa42be0
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/ServerSubLevelContainer.java
@@ -0,0 +1,183 @@
+package dev.ryanhcode.sable.api.sublevel;
+
+
+import dev.ryanhcode.sable.Sable;
+import dev.ryanhcode.sable.companion.math.Pose3d;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import dev.ryanhcode.sable.sublevel.SubLevel;
+import dev.ryanhcode.sable.sublevel.storage.SubLevelOccupancySavedData;
+import dev.ryanhcode.sable.sublevel.storage.SubLevelRemovalReason;
+import dev.ryanhcode.sable.sublevel.storage.holding.SubLevelHoldingChunkMap;
+import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem;
+import dev.ryanhcode.sable.sublevel.system.SubLevelTrackingSystem;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Holder;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.biome.Biome;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Vector3d;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Holds all sub-levels and plots in a {@link ServerLevel}
+ */
+public class ServerSubLevelContainer extends SubLevelContainer {
+
+ /**
+ * The physics system in this container
+ */
+ private @Nullable SubLevelPhysicsSystem physics;
+
+ /**
+ * The tracking system in this container
+ */
+ private @Nullable SubLevelTrackingSystem tracking;
+
+ /**
+ * The holding chunk map for this sub-level container.
+ */
+ private SubLevelHoldingChunkMap holdingChunkMap;
+
+ /**
+ * Creates a new sub-level container with the given side length and plot size.
+ *
+ * @param level the level of the plotgrid
+ * @param logSideLength the log_2 of the amount of chunks in the side of the plotgrid
+ * @param logPlotSize the log_2 of the amount of chunks in the side of a plot
+ * @param originX the X coordinate in plots of the origin of the plotgrid
+ * @param originZ the Z coordinate in plots of the origin of the plotgrid
+ */
+ public ServerSubLevelContainer(final Level level, final int logSideLength, final int logPlotSize, final int originX, final int originZ) {
+ super(level, logSideLength, logPlotSize, originX, originZ);
+ }
+
+ /**
+ * Initialize after method construction is done
+ */
+ public void initialize() {
+ this.holdingChunkMap = new SubLevelHoldingChunkMap(this.getLevel(), this);
+ this.holdingChunkMap.bootstrapAllHoldingChunks();
+ }
+
+ /**
+ * Called every tick for the plotgrid.
+ */
+ @Override
+ public void tick() {
+ super.tick();
+ this.holdingChunkMap.processChanges();
+ }
+
+ /**
+ * Sets the internal physics system.
+ */
+ @ApiStatus.Internal
+ public void takePhysicsSystem(final SubLevelPhysicsSystem physics) {
+ this.physics = physics;
+ }
+
+ /**
+ * Sets the internal tracking system.
+ */
+ @ApiStatus.Internal
+ public void takeTrackingSystem(final SubLevelTrackingSystem tracking) {
+ this.tracking = tracking;
+ }
+
+ /**
+ * @return the physics pipeline in this container
+ */
+ public @NotNull SubLevelPhysicsSystem physicsSystem() {
+ assert this.physics != null;
+ return this.physics;
+ }
+
+ /**
+ * @return the physics pipeline in this container
+ */
+ public @NotNull SubLevelTrackingSystem trackingSystem() {
+ assert this.tracking != null;
+ return this.tracking;
+ }
+
+ /**
+ * Removes a sub-level with a local plot coordinate
+ */
+ @Override
+ public void removeSubLevel(final int x, final int z, final SubLevelRemovalReason reason) {
+ final ServerSubLevel subLevel = (ServerSubLevel) this.getSubLevel(x, z);
+ if (subLevel == null) {
+ throw new IllegalStateException("No sub-level at " + x + ", " + z);
+ }
+
+ if (reason == SubLevelRemovalReason.REMOVED) {
+ subLevel.deleteAllEntities();
+ }
+
+ super.removeSubLevel(x, z, reason);
+
+ if (reason == SubLevelRemovalReason.REMOVED) {
+ final ServerLevel level = this.getLevel();
+ SubLevelOccupancySavedData.getOrLoad(level).setDirty();
+ this.holdingChunkMap.queueDeletion(subLevel);
+ }
+ }
+
+ @Override
+ protected SubLevel createSubLevel(final int globalPlotX, final int globalPlotZ, final Pose3d pose, final UUID uuid) {
+ final ServerLevel level = this.getLevel();
+ final ServerSubLevel subLevel = new ServerSubLevel(level, globalPlotX, globalPlotZ, pose);
+ subLevel.setUniqueId(uuid);
+
+ final Vector3d position = pose.position();
+ final BlockPos blockPos = BlockPos.containing(position.x, position.y, position.z);
+
+ if (level.isLoaded(blockPos)) {
+ final Holder holder = level.getBiome(blockPos);
+ final Optional> key = holder.unwrapKey();
+
+ //noinspection OptionalIsPresent
+ if (key.isPresent()) {
+ subLevel.getPlot().setBiome(key.get());
+ }
+ }
+
+ return subLevel;
+ }
+
+ public SubLevelHoldingChunkMap getHoldingChunkMap() {
+ return this.holdingChunkMap;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public List getAllSubLevels() {
+ return (List) super.getAllSubLevels();
+ }
+
+ /**
+ * @return the level of the plotgrid.
+ */
+ @Override
+ public ServerLevel getLevel() {
+ return (ServerLevel) super.getLevel();
+ }
+
+ /**
+ * Frees all native resources
+ */
+ public void close() {
+ try {
+ this.holdingChunkMap.close();
+ } catch (final Exception e) {
+ Sable.LOGGER.error("Failed closing sub-level holding chunk map", e);
+ }
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java
new file mode 100644
index 0000000..0e17ffd
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java
@@ -0,0 +1,531 @@
+package dev.ryanhcode.sable.api.sublevel;
+
+import dev.ryanhcode.sable.Sable;
+import dev.ryanhcode.sable.companion.math.BoundingBox3dc;
+import dev.ryanhcode.sable.companion.math.Pose3d;
+import dev.ryanhcode.sable.mixinterface.plot.SubLevelContainerHolder;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import dev.ryanhcode.sable.sublevel.SubLevel;
+import dev.ryanhcode.sable.sublevel.plot.LevelPlot;
+import dev.ryanhcode.sable.sublevel.plot.PlotChunkHolder;
+import dev.ryanhcode.sable.sublevel.storage.SubLevelOccupancySavedData;
+import dev.ryanhcode.sable.sublevel.storage.SubLevelRemovalReason;
+import dev.ryanhcode.sable.util.iterator.ListBackedFilterIterator;
+import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import it.unimi.dsi.fastutil.objects.ObjectList;
+import net.minecraft.client.multiplayer.ClientLevel;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.SectionPos;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.level.ChunkPos;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.chunk.LevelChunk;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Vector2i;
+
+import java.util.*;
+
+/**
+ * Holds all sub-levels and plots in a {@link Level}
+ */
+public abstract class SubLevelContainer {
+
+ public static int DEFAULT_LOG_SIZE_LENGTH = 7;
+ public static int DEFAULT_LOG_PLOT_SIZE = 7;
+
+ /**
+ * The origin of the plotyard in plots.
+ * We want the plotyard to be over 30 million blocks out.
+ */
+ public static final int DEFAULT_ORIGIN = 10000;//Mth.ceil(30_000_000.0 / (1 << DEFAULT_LOG_PLOT_SIZE));
+ /**
+ * The plotgrid storage for all loaded sub-levels
+ */
+ protected final SubLevel[] subLevels;
+ /**
+ * All of the loaded sub-levels in the plotgrid
+ */
+ private final List allSubLevels = new ObjectArrayList<>();
+ /**
+ * All of the loaded sub-levels in the plotgrid, by uuid
+ */
+ private final Map subLevelsByUUID = new HashMap<>();
+ /**
+ * The occupancy of the plotgrid, including loaded and unloaded plots
+ */
+ private final BitSet occupancy;
+ /**
+ * All observers/listeners for the plotgrid
+ */
+ private final List observers = new ObjectArrayList<>();
+
+ /**
+ * The level of the plotgrid
+ */
+ private final Level level;
+ /**
+ * The log_2 of the side length of the plotgrid in plots
+ */
+ private final int logSideLength;
+ /**
+ * The log_2 of the amount of chunks in the side of a plot
+ */
+ private final int logPlotSize;
+
+ /**
+ * The X origin of the plotgrid in plot coordinates
+ */
+ private final int originX;
+ /**
+ * The Z origin of the plotgrid in plot coordinates
+ */
+ private final int originZ;
+
+ /**
+ * @param level the level
+ * @return the plot container in a level
+ */
+ public static @Nullable SubLevelContainer getContainer(final Level level) {
+ if (level instanceof final SubLevelContainerHolder holder) {
+ return holder.sable$getPlotContainer();
+ }
+ return null;
+ }
+
+ /**
+ * @param level the level
+ * @return the plot container in a level
+ */
+ public static @Nullable ServerSubLevelContainer getContainer(final ServerLevel level) {
+ if (level instanceof final SubLevelContainerHolder holder) {
+ return (ServerSubLevelContainer) holder.sable$getPlotContainer();
+ }
+ return null;
+ }
+
+ /**
+ * @param level the level
+ * @return the plot container in a level
+ */
+ public static @Nullable ClientSubLevelContainer getContainer(final ClientLevel level) {
+ if (level instanceof final SubLevelContainerHolder holder) {
+ return (ClientSubLevelContainer) holder.sable$getPlotContainer();
+ }
+ return null;
+ }
+
+ /**
+ * Creates a new sub-level container with the given side length and plot size.
+ *
+ * @param level the level of the plotgrid
+ * @param logSideLength the log_2 of the amount of chunks in the side of the plotgrid
+ * @param logPlotSize the log_2 of the amount of chunks in the side of a plot
+ * @param originX the X coordinate in plots of the origin of the plotgrid
+ * @param originZ the Z coordinate in plots of the origin of the plotgrid
+ */
+ public SubLevelContainer(final Level level, final int logSideLength, final int logPlotSize, final int originX, final int originZ) {
+ this.level = level;
+ this.logSideLength = logSideLength;
+ this.logPlotSize = logPlotSize;
+ this.originX = originX;
+ this.originZ = originZ;
+ this.subLevels = new SubLevel[(1 << logSideLength) * (1 << logSideLength)];
+ this.occupancy = new BitSet(this.subLevels.length);
+ }
+
+ /**
+ * Called every tick for the plotgrid.
+ */
+ public void tick() {
+ this.allSubLevels.forEach(SubLevel::tick);
+ this.processSubLevelRemovals();
+
+ this.observers.forEach(observer -> observer.tick(this));
+ }
+
+ /**
+ * Processes & follows through on queued sub-level removals
+ */
+ public void processSubLevelRemovals() {
+ for (final SubLevel subLevel : this.allSubLevels) {
+ if (subLevel instanceof final ServerSubLevel serverSubLevel) {
+ if (!serverSubLevel.isRemoved() && serverSubLevel.getMassTracker().isInvalid()) {
+ serverSubLevel.getPlot().destroyAllBlocks();
+ serverSubLevel.markRemoved();
+ }
+ }
+
+ if (subLevel.isRemoved()) {
+ final LevelPlot plot = subLevel.getPlot();
+ final ChunkPos plotPos = plot.plotPos;
+ this.removeSubLevel(plotPos.x - this.originX, plotPos.z - this.originZ, SubLevelRemovalReason.REMOVED);
+ }
+ }
+ }
+
+ /**
+ * Adds an observer to the plotgrid.
+ */
+ public void addObserver(final SubLevelObserver observer) {
+ this.observers.add(observer);
+ }
+
+ /**
+ * @return the first empty plot coordinate in the grid, using occupancy data
+ */
+ private Vector2i getFirstEmptyPlot() {
+ for (int x = 0; x < (1 << this.logSideLength); x++) {
+ for (int z = 0; z < (1 << this.logSideLength); z++) {
+ if (!this.occupancy.get(this.getIndex(x, z))) {
+ return new Vector2i(x, z);
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @return the index of the plot at the given plot coordinates.
+ */
+ @ApiStatus.Internal
+ public int getIndex(final int x, final int z) {
+ return x + (z << this.logSideLength);
+ }
+
+ /**
+ * @return the plot at the given local plot coordinates.
+ */
+ private @Nullable LevelPlot getLocalPlot(final int x, final int z) {
+ if (x < 0 || x >= (1 << this.logSideLength) || z < 0 || z >= (1 << this.logSideLength)) {
+ return null; // out of bounds
+ }
+
+ final SubLevel subLevel = this.subLevels[this.getIndex(x, z)];
+
+ if (subLevel == null) {
+ return null;
+ }
+
+ return subLevel.getPlot();
+ }
+
+ /**
+ * @return the sub-level at the given local plot coordinates.
+ */
+ public @Nullable SubLevel getSubLevel(final int x, final int z) {
+ if (x < 0 || x >= (1 << this.logSideLength) || z < 0 || z >= (1 << this.logSideLength)) {
+ return null; // out of bounds
+ }
+
+ return this.subLevels[this.getIndex(x, z)];
+ }
+
+ /**
+ * Allocates a new plot at the given local plot coordinates.
+ *
+ * @return the allocated plot
+ */
+ public SubLevel allocateNewSubLevel(final Pose3d pose) {
+ final Vector2i firstEmptyPlot = this.getFirstEmptyPlot();
+
+ if (firstEmptyPlot == null) {
+ throw new IllegalStateException("No empty plots left in the plotgrid");
+ }
+
+ return this.allocateSubLevel(UUID.randomUUID(), firstEmptyPlot.x, firstEmptyPlot.y, pose);
+ }
+
+ /**
+ * Allocates a new plot at the given local plot coordinates.
+ *
+ * @return the allocated plot
+ */
+ public SubLevel allocateSubLevel(final UUID uuid, final int x, final int z, final Pose3d pose) {
+ if (this.getLocalPlot(x, z) != null) {
+ throw new IllegalArgumentException("Plot already exists at " + x + ", " + z);
+ }
+
+ if (x < 0 || x >= (1 << this.logSideLength) || z < 0 || z >= (1 << this.logSideLength)) {
+ throw new IllegalArgumentException("Plot coordinates out of bounds: " + x + ", " + z);
+ }
+
+ final SubLevel subLevel;
+
+ // Create a new sub-level based on the level type
+ subLevel = this.createSubLevel(x + this.originX, z + this.originZ, pose, uuid);
+
+ final int index = this.getIndex(x, z);
+ this.subLevels[index] = subLevel;
+ this.getOccupancy().set(index);
+ this.allSubLevels.add(subLevel);
+ this.subLevelsByUUID.put(subLevel.getUniqueId(), subLevel);
+ this.observers.forEach(observer -> observer.onSubLevelAdded(subLevel));
+
+ if (this.level instanceof final ServerLevel serverLevel) {
+ SubLevelOccupancySavedData.getOrLoad(serverLevel).setDirty();
+ }
+
+ return subLevel;
+ }
+
+ /**
+ * Creates a new sub-level with the given global plot coordinates and pose.
+ *
+ * @param globalPlotX the global plot X coordinate
+ * @param globalPlotZ the global plot Z coordinate
+ * @param pose the initialization pose of the sub-level
+ * @param uuid the unique ID of the sub-level
+ * @return a new {@link SubLevel} instance
+ */
+ protected abstract SubLevel createSubLevel(int globalPlotX, int globalPlotZ, Pose3d pose, UUID uuid);
+
+ /**
+ * Gets a chunk from the plotgrid.
+ *
+ * @param pos the global chunk position
+ */
+ public @Nullable LevelChunk getChunk(final ChunkPos pos) {
+ if (!this.inBounds(pos)) {
+ return null;
+ }
+
+ final LevelPlot plot = this.getPlot(pos);
+ if (plot == null) {
+ return null;
+ }
+
+ final ChunkPos local = plot.toLocal(pos);
+ return plot.getChunk(local);
+ }
+
+ /**
+ * Gets a chunk holder from the plotgrid.
+ *
+ * @param pos the global chunk position
+ */
+ public @Nullable PlotChunkHolder getChunkHolder(final ChunkPos pos) {
+ if (!this.inBounds(pos)) {
+ return null;
+ }
+
+ final LevelPlot plot = this.getPlot(pos);
+ if (plot == null) {
+ return null;
+ }
+
+ final ChunkPos local = plot.toLocal(pos);
+ return plot.getChunkHolder(local);
+ }
+
+ /**
+ * Gets the plot at the given global chunk position.
+ *
+ * @param chunkX the global chunk X position
+ * @param chunkZ the global chunk Z position
+ */
+ public @Nullable LevelPlot getPlot(final int chunkX, final int chunkZ) {
+ final int plotX = (chunkX >> this.logPlotSize) - this.originX;
+ final int plotZ = (chunkZ >> this.logPlotSize) - this.originZ;
+
+ return this.getLocalPlot(plotX, plotZ);
+ }
+
+ /**
+ * Gets the plot at the given global chunk position.
+ *
+ * @param pos the global chunk position
+ */
+ public @Nullable LevelPlot getPlot(final ChunkPos pos) {
+ final int plotX = (pos.x >> this.logPlotSize) - this.originX;
+ final int plotZ = (pos.z >> this.logPlotSize) - this.originZ;
+
+ return this.getLocalPlot(plotX, plotZ);
+ }
+
+ /**
+ * @return if a global chunk position is within the plotgrid.
+ */
+ public boolean inBounds(final ChunkPos pos) {
+ return this.inBounds(pos.x, pos.z);
+ }
+
+ /**
+ * @return if a global block position is within the plotgrid.
+ */
+ public boolean inBounds(final BlockPos pos) {
+ return this.inBounds(pos.getX() >> SectionPos.SECTION_BITS, pos.getZ() >> SectionPos.SECTION_BITS);
+ }
+
+ /**
+ * @return if a global chunk position is within the plotgrid.
+ */
+ public boolean inBounds(final int x, final int z) {
+ final int plotX = (x >> this.logPlotSize) - this.originX;
+ final int plotZ = (z >> this.logPlotSize) - this.originZ;
+
+ final int sideLength = 1 << this.logSideLength;
+ return (plotX >= 0 && plotX < sideLength && plotZ >= 0 && plotZ < sideLength);
+ }
+
+ /**
+ * Adds a populated chunk in the plotgrid at the given global chunk position.
+ *
+ * @param pos the global chunk position
+ */
+ public void newPopulatedChunk(final ChunkPos pos, final LevelChunk chunk) {
+ if (!this.inBounds(pos)) {
+ return;
+ }
+
+ final int plotX = (pos.x >> this.logPlotSize) - this.originX;
+ final int plotZ = (pos.z >> this.logPlotSize) - this.originZ;
+
+ final LevelPlot plot = this.getLocalPlot(plotX, plotZ);
+
+ if (plot == null) {
+ Sable.LOGGER.error("Cannot add chunk at {}, {} in nonexistent sub-level plot", plotX, plotZ);
+ return;
+ }
+
+ final ChunkPos local = plot.toLocal(pos);
+
+ if (plot.getChunkHolder(local) != null) {
+ throw new IllegalStateException("Chunk already exists at " + pos);
+ }
+
+ final PlotChunkHolder holder = PlotChunkHolder.create(chunk.getLevel(), pos, plot.getLightEngine(), chunk);
+
+ plot.addChunkHolder(local, holder, false);
+ }
+
+ /**
+ * Gets the players tracking a plot chunk.
+ *
+ * @return the players tracking the chunk
+ */
+ public List getPlayersTracking(final ChunkPos chunkPos) {
+ final LevelPlot plot = this.getPlot(chunkPos);
+ if (plot == null) {
+ return List.of();
+ }
+
+ final SubLevel subLevel = plot.getSubLevel();
+
+ if (subLevel instanceof final ServerSubLevel serverSubLevel) {
+ final Collection trackingPlayers = serverSubLevel.getTrackingPlayers();
+ final ObjectList players = new ObjectArrayList<>(trackingPlayers.size());
+
+ for (final UUID uuid : serverSubLevel.getTrackingPlayers()) {
+ final ServerPlayer player = this.level.getServer().getPlayerList().getPlayer(uuid);
+
+ if (player != null) {
+ players.add(player);
+ }
+ }
+
+ return players;
+ }
+
+ return List.of();
+ }
+
+ /**
+ * @return all of the plots in the plotgrid.
+ */
+ public List extends SubLevel> getAllSubLevels() {
+ return this.allSubLevels;
+ }
+
+ /**
+ * @return the level of the plotgrid.
+ */
+ public Level getLevel() {
+ return this.level;
+ }
+
+ /**
+ * @return the log_2 of the side length of a plot
+ */
+ public int getLogPlotSize() {
+ return this.logPlotSize;
+ }
+
+ /**
+ * @return the log_2 of the side length of the plotgrid
+ */
+ public int getLogSideLength() {
+ return this.logSideLength;
+ }
+
+ /**
+ * @return the origin of the plotgrid in plot coordinates
+ */
+ public Vector2i getOrigin() {
+ return new Vector2i(this.originX, this.originZ);
+ }
+
+
+ /**
+ * Removes a sub-level with a local plot coordinate
+ */
+ public void removeSubLevel(final int x, final int z, final SubLevelRemovalReason reason) {
+ final SubLevel subLevel = this.getSubLevel(x, z);
+ if (subLevel == null) {
+ throw new IllegalStateException("No sub-level at " + x + ", " + z);
+ }
+
+ this.observers.forEach(observer -> observer.onSubLevelRemoved(subLevel, reason));
+ subLevel.onRemove();
+
+ final int index = this.getIndex(x, z);
+ this.subLevels[index] = null;
+ this.allSubLevels.remove(subLevel);
+ this.subLevelsByUUID.remove(subLevel.getUniqueId());
+
+ if (reason == SubLevelRemovalReason.REMOVED) {
+ this.getOccupancy().clear(index);
+ }
+ }
+
+ /**
+ * @return the count of loaded sub-levels
+ */
+ public int getLoadedCount() {
+ return this.allSubLevels.size();
+ }
+
+ public Iterable queryIntersecting(final BoundingBox3dc bounds) {
+ return () -> new ListBackedFilterIterator<>((subLevel) -> subLevel.boundingBox().intersects(bounds), this.allSubLevels);
+ }
+
+ /**
+ * Removes a sub-level from the plotgrid.
+ *
+ * @param subLevel the sub-level to remove
+ * @param reason the reason for removal
+ */
+ public void removeSubLevel(final SubLevel subLevel, final SubLevelRemovalReason reason) {
+ final int x = subLevel.getPlot().plotPos.x - this.originX;
+ final int z = subLevel.getPlot().plotPos.z - this.originZ;
+ this.removeSubLevel(x, z, reason);
+ }
+
+ /**
+ * Retrieves a particular sub-level by its UUID.
+ *
+ * @param uuid the UUID of the sub-level
+ */
+ public @Nullable SubLevel getSubLevel(final UUID uuid) {
+ return this.subLevelsByUUID.get(uuid);
+ }
+
+ /**
+ * The occupancy of the plotgrid, including loaded and unloaded plots
+ */
+ @ApiStatus.Internal
+ public BitSet getOccupancy() {
+ return this.occupancy;
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelObserver.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelObserver.java
new file mode 100644
index 0000000..59ac039
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelObserver.java
@@ -0,0 +1,34 @@
+package dev.ryanhcode.sable.api.sublevel;
+
+import dev.ryanhcode.sable.sublevel.SubLevel;
+import dev.ryanhcode.sable.sublevel.storage.SubLevelRemovalReason;
+
+/**
+ * Observes additions, removals, and ticking of sub-levels.
+ */
+public interface SubLevelObserver {
+
+ /**
+ * Called after a sub-level is added to a {@link SubLevelContainer}.
+ *
+ * @param subLevel the sub-level that was added
+ */
+ default void onSubLevelAdded(final SubLevel subLevel) {
+ }
+
+ /**
+ * Called before a sub-level is removed from a {@link SubLevelContainer}.
+ *
+ * @param subLevel the sub-level that will be removed
+ */
+ default void onSubLevelRemoved(final SubLevel subLevel, final SubLevelRemovalReason reason) {
+ }
+
+ /**
+ * Called every tick for each {@link SubLevelContainer}.
+ *
+ * @param subLevels the sub-level container that is ticking
+ */
+ default void tick(final SubLevelContainer subLevels) {
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelTrackingPlugin.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelTrackingPlugin.java
new file mode 100644
index 0000000..6ad27a2
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelTrackingPlugin.java
@@ -0,0 +1,23 @@
+package dev.ryanhcode.sable.api.sublevel;
+
+import java.util.UUID;
+
+/**
+ * Other mods or projects (looking at you, Simulated!) may want to piggyback off of the snapshot interpolation
+ * system so that their content can also abide by it and benefit from its improvements. As such, we expose
+ * "tracking" plugins for these projects to give us players that need to be informed about the interpolation tick
+ * at any given moment.
+ */
+public interface SubLevelTrackingPlugin {
+
+ /**
+ * Players that need to be informed about the interpolation ticks from the server &
+ * the distances between them, and who should be actively running interpolation.
+ */
+ Iterable neededPlayers();
+
+ /**
+ * Called when sub-level tracking data is sent
+ */
+ void sendTrackingData(int interpolationTick);
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/SableAssembleCommands.java b/common/src/main/java/dev/ryanhcode/sable/command/SableAssembleCommands.java
new file mode 100644
index 0000000..e9fb31e
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/SableAssembleCommands.java
@@ -0,0 +1,395 @@
+package dev.ryanhcode.sable.command;
+
+import com.mojang.brigadier.arguments.IntegerArgumentType;
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import dev.ryanhcode.sable.api.SubLevelAssemblyHelper;
+import dev.ryanhcode.sable.api.command.SableCommandHelper;
+import dev.ryanhcode.sable.api.command.SubLevelArgumentType;
+import dev.ryanhcode.sable.companion.math.BoundingBox3i;
+import dev.ryanhcode.sable.companion.math.BoundingBox3ic;
+import dev.ryanhcode.sable.physics.chunk.VoxelNeighborhoodState;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import net.minecraft.commands.CommandBuildContext;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.commands.Commands;
+import net.minecraft.commands.arguments.coordinates.BlockPosArgument;
+import net.minecraft.core.BlockPos;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.levelgen.structure.BoundingBox;
+
+import java.util.*;
+import java.util.stream.IntStream;
+
+public class SableAssembleCommands {
+
+ public static final int DEFAULT_CONNECTED_ASSEMBLY_CAPACITY = 256_000;
+
+ /**
+ * Adds the following commands:
+ *
+ * - {@code /sable assemble area }
+ * - {@code /sable assemble connected [] []}
+ * - {@code /sable assemble sphere []}
+ * - {@code /sable assemble cube []}
+ * - {@code /sable shatter sub_level }
+ * - {@code /sable shatter connected [] []}
+ * - {@code /sable shatter sphere []}
+ * - {@code /sable shatter cube []}
+ * - {@code /sable shatter area }
+ *
+ */
+ public static void register(final LiteralArgumentBuilder sableBuilder, final CommandBuildContext buildContext) {
+ sableBuilder
+ .then(Commands.literal("assemble")
+ .then(Commands.literal("shatter")
+ .then(Commands.literal("sub_level")
+ .then(Commands.argument("sub_level", SubLevelArgumentType.subLevels())
+ .executes(SableAssembleCommands::executeShatterSubLevelCommand)))
+ .then(Commands.literal("connected")
+ .executes((ctx) ->
+ SableAssembleCommands.executeShatterConnected(ctx, BlockPos.containing(ctx.getSource().getPosition().subtract(0, 1, 0)), DEFAULT_CONNECTED_ASSEMBLY_CAPACITY))
+ .then(Commands.argument("from", BlockPosArgument.blockPos())
+ .executes((ctx) ->
+ SableAssembleCommands.executeShatterConnected(ctx, BlockPosArgument.getLoadedBlockPos(ctx, "from"), DEFAULT_CONNECTED_ASSEMBLY_CAPACITY))
+ .then(Commands.argument("capacity", IntegerArgumentType.integer(1, DEFAULT_CONNECTED_ASSEMBLY_CAPACITY * 100))
+ .executes((ctx) ->
+ SableAssembleCommands.executeShatterConnected(ctx, BlockPosArgument.getLoadedBlockPos(ctx, "from"), IntegerArgumentType.getInteger(ctx, "capacity"))))))
+ .then(Commands.literal("sphere")
+ .then(Commands.argument("radius", IntegerArgumentType.integer(0, 128))
+ .executes((ctx) -> SableAssembleCommands.executeShatterSphereCommand(ctx, BlockPos.containing(ctx.getSource().getPosition())))
+ .then(Commands.argument("origin", BlockPosArgument.blockPos())
+ .executes((ctx) -> SableAssembleCommands.executeShatterSphereCommand(ctx, BlockPosArgument.getLoadedBlockPos(ctx, "origin"))))))
+ .then(Commands.literal("cube")
+ .then(Commands.argument("range", IntegerArgumentType.integer(0, 128))
+ .executes((ctx) -> SableAssembleCommands.executeShatterCubeCommand(ctx, BlockPos.containing(ctx.getSource().getPosition())))
+ .then(Commands.argument("origin", BlockPosArgument.blockPos())
+ .executes((ctx) -> SableAssembleCommands.executeShatterCubeCommand(ctx, BlockPosArgument.getLoadedBlockPos(ctx, "origin"))))))
+ .then(Commands.literal("area")
+ .then(Commands.argument("from", BlockPosArgument.blockPos())
+ .then(Commands.argument("to", BlockPosArgument.blockPos())
+ .executes(SableAssembleCommands::executeShatterAreaCommand)))))
+
+ .then(Commands.literal("area")
+ .then(Commands.argument("from", BlockPosArgument.blockPos())
+ .then(Commands.argument("to", BlockPosArgument.blockPos())
+ .executes(SableAssembleCommands::executeAssembleAreaCommand))))
+
+ .then(Commands.literal("connected")
+ .executes((ctx) ->
+ SableAssembleCommands.executeAssembleConnectedCommand(ctx, BlockPos.containing(ctx.getSource().getPosition().subtract(0, 1, 0)), DEFAULT_CONNECTED_ASSEMBLY_CAPACITY))
+ .then(Commands.argument("from", BlockPosArgument.blockPos())
+ .executes((ctx) ->
+ SableAssembleCommands.executeAssembleConnectedCommand(ctx, BlockPosArgument.getLoadedBlockPos(ctx, "from"), DEFAULT_CONNECTED_ASSEMBLY_CAPACITY))
+ .then(Commands.argument("capacity", IntegerArgumentType.integer(1, DEFAULT_CONNECTED_ASSEMBLY_CAPACITY * 100))
+ .executes((ctx) ->
+ SableAssembleCommands.executeAssembleConnectedCommand(ctx, BlockPosArgument.getLoadedBlockPos(ctx, "from"), IntegerArgumentType.getInteger(ctx, "capacity"))))))
+
+ .then(Commands.literal("sphere")
+ .then(Commands.argument("radius", IntegerArgumentType.integer(0, 256))
+ .executes(ctx -> SableAssembleCommands.executeAssembleSphereCommand(ctx, BlockPos.containing(ctx.getSource().getPosition())))
+ .then(Commands.argument("origin", BlockPosArgument.blockPos())
+ .executes(ctx -> SableAssembleCommands.executeAssembleSphereCommand(ctx, BlockPosArgument.getLoadedBlockPos(ctx, "origin"))))))
+
+ .then(Commands.literal("cube")
+ .then(Commands.argument("range", IntegerArgumentType.integer(0, 256))
+ .executes(ctx -> SableAssembleCommands.executeAssembleCubeCommand(ctx, BlockPos.containing(ctx.getSource().getPosition())))
+ .then(Commands.argument("origin", BlockPosArgument.blockPos())
+ .executes(ctx -> SableAssembleCommands.executeAssembleCubeCommand(ctx, BlockPosArgument.getLoadedBlockPos(ctx, "origin")))))));
+ }
+
+ private static int executeShatterConnected(final CommandContext ctx, final BlockPos assemblyOrigin, final int assemblyCapacity) throws CommandSyntaxException {
+ final ServerLevel level = ctx.getSource().getLevel();
+
+ final SubLevelAssemblyHelper.GatherResult result = SubLevelAssemblyHelper.gatherConnectedBlocks(assemblyOrigin, level, assemblyCapacity, null);
+ if (result.assemblyState() != SubLevelAssemblyHelper.GatherResult.State.SUCCESS) {
+ ctx.getSource().sendFailure(Component.translatable(switch (result.assemblyState()) {
+ case TOO_MANY_BLOCKS -> "commands.sable.sub_level.shatter.connected.too_many_blocks";
+ case NO_BLOCKS -> "commands.sable.sub_level.shatter.no_blocks";
+ default -> throw new IllegalStateException("Unexpected value: " + result.assemblyState());
+ }, result.assemblyState() == SubLevelAssemblyHelper.GatherResult.State.TOO_MANY_BLOCKS ? assemblyCapacity : 0));
+ return 0;
+ }
+
+ final int blocksShattered = shatterBlocks(result.blocks(), level);
+ if (blocksShattered == 0) {
+ ctx.getSource().sendFailure(Component.translatable("commands.sable.sub_level.shatter.no_blocks"));
+ return 0;
+ }
+
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.sub_level.shatter.connected.success", blocksShattered), true);
+ return blocksShattered;
+ }
+
+ private static int executeShatterSubLevelCommand(final CommandContext ctx) throws CommandSyntaxException {
+ final ServerLevel level = ctx.getSource().getLevel();
+ final Collection subLevels = SubLevelArgumentType.getSubLevels(ctx,
+ "sub_level");
+
+ if (subLevels.isEmpty()) {
+ throw SableCommandHelper.ERROR_NO_SUB_LEVELS_FOUND.create();
+ }
+
+ final IntStream shatteredAmounts = subLevels
+ .stream()
+ .filter(subLevel -> { //Filter out single block sub-levels
+ int solidBlockCount = 0;
+ for (final Iterator it = BlockPos.betweenClosedStream(subLevel.getPlot().getBoundingBox().toMojang()).iterator(); it.hasNext(); ) {
+ final BlockPos pos = it.next();
+ if (VoxelNeighborhoodState.isSolid(level, pos, level.getBlockState(pos))) {
+ solidBlockCount++;
+ if (solidBlockCount > 1) {
+ return true;
+ }
+ }
+ }
+ return false;
+ })
+ .map(subLevel -> subLevel.getPlot().getBoundingBox())
+ .mapToInt(bounds -> shatterBoundingBox(bounds, level));
+
+ int blocksShattered = 0;
+ int sublevelsShattered = 0;
+
+ for (final PrimitiveIterator.OfInt it = shatteredAmounts.iterator(); it.hasNext(); ) {
+ final int i = it.next();
+ blocksShattered += i;
+ sublevelsShattered ++;
+ }
+
+ if (sublevelsShattered == 0) {
+ ctx.getSource().sendFailure(Component.translatable("commands.sable.sub_level.shatter.sub_level.only_single_block"));
+ return 0;
+ }
+
+ final int finalSublevelsShattered = sublevelsShattered;
+ final int finalBlocksShattered = blocksShattered;
+ if (sublevelsShattered == 1) {
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.sub_level.shatter.sub_level.success", Component.translatable("commands.sable.sub_level"), finalBlocksShattered), true);
+ } else {
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.sub_level.shatter.sub_level.success", Component.translatable("commands.sable.sub_levels", finalSublevelsShattered), finalBlocksShattered), true);
+ }
+ return blocksShattered;
+ }
+
+ private static int executeShatterAreaCommand(final CommandContext ctx) throws CommandSyntaxException {
+ final ServerLevel level = ctx.getSource().getLevel();
+ final BoundingBox3i boundingBox = new BoundingBox3i(BlockPosArgument.getLoadedBlockPos(ctx, "from"), BlockPosArgument.getLoadedBlockPos(ctx, "to"));
+
+ final int blocksShattered = shatterBoundingBox(boundingBox, level);
+ if (blocksShattered == 0) {
+ ctx.getSource().sendFailure(Component.translatable("commands.sable.sub_level.shatter.no_blocks"));
+ return 0;
+ }
+
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.sub_level.shatter.region.success", blocksShattered), true);
+ return blocksShattered;
+ }
+
+ private static int executeShatterSphereCommand(final CommandContext ctx, final BlockPos origin) {
+ final ServerLevel level = ctx.getSource().getLevel();
+ final int radius = IntegerArgumentType.getInteger(ctx, "radius");
+ final BoundingBox boundingBox = BoundingBox.fromCorners(
+ origin.offset(-radius, -radius, -radius),
+ origin.offset(radius, radius, radius)
+ );
+
+ final int radiusSquared = radius * radius;
+
+ final List blocks = BlockPos.betweenClosedStream(boundingBox).map(BlockPos::immutable).toList();
+ final List blocksInRadius = new ArrayList<>();
+ for (final BlockPos blockPos : blocks) {
+ if (origin.distSqr(blockPos) > radiusSquared) {
+ continue;
+ }
+ blocksInRadius.add(blockPos);
+ }
+ final int blocksShattered = shatterBlocks(blocksInRadius, level);
+ if (blocksShattered == 0) {
+ ctx.getSource().sendFailure(Component.translatable("commands.sable.sub_level.shatter.no_blocks"));
+ return 0;
+ }
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.sub_level.shatter.radius.success", blocksShattered), true);
+ return blocksShattered;
+ }
+
+ private static int executeShatterCubeCommand(final CommandContext ctx, final BlockPos origin) {
+ final ServerLevel level = ctx.getSource().getLevel();
+ final int radius = IntegerArgumentType.getInteger(ctx, "range");
+ final BoundingBox3i boundingBox = new BoundingBox3i(
+ origin.offset(-radius, -radius, -radius),
+ origin.offset(radius, radius, radius)
+ );
+
+ final int blocksShattered = shatterBoundingBox(boundingBox, level);
+ if (blocksShattered == 0) {
+ ctx.getSource().sendFailure(Component.translatable("commands.sable.sub_level.shatter.no_blocks"));
+ return 0;
+ }
+
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.sub_level.shatter.range.success", blocksShattered), true);
+ return blocksShattered;
+ }
+
+ private static int shatterBoundingBox(final BoundingBox3ic boundingBox, final ServerLevel level) {
+ return shatterBlocks(BlockPos.betweenClosedStream(boundingBox.toMojang()).map(BlockPos::immutable).toList(), level);
+ }
+
+ private static int shatterBlocks(final Collection blocks, final ServerLevel level) {
+ //Remove fragile blocks
+ for (final BlockPos pos : blocks) {
+ if (!VoxelNeighborhoodState.isSolid(level, pos, level.getBlockState(pos))) {
+ level.destroyBlock(pos, true);
+ }
+ }
+ int shattered = 0;
+ for (final BlockPos anchor : blocks) {
+ if (shatterBlockToSubLevel(level, anchor)) {
+ shattered++;
+ }
+ }
+ return shattered;
+ }
+
+ private static boolean shatterBlockToSubLevel(final ServerLevel level, final BlockPos anchor) {
+ if (!VoxelNeighborhoodState.isSolid(level, anchor, level.getBlockState(anchor))) {
+ return false;
+ }
+
+ final BoundingBox3i bounds = new BoundingBox3i(anchor.getX(), anchor.getY(), anchor.getZ(), anchor.getX() + 1, anchor.getY() + 1, anchor.getZ() + 1);
+ bounds.set(
+ bounds.minX - 1,
+ bounds.minY - 1,
+ bounds.minZ - 1,
+ bounds.maxX + 1,
+ bounds.maxY + 1,
+ bounds.maxZ + 1
+ );
+ SubLevelAssemblyHelper.assembleBlocks(level, anchor, List.of(anchor), bounds);
+ return true;
+ }
+
+ private static int executeAssembleAreaCommand(final CommandContext ctx) throws CommandSyntaxException {
+ final ServerLevel level = ctx.getSource().getLevel();
+ final BoundingBox boundingBox = BoundingBox.fromCorners(BlockPosArgument.getLoadedBlockPos(ctx, "from"), BlockPosArgument.getLoadedBlockPos(ctx, "to"));
+
+ final List blocks = BlockPos.betweenClosedStream(boundingBox).map(BlockPos::immutable).toList();
+ final BlockPos anchor = blocks.getFirst();
+
+ final BoundingBox3i bounds = new BoundingBox3i(boundingBox);
+ bounds.set(
+ bounds.minX - 1,
+ bounds.minY - 1,
+ bounds.minZ - 1,
+ bounds.maxX + 1,
+ bounds.maxY + 1,
+ bounds.maxZ + 1
+ );
+
+ final ServerSubLevel subLevel = SubLevelAssemblyHelper.assembleBlocks(level, anchor, blocks, bounds);
+ if (subLevel.getMassTracker().isInvalid()) {
+ ctx.getSource().sendFailure(Component.translatable("commands.sable.sub_level.assemble.no_blocks"));
+ return 0;
+ }
+
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.sub_level.assemble.region.success", blocks.size()), true);
+ return 1;
+ }
+
+ private static int executeAssembleCubeCommand(final CommandContext ctx, final BlockPos origin) {
+ final ServerLevel level = ctx.getSource().getLevel();
+ final int range = IntegerArgumentType.getInteger(ctx, "range");
+ final BoundingBox boundingBox = BoundingBox.fromCorners(origin.offset(-range, -range, -range), origin.offset(range, range, range));
+
+ final List blocks = BlockPos.betweenClosedStream(boundingBox).map(BlockPos::immutable).toList();
+ final BlockPos anchor = blocks.getFirst();
+
+ final BoundingBox3i bounds = new BoundingBox3i(boundingBox);
+ bounds.set(
+ bounds.minX - 1,
+ bounds.minY - 1,
+ bounds.minZ - 1,
+ bounds.maxX + 1,
+ bounds.maxY + 1,
+ bounds.maxZ + 1
+ );
+
+ final ServerSubLevel subLevel = SubLevelAssemblyHelper.assembleBlocks(level, anchor, blocks, bounds);
+ if (subLevel.getMassTracker().isInvalid()) {
+ ctx.getSource().sendFailure(Component.translatable("commands.sable.sub_level.assemble.no_blocks"));
+ return 0;
+ }
+
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.sub_level.assemble.range.success", blocks.size()), true);
+ return 1;
+ }
+
+ private static int executeAssembleConnectedCommand(final CommandContext ctx, final BlockPos assemblyOrigin, final int assemblyCapacity) throws CommandSyntaxException {
+ final ServerLevel level = ctx.getSource().getLevel();
+
+ final SubLevelAssemblyHelper.GatherResult result = SubLevelAssemblyHelper.gatherConnectedBlocks(assemblyOrigin, level, assemblyCapacity, null);
+ if (result.assemblyState() != SubLevelAssemblyHelper.GatherResult.State.SUCCESS) {
+ ctx.getSource().sendFailure(Component.translatable(result.assemblyState().errorKey, result.assemblyState() == SubLevelAssemblyHelper.GatherResult.State.TOO_MANY_BLOCKS ? assemblyCapacity : 0));
+ return 0;
+ }
+
+ SubLevelAssemblyHelper.assembleBlocks(level, assemblyOrigin, result.blocks(), result.boundingBox());
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.sub_level.assemble.connected.success", result.blocks().size()), true);
+ return 1;
+ }
+
+ private static int executeAssembleSphereCommand(final CommandContext ctx, final BlockPos origin) {
+ final int radius = IntegerArgumentType.getInteger(ctx, "radius");
+
+ final ServerLevel level = ctx.getSource().getLevel();
+
+ final Set blocks = new HashSet<>();
+
+ int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE;
+ int maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE;
+
+ final int radiusSquared = radius * radius;
+
+ for (int x = -radius; x <= radius; x++) {
+ for (int y = -radius; y <= radius; y++) {
+ for (int z = -radius; z <= radius; z++) {
+ if (x * x + y * y + z * z > radiusSquared) {
+ continue;
+ }
+ final BlockPos pos = origin.offset(x, y, z);
+
+ if (level.isLoaded(pos) && !level.getBlockState(pos).isAir()) {
+ blocks.add(pos);
+
+ minX = Math.min(minX, pos.getX());
+ minY = Math.min(minY, pos.getY());
+ minZ = Math.min(minZ, pos.getZ());
+
+ maxX = Math.max(maxX, pos.getX());
+ maxY = Math.max(maxY, pos.getY());
+ maxZ = Math.max(maxZ, pos.getZ());
+ }
+ }
+ }
+ }
+
+ if (blocks.isEmpty()) {
+ ctx.getSource().sendFailure(Component.translatable("commands.sable.sub_level.assemble.no_blocks"));
+ return 0;
+ }
+
+ final BoundingBox3i bounds = new BoundingBox3i(
+ minX, minY, minZ,
+ maxX, maxY, maxZ
+ );
+
+ SubLevelAssemblyHelper.assembleBlocks(level, origin, blocks, bounds);
+
+ final int finalBlocksCount = blocks.size();
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.sub_level.assemble.radius.success", finalBlocksCount), true);
+ return 1;
+ }
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/SableCommand.java b/common/src/main/java/dev/ryanhcode/sable/command/SableCommand.java
new file mode 100644
index 0000000..6f54408
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/SableCommand.java
@@ -0,0 +1,155 @@
+package dev.ryanhcode.sable.command;
+
+import com.mojang.brigadier.CommandDispatcher;
+import com.mojang.brigadier.arguments.BoolArgumentType;
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import dev.ryanhcode.sable.api.command.SableCommandHelper;
+import dev.ryanhcode.sable.api.command.SubLevelArgumentType;
+import dev.ryanhcode.sable.api.physics.handle.RigidBodyHandle;
+import dev.ryanhcode.sable.api.sublevel.ServerSubLevelContainer;
+import dev.ryanhcode.sable.companion.math.Pose3dc;
+import dev.ryanhcode.sable.network.packets.tcp.ClientboundEnterGizmoPacket;
+import dev.ryanhcode.sable.network.packets.udp.SableUDPEchoPacket;
+import dev.ryanhcode.sable.network.udp.SableUDPServer;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import dev.ryanhcode.sable.sublevel.storage.holding.GlobalSavedSubLevelPointer;
+import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem;
+import foundry.veil.api.network.VeilPacketManager;
+import net.minecraft.ChatFormatting;
+import net.minecraft.commands.CommandBuildContext;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.commands.Commands;
+import net.minecraft.network.chat.*;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.level.ServerPlayer;
+import org.joml.Quaterniondc;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+
+import java.util.Collection;
+import java.util.Formatter;
+import java.util.Locale;
+
+public class SableCommand {
+
+ public static void register(final CommandDispatcher dispatcher, final CommandBuildContext buildContext) {
+ final LiteralArgumentBuilder sableBuilder = Commands.literal("sable")
+ .requires(commandSourceStack -> commandSourceStack.hasPermission(2));
+
+ SablePhysicsCommands.register(sableBuilder, buildContext);
+ SableSpawnCommands.register(sableBuilder, buildContext);
+ SableSubLevelCommands.register(sableBuilder, buildContext);
+ SableAssembleCommands.register(sableBuilder, buildContext);
+ SableStorageCommands.register(sableBuilder, buildContext);
+
+ final LiteralArgumentBuilder debugBuilder = Commands.literal("debug");
+
+ SableJointCommands.register(debugBuilder, buildContext);
+ SableConfigCommands.register(debugBuilder, buildContext);
+
+ sableBuilder
+ .then(debugBuilder
+ .then(Commands.literal("udp_test").executes(ctx -> {
+ final SableUDPServer server = SableUDPServer.getServer(ctx.getSource().getServer());
+
+ if (server != null) {
+ server.sendUDPPacket(ctx.getSource().getPlayerOrException(), new SableUDPEchoPacket("Skibidi Toilet"), true);
+ }
+
+ return 1;
+ }))
+ );
+
+ sableBuilder
+ .then(Commands.literal("engage_gizmo")
+ .executes(SableCommand::executeEnableGizmoCommand))
+
+ .then(Commands.literal("paused")
+ .executes(SableCommand::executeTogglePhysicsPausedCommand)
+ .then(Commands.argument("paused", BoolArgumentType.bool())
+ .executes(SableCommand::executeSetPhysicsPausedCommand)))
+
+ .then(Commands.literal("info").then(Commands.argument("sub_level", SubLevelArgumentType.subLevels()).executes(ctx -> {
+ final CommandSourceStack source = ctx.getSource();
+ final ServerSubLevelContainer container = SableCommandHelper.requireSubLevelContainer(source);
+ final Collection subLevels = SubLevelArgumentType.getSubLevels(ctx, "sub_level");
+
+ if (subLevels.isEmpty()) {
+ throw SableCommandHelper.ERROR_NO_SUB_LEVELS_FOUND.create();
+ }
+
+ source.sendSuccess(() -> Component.translatable("commands.sable.info.count", subLevels.size()), false);
+ for (final ServerSubLevel subLevel : subLevels) {
+ final Pose3dc pose = subLevel.logicalPose();
+ source.sendSuccess(() -> {
+ final Vector3dc pos = pose.position();
+ final MutableComponent component = Component.translatable("commands.sable.info.name", Component.literal(subLevel.getName() != null ? subLevel.getName() : subLevel.getUniqueId().toString()));
+ final ResourceLocation dimension = subLevel.getLevel().dimension().location();
+ final GlobalSavedSubLevelPointer pointer = subLevel.getLastSerializationPointer();
+ final Component fileId = Component.translatable("commands.sable.info.name.tooltip", pointer != null ? pointer.toString() : "None yet");
+ component.setStyle(Style.EMPTY.withClickEvent(new ClickEvent(ClickEvent.Action.SUGGEST_COMMAND, new Formatter().format(Locale.ROOT, "/execute in %s run tp @s %.2f %.2f %.2f", dimension, pos.x(), pos.y(), pos.z()).toString()))
+ .withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, fileId))
+ .withColor(ChatFormatting.GRAY));
+ return component;
+ }, false);
+ source.sendSuccess(() -> {
+ final Vector3dc pos = pose.position();
+ return Component.translatable("commands.sable.info.position", pos.x(), pos.y(), pos.z());
+ }, false);
+ source.sendSuccess(() -> {
+ final Quaterniondc orientation = pose.orientation();
+ return Component.translatable("commands.sable.info.orientation", orientation.x(), orientation.y(), orientation.z(), orientation.w());
+ }, false);
+ source.sendSuccess(() -> {
+ return Component.translatable("commands.sable.info.mass", subLevel.getMassTracker().getMass());
+ }, false);
+
+ final SubLevelPhysicsSystem physicsSystem = container.physicsSystem();
+ final RigidBodyHandle handle = physicsSystem.getPhysicsHandle(subLevel);
+ source.sendSuccess(() -> {
+ final Vector3dc pos = handle.getLinearVelocity(new Vector3d());
+ return Component.translatable("commands.sable.info.linear_velocity",
+ pos.x(), pos.y(), pos.z());
+ }, false);
+ source.sendSuccess(() -> {
+ final Vector3dc pos = handle.getAngularVelocity(new Vector3d());
+ return Component.translatable("commands.sable.info.angular_velocity", pos.x(), pos.y(), pos.z());
+ }, false);
+ }
+ return subLevels.size();
+ })));
+
+
+ dispatcher.register(sableBuilder);
+
+ }
+
+ private static int executeEnableGizmoCommand(final CommandContext ctx) throws CommandSyntaxException {
+ final CommandSourceStack source = ctx.getSource();
+ final ServerPlayer player = source.getPlayerOrException();
+
+ SableCommandHelper.requireSubLevelPhysicsSystem(ctx).setPaused(true);
+
+ VeilPacketManager.player(player).sendPacket(new ClientboundEnterGizmoPacket());
+ return 1;
+ }
+
+ private static int executeTogglePhysicsPausedCommand(final CommandContext ctx) throws CommandSyntaxException {
+ final boolean pause = !SableCommandHelper.requireSubLevelPhysicsSystem(ctx).getPaused();
+ SableCommandHelper.requireSubLevelPhysicsSystem(ctx).setPaused(pause);
+
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.physics.paused_toggled.success", Boolean.toString(pause)), true);
+ return 1;
+ }
+
+ private static int executeSetPhysicsPausedCommand(final CommandContext ctx) throws CommandSyntaxException {
+ final boolean pause = BoolArgumentType.getBool(ctx, "paused");
+
+ SableCommandHelper.requireSubLevelPhysicsSystem(ctx).setPaused(pause);
+
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.physics.paused.success", Boolean.toString(pause)), true);
+ return 1;
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/SableConfigCommands.java b/common/src/main/java/dev/ryanhcode/sable/command/SableConfigCommands.java
new file mode 100644
index 0000000..b077628
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/SableConfigCommands.java
@@ -0,0 +1,97 @@
+package dev.ryanhcode.sable.command;
+
+import com.mojang.brigadier.arguments.FloatArgumentType;
+import com.mojang.brigadier.arguments.IntegerArgumentType;
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import dev.ryanhcode.sable.api.sublevel.SubLevelContainer;
+import dev.ryanhcode.sable.physics.config.PhysicsConfigData;
+import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem;
+import net.minecraft.commands.CommandBuildContext;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.commands.Commands;
+
+public class SableConfigCommands {
+
+ /**
+ * Adds the following commands:
+ *
+ * - {@code /sable config }
+ *
+ */
+ public static void register(final LiteralArgumentBuilder sableBuilder, final CommandBuildContext buildContext) {
+
+ sableBuilder.then(Commands.literal("config")
+ .then(Commands.literal("min_island_size")
+ .then(Commands.argument("size", IntegerArgumentType.integer(0, Integer.MAX_VALUE))
+ .executes(ctx -> {
+ final SubLevelPhysicsSystem physicsSystem = SubLevelContainer.getContainer(ctx.getSource().getLevel()).physicsSystem();
+ final PhysicsConfigData config = physicsSystem.getConfig();
+ config.minDynamicBodiesPerIsland = IntegerArgumentType.getInteger(ctx, "size");
+ physicsSystem.onConfigUpdated();
+ return 0;
+ }))
+ )
+ .then(Commands.literal("contact_spring_natural_frequency")
+ .then(Commands.argument("natural_frequency", FloatArgumentType.floatArg(0.0f))
+ .executes(ctx -> {
+ final SubLevelPhysicsSystem physicsSystem = SubLevelContainer.getContainer(ctx.getSource().getLevel()).physicsSystem();
+ final PhysicsConfigData config = physicsSystem.getConfig();
+ config.contactSpringFrequency = FloatArgumentType.getFloat(ctx, "natural_frequency");
+ physicsSystem.onConfigUpdated();
+ return 0;
+ }))
+ )
+ .then(Commands.literal("contact_spring_damping_ratio")
+ .then(Commands.argument("damping_ratio", FloatArgumentType.floatArg(0.0f))
+ .executes(ctx -> {
+ final SubLevelPhysicsSystem physicsSystem = SubLevelContainer.getContainer(ctx.getSource().getLevel()).physicsSystem();
+ final PhysicsConfigData config = physicsSystem.getConfig();
+ config.contactSpringDampingRatio = FloatArgumentType.getFloat(ctx, "damping_ratio");
+ physicsSystem.onConfigUpdated();
+ return 0;
+ }))
+ )
+ .then(Commands.literal("solver_iterations")
+ .then(Commands.argument("iterations", IntegerArgumentType.integer(0, Integer.MAX_VALUE))
+ .executes(ctx -> {
+ final SubLevelPhysicsSystem physicsSystem = SubLevelContainer.getContainer(ctx.getSource().getLevel()).physicsSystem();
+ final PhysicsConfigData config = physicsSystem.getConfig();
+ config.solverIterations = IntegerArgumentType.getInteger(ctx, "iterations");
+ physicsSystem.onConfigUpdated();
+ return 0;
+ }))
+ )
+ .then(Commands.literal("stabilization_iterations")
+ .then(Commands.argument("iterations", IntegerArgumentType.integer(0, Integer.MAX_VALUE))
+ .executes(ctx -> {
+ final SubLevelPhysicsSystem physicsSystem = SubLevelContainer.getContainer(ctx.getSource().getLevel()).physicsSystem();
+ final PhysicsConfigData config = physicsSystem.getConfig();
+ config.stabilizationIterations = IntegerArgumentType.getInteger(ctx, "iterations");
+ physicsSystem.onConfigUpdated();
+ return 0;
+ }))
+ )
+ .then(Commands.literal("pgs_iterations")
+ .then(Commands.argument("iterations", IntegerArgumentType.integer(0, Integer.MAX_VALUE))
+ .executes(ctx -> {
+ final SubLevelPhysicsSystem physicsSystem = SubLevelContainer.getContainer(ctx.getSource().getLevel()).physicsSystem();
+ final PhysicsConfigData config = physicsSystem.getConfig();
+ config.pgsIterations = IntegerArgumentType.getInteger(ctx, "iterations");
+ physicsSystem.onConfigUpdated();
+ return 0;
+ }))
+ )
+ .then(Commands.literal("substeps")
+ .then(Commands.argument("substeps", IntegerArgumentType.integer(0, Integer.MAX_VALUE))
+ .executes(ctx -> {
+ final SubLevelPhysicsSystem physicsSystem = SubLevelContainer.getContainer(ctx.getSource().getLevel()).physicsSystem();
+ final PhysicsConfigData config = physicsSystem.getConfig();
+ config.substepsPerTick = IntegerArgumentType.getInteger(ctx, "substeps");
+ physicsSystem.onConfigUpdated();
+ return 0;
+ }))
+ )
+ );
+
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/SableJointCommands.java b/common/src/main/java/dev/ryanhcode/sable/command/SableJointCommands.java
new file mode 100644
index 0000000..6a985bd
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/SableJointCommands.java
@@ -0,0 +1,87 @@
+package dev.ryanhcode.sable.command;
+
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
+import dev.ryanhcode.sable.api.command.SableCommandHelper;
+import dev.ryanhcode.sable.api.command.SubLevelArgumentType;
+import dev.ryanhcode.sable.api.physics.PhysicsPipeline;
+import dev.ryanhcode.sable.api.physics.constraint.rotary.RotaryConstraintConfiguration;
+import dev.ryanhcode.sable.api.sublevel.ServerSubLevelContainer;
+import dev.ryanhcode.sable.companion.math.JOMLConversion;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import net.minecraft.commands.CommandBuildContext;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.commands.Commands;
+import net.minecraft.commands.arguments.coordinates.Vec3Argument;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.phys.Vec3;
+
+import java.util.Collection;
+
+public class SableJointCommands {
+
+ public static final SimpleCommandExceptionType MISSING_JOINT_SUBLEVEL_TARGET =
+ new SimpleCommandExceptionType(Component.translatable("commands.sable.joint.missing_sublevel_target"));
+
+ /**
+ * Adds the following commands:
+ *
+ * - {@code /sable joint add radial }
+ *
+ */
+ public static void register(final LiteralArgumentBuilder sableBuilder, final CommandBuildContext buildContext) {
+
+ sableBuilder.then(Commands.literal("joint")
+ .then(Commands.literal("add")
+ .then(Commands.argument("subLevel1", SubLevelArgumentType.subLevels())
+ .then(Commands.argument("subLevel2", SubLevelArgumentType.subLevels())
+ .then(Commands.literal("rotary")
+ .then(Commands.argument("pos1", Vec3Argument.vec3(false))
+ .then(Commands.argument("pos2", Vec3Argument.vec3(false))
+ .then(Commands.argument("axis1", Vec3Argument.vec3(false))
+ .then(Commands.argument("axis2", Vec3Argument.vec3(false))
+ .executes(SableJointCommands::executeAddJointCommand)))))))))
+ );
+
+ }
+
+ private static int executeAddJointCommand(final CommandContext ctx) throws CommandSyntaxException {
+ final ServerSubLevelContainer container = SableCommandHelper.requireSubLevelContainer(ctx);
+ final PhysicsPipeline pipeline = SableCommandHelper.requireSubLevelPhysicsSystem(container).getPipeline();
+ addRotaryJoint(
+ pipeline,
+ SubLevelArgumentType.getSubLevels(ctx, "subLevel1"),
+ SubLevelArgumentType.getSubLevels(ctx, "subLevel2"),
+ Vec3Argument.getVec3(ctx, "pos1"), Vec3Argument.getVec3(ctx, "pos2"),
+ Vec3Argument.getVec3(ctx, "axis1"), Vec3Argument.getVec3(ctx, "axis2")
+ );
+
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.joint.success"), true);
+ return 0;
+ }
+
+ private static void addRotaryJoint(
+ final PhysicsPipeline pipeline,
+ final Collection subLevel1,
+ final Collection subLevel2,
+ final Vec3 pos1, final Vec3 pos2,
+ final Vec3 axis1, final Vec3 axis2
+ ) throws CommandSyntaxException {
+ final RotaryConstraintConfiguration constraintConfig = new RotaryConstraintConfiguration(
+ JOMLConversion.toJOML(pos1),
+ JOMLConversion.toJOML(pos2),
+ JOMLConversion.toJOML(axis1),
+ JOMLConversion.toJOML(axis2)
+ );
+
+ final ServerSubLevel jointSubLevel1 = subLevel1.stream().findFirst()
+ .orElseThrow(MISSING_JOINT_SUBLEVEL_TARGET::create);
+ final ServerSubLevel jointSubLevel2 = subLevel2.stream().findFirst()
+ .orElseThrow(MISSING_JOINT_SUBLEVEL_TARGET::create);
+
+ pipeline.addConstraint(jointSubLevel1, jointSubLevel2, constraintConfig);
+ }
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/SablePhysicsCommands.java b/common/src/main/java/dev/ryanhcode/sable/command/SablePhysicsCommands.java
new file mode 100644
index 0000000..ef74ea7
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/SablePhysicsCommands.java
@@ -0,0 +1,283 @@
+package dev.ryanhcode.sable.command;
+
+import com.mojang.brigadier.Command;
+import com.mojang.brigadier.arguments.DoubleArgumentType;
+import com.mojang.brigadier.builder.ArgumentBuilder;
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import dev.ryanhcode.sable.api.command.SableCommandHelper;
+import dev.ryanhcode.sable.api.command.SubLevelArgumentType;
+import dev.ryanhcode.sable.api.physics.PhysicsPipeline;
+import dev.ryanhcode.sable.companion.math.JOMLConversion;
+import dev.ryanhcode.sable.companion.math.Pose3d;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem;
+import net.minecraft.commands.CommandBuildContext;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.commands.Commands;
+import net.minecraft.commands.arguments.coordinates.RotationArgument;
+import net.minecraft.commands.arguments.coordinates.Vec3Argument;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.phys.Vec2;
+import net.minecraft.world.phys.Vec3;
+import org.joml.Quaterniond;
+import org.joml.Vector3d;
+
+import java.util.Collection;
+import java.util.function.Function;
+
+public class SablePhysicsCommands {
+
+ /**
+ * Adds the following commands:
+ *
+ * - {@code /sable physics impulse }
+ * - {@code /sable physics rotation }
+ * - {@code /sable physics rotation }
+ * - {@code /sable physics translation }
+ * - {@code /sable physics translation }
+ *
+ */
+ public static void register(final LiteralArgumentBuilder sableBuilder, final CommandBuildContext buildContext) {
+ sableBuilder.then(Commands.literal("physics")
+ .then(Commands.literal("impulse")
+ .then(Commands.argument("sub_level", SubLevelArgumentType.subLevels())
+ .then(Commands.literal("linear")
+ .then(Commands.argument("impulse", Vec3ArgumentAbsolute.vec3())
+ .executes((ctx) ->
+ SablePhysicsCommands.executeLinearImpulseCommand(ctx, true))
+ .then(Commands.literal("global")
+ .executes((ctx) ->
+ SablePhysicsCommands.executeLinearImpulseCommand(ctx, true)))
+ .then(Commands.literal("local")
+ .executes((ctx) ->
+ SablePhysicsCommands.executeLinearImpulseCommand(ctx, false)))
+ ))
+
+ .then(Commands.literal("angular")
+ .then(Commands.argument("impulse", Vec3ArgumentAbsolute.vec3())
+ .executes((ctx) ->
+ SablePhysicsCommands.executeAngularImpulseCommand(ctx, true))
+ .then(Commands.literal("global")
+ .executes((ctx) ->
+ SablePhysicsCommands.executeAngularImpulseCommand(ctx, true)))
+ .then(Commands.literal("local")
+ .executes((ctx) ->
+ SablePhysicsCommands.executeAngularImpulseCommand(ctx, false)))
+ ))
+ ))
+ .then(Commands.literal("rotation")
+ .then(Commands.argument("sub_level", SubLevelArgumentType.subLevels())
+ .then(wrapRotationWithMode(true))
+ .then(wrapRotationWithMode(false))
+ ))
+
+ .then(Commands.literal("translation")
+ .then(Commands.argument("sub_level", SubLevelArgumentType.subLevels())
+
+ .then(Commands.literal("add")
+ .then(Commands.argument("translation", Vec3ArgumentAbsolute.vec3())
+ .executes((ctx) ->
+ SablePhysicsCommands.executeAddTranslationCommand(ctx, true))
+ .then(Commands.literal("global")
+ .executes((ctx) ->
+ SablePhysicsCommands.executeAddTranslationCommand(ctx, true)))
+ .then(Commands.literal("local")
+ .executes((ctx) ->
+ SablePhysicsCommands.executeAddTranslationCommand(ctx, false)))
+ ))
+
+ .then(Commands.literal("set")
+ .then(Commands.argument("translation", Vec3Argument.vec3(false))
+ .executes(SablePhysicsCommands::executeSetTranslationCommand))))
+ )
+ );
+
+ }
+
+ private static Component getGlobalComponent(final boolean global) {
+ return Component.translatable("commands.sable.physics." + (global ? "global" : "local"));
+ }
+
+ private static int executeLinearImpulseCommand(final CommandContext ctx, final boolean global) throws CommandSyntaxException {
+ final SubLevelPhysicsSystem system = SableCommandHelper.requireSubLevelPhysicsSystem(ctx);
+
+ final Collection subLevels = SubLevelArgumentType.getSubLevels(ctx, "sub_level");
+ final Vec3 impulse = ctx.getArgument("impulse", Vec3.class);
+
+ if (subLevels.isEmpty()) {
+ throw SableCommandHelper.ERROR_NO_SUB_LEVELS_FOUND.create();
+ }
+
+ for (final ServerSubLevel subLevel : subLevels) {
+ Vec3 subLevelImpulse = impulse;
+ if (global) {
+ subLevelImpulse = subLevel.logicalPose().transformNormalInverse(subLevelImpulse);
+ }
+
+ system.getPhysicsHandle(subLevel)
+ .applyLinearImpulse(
+ JOMLConversion.toJOML(subLevelImpulse)
+ );
+ }
+
+ SableCommandHelper.sendSuccessDescribingSubLevelsAtIndex("commands.sable.physics.impulse.linear.success", ctx, subLevels, 1,
+ getGlobalComponent(global), impulse.x + ", " + impulse.y + ", " + impulse.z);
+ return 0;
+ }
+
+ private static int executeAngularImpulseCommand(final CommandContext ctx, final boolean global) throws CommandSyntaxException {
+ final SubLevelPhysicsSystem system = SableCommandHelper.requireSubLevelPhysicsSystem(ctx);
+
+ final Collection subLevels = SubLevelArgumentType.getSubLevels(ctx, "sub_level");
+ final Vec3 impulse = ctx.getArgument("impulse", Vec3.class);
+
+ if (subLevels.isEmpty()) {
+ throw SableCommandHelper.ERROR_NO_SUB_LEVELS_FOUND.create();
+ }
+
+ for (final ServerSubLevel subLevel : subLevels) {
+ Vec3 subLevelImpulse = impulse;
+ if (global) {
+ subLevelImpulse = subLevel.logicalPose().transformNormalInverse(subLevelImpulse);
+ }
+
+ system.getPhysicsHandle(subLevel)
+ .applyAngularImpulse(
+ JOMLConversion.toJOML(subLevelImpulse)
+ );
+ }
+
+ SableCommandHelper.sendSuccessDescribingSubLevelsAtIndex("commands.sable.physics.impulse.angular.success", ctx, subLevels, 1,
+ getGlobalComponent(global), impulse.x + ", " + impulse.y + ", " + impulse.z);
+ return 0;
+ }
+
+ private static ArgumentBuilder wrapRotationWithMode(final boolean add) {
+ return Commands.literal(add ? "add" : "set").then(wrapRotationWithReferenceFrame(add, false)).then(wrapRotationWithReferenceFrame(add, true));
+ }
+
+ private static ArgumentBuilder wrapRotationWithReferenceFrame(final boolean add, final boolean axis) {
+ final Command c = (ctx) -> SablePhysicsCommands.executeRotationCommand(ctx, add, axis, true);
+ final Function, ArgumentBuilder> f = (b) -> {
+ if (add)
+ b.then(wrapRotationWithGlobality(axis, true)).then(wrapRotationWithGlobality(axis, false));
+ return b;
+ };
+ final ArgumentBuilder b = axis ?
+ Commands.argument("axis", Vec3ArgumentAbsolute.vec3()).then(f.apply(Commands.argument("angle", DoubleArgumentType.doubleArg()).executes(c))) :
+ f.apply(Commands.argument("rotation", RotationArgument.rotation()).executes(c));
+
+ return Commands.literal(axis ? "axis" : "entity").then(b);
+ }
+
+ private static ArgumentBuilder wrapRotationWithGlobality(final boolean axis, final boolean global) {
+ return Commands.literal(global ? "global" : "local").executes((ctx) ->
+ SablePhysicsCommands.executeRotationCommand(ctx, true, axis, global));
+ }
+
+ private static int executeRotationCommand(final CommandContext ctx, final boolean add, final boolean axis, final boolean global) throws CommandSyntaxException {
+ final PhysicsPipeline pipeline = SableCommandHelper.requireSubLevelPhysicsPipeline(ctx);
+
+ final Quaterniond orientation = new Quaterniond();
+
+ Vec2 rotation2 = new Vec2(0, 0);
+ Vec3 rotationAxis = new Vec3(0, 0, 0);
+ double rotationAngle = 0;
+
+ if (axis) {
+ rotationAxis = ctx.getArgument("axis", Vec3.class);
+ rotationAngle = ctx.getArgument("angle", Double.class);
+ orientation.fromAxisAngleDeg(rotationAxis.x, rotationAxis.y, rotationAxis.z, rotationAngle);
+
+ if (rotationAxis.lengthSqr() == 0) {
+ throw SableCommandHelper.ERROR_NO_AXIS_FOR_ROTATION.create();
+ }
+ } else {
+ rotation2 = RotationArgument.getRotation(ctx, "rotation").getRotation(ctx.getSource());
+ orientation.rotateY(-Math.toRadians(rotation2.y));
+ orientation.rotateX(Math.toRadians(rotation2.x));
+ }
+
+ final Collection subLevels = SubLevelArgumentType.getSubLevels(ctx, "sub_level");
+
+ if (subLevels.isEmpty()) {
+ throw SableCommandHelper.ERROR_NO_SUB_LEVELS_FOUND.create();
+ }
+
+ for (final ServerSubLevel subLevel : subLevels) {
+ final Pose3d pose = subLevel.logicalPose();
+ if (add) {
+ if (global) {
+ pose.orientation().premul(orientation);
+ } else {
+ pose.orientation().mul(orientation);
+ }
+ } else {
+ pose.orientation().set(orientation);
+ }
+ pipeline.teleport(subLevel, pose.position(), pose.orientation());
+ }
+
+ if (axis) {
+ SableCommandHelper.sendSuccessDescribingSubLevelsAtIndex(
+ add ? "commands.sable.physics.rotation.add.success"
+ : "commands.sable.physics.rotation.set.success",
+ ctx, subLevels, 1,
+ getGlobalComponent(global), rotationAxis.x + ", " + rotationAxis.y + ", " + rotationAxis.z + ", " + rotationAngle);
+ } else {
+ SableCommandHelper.sendSuccessDescribingSubLevelsAtIndex(
+ add ? "commands.sable.physics.rotation.add.success"
+ : "commands.sable.physics.rotation.set.success",
+ ctx, subLevels, 1,
+ getGlobalComponent(global), rotation2.x + ", " + rotation2.y);
+ }
+ return 0;
+ }
+
+ private static int executeAddTranslationCommand(final CommandContext ctx, final boolean global) throws CommandSyntaxException {
+ final PhysicsPipeline pipeline = SableCommandHelper.requireSubLevelPhysicsPipeline(ctx);
+
+ final Collection subLevels = SubLevelArgumentType.getSubLevels(ctx, "sub_level");
+
+ if (subLevels.isEmpty()) {
+ throw SableCommandHelper.ERROR_NO_SUB_LEVELS_FOUND.create();
+ }
+
+ final Vec3 translation = ctx.getArgument("translation", Vec3.class);
+ final Vector3d sublevelTranslation = new Vector3d();
+ for (final ServerSubLevel subLevel : subLevels) {
+ JOMLConversion.toJOML(translation, sublevelTranslation);
+
+ if (!global) {
+ subLevel.logicalPose().transformNormal(sublevelTranslation);
+ }
+
+ pipeline.teleport(subLevel, subLevel.logicalPose().position().add(sublevelTranslation), subLevel.logicalPose().orientation());
+ }
+
+ SableCommandHelper.sendSuccessDescribingSubLevelsAtIndex("commands.sable.physics.translation.add.success", ctx, subLevels, 1,
+ getGlobalComponent(global), translation.x + ", " + translation.y + ", " + translation.z);
+ return 0;
+ }
+
+ private static int executeSetTranslationCommand(final CommandContext ctx) throws CommandSyntaxException {
+ final PhysicsPipeline pipeline = SableCommandHelper.requireSubLevelPhysicsPipeline(ctx);
+
+ final Collection subLevels = SubLevelArgumentType.getSubLevels(ctx, "sub_level");
+
+ if (subLevels.isEmpty()) {
+ throw SableCommandHelper.ERROR_NO_SUB_LEVELS_FOUND.create();
+ }
+
+ final Vector3d translation = JOMLConversion.toJOML(Vec3Argument.getVec3(ctx, "translation"));
+ for (final ServerSubLevel subLevel : subLevels) {
+ pipeline.teleport(subLevel, translation, subLevel.logicalPose().orientation());
+ }
+
+ SableCommandHelper.sendSuccessDescribingSubLevels("commands.sable.physics.translation.set.success", ctx, subLevels, translation.x + ", " + translation.y + ", " + translation.z);
+ return 0;
+ }
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/SableSpawnCommands.java b/common/src/main/java/dev/ryanhcode/sable/command/SableSpawnCommands.java
new file mode 100644
index 0000000..0205af7
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/SableSpawnCommands.java
@@ -0,0 +1,523 @@
+package dev.ryanhcode.sable.command;
+
+import com.mojang.brigadier.arguments.IntegerArgumentType;
+import com.mojang.brigadier.arguments.StringArgumentType;
+import com.mojang.brigadier.builder.ArgumentBuilder;
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.suggestion.SuggestionProvider;
+import dev.ryanhcode.sable.api.command.SableCommandHelper;
+import dev.ryanhcode.sable.api.command.SubLevelArgumentType;
+import dev.ryanhcode.sable.api.physics.constraint.PhysicsConstraintHandle;
+import dev.ryanhcode.sable.api.physics.constraint.rotary.RotaryConstraintConfiguration;
+import dev.ryanhcode.sable.api.physics.object.rope.RopeHandle;
+import dev.ryanhcode.sable.api.physics.object.rope.RopePhysicsObject;
+import dev.ryanhcode.sable.api.sublevel.ServerSubLevelContainer;
+import dev.ryanhcode.sable.api.sublevel.SubLevelContainer;
+import dev.ryanhcode.sable.companion.math.BoundingBox3dc;
+import dev.ryanhcode.sable.companion.math.JOMLConversion;
+import dev.ryanhcode.sable.companion.math.Pose3d;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import dev.ryanhcode.sable.sublevel.SubLevel;
+import dev.ryanhcode.sable.sublevel.plot.EmbeddedPlotLevelAccessor;
+import dev.ryanhcode.sable.sublevel.plot.LevelPlot;
+import dev.ryanhcode.sable.sublevel.plot.ServerLevelPlot;
+import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem;
+import dev.ryanhcode.sable.util.SchematicLoader;
+import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import net.minecraft.commands.CommandBuildContext;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.commands.Commands;
+import net.minecraft.commands.SharedSuggestionProvider;
+import net.minecraft.commands.arguments.blocks.BlockStateArgument;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.util.RandomSource;
+import net.minecraft.world.level.ChunkPos;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.Mirror;
+import net.minecraft.world.level.block.Rotation;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.levelgen.structure.BoundingBox;
+import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
+import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate;
+import net.minecraft.world.phys.Vec3;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Quaterniond;
+import org.joml.Vector3d;
+
+import java.util.Collection;
+import java.util.Locale;
+
+public class SableSpawnCommands {
+
+ private static final SuggestionProvider SUGGEST_TEMPLATES = (commandContext, suggestionsBuilder) -> {
+ final MinecraftServer server = commandContext.getSource().getServer();
+ return SchematicLoader.getSchematics(server).thenCompose(schematics -> {
+ final String remaining = suggestionsBuilder.getRemaining().toLowerCase(Locale.ROOT);
+ SharedSuggestionProvider.filterResources(schematics, remaining, resourceLocation -> resourceLocation, resourceLocation -> {
+ final String path = resourceLocation.getPath();
+ suggestionsBuilder.suggest(path.substring("schematics/".length(), path.length() - ".nbt".length()));
+ });
+ return suggestionsBuilder.buildFuture();
+ });
+ };
+
+ private static final BlockState DEFAULT_SPAWN_BLOCKSTATE = Blocks.STONE.defaultBlockState();
+
+ /**
+ * Adds the following commands:
+ *
+ * - {@code /sable spawn jenga [name]}
+ * - {@code /sable spawn [block] [name]}
+ * - {@code /sable spawn block [name]}
+ * - {@code /sable spawn clone [name]}
+ * - {@code /sable spawn schematic }
+ * - {@code /sable spawn }
+ * - {@code /sable spawn [name]}
+ *
+ */
+ public static void register(final LiteralArgumentBuilder sableBuilder, final CommandBuildContext buildContext) {
+
+ sableBuilder.then(Commands.literal("spawn")
+ .then(Commands.literal("jenga")
+ .then(namedSpawnFinale(Commands.argument("height", IntegerArgumentType.integer(1, 256)), SableSpawnCommands::spawnJenga)))
+
+ .then(Commands.literal("clone")
+ .then(namedSpawnFinale(Commands.argument("sub_level", SubLevelArgumentType.singleSubLevel()), SableSpawnCommands::cloneSubLevel)))
+
+ .then(Commands.literal("sphere")
+ .then(Commands.argument("radius", IntegerArgumentType.integer(2, 200))
+ .executes((ctx) -> SableSpawnCommands.spawnSphere(ctx, DEFAULT_SPAWN_BLOCKSTATE, null))
+ .then(namedSpawnFinale(Commands.argument("block", BlockStateArgument.block(buildContext)),
+ (ctx, name) -> SableSpawnCommands.spawnSphere(ctx, BlockStateArgument.getBlock(ctx, "block").getState(), name)))))
+
+ .then(Commands.literal("schematic")
+ .then(Commands.argument("name", StringArgumentType.string())
+ .suggests(SUGGEST_TEMPLATES)
+ .executes(SableSpawnCommands::executeSpawnSchematicCommand)))
+
+ .then(Commands.literal("joint_test")
+ .executes(SableSpawnCommands::executeSpawnJointTestCommand))
+
+ .then(namedSpawnFinale(Commands.literal("slope_test"), SableSpawnCommands::spawnSlopeTest))
+
+ .then(Commands.literal("rope_test")
+ .executes(SableSpawnCommands::executeSpawnRopeTestCommand))
+
+ .then(Commands.literal("grid")
+ .then(Commands.argument("sideLength", IntegerArgumentType.integer(1, 32))
+ .executes((ctx) -> SableSpawnCommands.spawnGrid(ctx, DEFAULT_SPAWN_BLOCKSTATE, null))
+ .then(namedSpawnFinale(Commands.argument("block", BlockStateArgument.block(buildContext)),
+ (ctx, name) -> SableSpawnCommands.spawnGrid(ctx, BlockStateArgument.getBlock(ctx, "block").getState(), name)))))
+
+ .then(Commands.literal("block")
+ .executes((ctx) -> spawnBlock(ctx, DEFAULT_SPAWN_BLOCKSTATE, null))
+ .then(namedSpawnFinale(Commands.argument("block", BlockStateArgument.block(buildContext)),
+ (ctx, name) -> spawnBlock(ctx, BlockStateArgument.getBlock(ctx, "block").getState(), name))))
+
+ .then(Commands.literal("platform")
+ .then(Commands.argument("size", IntegerArgumentType.integer(1, 32))
+ .executes((ctx) -> SableSpawnCommands.spawnPlatform(ctx, DEFAULT_SPAWN_BLOCKSTATE, null))
+ .then(namedSpawnFinale(Commands.argument("block", BlockStateArgument.block(buildContext)),
+ (ctx, name) -> SableSpawnCommands.spawnPlatform(ctx, BlockStateArgument.getBlock(ctx, "block").getState(), name)))))
+ );
+
+ }
+
+ @FunctionalInterface
+ private interface NamedSpawnInvoker {
+ int run(CommandContext ctx, @Nullable String name) throws CommandSyntaxException;
+ }
+
+ private static > T namedSpawnFinale(final T builder, final NamedSpawnInvoker invoker) {
+ builder.executes((ctx) -> invoker.run(ctx, null));
+ builder.then(Commands.argument("name", StringArgumentType.string())
+ .executes((ctx) -> invoker.run(ctx, StringArgumentType.getString(ctx, "name"))));
+ return builder;
+ }
+
+ private static int spawnJenga(final CommandContext ctx, @Nullable final String name) throws CommandSyntaxException {
+ final CommandSourceStack source = ctx.getSource();
+ final SubLevelContainer container = SableCommandHelper.requireSubLevelContainer(ctx);
+ final Vec3 pos = Vec3.atCenterOf(BlockPos.containing(source.getPosition()));
+ final int height = IntegerArgumentType.getInteger(ctx, "height");
+
+ for (int yOffset = 0; yOffset < height; yOffset++) {
+ final Direction.Axis axis = yOffset % 2 == 0 ? Direction.Axis.X : Direction.Axis.Z;
+ final Direction.Axis perpendicular = axis == Direction.Axis.X ? Direction.Axis.Z : Direction.Axis.X;
+
+ for (int index = -1; index <= 1; index++) {
+ final Pose3d pose = new Pose3d();
+ final Vector3d position = pose.position();
+ position.set(pos.x, pos.y, pos.z);
+
+ if (index != 0) {
+ position.add(JOMLConversion.atLowerCornerOf(Direction.get(index == 1 ? Direction.AxisDirection.POSITIVE : Direction.AxisDirection.NEGATIVE, axis).getNormal()));
+ }
+ position.add(0.0, yOffset, 0.0);
+ final Vector3d positionBackup = new Vector3d(position);
+
+ final SubLevel subLevel = container.allocateNewSubLevel(pose);
+ subLevel.setName(name);
+ final LevelPlot plot = subLevel.getPlot();
+
+ final ChunkPos center = plot.getCenterChunk();
+ plot.newEmptyChunk(center);
+
+
+ final EmbeddedPlotLevelAccessor accessor = plot.getEmbeddedLevelAccessor();
+ accessor.setBlock(BlockPos.ZERO, Blocks.SPRUCE_PLANKS.defaultBlockState(), 3);
+ for (int block = -1; block <= 1; block++) {
+ final BlockPos blockPos = BlockPos.ZERO.relative(Direction.get(Direction.AxisDirection.POSITIVE, perpendicular), block);
+
+ BlockState state = Blocks.OAK_PLANKS.defaultBlockState();
+
+ if (index == 0) {
+ state = Blocks.SPRUCE_PLANKS.defaultBlockState();
+ }
+
+ accessor.setBlock(blockPos, state, 3);
+ }
+ subLevel.logicalPose().position().set(positionBackup);
+ subLevel.updateLastPose();
+ }
+ }
+
+ source.sendSuccess(() -> Component.translatable("commands.sable.spawn.success", "jenga"), false);
+ return 1;
+ }
+
+ private static int cloneSubLevel(final CommandContext ctx, @Nullable final String name) throws CommandSyntaxException {
+ final CommandSourceStack source = ctx.getSource();
+ final ServerSubLevelContainer plotContainer = SableCommandHelper.requireSubLevelContainer(ctx);
+ final ServerSubLevel toClone = SubLevelArgumentType.getSingleSubLevel(ctx, "sub_level");
+
+ final BoundingBox3dc worldBounds = toClone.boundingBox();
+ final double height = worldBounds.maxY() - worldBounds.minY();
+
+ final CompoundTag tag = toClone.getPlot().save();
+
+ final ServerSubLevel subLevel = (ServerSubLevel) plotContainer.allocateNewSubLevel(
+ new Pose3d(
+ toClone.logicalPose().position().add(0, height * 1.2 + 2, 0, new Vector3d()),
+ new Quaterniond(), new Vector3d(0), new Vector3d(1)
+ )
+ );
+ final ServerLevelPlot plot = subLevel.getPlot();
+ plot.load(tag);
+ subLevel.updateLastPose();
+ if (name != null) {
+ subLevel.setName(name);
+ }
+
+ source.sendSuccess(() -> Component.translatable("commands.sable.spawn.clone.success"), false);
+ return 1;
+ }
+
+ private static int spawnSphere(final CommandContext ctx, final BlockState material, @Nullable final String name) throws CommandSyntaxException {
+ final CommandSourceStack source = ctx.getSource();
+
+ final SubLevelContainer plotContainer = SableCommandHelper.requireSubLevelContainer(ctx);
+
+ Vec3 playerPos = source.getPosition();
+ playerPos = Vec3.atCenterOf(BlockPos.containing(playerPos));
+
+ final Pose3d pose = new Pose3d();
+ pose.position().set(playerPos.x, playerPos.y, playerPos.z);
+
+ final SubLevel subLevel = plotContainer.allocateNewSubLevel(pose);
+ subLevel.setName(name);
+
+ final LevelPlot plot = subLevel.getPlot();
+
+ final ChunkPos center = plot.getCenterChunk();
+
+ final int radius = IntegerArgumentType.getInteger(ctx, "radius");
+ final int radiusChunks = (radius + 8) / 16;
+ for (int x = -radiusChunks; x <= radiusChunks; x++) {
+ for (int z = -radiusChunks; z <= radiusChunks; z++) {
+ plot.newEmptyChunk(new ChunkPos(center.x + x, center.z + z));
+ }
+ }
+
+ final BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
+ for (int x = -radius; x <= radius; x++) {
+ for (int z = -radius; z <= radius; z++) {
+ for (int y = -radius; y <= radius; y++) {
+ pos.set(x, y, z);
+ if (pos.distSqr(BlockPos.ZERO) <= radius * radius) {
+ plot.getEmbeddedLevelAccessor().setBlock(pos, material, 3);
+ }
+ }
+ }
+ }
+ subLevel.updateLastPose();
+
+ source.sendSuccess(() -> Component.translatable("commands.sable.spawn.success", "sphere"), false);
+ return 1;
+ }
+
+ private static int executeSpawnSchematicCommand(final CommandContext ctx) throws CommandSyntaxException {
+ final CommandSourceStack source = ctx.getSource();
+ final ServerLevel level = source.getLevel();
+
+ final StructureTemplate template = SchematicLoader.loadSchematic(level, ResourceLocation.fromNamespaceAndPath("sable", StringArgumentType.getString(ctx, "name")));
+
+ if (template == null) {
+ source.sendFailure(Component.translatable("commands.sable.place_schematic.failure"));
+ return 0;
+ }
+
+ final SubLevelContainer plotContainer = SableCommandHelper.requireSubLevelContainer(ctx);
+
+ final Vec3 spawnPos = source.getPosition();
+
+ final Pose3d pose = new Pose3d();
+ pose.position().set(spawnPos.x, spawnPos.y, spawnPos.z);
+
+ final SubLevel sublevel = plotContainer.allocateNewSubLevel(pose);
+ final LevelPlot plot = sublevel.getPlot();
+
+ final ChunkPos center = plot.getCenterChunk();
+
+ final BoundingBox bounds = template.getBoundingBox(BlockPos.ZERO, Rotation.NONE, BlockPos.ZERO, Mirror.NONE);
+
+ final int minChunkX = bounds.minX() >> 4;
+ final int minChunkZ = bounds.minZ() >> 4;
+
+ final int maxChunkX = bounds.maxX() >> 4;
+ final int maxChunkZ = bounds.maxZ() >> 4;
+
+ for (int x = minChunkX; x <= maxChunkX; x++) {
+ for (int z = minChunkZ; z <= maxChunkZ; z++) {
+ plot.newEmptyChunk(new ChunkPos(center.x + x, center.z + z));
+ }
+ }
+
+ final EmbeddedPlotLevelAccessor embedded = plot.getEmbeddedLevelAccessor();
+ template.placeInWorld(embedded, BlockPos.ZERO, BlockPos.ZERO, new StructurePlaceSettings(), RandomSource.create(), 3);
+ sublevel.updateLastPose();
+ sublevel.logicalPose().position().set(spawnPos.x, spawnPos.y, spawnPos.z);
+
+ source.sendSuccess(() -> Component.translatable("commands.sable.place_schematic.success"), false);
+ return 1;
+ }
+
+ private static int executeSpawnRopeTestCommand(final CommandContext ctx) throws CommandSyntaxException {
+ final CommandSourceStack source = ctx.getSource();
+
+ final ServerSubLevelContainer plotContainer = SableCommandHelper.requireSubLevelContainer(ctx);
+ final SubLevelPhysicsSystem system = SableCommandHelper.requireSubLevelPhysicsSystem(plotContainer);
+
+ final Vec3 playerPos = Vec3.atCenterOf(BlockPos.containing(source.getPosition()));
+ final Collection points = new ObjectArrayList<>();
+
+ for (int i = 0; i < 10; i++) {
+ points.add(JOMLConversion.toJOML(playerPos).add(i, 0, 0));
+ }
+
+ final RopePhysicsObject object = new RopePhysicsObject(points, 0.25);
+ system.addObject(object);
+ object.setAttachment(RopeHandle.AttachmentPoint.START, JOMLConversion.toJOML(playerPos), null);
+
+ source.sendSuccess(() -> Component.translatable("commands.sable.spawn.success", "rope_test"), false);
+ return 1;
+ }
+
+
+ private static int executeSpawnJointTestCommand(final CommandContext ctx) throws CommandSyntaxException {
+ final CommandSourceStack source = ctx.getSource();
+
+ final ServerSubLevelContainer plotContainer = SableCommandHelper.requireSubLevelContainer(ctx);
+
+ final Vec3 playerPos = Vec3.atCenterOf(BlockPos.containing(source.getPosition()));
+
+ final Pose3d pose1 = new Pose3d();
+ pose1.position().set(playerPos.x, playerPos.y, playerPos.z);
+
+ final Pose3d pose2 = new Pose3d();
+ pose2.position().set(playerPos.x, playerPos.y + 1.0, playerPos.z);
+
+ final ServerSubLevel subLevelA = (ServerSubLevel) plotContainer.allocateNewSubLevel(pose1);
+ final ServerSubLevel subLevelB = (ServerSubLevel) plotContainer.allocateNewSubLevel(pose2);
+
+ final LevelPlot plotA = subLevelA.getPlot();
+ final LevelPlot plotB = subLevelB.getPlot();
+
+ plotA.newEmptyChunk(plotA.getCenterChunk());
+ plotA.getEmbeddedLevelAccessor().setBlock(BlockPos.ZERO, Blocks.STONE.defaultBlockState(), 3);
+
+ plotB.newEmptyChunk(plotB.getCenterChunk());
+ plotB.getEmbeddedLevelAccessor().setBlock(BlockPos.ZERO, Blocks.STONE.defaultBlockState(), 3);
+
+ final RotaryConstraintConfiguration config = new RotaryConstraintConfiguration(
+ JOMLConversion.atBottomCenterOf(plotA.getCenterBlock().above().above()),
+ JOMLConversion.atBottomCenterOf(plotB.getCenterBlock()),
+ JOMLConversion.atLowerCornerOf(Direction.UP.getNormal()),
+ JOMLConversion.atLowerCornerOf(Direction.UP.getNormal())
+ );
+// final FreeConstraintConfiguration config = new FreeConstraintConfiguration();
+
+ final PhysicsConstraintHandle handle = SableCommandHelper.requireSubLevelPhysicsSystem(plotContainer)
+ .getPipeline().addConstraint(subLevelA, subLevelB, config);
+
+ handle.setContactsEnabled(false);
+ source.sendSuccess(() -> Component.translatable("commands.sable.spawn.success", "joint_test"), false);
+ return 1;
+ }
+
+ private static int spawnSlopeTest(final CommandContext ctx, final @Nullable String name) throws CommandSyntaxException {
+ final CommandSourceStack source = ctx.getSource();
+
+ final ServerSubLevelContainer plotContainer = SableCommandHelper.requireSubLevelContainer(ctx);
+
+ final Vec3 playerPos = Vec3.atCenterOf(BlockPos.containing(source.getPosition()));
+
+ final int gridSize = 9;
+ final double yawRange = Math.toRadians(90.0);
+ final double pitchRange = Math.toRadians(90.00);
+ final int rad = 3;
+
+ final int spacing = rad * 2 + 2;
+ for (int xo = 0; xo <= gridSize; xo++) {
+ for (int zo = 0; zo <= gridSize; zo++) {
+
+ final Pose3d pose1 = new Pose3d();
+ pose1.position().set(playerPos.x, playerPos.y, playerPos.z);
+
+ final ServerSubLevel subLevel = (ServerSubLevel) plotContainer.allocateNewSubLevel(pose1);
+ subLevel.setName(name);
+
+ final LevelPlot plotA = subLevel.getPlot();
+
+ final BlockState block = Blocks.END_STONE.defaultBlockState();
+ plotA.newEmptyChunk(plotA.getCenterChunk());
+
+ for (int lx = -rad; lx < rad; lx ++) {
+ for (int lz = -rad; lz < rad; lz++) {
+ plotA.getEmbeddedLevelAccessor().setBlock(new BlockPos(lx, 0, lz), block, 3);
+ }
+ }
+
+ final Vector3d pos = new Vector3d(playerPos.x + xo * spacing, playerPos.y, playerPos.z + zo * spacing);
+ final Quaterniond orientation = new Quaterniond();
+
+ orientation.rotateY(xo * yawRange / gridSize);
+ orientation.rotateX(zo * pitchRange / gridSize);
+
+ SableCommandHelper.requireSubLevelPhysicsPipeline(ctx).teleport(subLevel, pos, orientation);
+ }
+ }
+ source.sendSuccess(() -> Component.translatable("commands.sable.spawn.success", "slope_test"), false);
+ return 1;
+ }
+
+ private static int spawnGrid(final CommandContext ctx, final BlockState material, final @Nullable String name) throws CommandSyntaxException {
+ final CommandSourceStack source = ctx.getSource();
+
+ final SubLevelContainer plotContainer = SableCommandHelper.requireSubLevelContainer(ctx);
+
+ final Vec3 playerPos = source.getPosition();
+
+ final int sideLength = IntegerArgumentType.getInteger(ctx, "sideLength");
+
+ final Vec3[] positions = new Vec3[sideLength * sideLength * sideLength];
+
+ for (int x = 0; x < sideLength; x++) {
+ for (int z = 0; z < sideLength; z++) {
+ for (int y = 0; y < sideLength; y++) {
+ positions[x * sideLength * sideLength + z * sideLength + y] = new Vec3(x, y, z).scale(2.1).add(playerPos);
+ }
+ }
+ }
+
+ for (final Vec3 subLevelPos : positions) {
+ final Pose3d pose = new Pose3d();
+ pose.position().set(subLevelPos.x, subLevelPos.y, subLevelPos.z);
+
+ final SubLevel subLevel = plotContainer.allocateNewSubLevel(pose);
+ subLevel.setName(name);
+ final LevelPlot plot = subLevel.getPlot();
+
+ final ChunkPos center = plot.getCenterChunk();
+ plot.newEmptyChunk(center);
+
+ plot.getEmbeddedLevelAccessor().setBlock(BlockPos.ZERO, material, 3);
+ subLevel.updateLastPose();
+ }
+
+ source.sendSuccess(() -> Component.translatable("commands.sable.spawn.success", "grid"), false);
+ return 1;
+ }
+
+ private static int spawnBlock(final CommandContext ctx, final BlockState material, final @Nullable String name) throws CommandSyntaxException {
+ final CommandSourceStack source = ctx.getSource();
+
+ final SubLevelContainer plotContainer = SableCommandHelper.requireSubLevelContainer(ctx);
+
+ final Vec3 playerPos = source.getPosition();
+
+ final Pose3d pose = new Pose3d();
+ pose.position().set(playerPos.x, playerPos.y, playerPos.z);
+
+ final SubLevel subLevel = plotContainer.allocateNewSubLevel(pose);
+ subLevel.setName(name);
+ final LevelPlot plot = subLevel.getPlot();
+
+ final ChunkPos center = plot.getCenterChunk();
+ plot.newEmptyChunk(center);
+
+ plot.getEmbeddedLevelAccessor().setBlock(BlockPos.ZERO, material, 3);
+ subLevel.updateLastPose();
+
+ source.sendSuccess(() -> Component.translatable("commands.sable.spawn.success", "block"), false);
+ return 1;
+ }
+
+ private static int spawnPlatform(final CommandContext ctx, final BlockState material, final @Nullable String name) throws CommandSyntaxException {
+ final CommandSourceStack source = ctx.getSource();
+
+ final SubLevelContainer plotContainer = SableCommandHelper.requireSubLevelContainer(ctx);
+
+ final Vec3 playerPos = source.getPosition();
+
+ final Pose3d pose = new Pose3d();
+ pose.position().set(playerPos.x, playerPos.y, playerPos.z);
+
+ final SubLevel subLevel = plotContainer.allocateNewSubLevel(pose);
+ subLevel.setName(name);
+ final LevelPlot plot = subLevel.getPlot();
+
+ final ChunkPos center = plot.getCenterChunk();
+
+ final int size = IntegerArgumentType.getInteger(ctx, "size");
+ final int radiusChunks = (size + 8) / 16;
+ for (int x = -radiusChunks; x <= radiusChunks; x++) {
+ for (int z = -radiusChunks; z <= radiusChunks; z++) {
+ plot.newEmptyChunk(new ChunkPos(center.x + x, center.z + z));
+ }
+ }
+ for (int x = -size; x <= size; x++) {
+ for (int z = -size; z <= size; z++) {
+ plot.getEmbeddedLevelAccessor().setBlock(new BlockPos(x, 0, z), material, 2);
+ }
+ }
+ subLevel.updateLastPose();
+ SableCommandHelper.requireSubLevelPhysicsPipeline(ctx).teleport(
+ (ServerSubLevel) subLevel,
+ new Vector3d(playerPos.x, playerPos.y, playerPos.z),
+ pose.orientation()
+ );
+
+ source.sendSuccess(() -> Component.translatable("commands.sable.spawn.success", "platform"), false);
+ return 1;
+ }
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/SableStorageCommands.java b/common/src/main/java/dev/ryanhcode/sable/command/SableStorageCommands.java
new file mode 100644
index 0000000..6dcf559
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/SableStorageCommands.java
@@ -0,0 +1,166 @@
+package dev.ryanhcode.sable.command;
+
+import com.mojang.brigadier.arguments.StringArgumentType;
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import dev.ryanhcode.sable.api.sublevel.ServerSubLevelContainer;
+import dev.ryanhcode.sable.companion.math.Pose3d;
+import dev.ryanhcode.sable.sublevel.storage.holding.GlobalSavedSubLevelPointer;
+import dev.ryanhcode.sable.sublevel.storage.holding.SavedSubLevelPointer;
+import dev.ryanhcode.sable.sublevel.storage.holding.SubLevelHoldingChunk;
+import dev.ryanhcode.sable.sublevel.storage.holding.SubLevelHoldingChunkMap;
+import dev.ryanhcode.sable.sublevel.storage.region.SubLevelRegionFile;
+import dev.ryanhcode.sable.sublevel.storage.serialization.SubLevelData;
+import dev.ryanhcode.sable.sublevel.storage.serialization.SubLevelStorage;
+import net.minecraft.ChatFormatting;
+import net.minecraft.commands.CommandBuildContext;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.commands.Commands;
+import net.minecraft.network.chat.*;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.ChunkPos;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+
+import java.io.File;
+import java.util.Formatter;
+import java.util.Locale;
+
+public class SableStorageCommands {
+
+ public static void register(final LiteralArgumentBuilder sableBuilder, final CommandBuildContext buildContext) {
+ sableBuilder.then(Commands.literal("storage")
+ .then(Commands.literal("find_all_sub_levels")
+ .executes(ctx -> {
+ final ServerLevel level = ctx.getSource().getLevel();
+ final ServerSubLevelContainer container = ServerSubLevelContainer.getContainer(level);
+ final SubLevelHoldingChunkMap holdingChunkMap = container.getHoldingChunkMap();
+ final SubLevelStorage storage = holdingChunkMap.getStorage();
+ final CommandSourceStack source = ctx.getSource();
+
+ final File[] regionFiles = storage.getFolder().toFile().listFiles((dir, name) -> name.endsWith(SubLevelRegionFile.FILE_EXTENSION));
+
+ if (regionFiles != null) {
+ for (final File regionFile : regionFiles) {
+ final String fileName = regionFile.getName();
+ final String withoutExtension = fileName.substring(0, fileName.length() - SubLevelRegionFile.FILE_EXTENSION.length());
+ final String[] parts = withoutExtension.split("\\.");
+ if (parts.length != 3) continue;
+
+ final int regionX, regionZ;
+ try {
+ regionX = Integer.parseInt(parts[1]);
+ regionZ = Integer.parseInt(parts[2]);
+ } catch (final NumberFormatException e) {
+ continue;
+ }
+
+ for (int localX = 0; localX < SubLevelRegionFile.SIDE_LENGTH; localX++) {
+ for (int localZ = 0; localZ < SubLevelRegionFile.SIDE_LENGTH; localZ++) {
+ final ChunkPos chunkPos = new ChunkPos(
+ regionX * SubLevelRegionFile.SIDE_LENGTH + localX,
+ regionZ * SubLevelRegionFile.SIDE_LENGTH + localZ
+ );
+
+ final SubLevelHoldingChunk holdingChunk = storage.attemptLoadHoldingChunk(chunkPos);
+ if (holdingChunk == null) continue;
+
+ for (final SavedSubLevelPointer pointer : holdingChunk.getSubLevelPointers()) {
+ final SubLevelData data = storage.attemptLoadSubLevel(chunkPos, pointer);
+ logFoundSubLevel(pointer, data, chunkPos, source, level);
+ }
+ }
+ }
+ }
+ }
+ return 1;
+ }))
+ .then(Commands.literal("find")
+ .then(Commands.argument("name", StringArgumentType.string())
+ .executes(ctx -> {
+ final ServerLevel level = ctx.getSource().getLevel();
+ final ServerSubLevelContainer container = ServerSubLevelContainer.getContainer(level);
+ final SubLevelHoldingChunkMap holdingChunkMap = container.getHoldingChunkMap();
+ final SubLevelStorage storage = holdingChunkMap.getStorage();
+ final CommandSourceStack source = ctx.getSource();
+ final String nameArgument = StringArgumentType.getString(ctx, "name");
+
+ final File[] regionFiles = storage.getFolder().toFile().listFiles((dir, name) -> name.endsWith(SubLevelRegionFile.FILE_EXTENSION));
+
+ if (regionFiles != null) {
+ for (final File regionFile : regionFiles) {
+ final String fileName = regionFile.getName();
+ final String withoutExtension = fileName.substring(0, fileName.length() - SubLevelRegionFile.FILE_EXTENSION.length());
+ final String[] parts = withoutExtension.split("\\.");
+ if (parts.length != 3) continue;
+
+ final int regionX, regionZ;
+ try {
+ regionX = Integer.parseInt(parts[1]);
+ regionZ = Integer.parseInt(parts[2]);
+ } catch (final NumberFormatException e) {
+ continue;
+ }
+
+ for (int localX = 0; localX < SubLevelRegionFile.SIDE_LENGTH; localX++) {
+ for (int localZ = 0; localZ < SubLevelRegionFile.SIDE_LENGTH; localZ++) {
+ final ChunkPos chunkPos = new ChunkPos(
+ regionX * SubLevelRegionFile.SIDE_LENGTH + localX,
+ regionZ * SubLevelRegionFile.SIDE_LENGTH + localZ
+ );
+
+ final SubLevelHoldingChunk holdingChunk = storage.attemptLoadHoldingChunk(chunkPos);
+ if (holdingChunk == null) continue;
+
+ for (final SavedSubLevelPointer pointer : holdingChunk.getSubLevelPointers()) {
+ final SubLevelData data = storage.attemptLoadSubLevel(chunkPos, pointer);
+
+ final String name = data.fullTag().contains("display_name")
+ ? data.fullTag().getString("display_name")
+ : data.uuid().toString();
+ if (name != null && name.equals(nameArgument)) {
+ logFoundSubLevel(pointer, data, chunkPos, source, level);
+ }
+ }
+ }
+ }
+ }
+ }
+ return 1;
+ })))
+
+ );
+ }
+
+ private static void logFoundSubLevel(final SavedSubLevelPointer pointer, final SubLevelData data, final ChunkPos chunkPos, final CommandSourceStack source, final ServerLevel level) {
+ if (data == null) return;
+
+ final String name = data.fullTag().contains("display_name")
+ ? data.fullTag().getString("display_name")
+ : data.uuid().toString();
+ final GlobalSavedSubLevelPointer globalPointer = new GlobalSavedSubLevelPointer(chunkPos, pointer.storageIndex(), pointer.subLevelIndex());
+
+ final Pose3d pose = data.pose();
+
+ source.sendSuccess(() -> {
+ final Vector3dc pos = pose.position();
+ final MutableComponent component = Component.translatable("commands.sable.info.name", Component.literal(name));
+ final ResourceLocation dimension = level.dimension().location();
+ final Component fileId = Component.translatable("commands.sable.info.name.tooltip", globalPointer.toString());
+ component.setStyle(Style.EMPTY.withClickEvent(new ClickEvent(ClickEvent.Action.SUGGEST_COMMAND, new Formatter().format(Locale.ROOT, "/execute in %s run tp @s %.2f %.2f %.2f", dimension, pos.x(), pos.y(), pos.z()).toString()))
+ .withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, fileId))
+ .withColor(ChatFormatting.GRAY));
+ return component;
+ }, false);
+
+ source.sendSuccess(() -> {
+ final Vector3dc pos = pose.position();
+ return Component.translatable("commands.sable.info.position", pos.x(), pos.y(), pos.z());
+ }, false);
+
+ source.sendSuccess(() -> {
+ final Vector3d size = data.bounds().size();
+ return Component.translatable("commands.sable.info.world_bounds", size.x, size.y, size.z);
+ }, false);
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/SableSubLevelCommands.java b/common/src/main/java/dev/ryanhcode/sable/command/SableSubLevelCommands.java
new file mode 100644
index 0000000..90a0ccf
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/SableSubLevelCommands.java
@@ -0,0 +1,198 @@
+package dev.ryanhcode.sable.command;
+
+import com.mojang.brigadier.arguments.StringArgumentType;
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import dev.ryanhcode.sable.api.command.SableCommandHelper;
+import dev.ryanhcode.sable.api.command.SubLevelArgumentType;
+import dev.ryanhcode.sable.api.physics.PhysicsPipeline;
+import dev.ryanhcode.sable.api.sublevel.SubLevelContainer;
+import dev.ryanhcode.sable.companion.math.JOMLConversion;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import dev.ryanhcode.sable.sublevel.SubLevel;
+import dev.ryanhcode.sable.sublevel.plot.LevelPlot;
+import dev.ryanhcode.sable.sublevel.storage.SubLevelRemovalReason;
+import net.minecraft.commands.CommandBuildContext;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.commands.Commands;
+import net.minecraft.commands.arguments.coordinates.Coordinates;
+import net.minecraft.commands.arguments.coordinates.RotationArgument;
+import net.minecraft.commands.arguments.coordinates.Vec3Argument;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.phys.Vec2;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Quaterniond;
+import org.joml.Vector2i;
+import org.joml.Vector3d;
+
+import java.util.Collection;
+import java.util.Objects;
+
+public class SableSubLevelCommands {
+
+ /**
+ * Adds the following commands:
+ *
+ * - {@code /sable name set }
+ * - {@code /sable name clear }
+ * - {@code /sable name get }
+ * - {@code /sable teleport }
+ * - {@code /sable remove }
+ *
+ */
+ public static void register(final LiteralArgumentBuilder sableBuilder, final CommandBuildContext buildContext) {
+ sableBuilder
+ .then(Commands.literal("name")
+ .then(Commands.literal("set")
+ .then(Commands.argument("sub_level", SubLevelArgumentType.subLevels())
+ .then(Commands.argument("name", StringArgumentType.string())
+ .executes(SableSubLevelCommands::executeSetSubLevelNameCommand))))
+ .then(Commands.literal("clear")
+ .then(Commands.argument("sub_level", SubLevelArgumentType.subLevels())
+ .executes(SableSubLevelCommands::executeClearSubLevelNameCommand)))
+ .then(Commands.literal("get")
+ .then(Commands.argument("sub_level", SubLevelArgumentType.singleSubLevel())
+ .executes(SableSubLevelCommands::executeGetSubLevelNameCommand))))
+
+ .then(Commands.literal("teleport")
+ .then(Commands.argument("targets", SubLevelArgumentType.subLevels())
+ .then(Commands.argument("destination", Vec3Argument.vec3(false))
+ .executes((ctx) -> SableSubLevelCommands.executeTeleportSubLevelCommand(ctx, null))
+ .then(Commands.argument("angle", RotationArgument.rotation())
+ .executes((ctx) -> SableSubLevelCommands.executeTeleportSubLevelCommand(
+ ctx, RotationArgument.getRotation(ctx, "angle"))
+ ))
+ )))
+
+ .then(Commands.literal("remove")
+ .then(Commands.argument("targets", SubLevelArgumentType.subLevels())
+ .executes(SableSubLevelCommands::executeRemoveSubLevelCommand)));
+ }
+
+ private static int setSubLevelNames(final Collection subLevels, @Nullable final String name) {
+ int modifiedCount = 0;
+ for (final SubLevel target : subLevels) {
+ if (!Objects.equals(target.getName(), name)) {
+ target.setName(name);
+ modifiedCount++;
+ }
+ }
+ return modifiedCount;
+ }
+
+ private static int executeSetSubLevelNameCommand(final CommandContext ctx) throws CommandSyntaxException {
+ final Collection subLevels = SubLevelArgumentType.getSubLevels(ctx, "sub_level");
+ final String name = StringArgumentType.getString(ctx, "name");
+
+ if (subLevels.isEmpty()) {
+ throw SableCommandHelper.ERROR_NO_SUB_LEVELS_FOUND.create();
+ }
+
+ final int modifiedCount = setSubLevelNames(subLevels, name);
+
+ if (modifiedCount == 0) {
+ throw SableCommandHelper.ERROR_NO_SUB_LEVELS_MODIFIED.create();
+ }
+
+ if (modifiedCount == 1) {
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.sub_level.set_name.success_singular", name), true);
+ } else {
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.sub_level.set_name.success_multiple", modifiedCount, name), true);
+ }
+ return modifiedCount;
+ }
+
+ private static int executeClearSubLevelNameCommand(final CommandContext ctx) throws CommandSyntaxException {
+ final Collection subLevels = SubLevelArgumentType.getSubLevels(ctx, "sub_level");
+
+ if (subLevels.isEmpty()) {
+ throw SableCommandHelper.ERROR_NO_SUB_LEVELS_FOUND.create();
+ }
+
+ final int modifiedCount = setSubLevelNames(subLevels, null);
+
+ if (modifiedCount == 0) {
+ throw SableCommandHelper.ERROR_NO_SUB_LEVELS_MODIFIED.create();
+ }
+
+ if (modifiedCount == 1) {
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.sub_level.clear_name.success_singular"), true);
+ } else {
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.sub_level.clear_name.success_multiple", modifiedCount), true);
+ }
+ return modifiedCount;
+ }
+
+ private static int executeGetSubLevelNameCommand(final CommandContext ctx) throws CommandSyntaxException {
+ final SubLevel subLevel = SubLevelArgumentType.getSingleSubLevel(ctx, "sub_level");
+
+ if (subLevel.getName() == null) {
+ throw SableCommandHelper.ERROR_SUB_LEVEL_UNNAMED.create();
+ } else {
+ ctx.getSource().sendSuccess(() -> Component.translatable("commands.sable.sub_level.get_name.success", subLevel.getName()), true);
+ return 1;
+ }
+ }
+
+ private static int executeTeleportSubLevelCommand(final CommandContext ctx, final @Nullable Coordinates angle) throws CommandSyntaxException {
+ final PhysicsPipeline pipeline = SableCommandHelper.requireSubLevelPhysicsPipeline(ctx);
+
+ final Collection targets = SubLevelArgumentType.getSubLevels(ctx, "targets");
+
+ if (targets.isEmpty()) {
+ throw SableCommandHelper.ERROR_NO_SUB_LEVELS_FOUND.create();
+ }
+
+ final Vector3d destination = JOMLConversion.toJOML(Vec3Argument.getVec3(ctx, "destination"));
+
+ final Quaterniond orientation = new Quaterniond();
+
+ final Vec2 rotation = angle != null ? angle.getRotation(ctx.getSource()) : null;
+ if (angle != null) {
+ orientation.rotateY(-Math.toRadians(rotation.y));
+ orientation.rotateX(Math.toRadians(rotation.x));
+ }
+
+ for (final ServerSubLevel target : targets) {
+ pipeline.resetVelocity(target);
+ pipeline.teleport(target, destination, angle != null ? orientation : target.logicalPose().orientation());
+ }
+
+ if (angle != null) {
+ SableCommandHelper.sendSuccessDescribingSubLevels(
+ "commands.sable.sub_level.teleport_with_orientation.success",
+ ctx, targets,
+ destination.x, destination.y, destination.z,
+ rotation.x, rotation.y
+ );
+ } else {
+ SableCommandHelper.sendSuccessDescribingSubLevels(
+ "commands.sable.sub_level.teleport.success",
+ ctx, targets,
+ destination.x, destination.y, destination.z
+ );
+ }
+ return 1;
+ }
+
+ private static int executeRemoveSubLevelCommand(final CommandContext ctx) throws CommandSyntaxException {
+ final SubLevelContainer container = SableCommandHelper.requireSubLevelContainer(ctx);
+
+ final Collection targets = SubLevelArgumentType.getSubLevels(ctx, "targets");
+
+ if (targets.isEmpty()) {
+ throw SableCommandHelper.ERROR_NO_SUB_LEVELS_FOUND.create();
+ }
+
+ for (final SubLevel target : targets) {
+ final LevelPlot plot = target.getPlot();
+ final Vector2i origin = container.getOrigin();
+ container.removeSubLevel(plot.plotPos.x - origin.x, plot.plotPos.z - origin.y, SubLevelRemovalReason.REMOVED);
+ }
+
+ SableCommandHelper.sendSuccessDescribingSubLevels("commands.sable.sub_level.remove.success", ctx, targets);
+ return 1;
+ }
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/Vec3ArgumentAbsolute.java b/common/src/main/java/dev/ryanhcode/sable/command/Vec3ArgumentAbsolute.java
new file mode 100644
index 0000000..0678ec1
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/Vec3ArgumentAbsolute.java
@@ -0,0 +1,94 @@
+package dev.ryanhcode.sable.command;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.arguments.ArgumentType;
+import com.mojang.brigadier.context.CommandContext;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.suggestion.Suggestions;
+import com.mojang.brigadier.suggestion.SuggestionsBuilder;
+import net.minecraft.commands.Commands;
+import net.minecraft.commands.SharedSuggestionProvider;
+import net.minecraft.commands.arguments.coordinates.Vec3Argument;
+import net.minecraft.world.phys.Vec3;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Predicate;
+
+import static net.minecraft.commands.arguments.coordinates.WorldCoordinate.ERROR_EXPECTED_DOUBLE;
+
+public class Vec3ArgumentAbsolute implements ArgumentType
+{
+ public static Vec3ArgumentAbsolute vec3() {
+ return new Vec3ArgumentAbsolute();
+ }
+ private static final Collection EXAMPLES = Arrays.asList("0 0 0");
+ @Override
+ public Vec3 parse(StringReader stringReader) throws CommandSyntaxException {
+ int i = stringReader.getCursor();
+ double worldCoordinate = parseDouble(stringReader);
+ if (stringReader.canRead() && stringReader.peek() == ' ') {
+ stringReader.skip();
+ double worldCoordinate2 = parseDouble(stringReader);
+ if (stringReader.canRead() && stringReader.peek() == ' ') {
+ stringReader.skip();
+ double worldCoordinate3 = parseDouble(stringReader);
+ return new Vec3(worldCoordinate,worldCoordinate2,worldCoordinate3);
+ } else {
+ stringReader.setCursor(i);
+ throw Vec3Argument.ERROR_NOT_COMPLETE.createWithContext(stringReader);
+ }
+ } else {
+ stringReader.setCursor(i);
+ throw Vec3Argument.ERROR_NOT_COMPLETE.createWithContext(stringReader);
+ }
+ }
+ private double parseDouble(StringReader stringReader) throws CommandSyntaxException
+ {
+ if (!stringReader.canRead()) {
+ throw ERROR_EXPECTED_DOUBLE.createWithContext(stringReader);
+ } else {
+ int i = stringReader.getCursor();
+ double d = stringReader.canRead() && stringReader.peek() != ' ' ? stringReader.readDouble() : (double)0.0F;
+ String string = stringReader.getString().substring(i, stringReader.getCursor());
+ if (string.isEmpty()) {
+ return 0;
+ } else {
+ return d;
+ }
+ }
+ }
+
+ public CompletableFuture listSuggestions(CommandContext commandContext, SuggestionsBuilder suggestionsBuilder) {
+ if (!(commandContext.getSource() instanceof SharedSuggestionProvider)) {
+ return Suggestions.empty();
+ } else {
+ String string = suggestionsBuilder.getRemaining();
+ List list = Lists.newArrayList();
+ Predicate predicate = Commands.createValidator(this::parse);
+ String[] strings = Strings.isNullOrEmpty(string)? new String[0] : string.split(" ");
+
+ for (int i = 3; i > strings.length; i--) {
+ StringBuilder s = new StringBuilder();
+ for (int j = 0; j < i; j++) {
+ s.append(j < strings.length ? strings[j] : "0");
+ if(j < i-1)
+ s.append(" ");
+ }
+ if(!predicate.test(s.toString()) && i == 3)
+ break;
+ list.add(s.toString());
+ }
+ return SharedSuggestionProvider.suggest(list,suggestionsBuilder);
+ }
+ }
+
+ @Override
+ public Collection getExamples() {
+ return EXAMPLES;
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/argument/SubLevelSelector.java b/common/src/main/java/dev/ryanhcode/sable/command/argument/SubLevelSelector.java
new file mode 100644
index 0000000..c74921c
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/argument/SubLevelSelector.java
@@ -0,0 +1,139 @@
+package dev.ryanhcode.sable.command.argument;
+
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import dev.ryanhcode.sable.ActiveSableCompanion;
+import dev.ryanhcode.sable.Sable;
+import dev.ryanhcode.sable.api.SubLevelHelper;
+import dev.ryanhcode.sable.api.command.SableCommandHelper;
+import dev.ryanhcode.sable.api.entity.EntitySubLevelUtil;
+import dev.ryanhcode.sable.api.sublevel.ServerSubLevelContainer;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import it.unimi.dsi.fastutil.Pair;
+import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.phys.BlockHitResult;
+import net.minecraft.world.phys.HitResult;
+import net.minecraft.world.phys.Vec3;
+import org.joml.Vector3d;
+
+import java.util.*;
+
+public class SubLevelSelector {
+
+ private final SubLevelSelectorType type;
+ private final List> modifiers;
+
+ public SubLevelSelector(final SubLevelSelectorType type, final List> modifiers) {
+ this.type = type;
+ this.modifiers = modifiers;
+ }
+
+ public SubLevelSelectorType getSelectorType() {
+ return this.type;
+ }
+
+ public Collection getSubLevels(final CommandSourceStack source) throws CommandSyntaxException {
+ if (this.type == null) {
+ return List.of();
+ }
+
+ final ServerLevel level = source.getLevel();
+ final ServerSubLevelContainer container = SableCommandHelper.requireSubLevelContainer(source);
+
+ final Iterable containerBodies = container.getAllSubLevels();
+ final Collection bodies = new ObjectArrayList<>();
+
+ for (final ServerSubLevel subLevel : containerBodies) {
+ bodies.add(subLevel);
+ }
+
+ if (bodies.isEmpty()) {
+ return Collections.emptySet();
+ }
+
+ final ActiveSableCompanion helper = Sable.HELPER;
+ final Collection collectedSubLevels = switch (this.type) {
+ case ALL -> new HashSet<>(bodies);
+ case NEAREST -> {
+ double closest = Double.MAX_VALUE;
+ ServerSubLevel closestSubLevel = null;
+
+ for (final ServerSubLevel body : bodies) {
+ final Vec3 sourcePosition = helper.projectOutOfSubLevel(source.getLevel(), source.getPosition());
+ final double distance = body.logicalPose().position().distance(sourcePosition.x, sourcePosition.y, sourcePosition.z);
+
+ if (distance < closest) {
+ closest = distance;
+ closestSubLevel = body;
+ }
+ }
+
+ yield Collections.singleton(closestSubLevel);
+ }
+ case RANDOM -> {
+ final List list = new ArrayList<>(bodies);
+ yield Collections.singleton(list.get(level.random.nextInt(list.size())));
+ }
+ case INSIDE -> {
+ final ServerSubLevel subLevel = (ServerSubLevel) helper.getContaining(level, source.getPosition());
+ if (subLevel != null) {
+ yield Collections.singleton(subLevel);
+ } else {
+ yield Collections.emptySet();
+ }
+ }
+ case TRACKING -> {
+ if (source.getEntity() == null) {
+ yield Collections.emptySet();
+ }
+
+ final ServerSubLevel subLevel = (ServerSubLevel) Sable.HELPER.getTrackingSubLevel(source.getEntity());
+
+ if (subLevel != null) {
+ yield Collections.singleton(subLevel);
+ } else {
+ yield Collections.emptySet();
+ }
+ }
+ case VIEWED -> {
+ if (source.getEntity() != null) {
+ final HitResult res = source.getEntity().pick(100.0, 1.0f, true);
+
+ if (res instanceof final BlockHitResult blockHitResult) {
+ final ServerSubLevel containing = (ServerSubLevel) helper.getContaining(level, blockHitResult.getBlockPos());
+ if (containing != null) {
+ yield Collections.singleton(containing);
+ } else {
+ yield Collections.emptySet();
+ }
+ } else {
+ yield Collections.emptySet();
+ }
+ } else {
+ yield Collections.emptySet();
+ }
+ }
+ case LATEST -> {
+ final List subLevels = container.getAllSubLevels();
+ if (subLevels.isEmpty()) {
+ yield Collections.emptySet();
+ }
+ yield Collections.singleton(subLevels.getLast());
+ }
+ };
+
+ List modifiedSubLevels = new ObjectArrayList<>(collectedSubLevels);
+
+ final Vector3d position = new Vector3d(source.getPosition().x, source.getPosition().y, source.getPosition().z);
+ this.modifiers.sort(
+ Comparator.comparingInt(a -> a.first().getFilterPriority().ordinal())
+ );
+ for (final Pair modifier : this.modifiers) {
+ modifiedSubLevels = modifier.right().apply(modifiedSubLevels, position);
+ }
+
+ return modifiedSubLevels;
+ }
+
+}
\ No newline at end of file
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/argument/SubLevelSelectorModifierType.java b/common/src/main/java/dev/ryanhcode/sable/command/argument/SubLevelSelectorModifierType.java
new file mode 100644
index 0000000..369dc62
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/argument/SubLevelSelectorModifierType.java
@@ -0,0 +1,99 @@
+package dev.ryanhcode.sable.command.argument;
+
+import com.mojang.brigadier.Message;
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import it.unimi.dsi.fastutil.Pair;
+import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
+import net.minecraft.network.chat.Component;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Vector3d;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public class SubLevelSelectorModifierType {
+
+ private static final Map MODIFIERS_BY_NAME = new Object2ObjectOpenHashMap<>();
+
+ private static final SimpleCommandExceptionType UNKNOWN_PROPERTY_NAME =
+ new SimpleCommandExceptionType(Component.translatable("argument.sable.sub_level.unknown_property"));
+
+ private final String name;
+ private final Parser parser;
+ private final FilterPriority filterPriority;
+
+ public SubLevelSelectorModifierType(final String name, final Parser parser, final FilterPriority priority) {
+ this.name = name;
+ this.parser = parser;
+ this.filterPriority = priority;
+ }
+
+ public static void registerType(final String name, final Parser parser, final FilterPriority filterPriority) {
+ if (MODIFIERS_BY_NAME.containsKey(name)) {
+ throw new IllegalArgumentException("Modifier type " + name + " already registered");
+ }
+ MODIFIERS_BY_NAME.put(name, new SubLevelSelectorModifierType(name, parser, filterPriority));
+ }
+
+ public static SubLevelSelectorModifierType getModifier(final String propertyName, final StringReader readerForErrorContext) throws CommandSyntaxException {
+ if (!MODIFIERS_BY_NAME.containsKey(propertyName)) {
+ throw UNKNOWN_PROPERTY_NAME.createWithContext(readerForErrorContext);
+ }
+ return MODIFIERS_BY_NAME.get(propertyName);
+ }
+
+ public static void clearRegistry() {
+ MODIFIERS_BY_NAME.clear();
+ }
+
+ public static List> getAllNamesWithTooltip() {
+ final ArrayList> modifiers = new ArrayList<>();
+ for (final SubLevelSelectorModifierType modifier : MODIFIERS_BY_NAME.values()) {
+ modifiers.add(Pair.of(modifier.name, Component.translatable("argument.sable.sub_level.modifier." + modifier.name)));
+ }
+ return modifiers;
+ }
+
+ public Parser getParser() {
+ return this.parser;
+ }
+
+ public FilterPriority getFilterPriority() {
+ return this.filterPriority;
+ }
+
+ /**
+ * Ensures that something like {@code [limit=1,sort=nearest]} applies the sort first, then the limit
+ */
+ public enum FilterPriority {
+ POSITION,
+ FILTER,
+ SORTING,
+ SORTING_SELECTION,
+ }
+
+ public interface Parser {
+ SubLevelSelectorModifierType.Modifier parse(final StringReader value) throws CommandSyntaxException;
+ }
+
+ public interface Modifier {
+ /**
+ * @return the maximum quantity of sub-levels this modifier could ever produce
+ */
+ int getMaxResults();
+
+ /**
+ * Applies the modifier to the selected sub-levels
+ *
+ * @param selected The currently selected sub-levels
+ * @param sourcePos The position of the source, should only be modified by modifiers with priority of {@link FilterPriority#POSITION}
+ * @return The filtered sub-levels
+ */
+ @Nullable List apply(final List selected, Vector3d sourcePos);
+ }
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/argument/SubLevelSelectorModifiers.java b/common/src/main/java/dev/ryanhcode/sable/command/argument/SubLevelSelectorModifiers.java
new file mode 100644
index 0000000..37ca0fe
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/argument/SubLevelSelectorModifiers.java
@@ -0,0 +1,211 @@
+package dev.ryanhcode.sable.command.argument;
+
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
+import dev.ryanhcode.sable.api.command.SubLevelArgumentType;
+import dev.ryanhcode.sable.command.argument.modifier_type.*;
+import net.minecraft.advancements.critereon.MinMaxBounds;
+import net.minecraft.network.chat.Component;
+
+public class SubLevelSelectorModifiers {
+
+ public static final SimpleCommandExceptionType EXPECTED_END_OF_MODIFIER =
+ new SimpleCommandExceptionType(Component.translatable("argument.sable.sub_level.expected_end_of_modifier"));
+ public static final SimpleCommandExceptionType EXPECTED_POSITIVE_INTEGER =
+ new SimpleCommandExceptionType(Component.translatable("argument.sable.sub_level.expected_positive_integer"));
+ public static final SimpleCommandExceptionType EXPECTED_POSITIVE_DECIMAL =
+ new SimpleCommandExceptionType(Component.translatable("argument.sable.sub_level.expected_positive_decimal"));
+ public static final SimpleCommandExceptionType EXPECTED_SORTING_TYPE =
+ new SimpleCommandExceptionType(Component.translatable("argument.sable.sub_level.expected_sorting"));
+ public static final SimpleCommandExceptionType EXPECTED_POSITIVE_RANGE =
+ new SimpleCommandExceptionType(Component.translatable("argument.sable.sub_level.expected_positive_range"));
+
+ private static void registerDoubleArgument(final String name, final boolean onlyPositive, final SubLevelDoubleFilter.Factory factory) {
+ SubLevelSelectorModifierType.registerType(name, (reader) -> {
+ final int i = reader.getCursor();
+ final double value = reader.readDouble();
+ if (onlyPositive && value < 0) {
+ reader.setCursor(i);
+ throw EXPECTED_POSITIVE_DECIMAL.createWithContext(reader);
+ }
+ return factory.create(value);
+ }, SubLevelSelectorModifierType.FilterPriority.FILTER);
+ }
+
+ private static void registerDoubleRangeArgument(final String name, final boolean onlyPositive, final SubLevelDoubleRangeFilter.Factory factory) {
+ SubLevelSelectorModifierType.registerType(name, (reader) -> {
+ final int i = reader.getCursor();
+ final MinMaxBounds.Doubles doubles = MinMaxBounds.Doubles.fromReader(reader);
+ if (onlyPositive && ((
+ doubles.min().isPresent() && doubles.min().get() < 0
+ ) || (
+ doubles.max().isPresent() && doubles.max().get() < 0
+ ))) {
+ reader.setCursor(i);
+ throw EXPECTED_POSITIVE_RANGE.createWithContext(reader);
+ }
+ return factory.create(doubles);
+ }, SubLevelSelectorModifierType.FilterPriority.FILTER);
+ }
+
+ public static void registerModifiers() {
+ registerDoubleRangeArgument("distance", true, SubLevelDoubleRangeFilter.squared(
+ (subLevel, sourcePos) -> subLevel.logicalPose().position().distanceSquared(sourcePos)
+ ));
+ registerDoubleRangeArgument("x", false, SubLevelDoubleRangeFilter.linear(
+ (subLevel, sourcePos) -> subLevel.logicalPose().position().x()
+ ));
+ registerDoubleRangeArgument("y", false, SubLevelDoubleRangeFilter.linear(
+ (subLevel, sourcePos) -> subLevel.logicalPose().position().y()
+ ));
+ registerDoubleRangeArgument("z", false, SubLevelDoubleRangeFilter.linear(
+ (subLevel, sourcePos) -> subLevel.logicalPose().position().z()
+ ));
+ registerDoubleArgument("dx", false, SubLevelDoubleFilter.factory(
+ (subLevel, sourcePos, value) -> {
+ final double dx = subLevel.logicalPose().position().x() - sourcePos.x();
+ if (value < 0) {
+ return dx < 0 && dx > value;
+ } else {
+ return dx > 0 && dx < value;
+ }
+ }
+ ));
+ registerDoubleArgument("dy", false, SubLevelDoubleFilter.factory(
+ (subLevel, sourcePos, value) -> {
+ final double dy = subLevel.logicalPose().position().y() - sourcePos.y();
+ if (value < 0) {
+ return dy < 0 && dy > value;
+ } else {
+ return dy > 0 && dy < value;
+ }
+ }
+ ));
+ registerDoubleArgument("dz", false, SubLevelDoubleFilter.factory(
+ (subLevel, sourcePos, value) -> {
+ final double dz = subLevel.logicalPose().position().z() - sourcePos.z();
+ if (value < 0) {
+ return dz < 0 && dz > value;
+ } else {
+ return dz > 0 && dz < value;
+ }
+ }
+ ));
+
+ registerDoubleRangeArgument("vx", false, SubLevelDoubleRangeFilter.linear(
+ (subLevel, sourcePos) -> subLevel.latestLinearVelocity.x
+ ));
+ registerDoubleRangeArgument("vy", false, SubLevelDoubleRangeFilter.linear(
+ (subLevel, sourcePos) -> subLevel.latestLinearVelocity.y
+ ));
+ registerDoubleRangeArgument("vz", false, SubLevelDoubleRangeFilter.linear(
+ (subLevel, sourcePos) -> subLevel.latestLinearVelocity.z
+ ));
+ registerDoubleRangeArgument("speed", true, SubLevelDoubleRangeFilter.squared(
+ (subLevel, sourcePos) -> subLevel.latestLinearVelocity.lengthSquared()
+ ));
+
+ registerDoubleRangeArgument("mass", true, SubLevelDoubleRangeFilter.linear(
+ (subLevel, sourcePos) -> subLevel.getMassTracker().getMass()
+ ));
+
+ registerDoubleRangeArgument("volume", true, SubLevelDoubleRangeFilter.linear(
+ (subLevel, sourcePos) -> subLevel.getPlot().getBoundingBox().volume()
+ ));
+ registerDoubleRangeArgument("width", true, SubLevelDoubleRangeFilter.linear(
+ (subLevel, sourcePos) -> subLevel.getPlot().getBoundingBox().width()
+ ));
+ registerDoubleRangeArgument("height", true, SubLevelDoubleRangeFilter.linear(
+ (subLevel, sourcePos) -> subLevel.getPlot().getBoundingBox().height()
+ ));
+ registerDoubleRangeArgument("length", true, SubLevelDoubleRangeFilter.linear(
+ (subLevel, sourcePos) -> subLevel.getPlot().getBoundingBox().length()
+ ));
+
+ SubLevelSelectorModifierType.registerType("name", (reader) -> {
+ final String name = readUntilEndOfModifier(reader);
+ return new SubLevelNameFilter(name);
+ }, SubLevelSelectorModifierType.FilterPriority.FILTER);
+
+ SubLevelSelectorModifierType.registerType("sort", (reader) -> {
+ SubLevelArgumentType.setSuggestions(reader, "nearest", "furthest");
+ final String filtering = tryReadString(reader, EXPECTED_SORTING_TYPE, "nearest", "furthest");
+ expectEndOfModifier(reader);
+ return new SubLevelSortModifier(filtering);
+ }, SubLevelSelectorModifierType.FilterPriority.SORTING);
+
+ SubLevelSelectorModifierType.registerType("limit", (reader) -> {
+ final int limit = readPositiveIntStrict(reader);
+ return new SubLevelLimitFilter(limit);
+ }, SubLevelSelectorModifierType.FilterPriority.SORTING_SELECTION);
+ }
+
+ /**
+ * Normal {@link StringReader#readInt()} will try to read a {@code .} as a decimal point and fail, this will ignore all non 0-9 characters and terminate
+ */
+ private static Integer readPositiveIntStrict(final StringReader reader) throws CommandSyntaxException {
+ final StringBuilder builder = new StringBuilder();
+ while (reader.canRead() && reader.peek() >= '0' && reader.peek() <= '9') {
+ builder.append(reader.read());
+ }
+ if (builder.isEmpty()) {
+ throw EXPECTED_POSITIVE_INTEGER.createWithContext(reader);
+ }
+ return Integer.parseInt(builder.toString());
+ }
+
+ private static boolean isEndOfModifier(final StringReader reader) {
+ return reader.peek() == ',' || reader.peek() == ']';
+ }
+
+ private static String readUntilEndOfModifier(final StringReader reader) throws CommandSyntaxException {
+ final StringBuilder builder = new StringBuilder();
+ if (reader.canRead() && reader.peek() == '"') {
+ reader.skip();
+ boolean thereIsNoEscape = false;
+ while (reader.canRead() && (thereIsNoEscape || reader.peek() != '"')) {
+ if (!thereIsNoEscape && reader.peek() == '\\') {
+ thereIsNoEscape = true;
+ reader.skip();
+ } else {
+ builder.append(reader.read());
+ thereIsNoEscape = false;
+ }
+ }
+ if (reader.canRead()) {
+ reader.skip();
+ } else {
+ throw CommandSyntaxException.BUILT_IN_EXCEPTIONS.readerExpectedEndOfQuote().createWithContext(reader);
+ }
+ } else {
+ while (reader.canRead() && !isEndOfModifier(reader)) {
+ builder.append(reader.read());
+ }
+ }
+ return builder.toString();
+ }
+
+ private static String tryReadString(final StringReader reader, final SimpleCommandExceptionType exception, final String... accepted) throws CommandSyntaxException {
+ final StringBuilder builder = new StringBuilder();
+ while (reader.canRead()) {
+ if (isEndOfModifier(reader)) {
+ throw exception.createWithContext(reader);
+ }
+ builder.append(reader.read());
+ for (final String s : accepted) {
+ if (builder.toString().equals(s)) {
+ return builder.toString();
+ }
+ }
+ }
+ throw exception.createWithContext(reader);
+ }
+
+ private static void expectEndOfModifier(final StringReader reader) throws CommandSyntaxException {
+ if (!reader.canRead() || !isEndOfModifier(reader)) {
+ throw EXPECTED_END_OF_MODIFIER.createWithContext(reader);
+ }
+ }
+
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/argument/SubLevelSelectorType.java b/common/src/main/java/dev/ryanhcode/sable/command/argument/SubLevelSelectorType.java
new file mode 100644
index 0000000..4a031bf
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/argument/SubLevelSelectorType.java
@@ -0,0 +1,45 @@
+package dev.ryanhcode.sable.command.argument;
+
+import net.minecraft.network.chat.Component;
+
+public enum SubLevelSelectorType {
+ ALL('e', Component.translatable("argument.sable.body.selector.all"), false),
+ NEAREST('n', Component.translatable("argument.sable.body.selector.nearest"), true),
+ RANDOM('r', Component.translatable("argument.sable.body.selector.random"), true),
+ VIEWED('v', Component.translatable("argument.sable.body.selector.viewed"), true),
+ LATEST('l', Component.translatable("argument.sable.body.selector.latest"), true),
+ TRACKING('t', Component.translatable("argument.sable.body.selector.tracking"), true),
+ INSIDE('i', Component.translatable("argument.sable.body.selector.inside"), true);
+
+ private final char selector;
+ private final Component tooltip;
+ private final boolean single;
+
+ SubLevelSelectorType(final char selector, final Component tooltip, final boolean single) {
+ this.selector = selector;
+ this.tooltip = tooltip;
+ this.single = single;
+ }
+
+ public static SubLevelSelectorType of(final char c) {
+ for (final SubLevelSelectorType type : SubLevelSelectorType.values()) {
+ if (type.selector == c) {
+ return type;
+ }
+ }
+
+ return null;
+ }
+
+ public char getChar() {
+ return this.selector;
+ }
+
+ public Component getTooltip() {
+ return this.tooltip;
+ }
+
+ public boolean single() {
+ return this.single;
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/argument/modifier_type/SubLevelDoubleFilter.java b/common/src/main/java/dev/ryanhcode/sable/command/argument/modifier_type/SubLevelDoubleFilter.java
new file mode 100644
index 0000000..d36d7a1
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/argument/modifier_type/SubLevelDoubleFilter.java
@@ -0,0 +1,59 @@
+package dev.ryanhcode.sable.command.argument.modifier_type;
+
+import dev.ryanhcode.sable.command.argument.SubLevelSelectorModifierType;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+
+import java.util.List;
+
+public class SubLevelDoubleFilter implements SubLevelSelectorModifierType.Modifier {
+ private final double value;
+ private final DoublePredicate valuePredicate;
+
+ private SubLevelDoubleFilter(final double value, final DoublePredicate valuePredicate) {
+ this.value = value;
+ this.valuePredicate = valuePredicate;
+ }
+
+ public static SubLevelDoubleFilter.Factory factory(final DoublePredicate valuePredicate) {
+ return new SubLevelDoubleFilter.Factory(valuePredicate);
+ }
+
+ @Override
+ public int getMaxResults() {
+ return Integer.MAX_VALUE;
+ }
+
+ @Override
+ public @Nullable List apply(final List selected, final Vector3d sourcePos) {
+ final List filtered = new ObjectArrayList<>();
+
+ for (final ServerSubLevel subLevel : selected) {
+ if (this.valuePredicate.fromSublevel(subLevel, sourcePos, this.value)) {
+ filtered.add(subLevel);
+ }
+ }
+
+ return filtered;
+ }
+
+ @FunctionalInterface
+ public interface DoublePredicate {
+ boolean fromSublevel(ServerSubLevel subLevel, Vector3dc sourcePos, double test);
+ }
+
+ public static class Factory {
+ private final DoublePredicate doublePredicate;
+
+ public Factory(final DoublePredicate doublePredicate) {
+ this.doublePredicate = doublePredicate;
+ }
+
+ public SubLevelDoubleFilter create(final double value) {
+ return new SubLevelDoubleFilter(value, this.doublePredicate);
+ }
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/argument/modifier_type/SubLevelDoubleRangeFilter.java b/common/src/main/java/dev/ryanhcode/sable/command/argument/modifier_type/SubLevelDoubleRangeFilter.java
new file mode 100644
index 0000000..67d5200
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/argument/modifier_type/SubLevelDoubleRangeFilter.java
@@ -0,0 +1,75 @@
+package dev.ryanhcode.sable.command.argument.modifier_type;
+
+import dev.ryanhcode.sable.command.argument.SubLevelSelectorModifierType;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import net.minecraft.advancements.critereon.MinMaxBounds;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Vector3d;
+import org.joml.Vector3dc;
+
+import java.util.List;
+
+public class SubLevelDoubleRangeFilter implements SubLevelSelectorModifierType.Modifier {
+ private final MinMaxBounds.Doubles range;
+ private final DoubleGetter valueGetter;
+ private final boolean squared;
+
+ private SubLevelDoubleRangeFilter(final MinMaxBounds.Doubles range, final DoubleGetter valueGetter, final boolean squared) {
+ this.range = range;
+ this.valueGetter = valueGetter;
+ this.squared = squared;
+ }
+
+ public static SubLevelDoubleRangeFilter.Factory linear(final DoubleGetter valueGetter) {
+ return new SubLevelDoubleRangeFilter.Factory(valueGetter, false);
+ }
+
+ public static SubLevelDoubleRangeFilter.Factory squared(final DoubleGetter valueGetter) {
+ return new SubLevelDoubleRangeFilter.Factory(valueGetter, true);
+ }
+
+ @Override
+ public int getMaxResults() {
+ return Integer.MAX_VALUE;
+ }
+
+ @Override
+ public @Nullable List apply(final List selected, final Vector3d sourcePos) {
+ final List filtered = new ObjectArrayList<>();
+
+ for (final ServerSubLevel subLevel : selected) {
+ final double value = this.valueGetter.fromSublevel(subLevel, sourcePos);
+ if (this.squared) {
+ if (this.range.matchesSqr(value)) {
+ filtered.add(subLevel);
+ }
+ } else {
+ if (this.range.matches(value)) {
+ filtered.add(subLevel);
+ }
+ }
+ }
+
+ return filtered;
+ }
+
+ @FunctionalInterface
+ public interface DoubleGetter {
+ double fromSublevel(ServerSubLevel subLevel, Vector3dc sourcePos);
+ }
+
+ public static class Factory {
+ private final DoubleGetter doubleGetter;
+ private final boolean squared;
+
+ public Factory(final DoubleGetter doubleGetter, final boolean squared) {
+ this.doubleGetter = doubleGetter;
+ this.squared = squared;
+ }
+
+ public SubLevelDoubleRangeFilter create(final MinMaxBounds.Doubles range) {
+ return new SubLevelDoubleRangeFilter(range, this.doubleGetter, this.squared);
+ }
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/argument/modifier_type/SubLevelLimitFilter.java b/common/src/main/java/dev/ryanhcode/sable/command/argument/modifier_type/SubLevelLimitFilter.java
new file mode 100644
index 0000000..c883280
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/argument/modifier_type/SubLevelLimitFilter.java
@@ -0,0 +1,30 @@
+package dev.ryanhcode.sable.command.argument.modifier_type;
+
+import dev.ryanhcode.sable.command.argument.SubLevelSelectorModifierType;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Vector3d;
+
+import java.util.List;
+
+public class SubLevelLimitFilter implements SubLevelSelectorModifierType.Modifier {
+ private final int limit;
+
+ public SubLevelLimitFilter(final int limit) {
+ this.limit = limit;
+ }
+
+ @Override
+ public int getMaxResults() {
+ return this.limit;
+ }
+
+ @Override
+ public @Nullable List apply(final List selected, final Vector3d sourcePos) {
+ if (selected.size() > this.limit) {
+ return new ObjectArrayList<>(selected.subList(0, this.limit));
+ }
+ return selected;
+ }
+}
diff --git a/common/src/main/java/dev/ryanhcode/sable/command/argument/modifier_type/SubLevelNameFilter.java b/common/src/main/java/dev/ryanhcode/sable/command/argument/modifier_type/SubLevelNameFilter.java
new file mode 100644
index 0000000..f552a0f
--- /dev/null
+++ b/common/src/main/java/dev/ryanhcode/sable/command/argument/modifier_type/SubLevelNameFilter.java
@@ -0,0 +1,33 @@
+package dev.ryanhcode.sable.command.argument.modifier_type;
+
+import dev.ryanhcode.sable.command.argument.SubLevelSelectorModifierType;
+import dev.ryanhcode.sable.sublevel.ServerSubLevel;
+import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Vector3d;
+
+import java.util.List;
+
+public class SubLevelNameFilter implements SubLevelSelectorModifierType.Modifier {
+ private final String name;
+
+ public SubLevelNameFilter(final String name) {
+ this.name = name;
+ }
+
+ @Override
+ public int getMaxResults() {
+ return Integer.MAX_VALUE;
+ }
+
+ @Override
+ public @Nullable List apply(final List