bastools/api/src/main/java/io/github/applecommander/bastools/api/shapes/BitmapShape.java

358 lines
13 KiB
Java

package io.github.applecommander.bastools.api.shapes;
import java.awt.Point;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Represents a bitmap copy of the shape.
* This may be useful for displaying the shape or for defining shapes as a bitmap is
* easier to understand than vectors.
*/
public class BitmapShape implements Shape {
public final String label;
public final List<List<Boolean>> grid = new ArrayList<>();
public final Point origin = new Point();
public BitmapShape() {
this(0, 0, null);
}
public BitmapShape(String label) {
this(0, 0, label);
}
public BitmapShape(int height, int width) {
this(height, width, null);
}
public BitmapShape(int height, int width, String label) {
this.label = label;
while (this.grid.size() < height) {
this.grid.add(newRow(width));
}
}
private List<Boolean> newRow(int width) {
List<Boolean> row = new ArrayList<>();
while (row.size() < width) {
row.add(Boolean.FALSE);
}
return row;
}
public void insertColumn() {
origin.x++;
for (List<Boolean> row : grid) {
row.add(0, Boolean.FALSE);
}
}
public void addColumn() {
for (List<Boolean> row : grid) {
row.add(Boolean.FALSE);
}
}
public void insertRow() {
origin.y++;
grid.add(0, newRow(getWidth()));
}
public void addRow() {
grid.add(newRow(getWidth()));
}
public void appendBitmapRow(String line) {
line = line.trim();
List<Boolean> row = new ArrayList<>();
Runnable setOrigin = () -> {
// Share origin logic for '+' and '*'
origin.x = row.size();
origin.y = grid.size();
};
for (char pixel : line.toCharArray()) {
switch (Character.toLowerCase(pixel)) {
case '+':
setOrigin.run();
// fall through to '.'
case '.':
row.add(Boolean.FALSE);
break;
case '*':
setOrigin.run();
// fall through to 'x'
case 'x':
row.add(Boolean.TRUE);
break;
default:
throw new RuntimeException("Unexpected bitmap pixel type: " + pixel);
}
}
grid.add(row);
}
public int getHeight() {
return grid.size();
}
public int getWidth() {
return grid.isEmpty() ? 0 : grid.get(0).size();
}
public void plot(int x, int y) {
plot(x, y, Boolean.TRUE);
}
public void plot(int x, int y, Boolean pixel) {
if (x < 0 || x >= getWidth() || y < 0 || y >= getHeight()) {
return;
}
grid.get(y).set(x, pixel);
}
public Boolean get(int x, int y) {
if (x < 0 || x >= getWidth() || y < 0 || y >= getHeight()) {
return Boolean.FALSE;
}
return grid.get(y).get(x);
}
public Boolean get(Point point) {
return get(point.x, point.y);
}
@Override
public boolean isEmpty() {
boolean hasData = false;
for (List<Boolean> row : grid) {
for (Boolean plot : row) {
hasData |= plot;
}
}
return !hasData;
}
@Override
public String getLabel() {
return label;
}
@Override
public BitmapShape toBitmap() {
return this;
}
/**
* Convert this bitmap shape to a vector shape. The shape chosen encodes to the least number of bytes
* in the resulting file.
*/
@Override
public VectorShape toVector() {
List<Supplier<VectorShape>> scans = Arrays.asList(
new SweepVectorization(this, VectorCommand.MOVE_RIGHT, VectorCommand.MOVE_UP),
new SweepVectorization(this, VectorCommand.MOVE_RIGHT, VectorCommand.MOVE_DOWN),
new SweepVectorization(this, VectorCommand.MOVE_LEFT, VectorCommand.MOVE_UP),
new SweepVectorization(this, VectorCommand.MOVE_LEFT, VectorCommand.MOVE_DOWN),
new SweepVectorization(this, VectorCommand.MOVE_DOWN, VectorCommand.MOVE_RIGHT),
new SweepVectorization(this, VectorCommand.MOVE_DOWN, VectorCommand.MOVE_LEFT),
new SweepVectorization(this, VectorCommand.MOVE_UP, VectorCommand.MOVE_RIGHT),
new SweepVectorization(this, VectorCommand.MOVE_UP, VectorCommand.MOVE_LEFT),
new EuclidianDistanceVectorization(this)
);
int byteLength = Integer.MAX_VALUE;
VectorShape vshape = null;
for (Supplier<VectorShape> scan : scans) {
VectorShape candidate = scan.get();
int length = candidate.toBytes().length;
if (vshape == null || byteLength >= length) {
vshape = candidate;
byteLength = length;
}
}
return vshape;
}
/**
* Encode a bitmap shape by going to a corner and sweeping back-and-forth across the image.
* The resulting shape is not optimal, so the {@link VectorShape#optimize()} should be used.
* Note that this class is setup to be dynamic in the chosen corner.
*/
public static class SweepVectorization implements Supplier<VectorShape> {
private VectorCommand[] toOrigin;
private VectorCommand movement;
private VectorCommand next;
private Point point;
private BitmapShape bitmapShape;
private VectorShape vectorShape;
private int width;
private int height;
/**
* Create an instance of the sweep method.
*
* @param bitmapShape is the shape to be converted
* @param initialMovement is the initial sweep movement
* @param next is the direction to advance for each line
*/
public SweepVectorization(BitmapShape bitmapShape, VectorCommand initialMovement, VectorCommand next) {
Objects.requireNonNull(bitmapShape);
Objects.requireNonNull(initialMovement);
Objects.requireNonNull(next);
if (initialMovement.horizontal == next.horizontal || initialMovement.vertical == next.vertical) {
throw new IllegalArgumentException("One vector must be horizontal and the other vector must be vertical");
}
this.toOrigin = new VectorCommand[] { next.opposite(), initialMovement.opposite() };
this.movement = initialMovement;
this.next = next;
this.bitmapShape = bitmapShape;
this.width = bitmapShape.getWidth();
this.height = bitmapShape.getHeight();
this.point = new Point(bitmapShape.origin);
this.vectorShape = new VectorShape();
}
public VectorShape get() {
findStartPosition();
while (!onOrAtEdge(next)) {
scanRow();
plotOrMove(next);
movement = movement.opposite();
point.translate(next.xmove, next.ymove);
}
return vectorShape;
}
public void findStartPosition() {
for (VectorCommand vector : toOrigin) {
while (!onOrAtEdge(vector)) {
vectorShape.vectors.add(vector);
point.translate(vector.xmove, vector.ymove);
}
}
}
public void scanRow() {
while (!onOrAtEdge(movement)) {
plotOrMove(movement);
point.translate(movement.xmove, movement.ymove);
}
}
public void plotOrMove(VectorCommand vector) {
if (bitmapShape.get(point)) {
vectorShape.appendShortCommand(Character.toUpperCase(vector.shortCommand));
} else {
vectorShape.appendShortCommand(Character.toLowerCase(vector.shortCommand));
}
}
public boolean onOrAtEdge(VectorCommand vector) {
// No clever way to do this?
switch (vector) {
case MOVE_DOWN:
case PLOT_DOWN:
return point.y >= height;
case MOVE_UP:
case PLOT_UP:
return point.y < 0;
case MOVE_LEFT:
case PLOT_LEFT:
return point.x < 0;
case MOVE_RIGHT:
case PLOT_RIGHT:
return point.x >= width;
default:
throw new RuntimeException("Unexpected vector: " + vector);
}
}
}
/**
* Encode a bitmap shape by using the Euclidean distance between plotted points to determine
* which vectors take precedence. Most of the math is captured in the Point class, which makes
* this much more straight-forward.
*/
public static class EuclidianDistanceVectorization implements Supplier<VectorShape> {
private List<Point> points;
private BitmapShape bitmapShape;
private VectorShape vshape;
public EuclidianDistanceVectorization(BitmapShape bitmapShape) {
this.bitmapShape = bitmapShape;
this.points = new ArrayList<>();
this.vshape = new VectorShape();
// Collect vector targets
for (int y=0; y<bitmapShape.getHeight(); y++) {
for (int x=0; x<bitmapShape.getWidth(); x++) {
if (bitmapShape.get(x,y)) {
points.add(new Point(x,y));
}
}
}
}
@Override
public VectorShape get() {
Point point = new Point(bitmapShape.origin);
boolean plotFirst = false;
while (!points.isEmpty()) {
Point target = null;
double distance = Double.MAX_VALUE;
for (Point candidate : points) {
double candidateDistance = point.distance(candidate);
if (distance >= candidateDistance) {
distance = candidateDistance;
target = candidate;
}
}
moveTo(plotFirst, point, target);
points.remove(target);
point = target;
plotFirst = true;
}
// Need to plot that last pixel
vshape.plotUp();
return vshape;
}
/**
* Move from origin to target. General strategy is to work from distance between points and bias the one that "loses"
* each time while resetting the one that "won". Hopefully this draws more of a jagged line than something entirely
* straight. (The goal being to prevent a bunch of plot up's in the worst case that doesn't encode nicely.)
* @param plotFirst Indicates if the first vector needs to plot; otherwise all other vectors will be moves
* @param origin Is the point of origin (which can be a plotted pixel)
* @param target Is the target point
*/
public void moveTo(boolean plotFirst, Point origin, Point target) {
if (origin.equals(target)) return;
VectorCommand xvector = origin.x < target.x ? VectorCommand.MOVE_RIGHT : VectorCommand.MOVE_LEFT;
VectorCommand yvector = origin.y < target.y ? VectorCommand.MOVE_DOWN : VectorCommand.MOVE_UP;
if (plotFirst) {
xvector = xvector.plot();
yvector = yvector.plot();
}
Point point = new Point(origin);
int xdist = Math.abs(point.x - target.x);
int ydist = Math.abs(point.y - target.y);
while (!point.equals(target)) {
if (xdist > ydist) {
xdist = Math.abs(point.x - target.x);
ydist += 1;
if (point.x != target.x) {
point.translate(xvector.xmove, xvector.ymove);
vshape.append(xvector);
xvector = xvector.move();
yvector = yvector.move();
}
} else {
xdist += 1;
ydist = Math.abs(point.y - target.y);
if (point.y != target.y) {
point.translate(yvector.xmove, yvector.ymove);
vshape.append(yvector);
xvector = xvector.move();
yvector = yvector.move();
}
}
}
}
}
}