Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,50 @@ The packet module never creates server-side entities. It resolves viewer UUIDs t

`teleportOrigin(x, y, z)` preserves a shape's world-space geometry by shifting Text Display translation metadata while relocating its virtual origin. For root-anchored shapes, the metadata updates and root teleport are sent in one VirtualEntities bundle on bundle-capable clients, avoiding an intermediate visual jump.

## Animation and updates

Spawned shapes can change after they appear. Updates reuse the existing Text Display entities, so the client animates them instead of respawning anything:

```java
PacketLine line = shapes.line(origin, start, end, 0.05f)
.interpolationDuration(4) // geometry and color changes animate over 4 ticks
.teleportDuration(2) // translate() animates over 2 ticks
.build();
line.addViewer(player.getUniqueId());
line.spawn();

line.setPoints(newStart, newEnd); // stretch or rotate the line in place
line.setColor(0x8000FF00); // fade to translucent green
line.translate(0, 1, 0); // move the whole shape one block up
```

- `setPoints(...)` is available on `LineShape`, `PolylineShape`, `TriangleShape`, and `ParallelogramShape`. In packet mode, every part of a shape is updated in one VirtualEntities bundle, so it changes in a single frame.
- A polyline whose point count grows animates the existing segments; new segments appear immediately and removed ones disappear.
- Text Display background color follows the same interpolation timing as the transformation.
- `translate` moves the geometry, unlike `teleportOrigin`, which only rebases the entities. Root-anchored packet shapes move by teleporting only their anchor.
- Invalid geometry, such as a zero-length line, is rejected before anything is sent or stored.

### Styles, groups, and boxes

`ShapeStyle` bundles appearance and animation settings and applies them to any builder with `style(...)`. `ShapeGroup` manages several shapes as one: lifecycle, viewers, color, animation settings, and movement. `BoxOutline` (12 edges) and `BoxFaces` (6 outward-facing faces) are groups whose `setBounds` moves every part in place, which makes animated selection boxes and cursors cheap:

```java
ShapeStyle style = ShapeStyle.DEFAULT.withColor(0xC0FFFFFF).withInterpolationDuration(2);
BoxOutline cursor = shapes.boxOutline(origin, min, max, 0.02f, style);
cursor.addViewer(player.getUniqueId());
cursor.spawn();

cursor.setBounds(newMin, newMax); // glides to the next cell
shapes.batch(() -> { // packet mode: several shapes in one frame
cursor.setColor(0xC0FF4040);
preview.setPoints(a, b, c);
});
```

Direct Paper and Spigot shapes support the same updates through the Bukkit Display API. Their updates must run on the thread that owns the entities.

`ShapeGeometry` and `BoxGeometry` expose the platform-neutral transforms and box edges and faces for custom renderers.

## Shape API

All shape implementations support:
Expand All @@ -133,8 +177,13 @@ All shape implementations support:
| `getViewerUUIDs()` | Return a copy of configured viewers. |
| `getEntityUUIDs()` | Return Text Display UUIDs for the shape. |
| `teleportOrigin(x, y, z)` | Rebase the virtual origin without moving the rendered geometry. |
| `translate(dx, dy, dz)` | Move the rendered geometry, animated by the teleport duration. |
| `setColor(argb)` / `getColor()` | Change the background color, animated by the interpolation duration. |
| `setInterpolationDuration(ticks)` | Animate later geometry and color updates. |
| `setTeleportDuration(ticks)` | Animate later `translate` calls (0-59 ticks). |
| `getEntityCount()` | Count the Text Display entities in use. |

Builders provide color, brightness, see-through, view range, double-sided, root-anchor, and line roll controls. The supported shape types are Line, Polyline, Triangle, and Parallelogram.
Builders provide color, brightness, see-through, view range, double-sided, root-anchor, interpolation duration, teleport duration, style, and line roll controls. The supported shape types are Line, Polyline, Triangle, and Parallelogram, plus the `BoxOutline` and `BoxFaces` groups.

## Migrating From 2.x

Expand All @@ -145,7 +194,7 @@ Builders provide color, brightness, see-through, view range, double-sided, root-

## Verification

`integration/mineflayer/run-e2e.sh` launches Paper 1.21.11 with PacketEvents, creates a packet-only root-anchored line, and verifies from Mineflayer that Text Display spawn, metadata rebase, root movement, and packet bundle ordering all work. The same test can run from the manual GitHub Actions E2E workflow. Mineflayer still only speaks up to Minecraft 26.1, so that harness keeps its 1.21.11 server even though the modules target 26.3.
`integration/mineflayer/run-e2e.sh` launches Paper 1.21.11 with PacketEvents, creates a packet-only root-anchored line, and verifies from Mineflayer that Text Display spawn, metadata rebase, root movement, and packet bundle ordering all work. It also checks that an animated `setPoints` and `setColor` update reuses the same entity with the requested interpolation, and that `translate` moves the root anchor with the requested teleport duration. The fixture is compiled against the version installed from the current checkout. The same test can run from the manual GitHub Actions E2E workflow. Mineflayer still only speaks up to Minecraft 26.1, so that harness keeps its 1.21.11 server even though the modules target 26.3.

## Credits

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package dev.twme.textdisplayshape.geometry;

import java.util.ArrayList;
import java.util.List;

import org.joml.Vector3f;

/** Edges and faces of an axis-aligned box. */
public final class BoxGeometry {

/** One of the twelve box edges. */
public record Edge(Vector3f from, Vector3f to) {
public Edge {
from = new Vector3f(from);
to = new Vector3f(to);
}
}

/**
* One of the six box faces as a parallelogram: {@code corner} is shared by
* the two edges ending at {@code first} and {@code second}. The front side
* ({@code (first - corner) x (second - corner)}) faces outward.
*/
public record Face(Vector3f corner, Vector3f first, Vector3f second) {
public Face {
corner = new Vector3f(corner);
first = new Vector3f(first);
second = new Vector3f(second);
}
}

private BoxGeometry() {
}

/** The eight corners, indexed by bits x=4, y=2, z=1. */
public static List<Vector3f> corners(Vector3f min, Vector3f max) {
Vector3f low = new Vector3f(min).min(max);
Vector3f high = new Vector3f(min).max(max);
List<Vector3f> corners = new ArrayList<>(8);
for (int index = 0; index < 8; index++) {
corners.add(new Vector3f(
(index & 4) != 0 ? high.x : low.x,
(index & 2) != 0 ? high.y : low.y,
(index & 1) != 0 ? high.z : low.z));
}
return corners;
}

/** The twelve edges in a stable order: four along X, four along Y, four along Z. */
public static List<Edge> edges(Vector3f min, Vector3f max) {
List<Vector3f> c = corners(min, max);
List<Edge> edges = new ArrayList<>(12);
int[][] pairs = {
{0, 4}, {1, 5}, {2, 6}, {3, 7},
{0, 2}, {1, 3}, {4, 6}, {5, 7},
{0, 1}, {2, 3}, {4, 5}, {6, 7}
};
for (int[] pair : pairs) {
edges.add(new Edge(c.get(pair[0]), c.get(pair[1])));
}
return edges;
}

/** The six faces with outward front sides: -X, +X, -Y, +Y, -Z, +Z. */
public static List<Face> faces(Vector3f min, Vector3f max) {
List<Vector3f> c = corners(min, max);
List<Face> faces = new ArrayList<>(6);
faces.add(new Face(c.get(0), c.get(1), c.get(2)));
faces.add(new Face(c.get(4), c.get(6), c.get(5)));
faces.add(new Face(c.get(0), c.get(4), c.get(1)));
faces.add(new Face(c.get(2), c.get(3), c.get(6)));
faces.add(new Face(c.get(0), c.get(2), c.get(4)));
faces.add(new Face(c.get(1), c.get(5), c.get(3)));
return faces;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package dev.twme.textdisplayshape.geometry;

import java.util.Objects;

import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;

import dev.twme.textdisplayshape.util.TRSResult;

/**
* One Text Display transformation in Minecraft's
* {@code translation * leftRotation * scale * rightRotation} form.
*
* <p>Translations produced by {@link ShapeGeometry} are absolute world
* coordinates; renderers subtract the entity origin before sending them.
* Instances are immutable: every accessor returns a defensive copy.</p>
*/
public final class DisplayTransform {

private final Vector3f translation;
private final Quaternionf leftRotation;
private final Vector3f scale;
private final Quaternionf rightRotation;

public DisplayTransform(
Vector3f translation,
Quaternionf leftRotation,
Vector3f scale,
Quaternionf rightRotation
) {
this.translation = new Vector3f(Objects.requireNonNull(translation, "translation"));
this.leftRotation = new Quaternionf(Objects.requireNonNull(leftRotation, "leftRotation"));
this.scale = new Vector3f(Objects.requireNonNull(scale, "scale"));
this.rightRotation = new Quaternionf(Objects.requireNonNull(rightRotation, "rightRotation"));
}

/** Converts an analytic TRS decomposition. */
public static DisplayTransform of(TRSResult result) {
return new DisplayTransform(
result.translation(),
result.leftRotation(),
result.scale(),
result.rightRotation()
);
}

/**
* Converts a matrix without shear, such as a line matrix. The rotation is
* read without normalizing out scale, matching how lines have always been
* sent in packet mode.
*/
public static DisplayTransform ofUnshearedMatrix(Matrix4f matrix) {
Vector3f translation = matrix.getTranslation(new Vector3f());
Vector3f scale = matrix.getScale(new Vector3f());
Quaternionf rotation = matrix.getUnnormalizedRotation(new Quaternionf());
return new DisplayTransform(translation, rotation, scale, new Quaternionf());
}

public Vector3f translation() {
return new Vector3f(translation);
}

public Quaternionf leftRotation() {
return new Quaternionf(leftRotation);
}

public Vector3f scale() {
return new Vector3f(scale);
}

public Quaternionf rightRotation() {
return new Quaternionf(rightRotation);
}

/** Returns a copy whose translation is expressed relative to {@code (x, y, z)}. */
public DisplayTransform relativeTo(double x, double y, double z) {
return new DisplayTransform(
new Vector3f(translation).sub((float) x, (float) y, (float) z),
leftRotation,
scale,
rightRotation
);
}

/** Returns a copy with every scale component multiplied by {@code factor}. */
public DisplayTransform scaled(float factor) {
return new DisplayTransform(translation, leftRotation, new Vector3f(scale).mul(factor), rightRotation);
}

/** Converts back to a matrix, for platforms that accept one. */
public Matrix4f toMatrix() {
return new Matrix4f()
.translate(translation)
.rotate(leftRotation)
.scale(scale)
.rotate(rightRotation);
}

@Override
public boolean equals(Object other) {
return other instanceof DisplayTransform that
&& translation.equals(that.translation)
&& leftRotation.equals(that.leftRotation)
&& scale.equals(that.scale)
&& rightRotation.equals(that.rightRotation);
}

@Override
public int hashCode() {
return Objects.hash(translation, leftRotation, scale, rightRotation);
}

@Override
public String toString() {
return "DisplayTransform[translation=" + translation + ", leftRotation=" + leftRotation
+ ", scale=" + scale + ", rightRotation=" + rightRotation + "]";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package dev.twme.textdisplayshape.geometry;

import java.util.ArrayList;
import java.util.List;

import org.joml.Vector3f;

import dev.twme.textdisplayshape.util.TRSResult;
import dev.twme.textdisplayshape.util.TextDisplayUtil;

/**
* Platform-neutral Text Display transforms for every built-in shape. All
* renderers use these methods, so direct and packet shapes stay identical.
*/
public final class ShapeGeometry {

private ShapeGeometry() {
}

/** One entity per face: the front, then the back when double-sided. */
public static List<DisplayTransform> line(
Vector3f p1, Vector3f p2, float thickness, float roll, boolean doubleSided) {
List<DisplayTransform> transforms = new ArrayList<>(doubleSided ? 2 : 1);
transforms.add(DisplayTransform.ofUnshearedMatrix(TextDisplayUtil.textDisplayLine(p1, p2, thickness, roll)));
if (doubleSided) {
transforms.add(DisplayTransform.ofUnshearedMatrix(
TextDisplayUtil.textDisplayLine(p2, p1, thickness, -roll)));
}
return transforms;
}

/** Segments in order, then the closing segment when {@code closed}. */
public static List<DisplayTransform> polyline(
List<Vector3f> points, float thickness, float roll, boolean closed, boolean doubleSided) {
List<DisplayTransform> transforms = new ArrayList<>();
if (points.size() < 2) {
return transforms;
}
for (int index = 0; index < points.size() - 1; index++) {
transforms.addAll(line(points.get(index), points.get(index + 1), thickness, roll, doubleSided));
}
if (closed && points.size() > 2) {
transforms.addAll(line(points.get(points.size() - 1), points.get(0), thickness, roll, doubleSided));
}
return transforms;
}

/** Three entities per face. */
public static List<DisplayTransform> triangle(
Vector3f p1, Vector3f p2, Vector3f p3, boolean doubleSided) {
List<DisplayTransform> transforms = new ArrayList<>(doubleSided ? 6 : 3);
for (TRSResult result : TextDisplayUtil.computeTriangleTRS(p1, p2, p3)) {
transforms.add(DisplayTransform.of(result));
}
if (doubleSided) {
for (TRSResult result : TextDisplayUtil.computeTriangleTRS(p1, p3, p2)) {
transforms.add(DisplayTransform.of(result));
}
}
return transforms;
}

/** One entity per face. */
public static List<DisplayTransform> parallelogram(
Vector3f p1, Vector3f p2, Vector3f p3, boolean doubleSided) {
List<DisplayTransform> transforms = new ArrayList<>(doubleSided ? 2 : 1);
transforms.add(DisplayTransform.of(TextDisplayUtil.computeParallelogramTRS(p1, p2, p3)));
if (doubleSided) {
transforms.add(DisplayTransform.of(TextDisplayUtil.computeParallelogramTRS(p1, p3, p2)));
}
return transforms;
}
}
Loading
Loading