NoiseGradientPaint.java
package swingtree.style;
import org.jspecify.annotations.Nullable;
import swingtree.api.NoiseFunction;
import java.awt.*;
import java.awt.geom.AffineTransform;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.ColorModel;
import java.awt.image.Raster;
import java.awt.image.WritableRaster;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
final class NoiseGradientPaint implements Paint
{
/**
* Cache for the context - when the bounds, center, and transform are unchanged, then the context is the same
*/
private static class CachedContext
{
private final Rectangle bounds;
private final Point2D center;
private final AffineTransform transform;
private final NoiseGradientPaintContext cachedContext;
private CachedContext(Rectangle bounds, Point2D center, AffineTransform transform, NoiseGradientPaintContext context) {
this.bounds = bounds;
this.center = center;
this.transform = transform;
cachedContext = context;
}
private NoiseGradientPaint.@Nullable NoiseGradientPaintContext get(Rectangle bounds, Point2D center, AffineTransform transform) {
if (this.bounds.equals(bounds) && this.center.equals(center) && this.transform.equals(transform))
return cachedContext;
else
return null;
}
}
private final Point2D center;
private final float scaleX;
private final float scaleY;
private final float rotation;
private final NoiseFunction noiseFunction;
private final float[] localFractions;
private final float[] redStepLookup;
private final float[] greenStepLookup;
private final float[] blueStepLookup;
private final float[] alphaStepLookup;
private final Color[] colors;
private static final float INT_TO_FLOAT_CONST = 1f / 255f;
private @Nullable CachedContext cached;
public NoiseGradientPaint(
final Point2D center,
final float scaleX,
final float scaleY,
final float rotation,
final float[] fractions,
final Color[] colors,
final NoiseFunction noiseFunction
)
throws IllegalArgumentException
{
this.scaleX = scaleX;
this.scaleY = scaleY;
this.rotation = rotation;
this.noiseFunction = Objects.requireNonNull(noiseFunction);
// Check that fractions and colors are of the same size
if (fractions.length != colors.length) {
throw new IllegalArgumentException("Fractions and colors must be equal in size");
}
final java.util.List<Float> fractionList = new java.util.ArrayList<Float>(fractions.length);
for (float f : fractions) {
if (f < 0 || f > 1) {
throw new IllegalArgumentException("Fraction values must be in the range 0 to 1: " + f);
}
fractionList.add(f);
}
// Adjust fractions and colors array in the case where startvalue != 0.0f and/or endvalue != 1.0f
final java.util.List<Color> colorList = new java.util.ArrayList<Color>(colors.length);
colorList.addAll(java.util.Arrays.asList(colors));
// Calculate the Color at the top dead center (mix between first and last)
final Color start = colorList.get(0);
final Color last = colorList.get(colorList.size()-1);
final float centerVal = 1.0f - fractionList.get(fractionList.size()-1);
final float lastToStartRange = centerVal + fractionList.get(0);
final float firstFraction = fractionList.get(0);
final float lastFraction = fractionList.get(fractionList.size()-1);
if ( firstFraction != 0f || lastFraction != 1f ) {
Color centerColor = getColorFromFraction(last, start, (int)(lastToStartRange * 10000), (int)(centerVal * 10000));
// Assure that fractions start with 0.0f
if (firstFraction != 0.0f) {
fractionList.add(0, 0.0f);
colorList.add(0, centerColor);
}
// Assure that fractions end with 1.0f
if (lastFraction != 1.0f) {
fractionList.add(1.0f);
colorList.add(centerColor);
}
}
// Set the values
this.center = center;
this.colors = colorList.toArray(new Color[0]);
// Prepare lookup table for the angles of each fraction
final int MAX_FRACTIONS = fractionList.size();
this.localFractions = new float[MAX_FRACTIONS];
for (int i = 0; i < MAX_FRACTIONS; i++) {
localFractions[i] = fractionList.get(i);
}
// Prepare lookup tables for the color stepsize of each color
this.redStepLookup = new float[this.colors.length];
this.greenStepLookup = new float[this.colors.length];
this.blueStepLookup = new float[this.colors.length];
this.alphaStepLookup = new float[this.colors.length];
for (int i = 0; i < (this.colors.length - 1); i++) {
this.redStepLookup[i] = ((this.colors[i + 1].getRed() - this.colors[i].getRed()) * INT_TO_FLOAT_CONST) / (localFractions[i + 1] - localFractions[i]);
this.greenStepLookup[i] = ((this.colors[i + 1].getGreen() - this.colors[i].getGreen()) * INT_TO_FLOAT_CONST) / (localFractions[i + 1] - localFractions[i]);
this.blueStepLookup[i] = ((this.colors[i + 1].getBlue() - this.colors[i].getBlue()) * INT_TO_FLOAT_CONST) / (localFractions[i + 1] - localFractions[i]);
this.alphaStepLookup[i] = ((this.colors[i + 1].getAlpha() - this.colors[i].getAlpha()) * INT_TO_FLOAT_CONST) / (localFractions[i + 1] - localFractions[i]);
}
}
public Point2D getCenter() {
return center;
}
public Point2D getScale() {
return new Point2D.Float(scaleX, scaleY);
}
public float getRotation() {
return rotation;
}
public NoiseFunction getNoiseFunction() {
return noiseFunction;
}
public List<Color> getColors() {
return Stream.of(colors).collect(Collectors.toList());
}
private static Color getColorFromFraction(
final Color START_COLOR,
final Color DESTINATION_COLOR,
final int RANGE,
final int VALUE
) {
final float SOURCE_RED = START_COLOR.getRed() * INT_TO_FLOAT_CONST;
final float SOURCE_GREEN = START_COLOR.getGreen() * INT_TO_FLOAT_CONST;
final float SOURCE_BLUE = START_COLOR.getBlue() * INT_TO_FLOAT_CONST;
final float SOURCE_ALPHA = START_COLOR.getAlpha() * INT_TO_FLOAT_CONST;
final float DESTINATION_RED = DESTINATION_COLOR.getRed() * INT_TO_FLOAT_CONST;
final float DESTINATION_GREEN = DESTINATION_COLOR.getGreen() * INT_TO_FLOAT_CONST;
final float DESTINATION_BLUE = DESTINATION_COLOR.getBlue() * INT_TO_FLOAT_CONST;
final float DESTINATION_ALPHA = DESTINATION_COLOR.getAlpha() * INT_TO_FLOAT_CONST;
final float RED_DELTA = DESTINATION_RED - SOURCE_RED;
final float GREEN_DELTA = DESTINATION_GREEN - SOURCE_GREEN;
final float BLUE_DELTA = DESTINATION_BLUE - SOURCE_BLUE;
final float ALPHA_DELTA = DESTINATION_ALPHA - SOURCE_ALPHA;
final float RED_FRACTION = RED_DELTA / RANGE;
final float GREEN_FRACTION = GREEN_DELTA / RANGE;
final float BLUE_FRACTION = BLUE_DELTA / RANGE;
final float ALPHA_FRACTION = ALPHA_DELTA / RANGE;
float red = SOURCE_RED + RED_FRACTION * VALUE;
float green = SOURCE_GREEN + GREEN_FRACTION * VALUE;
float blue = SOURCE_BLUE + BLUE_FRACTION * VALUE;
float alpha = SOURCE_ALPHA + ALPHA_FRACTION * VALUE;
red = red < 0f ? 0f : (red > 1f ? 1f : red);
green = green < 0f ? 0f : (green > 1f ? 1f : green);
blue = blue < 0f ? 0f : (blue > 1f ? 1f : blue);
alpha = alpha < 0f ? 0f : (alpha > 1f ? 1f : alpha);
return new Color(red, green, blue, alpha);
}
@Override
public PaintContext createContext(
final ColorModel COLOR_MODEL,
final Rectangle DEVICE_BOUNDS,
final Rectangle2D USER_BOUNDS,
final AffineTransform TRANSFORM,
final RenderingHints HINTS
) {
if (cached != null) {
NoiseGradientPaintContext c = cached.get(DEVICE_BOUNDS, center, TRANSFORM);
if (c != null)
return c;
}
NoiseGradientPaintContext context = new NoiseGradientPaintContext(center, TRANSFORM);
cached = new CachedContext(DEVICE_BOUNDS, center, TRANSFORM, context);
return context;
}
@Override
public int getTransparency() {
return Transparency.TRANSLUCENT;
}
@Override
public int hashCode() {
int hash = 7;
hash = 97 * hash + Objects.hashCode(this.center);
hash = 97 * hash + Float.floatToIntBits(this.scaleX);
hash = 97 * hash + Float.floatToIntBits(this.scaleY);
hash = 97 * hash + Float.floatToIntBits(this.rotation);
hash = 97 * hash + Objects.hashCode(this.noiseFunction);
hash = 97 * hash + Arrays.hashCode(this.localFractions);
hash = 97 * hash + Arrays.deepHashCode(this.colors);
return hash;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
final NoiseGradientPaint other = (NoiseGradientPaint) obj;
if (Float.floatToIntBits(this.scaleX) != Float.floatToIntBits(other.scaleX))
return false;
if (Float.floatToIntBits(this.scaleY) != Float.floatToIntBits(other.scaleY))
return false;
if (Float.floatToIntBits(this.rotation) != Float.floatToIntBits(other.rotation))
return false;
if (!Objects.equals(this.center, other.center))
return false;
if (!Objects.equals(this.noiseFunction, other.noiseFunction))
return false;
if (!Objects.deepEquals(this.localFractions, other.localFractions))
return false;
if (!Objects.deepEquals(this.colors, other.colors))
return false;
return true;
}
private final class NoiseGradientPaintContext implements PaintContext
{
final private Point2D center;
private final HashMap<Long, WritableRaster> cachedRasters;
public NoiseGradientPaintContext(final Point2D center, AffineTransform transform) {
this.cachedRasters = new HashMap<>();
try {
this.center = transform.transform(center, null); //user to device
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
@Override public void dispose() {}
@Override
public ColorModel getColorModel() {
return ColorModel.getRGBdefault();
}
@Override
public @Nullable Raster getRaster(
final int X,
final int Y,
final int TILE_WIDTH,
final int TILE_HEIGHT
) {
try {
long index = ((long)X << 32) | (long)Y;
WritableRaster raster = cachedRasters.get(index);
if (raster == null)
raster = getColorModel().createCompatibleWritableRaster(TILE_WIDTH, TILE_HEIGHT); // Create raster for given colormodel
else
return raster;
final int MAX = localFractions.length - 1;
// Create data array with place for red, green, blue and alpha values
final int[] data = new int[(TILE_WIDTH * TILE_HEIGHT * 4)];
IntStream.range(0, TILE_WIDTH * TILE_HEIGHT)
.parallel()
.forEach(tileIndex -> {
double currentRed = 0;
double currentGreen = 0;
double currentBlue = 0;
double currentAlpha = 0;
float onGradientRange;
int tileY = tileIndex / TILE_WIDTH;
int tileX = tileIndex % TILE_WIDTH;
double localX = ( X + tileX - center.getX() ) / scaleX;
double localY = ( Y + tileY - center.getY() ) / scaleY;
if ( rotation != 0f && rotation % 360f != 0f ) {
final double angle = Math.toRadians(rotation);
final double sin = Math.sin(angle);
final double cos = Math.cos(angle);
final double newX = localX * cos - localY * sin;
final double newY = localX * sin + localY * cos;
localX = newX;
localY = newY;
}
float x = (float) localX;
float y = (float) localY;
onGradientRange = noiseFunction.getFractionAt( x, y );
// Check for each angle in fractionAngles array
for (int i = 0; i < MAX; i++) {
if ((onGradientRange >= localFractions[i])) {
currentRed = colors[i].getRed() * INT_TO_FLOAT_CONST + (onGradientRange - localFractions[i]) * redStepLookup[i];
currentGreen = colors[i].getGreen() * INT_TO_FLOAT_CONST + (onGradientRange - localFractions[i]) * greenStepLookup[i];
currentBlue = colors[i].getBlue() * INT_TO_FLOAT_CONST + (onGradientRange - localFractions[i]) * blueStepLookup[i];
currentAlpha = colors[i].getAlpha() * INT_TO_FLOAT_CONST + (onGradientRange - localFractions[i]) * alphaStepLookup[i];
}
}
// Fill data array with calculated color values
final int BASE = (tileY * TILE_WIDTH + tileX) * 4;
data[BASE + 0] = (int) Math.round(currentRed * 255);
data[BASE + 1] = (int) Math.round(currentGreen * 255);
data[BASE + 2] = (int) Math.round(currentBlue * 255);
data[BASE + 3] = (int) Math.round(currentAlpha * 255);
});
// Fill the raster with the data
raster.setPixels(0, 0, TILE_WIDTH, TILE_HEIGHT, data);
cachedRasters.put(index, raster);
return raster;
}
catch (Exception ex) {
System.err.println(ex);
return null;
}
}
}
}