ConicalGradientPaint.java
package swingtree.style;
/*
* Copyright (c) 2012, Gerrit Grunwald
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* The names of its contributors may not be used to endorse or promote
* products derived from this software without specific prior written
* permission.
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
* THE POSSIBILITY OF SUCH DAMAGE.
*
* Modifications 2018, by Kevin Kieffer
* The following modifications have been made and noted with "KK" in the comments:
* 1. Added caches for the PaintContext and the Rasters, to speed up repaints when nothing has changed
* 2. Apply the inverse trasform prior to calcuating the angle - this allows graphics transforms (such as rotations, shears) to affect the angle
* 3. Replace the Pythag + arcos calculation with an arctan calcuation
* 4. Adjust the colors at 0.0, 1.0 (top dead center) to blend the first and last defined color (rather than setting them to the first color)
*
* Adopted from https://github.com/kkieffer/jZELD/blob/master/src/main/java/com/github/kkieffer/jzeld/attributes/ConicalGradientPaint.java
* and incorporated into the SwingTree project in 2024.
*
* Modifications 2014, by Daniel Nepp:
* - Deletion of the error-prone method "recalculate" and introduction of a new rotation offset field variable.
*/
import org.jspecify.annotations.Nullable;
import java.awt.*;
import java.awt.geom.AffineTransform;
import java.awt.geom.NoninvertibleTransformException;
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.HashMap;
/**
* A paint class that creates conical gradients around a given center point
* It could be used in the same way as LinearGradientPaint and RadialGradientPaint
* and follows the same syntax.
* You could use floats from 0.0 to 1.0 for the fractions which is standard but it's
* also possible to use angles from 0.0 to 360 degrees which is most of the times
* much easier to handle.
* Gradients always start at the top with a clockwise direction and you could
* rotate the gradient around the center by given offset.
* The offset could also be defined from -0.5 to +0.5 or -180 to +180 degrees.
* If you would like to use degrees instead of values from 0 to 1 you have to use
* the full constructor and set the USE_DEGREES variable to true
* @version 1.0
* @author hansolo
*/
final class ConicalGradientPaint 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 ConicalGradientPaintContext _cachedContext;
private CachedContext(
Rectangle bounds,
Point2D center,
AffineTransform transform,
ConicalGradientPaintContext context
) {
_bounds = bounds;
_center = center;
_transform = transform;
_cachedContext = context;
}
/**
* KK: Get the cached context - if it matches the supplied arguments. If the arguments aren't equal to the cached version, return null
* @param bounds A {@link Rectangle} representing the bounds of the context to be created (device space).
* @param center The center of the gradient (device space).
* @param transform The transform to be applied to the context (user space to device space).
* @return the cached context, or null if the arguments don't match
*/
private @Nullable ConicalGradientPaintContext get(
Rectangle bounds,
Point2D center,
AffineTransform transform
) {
if (_bounds.equals(bounds) && _center.equals(center) && _transform.equals(transform))
return _cachedContext;
else
return null;
}
}
private final Point2D _center;
private final float _angleOffset;
private final float[] _fractionOffsets;
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;
/**
* Standard constructor which takes the FRACTIONS in values from 0.0f to 1.0f
* @param center The center of the gradient circle.
* @param GIVEN_FRACTIONS The fractions of the gradient in values from 0.0f to 1.0f, these should match the COLORS array.
* @param GIVEN_COLORS The colors of the gradient, these should match the FRACTIONS array.
* @throws IllegalArgumentException if fractions and colors are not of the same size.
*/
public ConicalGradientPaint(final Point2D center, final float[] GIVEN_FRACTIONS, final Color[] GIVEN_COLORS) throws IllegalArgumentException {
this(false, center, 0.0f, GIVEN_FRACTIONS, GIVEN_COLORS);
}
/**
* Enhanced constructor which takes the FRACTIONS in degress from 0.0f to 360.0f and
* also an GIVEN_OFFSET in degrees around the rotation CENTER
* @param usesDegrees true if fractions are in degrees, false if fractions are in 0.0 to 1.0
* @param center the center of the gradient
* @param offset the offset of the gradient in degrees
* @param fractions the fractions of the gradient in degrees or 0.0 to 1.0
* @param colors the colors of the gradient
* @throws IllegalArgumentException if fractions and colors are not of the same size
*/
public ConicalGradientPaint(
final boolean usesDegrees,
final Point2D center,
final float offset,
final float[] fractions,
final Color[] colors
)
throws IllegalArgumentException
{
// 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);
final float finalOffset;
if (usesDegrees) {
final float degFraction = 1f / 360f;
if (Float.compare((offset * degFraction), -0.5f) == 0) {
finalOffset = -0.5f;
} else if (Float.compare((offset * degFraction), 0.5f) == 0) {
finalOffset = 0.5f;
} else {
finalOffset = (offset * degFraction);
}
for (final float fraction : fractions) {
fractionList.add((fraction * degFraction));
}
} else {
// Now it seems to work with rotation of 0.5f, below is the old code to correct the problem
// if (GIVEN_OFFSET == -0.5)
// {
// // This is needed because of problems in the creation of the Raster
// // with a angle offset of exactly -0.5
// OFFSET = -0.49999f;
// }
// else if (GIVEN_OFFSET == 0.5)
// {
// // This is needed because of problems in the creation of the Raster
// // with a angle offset of exactly +0.5
// OFFSET = 0.499999f;
// }
// else
{
finalOffset = offset;
}
for (final float fraction : fractions) {
fractionList.add(fraction);
}
}
// Check for valid offset
if (finalOffset > 0.5f || finalOffset < -0.5f) {
throw new IllegalArgumentException("Offset has to be in the range of -0.5 to 0.5");
}
_angleOffset = offset;
// 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));
//KK: 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
_center = center;
_colors = colorList.toArray(new Color[colorList.size()]);
// Prepare lookup table for the angles of each fraction
final int MAX_FRACTIONS = fractionList.size();
_fractionOffsets = new float[MAX_FRACTIONS];
for (int i = 0; i < MAX_FRACTIONS; i++) {
_fractionOffsets[i] = fractionList.get(i) * 360f;
}
// Prepare lookup tables for the color stepsize of each color
_redStepLookup = new float[_colors.length];
_greenStepLookup = new float[_colors.length];
_blueStepLookup = new float[_colors.length];
_alphaStepLookup = new float[_colors.length];
for (int i = 0; i < (_colors.length - 1); i++) {
_redStepLookup[i] = ((_colors[i + 1].getRed() - _colors[i].getRed()) * INT_TO_FLOAT_CONST) / (_fractionOffsets[i + 1] - _fractionOffsets[i]);
_greenStepLookup[i] = ((_colors[i + 1].getGreen() - _colors[i].getGreen()) * INT_TO_FLOAT_CONST) / (_fractionOffsets[i + 1] - _fractionOffsets[i]);
_blueStepLookup[i] = ((_colors[i + 1].getBlue() - _colors[i].getBlue()) * INT_TO_FLOAT_CONST) / (_fractionOffsets[i + 1] - _fractionOffsets[i]);
_alphaStepLookup[i] = ((_colors[i + 1].getAlpha() - _colors[i].getAlpha()) * INT_TO_FLOAT_CONST) / (_fractionOffsets[i + 1] - _fractionOffsets[i]);
}
}
/**
* With the START_COLOR at the beginning and the DESTINATION_COLOR at the end of the given RANGE the method will calculate
* and return the color that equals the given VALUE.
* e.g. a START_COLOR of BLACK (R:0, G:0, B:0, A:255) and a DESTINATION_COLOR of WHITE(R:255, G:255, B:255, A:255)
* with a given RANGE of 100 and a given VALUE of 50 will return the color that is exactly in the middle of the
* gradient between black and white which is gray(R:128, G:128, B:128, A:255)
* So this method is really useful to calculate colors in gradients between two given colors.
* @param START_COLOR The color at the beginning of the gradient.
* @param DESTINATION_COLOR The color at the end of the gradient.
* @param RANGE The range of the gradient.
* @param VALUE The value in the range of the gradient.
* @return Color calculated from a range of values by given value
*/
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 java.awt.PaintContext createContext(final ColorModel COLOR_MODEL,
final Rectangle DEVICE_BOUNDS,
final Rectangle2D USER_BOUNDS,
final AffineTransform TRANSFORM,
final RenderingHints HINTS) {
//KK - speed up repaints by caching the context
if (_cached != null) {
ConicalGradientPaintContext c = _cached.get(DEVICE_BOUNDS, _center, TRANSFORM);
if (c != null)
return c;
}
ConicalGradientPaintContext context = new ConicalGradientPaintContext(_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 + _center.hashCode();
hash = 97 * hash + Float.floatToIntBits(_angleOffset);
hash = 97 * hash + java.util.Arrays.hashCode(_fractionOffsets);
hash = 97 * hash + java.util.Arrays.hashCode(_redStepLookup);
hash = 97 * hash + java.util.Arrays.hashCode(_greenStepLookup);
hash = 97 * hash + java.util.Arrays.hashCode(_blueStepLookup);
hash = 97 * hash + java.util.Arrays.hashCode(_alphaStepLookup);
hash = 97 * hash + java.util.Arrays.hashCode(_colors);
return hash;
}
@Override
public boolean equals(final Object OBJ) {
if (this == OBJ) {
return true;
}
if (OBJ == null) {
return false;
}
if (getClass() != OBJ.getClass()) {
return false;
}
final ConicalGradientPaint OTHER = (ConicalGradientPaint) OBJ;
if (!_center.equals(OTHER._center)) {
return false;
}
if (Float.floatToIntBits(_angleOffset) != Float.floatToIntBits(OTHER._angleOffset)) {
return false;
}
if (!java.util.Arrays.equals(_fractionOffsets, OTHER._fractionOffsets)) {
return false;
}
if (!java.util.Arrays.equals(_redStepLookup, OTHER._redStepLookup)) {
return false;
}
if (!java.util.Arrays.equals(_greenStepLookup, OTHER._greenStepLookup)) {
return false;
}
if (!java.util.Arrays.equals(_blueStepLookup, OTHER._blueStepLookup)) {
return false;
}
if (!java.util.Arrays.equals(_alphaStepLookup, OTHER._alphaStepLookup)) {
return false;
}
if (!java.util.Arrays.equals(_colors, OTHER._colors)) {
return false;
}
return true;
}
private final class ConicalGradientPaintContext implements PaintContext
{
final private Point2D _center;
private final HashMap<Long, WritableRaster> _cachedRasters;
private final AffineTransform _transform;
public ConicalGradientPaintContext(final Point2D CENTER, AffineTransform transform) {
_cachedRasters = new HashMap<>();
_transform = transform;
try {
_center = transform.transform(CENTER, null); //user to device
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
@Override
public void dispose() {
}
@Override
public java.awt.image.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;
//get the rotation center in device space
final double ROTATION_CENTER_X = -X + _center.getX();
final double ROTATION_CENTER_Y = -Y + _center.getY();
//Convert to user space
Point2D rotationCenter = _transform.inverseTransform(new Point2D.Double(ROTATION_CENTER_X, ROTATION_CENTER_Y), null); //device to user
final int MAX = _fractionOffsets.length - 1;
// KK: use memory allocated by raster directly
// Create data array with place for red, green, blue and alpha values
final int[] data = new int[(TILE_WIDTH * TILE_HEIGHT * 4)];
//final int[] data = new int[4];
double dx;
double dy;
double angle;
double currentRed = 0;
double currentGreen = 0;
double currentBlue = 0;
double currentAlpha = 0;
for (int tileY = 0; tileY < TILE_HEIGHT; tileY++) {
for (int tileX = 0; tileX < TILE_WIDTH; tileX++) {
Point2D d = _transform.inverseTransform(new Point2D.Double(tileX, tileY), null); //device to user
// Calculate the distance between the current position and the rotation angle
dx = d.getX() - rotationCenter.getX();
dy = d.getY() - rotationCenter.getY();
//KK: simplify by just taking the arctangent
double distance = Math.sqrt(dx * dx + dy * dy);
// Avoid division by zero
if (distance == 0) {
distance = 1;
}
// 0 degree on top
angle = Math.abs(Math.toDegrees(Math.acos(dx / distance)));
//angle = Math.abs(Math.toDegrees(-Math.atan2(dy, dx)));
if (dx >= 0 && dy <= 0) {
angle = 90.0 - angle;
} else if (dx >= 0 && dy >= 0) {
angle += 90.0;
} else if (dx <= 0 && dy >= 0) {
angle += 90.0;
} else if (dx <= 0 && dy <= 0) {
angle = 450.0 - angle;
}
angle += _angleOffset;
if (angle > 360.0) {
angle -= 360.0;
} else if (angle < 0.0) {
angle += 360.0;
}
// Check for each angle in fractionAngles array
for (int i = 0; i < MAX; i++) {
if ((angle >= _fractionOffsets[i])) {
currentRed = _colors[i].getRed() * INT_TO_FLOAT_CONST + (angle - _fractionOffsets[i]) * _redStepLookup[i];
currentGreen = _colors[i].getGreen() * INT_TO_FLOAT_CONST + (angle - _fractionOffsets[i]) * _greenStepLookup[i];
currentBlue = _colors[i].getBlue() * INT_TO_FLOAT_CONST + (angle - _fractionOffsets[i]) * _blueStepLookup[i];
currentAlpha = _colors[i].getAlpha() * INT_TO_FLOAT_CONST + (angle - _fractionOffsets[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);
//raster.setPixel(tileX, tileY, data);
}
}
// Fill the raster with the data
raster.setPixels(0, 0, TILE_WIDTH, TILE_HEIGHT, data);
_cachedRasters.put(index, raster);
return raster;
}
catch (NoninvertibleTransformException ex) {
System.err.println(ex);
return null;
}
}
}
}