Graphics2DTarget.java
package com.varnernet.gerb4j.render;
import com.varnernet.gerb4j.Aperture;
import com.varnernet.gerb4j.ApertureTransform;
import com.varnernet.gerb4j.ArcGeometry;
import com.varnernet.gerb4j.Mirror;
import com.varnernet.gerb4j.Polarity;
import com.varnernet.gerb4j.QuadrantMode;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.Shape;
import java.awt.geom.AffineTransform;
import java.awt.geom.Arc2D;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Path2D;
import java.awt.geom.PathIterator;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.awt.geom.RoundRectangle2D;
import java.util.List;
/**
* {@link GerberOutputTarget} that renders to a Java2D {@link Graphics2D} context.
*
* <p>Extends {@link AbstractGerberTarget} which provides:
*
* <ul>
* <li>The {@link #drawPath} and {@link #drawArc} dispatch logic (final)
* <li>The {@link #convexHull} and {@link #getShapeVertices} geometry algorithms
* </ul>
*
* <p>This class is responsible only for the Graphics2D-specific concerns: coordinate scaling,
* Y-axis flipping, color selection, and rasterisation.
*/
public class Graphics2DTarget extends AbstractGerberTarget {
private final Graphics2D g2d;
private double scale;
private final Color darkColor;
private final Color clearColor;
private AffineTransform baseTransform;
private AffineTransform currentTransform;
// Store bounds for coordinate transformation
private final double minX;
private final double maxY;
/**
* Constructs a Graphics2DTarget with default colors.
*
* @param g2d the Graphics2D context
* @param scale the scale factor
*/
public Graphics2DTarget(final Graphics2D g2d, final double scale) {
this(g2d, scale, Color.BLACK, Color.WHITE);
}
/**
* Constructs a Graphics2DTarget with specified colors.
*
* @param g2d the Graphics2D context
* @param scale the scale factor
* @param darkColor the color for dark polarity
* @param clearColor the color for clear polarity
*/
public Graphics2DTarget(final Graphics2D g2d, final double scale, final Color darkColor, final Color clearColor) {
this(g2d, scale, darkColor, clearColor, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY);
}
/**
* Constructor with explicit Y-axis upper bound for coordinate transformation.
*
* @param g2d The Graphics2D context
* @param scale Scale factor (pixels per Gerber unit)
* @param darkColor Color for DARK polarity
* @param clearColor Color for CLEAR polarity
* @param maxY Maximum Y coordinate in Gerber space
*/
public Graphics2DTarget(
final Graphics2D g2d, final double scale, final Color darkColor, final Color clearColor, final double maxY) {
this(g2d, scale, darkColor, clearColor, Double.POSITIVE_INFINITY, maxY);
}
/**
* Constructor with full Gerber-space bounds for coordinate transformation.
*
* <p>Translates the Gerber coordinate origin so that {@code (minX, minY)} maps to the bottom-left
* of the Graphics2D viewport, and flips the Y axis so that Gerber Y-up becomes screen Y-down.
*
* @param g2d The Graphics2D context (caller should pre-translate for margins)
* @param scale Scale factor (pixels per Gerber unit)
* @param darkColor Color for DARK polarity
* @param clearColor Color for CLEAR polarity
* @param bounds Gerber-space bounding rectangle
*/
public Graphics2DTarget(
final Graphics2D g2d, final double scale, final Color darkColor, final Color clearColor, final Rectangle2D bounds) {
this(g2d, scale, darkColor, clearColor, bounds.getMinX(), bounds.getMaxY());
}
/**
* Internal constructor — all public constructors delegate here.
*
* @param g2d the Graphics2D context to render to
* @param scale the scale factor for rendering
* @param darkColor the color for dark polarity
* @param clearColor the color for clear polarity
* @param minX the minimum X coordinate
* @param maxY the maximum Y coordinate
*/
private Graphics2DTarget(
final Graphics2D g2d, final double scale, final Color darkColor, final Color clearColor, final double minX, final double maxY) {
this.g2d = g2d;
this.scale = scale;
this.darkColor = darkColor;
this.clearColor = clearColor;
this.minX = minX;
this.maxY = maxY;
this.baseTransform = g2d.getTransform();
this.currentTransform = new AffineTransform(baseTransform);
if (maxY > Double.NEGATIVE_INFINITY) {
applyCoordinateTransform();
}
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
}
/**
* Apply coordinate transformation from Gerber space to screen space.
*
* <ul>
* <li>Translates so that {@code (minX, maxY)} maps to screen {@code (0, 0)}
* <li>Flips Y so that Gerber Y-up becomes screen Y-down
* </ul>
*/
private void applyCoordinateTransform() {
AffineTransform t = new AffineTransform();
double dx = (minX < Double.POSITIVE_INFINITY) ? -minX * scale : 0;
t.translate(dx, maxY * scale);
t.scale(1, -1);
AffineTransform newBase = new AffineTransform(baseTransform);
newBase.concatenate(t);
baseTransform = newBase;
currentTransform = new AffineTransform(newBase);
g2d.setTransform(baseTransform);
}
// ── Flash operations (D03) ────────────────────────────────────────────────
@Override
public void drawCircle(
final Point2D center, final double diameter, final double rotationDegrees, final Polarity polarity) {
AffineTransform saved = g2d.getTransform();
g2d.setTransform(currentTransform);
setColor(polarity);
AffineTransform shapeT = new AffineTransform();
if (rotationDegrees != 0) {
shapeT.rotate(Math.toRadians(rotationDegrees), center.getX() * scale, center.getY() * scale);
}
g2d.transform(shapeT);
double radius = diameter * scale / 2.0;
g2d.fill(
new Ellipse2D.Double(
center.getX() * scale - radius,
center.getY() * scale - radius,
diameter * scale,
diameter * scale));
g2d.setTransform(saved);
}
@Override
public void drawRectangle(
final Point2D center, final double width, final double height, final double rotationDegrees, final Polarity polarity) {
AffineTransform saved = g2d.getTransform();
g2d.setTransform(currentTransform);
setColor(polarity);
if (rotationDegrees != 0) {
g2d.transform(
AffineTransform.getRotateInstance(
Math.toRadians(rotationDegrees), center.getX() * scale, center.getY() * scale));
}
double sw = width * scale;
double sh = height * scale;
g2d.fill(
new Rectangle2D.Double(
center.getX() * scale - sw / 2.0, center.getY() * scale - sh / 2.0, sw, sh));
g2d.setTransform(saved);
}
@Override
public void drawObround(
final Point2D center, final double width, final double height, final double rotationDegrees, final Polarity polarity) {
AffineTransform saved = g2d.getTransform();
g2d.setTransform(currentTransform);
setColor(polarity);
if (rotationDegrees != 0) {
g2d.transform(
AffineTransform.getRotateInstance(
Math.toRadians(rotationDegrees), center.getX() * scale, center.getY() * scale));
}
double sw = width * scale;
double sh = height * scale;
double corner = Math.min(sw, sh); // full arc diameter = short side
// Spec §4.4.2: the short-side ends are perfect semicircles whose diameter
// equals the short side. RoundRectangle2D arcw/arch are the FULL arc
// ellipse dimensions, so arcw = arch = min(w, h) gives the correct result.
g2d.fill(
new RoundRectangle2D.Double(
center.getX() * scale - sw / 2.0,
center.getY() * scale - sh / 2.0,
sw,
sh,
corner,
corner));
g2d.setTransform(saved);
}
@Override
public void drawPolygon(
final Point2D center, final int numVertices, final double diameter, final double rotationDegrees, final Polarity polarity) {
AffineTransform saved = g2d.getTransform();
g2d.setTransform(currentTransform);
setColor(polarity);
double radius = diameter * scale / 2.0;
double startAngle = Math.toRadians(rotationDegrees);
Path2D.Double path = new Path2D.Double();
for (int i = 0; i < numVertices; i++) {
double angle = startAngle + 2 * Math.PI * i / numVertices;
double x = center.getX() * scale + radius * Math.cos(angle);
double y = center.getY() * scale + radius * Math.sin(angle);
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
path.closePath();
g2d.fill(path);
g2d.setTransform(saved);
}
// ── Linear draw hooks (called by AbstractGerberTarget.drawPath) ───────────
/**
* Circular aperture D01 — BasicStroke with CAP_ROUND produces the correct swept cross-section, so
* no Minkowski calculation is needed.
*/
@Override
protected void renderPathStroked(final Path2D.Double path, final Aperture aperture, final Polarity polarity) {
AffineTransform saved = g2d.getTransform();
g2d.setTransform(currentTransform);
setColor(polarity);
float strokeWidth = (float) (aperture.getStrokeWidth() * scale);
g2d.setStroke(
new BasicStroke(
strokeWidth > 0 ? strokeWidth : 1.0f, aperture.getCapStyle(), aperture.getJoinStyle()));
AffineTransform scaleT = new AffineTransform();
scaleT.scale(scale, scale);
g2d.draw(new Path2D.Double(path, scaleT));
g2d.setTransform(saved);
}
/**
* Non-circular aperture D01 — exact Minkowski sum of the aperture cross-section with each segment
* (Gerber spec §4.8.4).
*
* <p>For each segment the convex hull of the aperture vertices translated to both endpoints is
* filled. Shared endpoints between adjacent segments ensure gap-free joins.
*/
@Override
protected void renderPathSweptRegion(final Path2D.Double path, final Shape apertureShape, final Polarity polarity) {
AffineTransform saved = g2d.getTransform();
g2d.setTransform(currentTransform);
setColor(polarity);
List<double[]> vertices = getShapeVertices(apertureShape, 0.01);
PathIterator it = path.getPathIterator(null);
double[] coords = new double[6];
double lastX = 0;
double lastY = 0;
while (!it.isDone()) {
int type = it.currentSegment(coords);
if (type == PathIterator.SEG_MOVETO) {
lastX = coords[0];
lastY = coords[1];
} else if (type == PathIterator.SEG_LINETO) {
fillSweptSegment(vertices, lastX, lastY, coords[0], coords[1]);
lastX = coords[0];
lastY = coords[1];
}
it.next();
}
g2d.setTransform(saved);
}
/**
* Fills the convex hull of the aperture at both endpoints, in pixel coords.
*
* @param aperture the aperture vertices
* @param x1 the x coordinate of the first point
* @param y1 the y coordinate of the first point
* @param x2 the x coordinate of the second point
* @param y2 the y coordinate of the second point
*/
private void fillSweptSegment(
final List<double[]> aperture, final double x1, final double y1, final double x2, final double y2) {
int n = aperture.size();
double[][] pts = new double[2 * n][2];
for (int i = 0; i < n; i++) {
pts[i][0] = (aperture.get(i)[0] + x1) * scale;
pts[i][1] = (aperture.get(i)[1] + y1) * scale;
pts[n + i][0] = (aperture.get(i)[0] + x2) * scale;
pts[n + i][1] = (aperture.get(i)[1] + y2) * scale;
}
g2d.fill(convexHull(pts));
}
// ── Region (G36/G37) ──────────────────────────────────────────────────────
@Override
public void drawRegion(final Path2D.Double region, final Polarity polarity) {
AffineTransform saved = g2d.getTransform();
g2d.setTransform(currentTransform);
setColor(polarity);
AffineTransform scaleT = new AffineTransform();
scaleT.scale(scale, scale);
g2d.fill(new Path2D.Double(region, scaleT));
g2d.setTransform(saved);
}
// ── Arc hooks (called by AbstractGerberTarget.drawArc) ────────────────────
/**
* Circular aperture arc — stroked with Arc2D.
*/
@Override
protected void renderArcStroked(
final Point2D startPoint,
final Point2D endPoint,
final Point2D centerOffset,
final Aperture aperture,
final Polarity polarity,
final boolean clockwise,
final QuadrantMode quadrantMode) {
AffineTransform saved = g2d.getTransform();
g2d.setTransform(currentTransform);
setColor(polarity);
ArcGeometry geom = new ArcGeometry(startPoint, endPoint, centerOffset, clockwise, quadrantMode);
// Negate angles: Gerber angles are CCW in Y-up; Arc2D draws CCW in Y-down
// so negating converts correctly, matching the Y-flip on currentTransform.
Arc2D.Double arc =
new Arc2D.Double(
geom.centerX * scale - geom.radius * scale,
geom.centerY * scale - geom.radius * scale,
2 * geom.radius * scale,
2 * geom.radius * scale,
Math.toDegrees(-geom.startAngle),
Math.toDegrees(-geom.angularExtent),
Arc2D.OPEN);
float strokeWidth = (float) (aperture.getStrokeWidth() * scale);
g2d.setStroke(
new BasicStroke(
strokeWidth > 0 ? strokeWidth : 1.0f, aperture.getCapStyle(), aperture.getJoinStyle()));
g2d.draw(arc);
g2d.setTransform(saved);
}
/**
* Non-circular aperture arc — Minkowski sweep approximation.
*/
@Override
protected void renderArcSweptRegion(
final Point2D startPoint,
final Point2D endPoint,
final Point2D centerOffset,
final Shape apertureShape,
final Polarity polarity,
final boolean clockwise,
final QuadrantMode quadrantMode) {
AffineTransform saved = g2d.getTransform();
g2d.setTransform(currentTransform);
setColor(polarity);
ArcGeometry geom = new ArcGeometry(startPoint, endPoint, centerOffset, clockwise, quadrantMode);
List<double[]> vertices = getShapeVertices(apertureShape, 0.01);
int segments = geom.approximationSegments();
double angleStep = geom.angularExtent / segments;
double prevX = startPoint.getX();
double prevY = startPoint.getY();
for (int i = 1; i <= segments; i++) {
double angle = geom.startAngle + i * angleStep;
double curX = geom.centerX + geom.radius * Math.cos(angle);
double curY = geom.centerY + geom.radius * Math.sin(angle);
fillSweptSegment(vertices, prevX, prevY, curX, curY);
prevX = curX;
prevY = curY;
}
g2d.setTransform(saved);
}
// ── Transform (LM/LR/LS) ─────────────────────────────────────────────────
@Override
public void setTransformation(final Double rotationDegrees, final Double scaleFactor, final Mirror mirroring) {
ApertureTransform t = new ApertureTransform(mirroring, rotationDegrees, scaleFactor);
currentTransform = new AffineTransform(baseTransform);
currentTransform.concatenate(t.toAffineTransform());
}
@Override
public void clearTransformation() {
currentTransform = new AffineTransform(baseTransform);
}
// ── Utilities ─────────────────────────────────────────────────────────────
/**
* Sets the Graphics2D color for the given polarity.
*
* @param polarity the polarity to set the color for
*/
private void setColor(final Polarity polarity) {
g2d.setColor(polarity == Polarity.DARK ? darkColor : clearColor);
}
/**
* Updates the scale factor.
*
* @param newScale the new scale factor
*/
public void setScale(final double newScale) {
this.scale = newScale;
}
}