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;
    }
}