diff --git a/src/main/java/pixelitor/filters/CircleWeave.java b/src/main/java/pixelitor/filters/CircleWeave.java index 06865881..69e9c7ba 100644 --- a/src/main/java/pixelitor/filters/CircleWeave.java +++ b/src/main/java/pixelitor/filters/CircleWeave.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 Laszlo Balazs-Csiki and Contributors + * Copyright 2025 Laszlo Balazs-Csiki and Contributors * * This file is part of Pixelitor. Pixelitor is free software: you * can redistribute it and/or modify it under the terms of the GNU @@ -60,7 +60,7 @@ protected Path2D createCurve(int width, int height) { int m = mParam.getValue(); int type = typeParam.getValue(); - boolean nonlin = hasNonlinDistort(); + boolean nonlin = transform.hasNonlinDistort(); return switch (type) { case TYPE_MYSTIC_ROSE -> createMysticRose(points, nonlin); @@ -88,8 +88,8 @@ private static Path2D createMysticRose(Point2D[] points, boolean nonlin) { private Path2D createCircles(Point2D[] points, boolean nonlin, int width, int height) { double radius = getRadius(width, height) / 2.0; - double cx = width * center.getRelativeX(); - double cy = height * center.getRelativeY(); + double cx = transform.getCx(width); + double cy = transform.getCy(height); Point2D imageCenter = new Point2D.Double(cx, cy); Path2D path = new Path2D.Double(); @@ -120,8 +120,8 @@ private static Path2D createTimesTable(int m, Point2D[] points, boolean nonlin) private Point2D[] calcPoints(int width, int height, int numPoints) { Point2D[] points = new Point2D[numPoints]; double r = getRadius(width, height); - double cx = width * center.getRelativeX(); - double cy = height * center.getRelativeY(); + double cx = transform.getCx(width); + double cy = transform.getCy(height); double angleIncrement = 2 * Math.PI / numPoints; for (int i = 0; i < points.length; i++) { diff --git a/src/main/java/pixelitor/filters/Cubes.java b/src/main/java/pixelitor/filters/Cubes.java index 0f23b5c6..0484942d 100644 --- a/src/main/java/pixelitor/filters/Cubes.java +++ b/src/main/java/pixelitor/filters/Cubes.java @@ -17,10 +17,18 @@ package pixelitor.filters; -import pixelitor.filters.gui.*; +import pixelitor.Canvas; +import pixelitor.Views; +import pixelitor.filters.gui.ColorParam; +import pixelitor.filters.gui.FilterButtonModel; +import pixelitor.filters.gui.IntChoiceParam; import pixelitor.filters.gui.IntChoiceParam.Item; +import pixelitor.filters.gui.RangeParam; +import pixelitor.filters.util.ShapeWithColor; +import pixelitor.io.FileIO; +import pixelitor.utils.Distortion; import pixelitor.utils.ImageUtils; -import pixelitor.utils.NonlinTransform; +import pixelitor.utils.Transform; import java.awt.BasicStroke; import java.awt.Color; @@ -28,9 +36,10 @@ import java.awt.Shape; import java.awt.geom.AffineTransform; import java.awt.geom.Path2D; -import java.awt.geom.Point2D; import java.awt.image.BufferedImage; import java.io.Serial; +import java.util.ArrayList; +import java.util.List; import static java.awt.Color.BLACK; import static java.awt.Color.GRAY; @@ -39,7 +48,6 @@ import static java.awt.RenderingHints.KEY_ANTIALIASING; import static java.awt.RenderingHints.VALUE_ANTIALIAS_ON; import static pixelitor.filters.gui.TransparencyPolicy.USER_ONLY_TRANSPARENCY; -import static pixelitor.utils.NonlinTransform.NONE; /** * The Render/Geometry/Cubes Pattern filter. @@ -63,7 +71,6 @@ public class Cubes extends ParametrizedFilter { new Item("Corner Cut 3", TYPE_CORNER_CUT3), new Item("Interlocking", TYPE_INTERLOCKING), }); -// private final RangeParam hollowDepthParam = new RangeParam("Hollow Depth (%)", 1, 50, 99); private final RangeParam sizeParam = new RangeParam("Size", 5, 20, 200); private final ColorParam topColorParam = new ColorParam("Top Color", WHITE, USER_ONLY_TRANSPARENCY); @@ -72,91 +79,91 @@ public class Cubes extends ParametrizedFilter { private final RangeParam edgeWidthParam = new RangeParam("Edge Width", 0, 0, 10); private final ColorParam edgeColorParam = new ColorParam("Edge Color", BLACK, USER_ONLY_TRANSPARENCY); - private final GroupedRangeParam scale = new GroupedRangeParam("Scale (%)", 1, 100, 500); - private final AngleParam rotate = new AngleParam("Rotate", 0); - private final EnumParam distortType = NonlinTransform.asParam(); - private final RangeParam distortAmount = NonlinTransform.createAmountParam(); + private final Transform transform = new Transform(); public Cubes() { super(false); - distortType.setupEnableOtherIf(distortAmount, NonlinTransform::hasAmount); edgeWidthParam.setupEnableOtherIfNotZero(edgeColorParam); -// typeParam.setupEnableOtherIf(hollowDepthParam, selectedType -> -// selectedType.valueIs(TYPE_HOLLOWED)); setParams( typeParam, sizeParam, -// hollowDepthParam, topColorParam, leftColorParam, rightColorParam, edgeWidthParam, edgeColorParam, - new DialogParam("Transform", - distortType, distortAmount, rotate, scale) - ); + transform.createDialogParam() + ).withAction(FilterButtonModel.createExportSvg(this::exportSVG)); } @Override public BufferedImage transform(BufferedImage src, BufferedImage dest) { dest = ImageUtils.createImageWithSameCM(src); - int width = dest.getWidth(); int height = dest.getHeight(); Graphics2D g = dest.createGraphics(); g.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON); - double sx = scale.getPercentage(0); - double sy = scale.getPercentage(0); - double angle = rotate.getValueInRadians(); - double cx = width / 2.0; - double cy = height / 2.0; - if (sx != 1 || sy != 1) { - g.translate(cx, cy); - g.scale(sx, sy); - g.translate(-cx, -cy); - } - if (angle != 0) { - g.rotate(angle, cx, cy); + AffineTransform at = transform.calcAffineTransform(width, height); + if (at != null) { + g.transform(at); } - NonlinTransform distortion = distortType.getSelected(); - double amount = distortAmount.getValueAsDouble(); - Point2D pivotPoint = new Point2D.Double(cx, cy); - float edgeWidth = edgeWidthParam.getValueAsFloat(); - boolean renderEdges = edgeWidth > 0; - Color edgeColor = null; - if (renderEdges) { + if (edgeWidth > 0) { g.setStroke(new BasicStroke(edgeWidth, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL)); - edgeColor = edgeColorParam.getColor(); } + List shapes = createDistortedShapes(width, height); + + for (ShapeWithColor shapeWithColor : shapes) { + g.setColor(shapeWithColor.color()); + g.fill(shapeWithColor.shape()); + + if (edgeWidth > 0) { + g.setColor(edgeColorParam.getColor()); + g.draw(shapeWithColor.shape()); + } + } + + g.dispose(); + return dest; + } + + private List createDistortedShapes(int width, int height) { + List shapes = createShapes(width, height); + + if (transform.hasNonlinDistort()) { + Distortion distortion = transform.createDistortion(width, height); + shapes = shapes.stream() + .map(sc -> sc.distort(distortion)) + .toList(); + } + return shapes; + } + + private List createShapes(int width, int height) { + List shapes = new ArrayList<>(); + Color topColor = topColorParam.getColor(); Color rightColor = rightColorParam.getColor(); Color leftColor = leftColorParam.getColor(); int type = typeParam.getSelected().getValue(); boolean interlocking = type == TYPE_INTERLOCKING; -// double hollowDepth = hollowDepthParam.getPercentage(); double size = sizeParam.getValueAsDouble(); - double longer = size * Math.cos(Math.PI / 6); double shorter = size * Math.sin(Math.PI / 6); double horizontalSpacing = interlocking ? 3 * longer : 2 * longer; - double verticalSpacing = size + shorter; - if (interlocking) { - verticalSpacing = size * 0.75; - } - int numCubesX = (int) (width / horizontalSpacing) + 2; + double verticalSpacing = interlocking ? size * 0.75 : (size + shorter); - int numCubesY = (int) (height / verticalSpacing) - + (interlocking ? 3 : 2); + int numCubesX = (int) (width / horizontalSpacing) + 2; + int numCubesY = (int) (height / verticalSpacing) + (interlocking ? 3 : 2); int numCornerCuts = switch (type) { case TYPE_BASIC, TYPE_INTERLOCKING -> 0; @@ -166,52 +173,62 @@ public BufferedImage transform(BufferedImage src, BufferedImage dest) { default -> throw new IllegalStateException("Unexpected value: " + type); }; - double verOffset = interlocking ? -size / 2 : 0; + double moveHorOffset = transform.getHorOffset(width); + double moveVerOffset = transform.getVerOffset(height); + + double verOffset = moveVerOffset + (interlocking ? -size / 2 : 0); for (int row = 0; row < numCubesY; row++) { - double horOffset = row % 2 == 0 ? 0 : longer; + double horOffset = moveHorOffset + (row % 2 == 0 ? 0 : longer); if (interlocking) { horOffset *= 1.5; } + for (int col = 0; col < numCubesX; col++) { - // base point (shared vertex) double baseX = horOffset + col * horizontalSpacing; double baseY = verOffset + row * verticalSpacing; - Path2D topFace = createTop(baseX, baseY, longer, shorter, interlocking, size); - renderShape(topFace, g, distortion, pivotPoint, amount, width, height, topColor, edgeColor); - - Path2D rightFace = createRight(baseX, baseY, longer, shorter, interlocking, size); - renderShape(rightFace, g, distortion, pivotPoint, amount, width, height, rightColor, edgeColor); + addCubeShapes(shapes, baseX, baseY, longer, shorter, interlocking, size, + topColor, rightColor, leftColor, numCornerCuts); + } + } - Path2D leftFace = createLeft(baseX, baseY, longer, shorter, interlocking, size); - renderShape(leftFace, g, distortion, pivotPoint, amount, width, height, leftColor, edgeColor); + return shapes; + } - for (int cut = 0; cut < numCornerCuts; cut++) { - double cutRatio = 1.0 - (double) (cut + 1) / (numCornerCuts + 1); + private void addCubeShapes(List shapes, + double baseX, double baseY, + double longer, double shorter, + boolean interlocking, double size, + Color topColor, Color rightColor, Color leftColor, + int numCornerCuts) { - AffineTransform carvedCube = new AffineTransform(); - carvedCube.translate(baseX, baseY); - if (cut % 2 == 0) { - carvedCube.rotate(Math.PI); - } - carvedCube.scale(cutRatio, cutRatio); - carvedCube.translate(-baseX, -baseY); + Path2D topFace = createTop(baseX, baseY, longer, shorter, interlocking, size); + Path2D rightFace = createRight(baseX, baseY, longer, shorter, interlocking, size); + Path2D leftFace = createLeft(baseX, baseY, longer, shorter, interlocking, size); - Shape miniTopFace = carvedCube.createTransformedShape(topFace); - renderShape(miniTopFace, g, distortion, pivotPoint, amount, width, height, topColor, edgeColor); + shapes.add(new ShapeWithColor(topFace, topColor)); + shapes.add(new ShapeWithColor(rightFace, rightColor)); + shapes.add(new ShapeWithColor(leftFace, leftColor)); - Shape miniLeftFace = carvedCube.createTransformedShape(leftFace); - renderShape(miniLeftFace, g, distortion, pivotPoint, amount, width, height, leftColor, edgeColor); + for (int cut = 0; cut < numCornerCuts; cut++) { + double cutRatio = 1.0 - (double) (cut + 1) / (numCornerCuts + 1); - Shape miniRightFace = carvedCube.createTransformedShape(rightFace); - renderShape(miniRightFace, g, distortion, pivotPoint, amount, width, height, rightColor, edgeColor); - } -// Shapes.fillCircle(baseX, baseY, 5, Color.RED, g); + AffineTransform carvedCube = new AffineTransform(); + carvedCube.translate(baseX, baseY); + if (cut % 2 == 0) { + carvedCube.rotate(Math.PI); } - } + carvedCube.scale(cutRatio, cutRatio); + carvedCube.translate(-baseX, -baseY); - g.dispose(); - return dest; + Shape miniTopFace = carvedCube.createTransformedShape(topFace); + Shape miniRightFace = carvedCube.createTransformedShape(rightFace); + Shape miniLeftFace = carvedCube.createTransformedShape(leftFace); + + shapes.add(new ShapeWithColor(miniTopFace, topColor)); + shapes.add(new ShapeWithColor(miniRightFace, rightColor)); + shapes.add(new ShapeWithColor(miniLeftFace, leftColor)); + } } private static Path2D createTop(double baseX, double baseY, double longer, double shorter, boolean interlocking, double size) { @@ -262,17 +279,20 @@ private static Path2D createLeft(double baseX, double baseY, double longer, doub return left; } - private static void renderShape(Shape shape, Graphics2D g, NonlinTransform distortion, Point2D pivotPoint, double amount, int width, int height, Color color, Color edgeColor) { - if (distortion != NONE) { - shape = distortion.transform(shape, pivotPoint, amount, width, height); - } - g.setColor(color); - g.fill(shape); - - if (edgeColor != null) { - g.setColor(edgeColor); - g.draw(shape); + private void exportSVG() { + Canvas canvas = Views.getActiveComp().getCanvas(); + int width = canvas.getWidth(); + int height = canvas.getHeight(); + List shapes = createDistortedShapes(width, height); + AffineTransform at = transform.calcAffineTransform(width, height); + if (at != null) { + shapes = shapes.stream() + .map(s -> s.transform(at)) + .toList(); } + String svgContent = ShapeWithColor.createSvgContent(shapes, canvas, null, + edgeWidthParam.getValue(), edgeColorParam.getColor()); + FileIO.saveSVG(svgContent, "cubes.svg"); } @Override diff --git a/src/main/java/pixelitor/filters/CurveFilter.java b/src/main/java/pixelitor/filters/CurveFilter.java index 3ba86c55..2c8ed8dc 100644 --- a/src/main/java/pixelitor/filters/CurveFilter.java +++ b/src/main/java/pixelitor/filters/CurveFilter.java @@ -26,9 +26,10 @@ import pixelitor.filters.gui.IntChoiceParam.Item; import pixelitor.filters.painters.AreaEffects; import pixelitor.io.FileIO; +import pixelitor.utils.Distortion; import pixelitor.utils.ImageUtils; -import pixelitor.utils.NonlinTransform; import pixelitor.utils.Shapes; +import pixelitor.utils.Transform; import java.awt.*; import java.awt.geom.AffineTransform; @@ -47,7 +48,6 @@ import static pixelitor.colors.FgBgColors.getBGColor; import static pixelitor.colors.FgBgColors.getFGColor; import static pixelitor.filters.gui.RandomizePolicy.IGNORE_RANDOMIZE; -import static pixelitor.utils.NonlinTransform.NONE; /** * Abstract superclass for the "Render/Curves" filters. @@ -91,24 +91,18 @@ public abstract class CurveFilter extends ParametrizedFilter { }, IGNORE_RANDOMIZE); private final BooleanParam waterMark = new BooleanParam("Watermarking"); - protected final ImagePositionParam center = new ImagePositionParam("Center"); - private final GroupedRangeParam scale = new GroupedRangeParam("Scale (%)", 1, 100, 500); - private final AngleParam rotate = new AngleParam("Rotate", 0); - private final EnumParam distortType = NonlinTransform.asParam(); - private final RangeParam distortAmount = NonlinTransform.createAmountParam(); + protected final Transform transform = new Transform(); private transient Shape exportedShape; protected CurveFilter() { super(false); - distortType.setupEnableOtherIf(distortAmount, NonlinTransform::hasAmount); - setParams( background, foreground, waterMark, - new DialogParam("Transform", distortType, distortAmount, center, rotate, scale), + transform.createDialogParam(), strokeParam.withStrokeWidth(2), effectsParam ).withAction(FilterButtonModel.createExportSvg(this::exportSVG)); @@ -149,43 +143,11 @@ public BufferedImage transform(BufferedImage src, BufferedImage dest) { return dest; } - NonlinTransform nonlin = distortType.getSelected(); - if (nonlin != NONE) { - double amount = distortAmount.getValueAsDouble(); - Point2D pivotPoint = center.getAbsolutePoint(src); - shape = nonlin.transform(shape, pivotPoint, amount, srcWidth, srcHeight); - } - - double scaleX = scale.getPercentage(0); - double scaleY = scale.getPercentage(1); - boolean hasScaling = scaleX != 1.0 || scaleY != 1.0; - - double relX = center.getRelativeX(); - double relY = center.getRelativeY(); - boolean hasTranslation = relX != 0.5 || relY != 0.5; - - boolean hasRotation = !rotate.hasDefault(); - - if (hasTranslation || hasRotation || hasScaling) { - double cx = srcWidth * relX; - double cy = srcHeight * relY; - - AffineTransform at; - if (hasScaling) { - // scale around the center point - at = AffineTransform.getTranslateInstance - (cx - scaleX * cx, cy - scaleY * cy); - at.scale(scaleX, scaleY); - } else { - at = new AffineTransform(); - } - if (hasRotation) { - at.rotate(rotate.getValueInRadians(), cx, cy); - } - if (hasTranslation) { - at.translate(cx - srcWidth / 2.0, cy - srcHeight / 2.0); - } + Distortion distortion = transform.createDistortion(srcWidth, srcHeight); + shape = distortion.distort(shape); + AffineTransform at = transform.calcAffineTransform(srcWidth, srcHeight); + if (at != null) { shape = at.createTransformedShape(shape); } @@ -353,8 +315,4 @@ public Path2D getPath() { return Shapes.smoothConnect(points); } } - - protected boolean hasNonlinDistort() { - return distortType.getSelected() != NONE; - } } \ No newline at end of file diff --git a/src/main/java/pixelitor/filters/FlowerOfLife.java b/src/main/java/pixelitor/filters/FlowerOfLife.java index c5e4143f..a557495f 100644 --- a/src/main/java/pixelitor/filters/FlowerOfLife.java +++ b/src/main/java/pixelitor/filters/FlowerOfLife.java @@ -1,5 +1,5 @@ /* - * Copyright 2023 Laszlo Balazs-Csiki and Contributors + * Copyright 2025 Laszlo Balazs-Csiki and Contributors * * This file is part of Pixelitor. Pixelitor is free software: you * can redistribute it and/or modify it under the terms of the GNU @@ -65,8 +65,8 @@ protected Path2D createCurve(int width, int height) { Path2D shape = new Path2D.Double(); double r = radius.getValueAsDouble(); - double cx = width * center.getRelativeX(); - double cy = height * center.getRelativeY(); + double cx = transform.getCx(width); + double cy = transform.getCy(height); Circle firstCircle = new Circle(cx, cy, r); Set circlesSet = new HashSet<>(); @@ -82,7 +82,7 @@ protected Path2D createCurve(int width, int height) { } } - boolean manyPoints = hasNonlinDistort(); + boolean manyPoints = transform.hasNonlinDistort(); for (Circle circle : circlesSet) { shape.append(circle.toShape(manyPoints), false); } diff --git a/src/main/java/pixelitor/filters/Grid.java b/src/main/java/pixelitor/filters/Grid.java index ce55142d..a42dc2df 100644 --- a/src/main/java/pixelitor/filters/Grid.java +++ b/src/main/java/pixelitor/filters/Grid.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 Laszlo Balazs-Csiki and Contributors + * Copyright 2025 Laszlo Balazs-Csiki and Contributors * * This file is part of Pixelitor. Pixelitor is free software: you * can redistribute it and/or modify it under the terms of the GNU @@ -74,6 +74,9 @@ private Path2D createTriangles(int width, int height, boolean diamond) { int horDiv = divisions.getValue(0); int verDiv = divisions.getValue(1); + double moveHorOffset = transform.getHorOffset(width); + double moveVerOffset = transform.getVerOffset(height); + // /\ // Width and height of / \ double cellW = width / (double) horDiv; @@ -96,23 +99,26 @@ private Path2D createTriangles(int width, int height, boolean diamond) { for (int i = -1; i < horDiv + 1; i++) { for (int j = 0; j < verDiv + 2; j += 2) { - baselessTriangle(shape, i * cellW, j * cellH, cellW, cellH); - baselessTriangle(shape, i * cellW + cellInterval, (j + 1) * cellH, cellW, cellH); + double x = i * cellW + moveHorOffset; + double y = j * cellH + moveVerOffset; + baselessTriangle(shape, x, y, cellW, cellH); + baselessTriangle(shape, x + cellInterval, y + cellH, cellW, cellH); } } // horizontal lines if (!diamond) { for (int i = 0; i <= verDiv; i++) { - double x = 0; + double x = moveHorOffset; if (i % 2 != 0) { x -= cellInterval; } - shape.moveTo(x, i * cellH); + double startY = i * cellH + moveVerOffset; + shape.moveTo(x, startY); // draw the line as multiple segments - double lineY = i * cellH; + double lineY = startY; for (int j = 0; j < horDiv + 1; j++) { x += cellW; shape.lineTo(x, lineY); @@ -138,13 +144,16 @@ private Path2D createRectangles(int width, int height) { int horDiv = divisions.getValue(0); int verDiv = divisions.getValue(1); + double moveHorOffset = transform.getHorOffset(width); + double moveVerOffset = transform.getVerOffset(height); + double cellW = width / (double) horDiv; double cellH = height / (double) verDiv; // horizontal lines for (int i = 0; i < verDiv + 1; i++) { - double lineY = i * cellH; - double x = 0; + double lineY = i * cellH + moveVerOffset; + double x = moveHorOffset; shape.moveTo(x, lineY); // draw the line as multiple segments for (int j = 0; j < horDiv; j++) { @@ -155,8 +164,8 @@ private Path2D createRectangles(int width, int height) { // vertical lines for (int i = 0; i < horDiv + 1; i++) { - double lineX = i * cellW; - double y = 0; + double lineX = i * cellW + moveHorOffset; + double y = moveVerOffset; shape.moveTo(lineX, y); // draw the line as multiple segments @@ -175,6 +184,9 @@ private Path2D createHexagons(int width, int height) { int horDiv = divisions.getValue(0); int verDiv = divisions.getValue(1); + double moveHorOffset = transform.getHorOffset(width); + double moveVerOffset = transform.getVerOffset(height); + // ___ // width and height of / \ double cellW = 2.0 * width / (3 * horDiv - 1); @@ -193,8 +205,10 @@ private Path2D createHexagons(int width, int height) { for (int i = -1; i < horDiv; i++) { for (int j = 0; j < verDiv + 2; j += 2) { - hexagonTopHalf(shape, i * cellSpace, j * cellH, cellW, cellH); - hexagonTopHalf(shape, i * cellSpace + cellInterval, (j + 1) * cellH, cellW, cellH); + double startX = i * cellSpace + moveHorOffset; + double startY = j * cellH + moveVerOffset; + hexagonTopHalf(shape, startX, startY, cellW, cellH); + hexagonTopHalf(shape, startX + cellInterval, startY + cellH, cellW, cellH); } } @@ -214,6 +228,9 @@ private Path2D createScales(int width, int height, boolean dragon) { int horDiv = divisions.getValue(0); int verDiv = divisions.getValue(1); + double moveHorOffset = transform.getHorOffset(width); + double moveVerOffset = transform.getVerOffset(height); + double cellW = width / (double) horDiv; double cellH = height / (double) verDiv; @@ -221,9 +238,10 @@ private Path2D createScales(int width, int height, boolean dragon) { for (int i = -1; i < horDiv; i++) { for (int j = 1; j < verDiv; j += 2) { - double x = i * cellW; - scale(shape, x, j * cellH, cellW, cellH, dragon); - scale(shape, x + cellInterval, (j + 1) * cellH, cellW, cellH, dragon); + double x = i * cellW + moveHorOffset; + double y = j * cellH + moveVerOffset; + scale(shape, x, y, cellW, cellH, dragon); + scale(shape, x + cellInterval, y + cellH, cellW, cellH, dragon); } } diff --git a/src/main/java/pixelitor/filters/LSystems.java b/src/main/java/pixelitor/filters/LSystems.java index 40733fad..65a9cf97 100644 --- a/src/main/java/pixelitor/filters/LSystems.java +++ b/src/main/java/pixelitor/filters/LSystems.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 Laszlo Balazs-Csiki and Contributors + * Copyright 2025 Laszlo Balazs-Csiki and Contributors * * This file is part of Pixelitor. Pixelitor is free software: you * can redistribute it and/or modify it under the terms of the GNU @@ -313,7 +313,9 @@ protected Shape createCurve(int width, int height) { Turtle turtle = fractalType.createTurtle(n); Path2D path = turtle.interpret(commands); - return Shapes.resizeToFit(path, width, height, margin); + return Shapes.resizeToFit(path, width, height, margin, + transform.getHorOffset(width), + transform.getVerOffset(height)); } private static String iterate(Type type, int order) { @@ -350,6 +352,9 @@ private record State(double x, double y, int angle) { public Turtle(int startAngle, int turnAngle) { this.angle = startAngle; this.turnAngle = turnAngle; + + // the turtle always starts at (0, 0), but the whole path + // will be rescaled after the Shapes.resizeToFit method this.x = 0; this.y = 0; this.moveDistance = 10; diff --git a/src/main/java/pixelitor/filters/Lissajous.java b/src/main/java/pixelitor/filters/Lissajous.java index 5e26faf8..3bfe4e2e 100644 --- a/src/main/java/pixelitor/filters/Lissajous.java +++ b/src/main/java/pixelitor/filters/Lissajous.java @@ -56,8 +56,8 @@ protected Path2D createCurve(int width, int height) { double bVal = b.getValueAsDouble(); double deltaVal = delta.getValueInRadians(); - double cx = width * center.getRelativeX(); - double cy = height * center.getRelativeY(); + double cx = transform.getCx(width); + double cy = transform.getCy(height); double w = width / 2.0; double h = height / 2.0; diff --git a/src/main/java/pixelitor/filters/Rose.java b/src/main/java/pixelitor/filters/Rose.java index 9e01f7e0..afbdbce9 100644 --- a/src/main/java/pixelitor/filters/Rose.java +++ b/src/main/java/pixelitor/filters/Rose.java @@ -20,17 +20,19 @@ import pixelitor.Canvas; import pixelitor.Views; import pixelitor.colors.Colors; -import pixelitor.filters.gui.*; +import pixelitor.filters.gui.ColorParam; +import pixelitor.filters.gui.FilterButtonModel; +import pixelitor.filters.gui.RangeParam; import pixelitor.filters.util.ShapeWithColor; import pixelitor.io.FileIO; +import pixelitor.utils.Distortion; import pixelitor.utils.ImageUtils; -import pixelitor.utils.NonlinTransform; +import pixelitor.utils.Transform; import java.awt.Graphics2D; import java.awt.Shape; import java.awt.geom.AffineTransform; import java.awt.geom.Path2D; -import java.awt.geom.Point2D; import java.awt.image.BufferedImage; import java.io.Serial; import java.util.List; @@ -40,7 +42,6 @@ import static java.awt.RenderingHints.KEY_ANTIALIASING; import static java.awt.RenderingHints.VALUE_ANTIALIAS_ON; import static pixelitor.filters.gui.TransparencyPolicy.USER_ONLY_TRANSPARENCY; -import static pixelitor.utils.NonlinTransform.NONE; /** * The Render/Geometry/Rose filter, generating a polar rose curve. @@ -53,26 +54,20 @@ public class Rose extends ParametrizedFilter { private final RangeParam nParam = new RangeParam("n", 1, 4, 20); private final RangeParam dParam = new RangeParam("d", 1, 7, 20); - private final ImagePositionParam center = new ImagePositionParam("Center"); private final ColorParam bgColor = new ColorParam("Background Color", BLACK, USER_ONLY_TRANSPARENCY); private final ColorParam fgColor = new ColorParam("Foreground Color", WHITE, USER_ONLY_TRANSPARENCY); - private final GroupedRangeParam scale = new GroupedRangeParam("Scale (%)", 1, 100, 500); - private final AngleParam rotate = new AngleParam("Rotate", 0); - private final EnumParam distortType = NonlinTransform.asParam(); - private final RangeParam distortAmount = NonlinTransform.createAmountParam(); + + private final Transform transform = new Transform(); public Rose() { super(false); - distortType.setupEnableOtherIf(distortAmount, NonlinTransform::hasAmount); - setParams( nParam, dParam, bgColor, fgColor, - new DialogParam("Transform", - distortType, distortAmount, center, rotate, scale) + transform.createDialogParam() ).withAction(FilterButtonModel.createExportSvg(this::exportSVG)); helpURL = "https://en.wikipedia.org/wiki/Rose_(mathematics)"; @@ -101,8 +96,8 @@ public BufferedImage transform(BufferedImage src, BufferedImage dest) { private Shape createShape(int width, int height) { Path2D path = new Path2D.Double(Path2D.WIND_EVEN_ODD); double radius = Math.min(width, height) / 2.0; - double cx = width * center.getRelativeX(); - double cy = height * center.getRelativeY(); + double cx = transform.getCx(width); + double cy = transform.getCy(height); int n = nParam.getValue(); int d = dParam.getValue(); @@ -123,30 +118,17 @@ private Shape createShape(int width, int height) { } } path.closePath(); + Shape shape = path; - NonlinTransform nonlin = distortType.getSelected(); - if (nonlin != NONE) { - double amount = distortAmount.getValueAsDouble(); - Point2D pivotPoint = new Point2D.Double(cx, cy); - path = nonlin.transform(path, pivotPoint, amount, width, height); + if (transform.hasNonlinDistort()) { + Distortion distortion = transform.createDistortion(width, height); + shape = distortion.distort(shape); } - - int scaleX = scale.getValue(0); - int scaleY = scale.getValue(1); - Shape shape; - if (scaleX != 100 || scaleY != 100) { - AffineTransform at = AffineTransform.getTranslateInstance(cx, cy); - at.scale(scaleX / 100.0, scaleY / 100.0); - at.translate(-cx, -cy); - shape = at.createTransformedShape(path); - } else { - shape = path; + AffineTransform at = transform.calcAffineTransform(width, height); + if (at != null) { + shape = at.createTransformedShape(shape); } - double angle = rotate.getValueInRadians(); - if (angle != 0) { - shape = AffineTransform.getRotateInstance(angle, cx, cy).createTransformedShape(shape); - } return shape; } diff --git a/src/main/java/pixelitor/filters/SpiderWeb.java b/src/main/java/pixelitor/filters/SpiderWeb.java index 0b90bd43..fc22af33 100644 --- a/src/main/java/pixelitor/filters/SpiderWeb.java +++ b/src/main/java/pixelitor/filters/SpiderWeb.java @@ -1,5 +1,5 @@ /* - * Copyright 2023 Laszlo Balazs-Csiki and Contributors + * Copyright 2025 Laszlo Balazs-Csiki and Contributors * * This file is part of Pixelitor. Pixelitor is free software: you * can redistribute it and/or modify it under the terms of the GNU @@ -52,13 +52,13 @@ public SpiderWeb() { protected Path2D createCurve(int width, int height) { Path2D shape = new Path2D.Double(); - double cx = width * center.getRelativeX(); - double cy = height * center.getRelativeY(); + double cx = transform.getCx(width); + double cy = transform.getCy(height); int numConnections = numConnectionsParam.getValue(); int numBranches = numBranchesParam.getValue(); double radius = Math.min(width / 2.0, height / 2.0); - double angle = 2 * Math.PI / numBranches; + double angleIncrement = 2 * Math.PI / numBranches; double[] sin = new double[numBranches]; double[] cos = new double[numBranches]; @@ -67,9 +67,9 @@ protected Path2D createCurve(int width, int height) { for (int br = 0; br < numBranches; br++) { shape.moveTo(cx, cy); - double alpha = br * angle; - cos[br] = FastMath.cos(alpha); - sin[br] = FastMath.sin(alpha); + double angle = br * angleIncrement; + cos[br] = FastMath.cos(angle); + sin[br] = FastMath.sin(angle); // draw the line as multiple segments for (int conn = 1; conn <= numConnections; conn++) { diff --git a/src/main/java/pixelitor/filters/Spiral.java b/src/main/java/pixelitor/filters/Spiral.java index f53bdbc1..618e6829 100644 --- a/src/main/java/pixelitor/filters/Spiral.java +++ b/src/main/java/pixelitor/filters/Spiral.java @@ -73,8 +73,8 @@ public Spiral() { @Override protected Path2D createCurve(int width, int height) { - double cx = width * center.getRelativeX(); - double cy = height * center.getRelativeY(); + double cx = transform.getCx(width); + double cy = transform.getCy(height); int numSpins = numSpinsParam.getValue(); diff --git a/src/main/java/pixelitor/filters/Spirograph.java b/src/main/java/pixelitor/filters/Spirograph.java index a9caec4b..bc76ac6a 100644 --- a/src/main/java/pixelitor/filters/Spirograph.java +++ b/src/main/java/pixelitor/filters/Spirograph.java @@ -1,5 +1,5 @@ /* - * Copyright 2023 Laszlo Balazs-Csiki and Contributors + * Copyright 2025 Laszlo Balazs-Csiki and Contributors * * This file is part of Pixelitor. Pixelitor is free software: you * can redistribute it and/or modify it under the terms of the GNU @@ -82,8 +82,8 @@ protected Path2D createCurve(int width, int height) { R *= z; d *= z; - double cx = width * center.getRelativeX(); - double cy = height * center.getRelativeY(); + double cx = transform.getCx(width); + double cy = transform.getCy(height); double maxValue = time.getValue(); if (maxValue == 0.0) { diff --git a/src/main/java/pixelitor/filters/util/ShapeWithColor.java b/src/main/java/pixelitor/filters/util/ShapeWithColor.java index 75b31a3e..79ee95a6 100644 --- a/src/main/java/pixelitor/filters/util/ShapeWithColor.java +++ b/src/main/java/pixelitor/filters/util/ShapeWithColor.java @@ -19,17 +19,28 @@ import pixelitor.Canvas; import pixelitor.colors.Colors; +import pixelitor.utils.Distortion; import pixelitor.utils.Shapes; import java.awt.Color; import java.awt.Shape; +import java.awt.geom.AffineTransform; import java.util.List; /** * Associates a {@link Shape} with a {@link Color}. */ public record ShapeWithColor(Shape shape, Color color) { + public ShapeWithColor transform(AffineTransform at) { + return new ShapeWithColor(at.createTransformedShape(shape), color); + } + public static String createSvgContent(List shapes, Canvas canvas, Color bgColor) { + return createSvgContent(shapes, canvas, bgColor, 0, null); + } + + public static String createSvgContent(List shapes, Canvas canvas, Color bgColor, + int strokeWidth, Color strokeColor) { StringBuilder content = new StringBuilder() .append(canvas.createSVGElement()) .append("\n"); @@ -37,18 +48,36 @@ public static String createSvgContent(List shapes, Canvas canvas content.append(String.format("\n", Colors.toHTMLHex(bgColor, true))); } - appendSvgPaths(shapes, content); + appendSvgPaths(shapes, content, strokeWidth, strokeColor); content.append(""); return content.toString(); } - private static void appendSvgPaths(List shapes, StringBuilder sb) { + private static void appendSvgPaths(List shapes, StringBuilder sb, + int strokeWidth, Color strokeColor) { for (ShapeWithColor shape : shapes) { String pathData = Shapes.toSvgPath(shape.shape()); String svgFillRule = Shapes.getSvgFillRule(shape.shape()); String colorHex = Colors.toHTMLHex(shape.color(), false); - sb.append("\n". - formatted(pathData, colorHex, svgFillRule)); + + StringBuilder pathAttrs = new StringBuilder(); + pathAttrs.append(String.format("d=\"%s\" ", pathData)); + pathAttrs.append(String.format("fill=\"#%s\" ", colorHex)); + pathAttrs.append(String.format("fill-rule=\"%s\"", svgFillRule)); + + if (strokeWidth > 0 && strokeColor != null) { + pathAttrs.append(String.format(" stroke=\"#%s\"", + Colors.toHTMLHex(strokeColor, false))); + pathAttrs.append(String.format(" stroke-width=\"%d\"", strokeWidth)); + pathAttrs.append(" stroke-linecap=\"butt\""); + pathAttrs.append(" stroke-linejoin=\"bevel\""); + } + + sb.append(String.format("\n", pathAttrs)); } } -} + + public ShapeWithColor distort(Distortion distortion) { + return new ShapeWithColor(distortion.distort(shape), color); + } +} \ No newline at end of file diff --git a/src/main/java/pixelitor/utils/Distortion.java b/src/main/java/pixelitor/utils/Distortion.java new file mode 100644 index 00000000..0143bcc5 --- /dev/null +++ b/src/main/java/pixelitor/utils/Distortion.java @@ -0,0 +1,35 @@ +/* + * Copyright 2025 Laszlo Balazs-Csiki and Contributors + * + * This file is part of Pixelitor. Pixelitor is free software: you + * can redistribute it and/or modify it under the terms of the GNU + * General Public License, version 3 as published by the Free + * Software Foundation. + * + * Pixelitor is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Pixelitor. If not, see . + */ + +package pixelitor.utils; + +import java.awt.Shape; +import java.awt.geom.Point2D; + +/** + * Nonlinear shape distortion settings. + */ +public record Distortion(NonlinTransform nonlinTransform, + Point2D pivotPoint, double amount, + int width, int height) { + public Shape distort(Shape input) { + if (nonlinTransform == NonlinTransform.NONE) { + return input; + } + return nonlinTransform.transform(input, pivotPoint, amount, width, height); + } +} diff --git a/src/main/java/pixelitor/utils/Shapes.java b/src/main/java/pixelitor/utils/Shapes.java index 566898e3..4fa10dde 100644 --- a/src/main/java/pixelitor/utils/Shapes.java +++ b/src/main/java/pixelitor/utils/Shapes.java @@ -2092,15 +2092,18 @@ public static void curvedLine(Path2D path, double curvature, /** * Resizes the given shape to fit centrally within a target rectangle - * without distortion, considering the given width, height, and margin. + * without distortion, considering the given width, height, margin, and offset. * - * @param shape The shape to be resized. - * @param width The width of the target rectangle. - * @param height The height of the target rectangle. - * @param margin The margin around the shape inside the target rectangle. + * @param shape The shape to be resized. + * @param width The width of the target rectangle. + * @param height The height of the target rectangle. + * @param margin The margin around the shape inside the target rectangle. + * @param startX The horizontal offset to apply after resizing. + * @param startY The vertical offset to apply after resizing. * @return A new shape that fits within the target rectangle. */ - public static Shape resizeToFit(Shape shape, double width, double height, double margin) { + public static Shape resizeToFit(Shape shape, double width, double height, double margin, + double startX, double startY) { Rectangle2D bounds = shape.getBounds2D(); double shapeAspectRatio = bounds.getWidth() / bounds.getHeight(); double areaWidth = width - 2 * margin; @@ -2111,11 +2114,21 @@ public static Shape resizeToFit(Shape shape, double width, double height, double if (shapeAspectRatio >= areaAspectRatio) { double newAreaHeight = areaWidth / shapeAspectRatio; double newAreaY = margin + (areaHeight - newAreaHeight) / 2.0; - targetArea = new Rectangle2D.Double(margin, newAreaY, areaWidth, newAreaHeight); + targetArea = new Rectangle2D.Double( + margin + startX, + newAreaY + startY, + areaWidth, + newAreaHeight + ); } else { double newAreaWidth = areaHeight * shapeAspectRatio; double newAreaX = margin + (areaWidth - newAreaWidth) / 2.0; - targetArea = new Rectangle2D.Double(newAreaX, margin, newAreaWidth, areaHeight); + targetArea = new Rectangle2D.Double( + newAreaX + startX, + margin + startY, + newAreaWidth, + areaHeight + ); } AffineTransform at = RectangularTransform.create(bounds, targetArea); diff --git a/src/main/java/pixelitor/utils/Transform.java b/src/main/java/pixelitor/utils/Transform.java new file mode 100644 index 00000000..bb6d9201 --- /dev/null +++ b/src/main/java/pixelitor/utils/Transform.java @@ -0,0 +1,104 @@ +/* + * Copyright 2025 Laszlo Balazs-Csiki and Contributors + * + * This file is part of Pixelitor. Pixelitor is free software: you + * can redistribute it and/or modify it under the terms of the GNU + * General Public License, version 3 as published by the Free + * Software Foundation. + * + * Pixelitor is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Pixelitor. If not, see . + */ + +package pixelitor.utils; + +import pixelitor.filters.gui.*; + +import java.awt.geom.AffineTransform; +import java.awt.geom.Point2D; + +import static pixelitor.utils.NonlinTransform.NONE; + +/** + * All the linear and nonlinear shape transform settings. + */ +public class Transform { + private final EnumParam distortType = NonlinTransform.asParam(); + private final RangeParam distortAmount = NonlinTransform.createAmountParam(); + protected final ImagePositionParam center = new ImagePositionParam("Center"); + private final GroupedRangeParam scale = new GroupedRangeParam("Scale (%)", 1, 100, 500); + private final AngleParam rotate = new AngleParam("Rotate", 0); + + public Transform() { + distortType.setupEnableOtherIf(distortAmount, NonlinTransform::hasAmount); + } + + public DialogParam createDialogParam() { + return new DialogParam("Transform", distortType, distortAmount, center, rotate, scale); + } + + public Distortion createDistortion(int width, int height) { + return new Distortion(distortType.getSelected(), + getPivotPoint(width, height), + distortAmount.getValueAsDouble(), + width, height); + } + + public AffineTransform calcAffineTransform(int width, int height) { + double relX = center.getRelativeX(); + double relY = center.getRelativeY(); + double scaleX = scale.getPercentage(0); + double scaleY = scale.getPercentage(1); + + boolean hasRotation = !rotate.hasDefault(); + boolean hasScaling = scaleX != 1.0 || scaleY != 1.0; + + if (!hasRotation && !hasScaling) { + return null; + } + + double cx = width * relX; + double cy = height * relY; + + AffineTransform at = AffineTransform.getTranslateInstance(cx, cy); + if (hasScaling) { + at.scale(scaleX, scaleY); + } + if (hasRotation) { + at.rotate(rotate.getValueInRadians()); + } + at.translate(-cx, -cy); + return at; + } + + public double getCx(int width) { + return width * center.getRelativeX(); + } + + public double getCy(int height) { + return height * center.getRelativeY(); + } + + // ensures that shapes that are not drawn around a center + // are still translated when moving the center selector + public double getHorOffset(int width) { + return width * (center.getRelativeX() - 0.5); + } + + public double getVerOffset(int height) { + return height * (center.getRelativeY() - 0.5); + } + + private Point2D getPivotPoint(int width, int height) { + return new Point2D.Double(getCx(width), getCy(height)); + } + + public boolean hasNonlinDistort() { + return distortType.getSelected() != NONE; + } +}