AreaTarget.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.Shape;
import java.awt.geom.AffineTransform;
import java.awt.geom.Arc2D;
import java.awt.geom.Area;
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.ArrayList;
import java.util.Collections;
import java.util.List;

/**
 * A {@link GerberOutputTarget} that composes all Gerber operations into a single {@link Area} in
 * <em>Gerber coordinate space</em> (Y-up, no pixel scale, no axis flip).
 *
 * <p>Extends {@link AbstractGerberTarget} which provides the geometry algorithms ({@link
 * #convexHull}, {@link #getShapeVertices}) and the {@link #drawPath}/ {@link #drawArc} dispatch
 * logic. This class is responsible only for the Area-specific concern: composing shapes via boolean
 * add/subtract.
 *
 * <p>DARK polarity operations add material ({@code Area.add()}); CLEAR polarity operations subtract
 * material ({@code Area.subtract()}).
 *
 * <p>A list of individual {@link PolarizedShape} objects is also maintained so that CAM consumers
 * can reason about discrete features without having to decompose the fused {@link Area}.
 */
public class AreaTarget extends AbstractGerberTarget {
    /**
     * Single fused shape: the boolean combination of all operations.
     */
    private final Area area = new Area();

    /**
     * Individual shapes with polarity metadata, for CAM consumers.
     */
    private final List<PolarizedShape> shapes = new ArrayList<>();

    /**
     * The polarity that adds material (normally {@link Polarity#DARK}).
     */
    private final Polarity basePolarity;

    /**
     * Current aperture transform, or {@code null} for identity.
     */
    private AffineTransform currentTransform = null;

    // ── Constructors ──────────────────────────────────────────────────────────

    /**
     * Constructs an {@code AreaTarget} with {@link Polarity#DARK} as the base.
     */
    public AreaTarget() {
        this(Polarity.DARK);
    }

    /**
     * Constructs an {@code AreaTarget} with an explicit base polarity.
     *
     * @param basePolarity the polarity that adds material ({@code Area.add()}); the opposite polarity
     *                     will subtract.
     */
    public AreaTarget(final Polarity basePolarity) {
        this.basePolarity = basePolarity;
    }

    // ── Results ───────────────────────────────────────────────────────────────

    /**
     * Returns a defensive copy of the composite area of all operations rendered so far.
     *
     * @return the composite area
     */
    public Area getArea() {
        return (Area) area.clone();
    }

    /**
     * Returns an unmodifiable view of all individual {@link PolarizedShape} objects in emission
     * order.
     *
     * @return the list of shapes
     */
    public List<PolarizedShape> getShapes() {
        return Collections.unmodifiableList(shapes);
    }

    // ── Core composition ──────────────────────────────────────────────────────

    /**
     * Applies the current aperture transform to {@code shape}, records it in the {@link
     * PolarizedShape} list, and boolean-adds or subtracts it from the composite area.
     *
     * @param shape    the shape to compose
     * @param polarity the polarity of the shape
     */
    private void compose(final Shape shape, final Polarity polarity) {
        Shape transformed =
                (currentTransform != null) ? currentTransform.createTransformedShape(shape) : shape;
        shapes.add(new PolarizedShape(transformed, polarity));
        if (polarity == basePolarity) {
            area.add(new Area(transformed));
        } else {
            area.subtract(new Area(transformed));
        }
    }

    // ── Flash operations (D03) ────────────────────────────────────────────────
    @Override
    public void drawCircle(
            final Point2D center, final double diameter, final double rotationDegrees, final Polarity polarity) {
        double r = diameter / 2.0;
        double cx = center.getX();
        double cy = center.getY();
        Shape circle = new Ellipse2D.Double(cx - r, cy - r, diameter, diameter);
        if (rotationDegrees != 0) {
            circle =
                    AffineTransform.getRotateInstance(Math.toRadians(rotationDegrees), cx, cy)
                            .createTransformedShape(circle);
        }
        compose(circle, polarity);
    }

    @Override
    public void drawRectangle(
            final Point2D center, final double width, final double height, final double rotationDegrees, final Polarity polarity) {
        double cx = center.getX();
        double cy = center.getY();
        Shape rect = new Rectangle2D.Double(cx - width / 2.0, cy - height / 2.0, width, height);
        if (rotationDegrees != 0) {
            rect =
                    AffineTransform.getRotateInstance(Math.toRadians(rotationDegrees), cx, cy)
                            .createTransformedShape(rect);
        }
        compose(rect, polarity);
    }

    @Override
    public void drawObround(
            final Point2D center, final double width, final double height, final double rotationDegrees, final Polarity polarity) {
        double cx = center.getX();
        double cy = center.getY();
        double cornerDiameter = Math.min(width, height);
        Shape obround =
                new RoundRectangle2D.Double(
                        cx - width / 2.0, cy - height / 2.0, width, height, cornerDiameter, cornerDiameter);
        if (rotationDegrees != 0) {
            obround =
                    AffineTransform.getRotateInstance(Math.toRadians(rotationDegrees), cx, cy)
                            .createTransformedShape(obround);
        }
        compose(obround, polarity);
    }

    @Override
    public void drawPolygon(
            final Point2D center, final int numVertices, final double diameter, final double rotationDegrees, final Polarity polarity) {
        double radius = diameter / 2.0;
        double startAngle = Math.toRadians(rotationDegrees);
        double cx = center.getX();
        double cy = center.getY();
        Path2D.Double path = new Path2D.Double();
        for (int i = 0; i < numVertices; i++) {
            double angle = startAngle + 2.0 * Math.PI * i / numVertices;
            double x = cx + radius * Math.cos(angle);
            double y = cy + radius * Math.sin(angle);
            if (i == 0) {
                path.moveTo(x, y);
            } else {
                path.lineTo(x, y);
            }
        }
        path.closePath();
        compose(path, polarity);
    }

    // ── Linear draw hooks (called by AbstractGerberTarget.drawPath) ───────────
    @Override
    protected void renderPathStroked(final Path2D.Double path, final Aperture aperture, final Polarity polarity) {
        float strokeWidth = (float) aperture.getStrokeWidth();
        BasicStroke stroke =
                new BasicStroke(
                        strokeWidth > 0 ? strokeWidth : 0.001f,
                        aperture.getCapStyle(),
                        aperture.getJoinStyle());
        compose(stroke.createStrokedShape(path), polarity);
    }

    @Override
    protected void renderPathSweptRegion(final Path2D.Double path, final Shape apertureShape, final Polarity 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) {
                compose(sweptSegmentHull(vertices, lastX, lastY, coords[0], coords[1]), polarity);
                lastX = coords[0];
                lastY = coords[1];
            }
            it.next();
        }
    }

    // ── Arc hooks (called by AbstractGerberTarget.drawArc) ────────────────────
    @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) {
        ArcGeometry geom = new ArcGeometry(startPoint, endPoint, centerOffset, clockwise, quadrantMode);
        Arc2D.Double arc =
                new Arc2D.Double(
                        geom.centerX - geom.radius,
                        geom.centerY - geom.radius,
                        2.0 * geom.radius,
                        2.0 * geom.radius,
                        Math.toDegrees(-geom.startAngle),
                        Math.toDegrees(-geom.angularExtent),
                        Arc2D.OPEN);
        float strokeWidth = (float) aperture.getStrokeWidth();
        BasicStroke stroke =
                new BasicStroke(
                        strokeWidth > 0 ? strokeWidth : 0.001f,
                        aperture.getCapStyle(),
                        aperture.getJoinStyle());
        compose(stroke.createStrokedShape(arc), polarity);
    }

    @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) {
        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);
            compose(sweptSegmentHull(vertices, prevX, prevY, curX, curY), polarity);
            prevX = curX;
            prevY = curY;
        }
    }

    // ── Region (G36/G37) ──────────────────────────────────────────────────────
    @Override
    public void drawRegion(final Path2D.Double region, final Polarity polarity) {
        compose(region, polarity);
    }

    // ── Aperture transforms (LM/LR/LS) ───────────────────────────────────────
    @Override
    public void setTransformation(final Double rotationDegrees, final Double scaleFactor, final Mirror mirroring) {
        ApertureTransform t = new ApertureTransform(mirroring, rotationDegrees, scaleFactor);
        currentTransform = t.toAffineTransform();
    }

    @Override
    public void clearTransformation() {
        currentTransform = null;
    }

    // ── Private geometry helpers ──────────────────────────────────────────────

    /**
     * Builds the convex hull of the aperture shape translated to both endpoints — Minkowski sum in
     * Gerber coordinate units.
     *
     * @param apertureVertices the vertices of the aperture shape
     * @param x1               the x coordinate of the first endpoint
     * @param y1               the y coordinate of the first endpoint
     * @param x2               the x coordinate of the second endpoint
     * @param y2               the y coordinate of the second endpoint
     * @return the convex hull path
     */
    private static Path2D.Double sweptSegmentHull(
            final List<double[]> apertureVertices, final double x1, final double y1, final double x2, final double y2) {
        int n = apertureVertices.size();
        double[][] points = new double[2 * n][2];
        for (int i = 0; i < n; i++) {
            double ax = apertureVertices.get(i)[0];
            double ay = apertureVertices.get(i)[1];
            points[i][0] = ax + x1;
            points[i][1] = ay + y1;
            points[n + i][0] = ax + x2;
            points[n + i][1] = ay + y2;
        }
        return convexHull(points);
    }
}