Aperture.java

package com.varnernet.gerb4j;

import com.varnernet.gerb4j.render.GerberOutputTarget;

import java.awt.BasicStroke;
import java.awt.Shape;
import java.awt.geom.AffineTransform;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.util.Collections;
import java.util.List;
import java.util.Map;

/**
 * Base class for all Gerber aperture types.
 *
 * <p>Each subclass knows its own geometry and is responsible for reporting it <em>and</em> for
 * rendering itself. {@link #getBounds()} returns the aperture's extent in local aperture
 * coordinates (centred at the origin, before any flash translation). {@link #getStrokeWidth()}
 * returns the effective pen width when this aperture is used for a linear draw (D01). {@link
 * #render(Point2D, GerberOutputTarget, Polarity)} draws the aperture body at the given flash point,
 * then punches any circular hole (if present) as a CLEAR overlay โ€” the same behaviour that was
 * previously duplicated four times in {@code GerberContext.renderAperture()}.
 */
public abstract class Aperture {
    private final String id;

    /**
     * Aperture attributes captured at AD time (snapshot of the aperture attribute dictionary).
     */
    private Map<String, List<String>> attributes = Collections.emptyMap();

    /**
     * Constructs an aperture with the given identifier.
     *
     * @param id the aperture identifier (e.g. {@code "D10"})
     */
    protected Aperture(final String id) {
        this.id = id;
    }

    /**
     * Returns the aperture ID.
     *
     * @return the aperture ID
     */
    public String getId() {
        return id;
    }

    /**
     * Attach a snapshot of the aperture attribute dictionary to this aperture. Per Gerber spec ยง5.3,
     * "the AD command attaches the aperture attributes at that moment in the attribute dictionary to
     * it."
     *
     * @param attrs an immutable snapshot โ€” should not be modified after attachment.
     */
    public void setAttributes(final Map<String, List<String>> attrs) {
        this.attributes = attrs != null ? attrs : Collections.emptyMap();
    }

    /**
     * Returns the full attribute map (unmodifiable).
     *
     * @return the attribute map
     */
    public Map<String, List<String>> getAttributes() {
        return Collections.unmodifiableMap(attributes);
    }

    /**
     * Returns the values for a single attribute name, or {@code null} if not present.
     *
     * @param name the attribute name
     * @return the attribute values
     */
    public List<String> getAttribute(final String name) {
        return attributes.getOrDefault(name, null);
    }

    /**
     * Returns this aperture's bounding box in local aperture coordinates, centred at (0, 0). Callers
     * translate by the flash point to get world-space extents.
     *
     * <p>Returns {@code null} for aperture types whose bounds cannot be determined without additional
     * context (e.g. {@link BlockAperture}).
     *
     * @return the bounding box or null
     */
    public abstract Rectangle2D getBounds();

    /**
     * Returns the effective stroke width (in Gerber units) when this aperture is used to draw a
     * linear path (D01). Defaults to 0.0 (hairline).
     *
     * @return the stroke width
     */
    public double getStrokeWidth() {
        return 0.0;
    }

    /**
     * Returns the {@link BasicStroke} cap style to use when this aperture is the pen for a D01 linear
     * draw.
     *
     * <p>Circular and obround apertures produce round end caps; rectangle apertures produce flat
     * (butt) end caps. Subclasses override as needed.
     *
     * @return one of {@link BasicStroke#CAP_ROUND}, {@link BasicStroke#CAP_BUTT}, or {@link
     * BasicStroke#CAP_SQUARE}.
     */
    public int getCapStyle() {
        return BasicStroke.CAP_ROUND;
    }

    /**
     * Returns the aperture's cross-section outline as a {@link Shape} in local coordinates (centred
     * at the origin). Used for swept-region rendering of D01 draws where the exact Minkowski sum of
     * the aperture and the path segment is computed and filled, rather than approximated with a
     * {@link BasicStroke}.
     *
     * <p>Returns {@code null} (the default) when {@link BasicStroke} rendering is sufficient โ€” e.g.
     * for {@link CircleAperture} where {@code CAP_ROUND} already produces the correct swept shape.
     *
     * @return a convex {@link Shape} representing the aperture cross-section, or {@code null} to fall
     * back to stroke-based rendering.
     */
    public Shape getApertureShape() {
        return null;
    }

    /**
     * Returns the {@link BasicStroke} join style to use when consecutive D01 line segments share a
     * vertex.
     *
     * <p>For circular/obround apertures the overlap of two round-capped segments produces a filled
     * circle at the vertex, matching {@link BasicStroke#JOIN_ROUND}. Rectangle apertures override
     * this to {@link BasicStroke#JOIN_MITER}.
     *
     * @return one of {@link BasicStroke#JOIN_ROUND}, {@link BasicStroke#JOIN_MITER}, or {@link
     * BasicStroke#JOIN_BEVEL}.
     */
    public int getJoinStyle() {
        return BasicStroke.JOIN_ROUND;
    }

    /**
     * Render this aperture as a flash (D03) at {@code flashPoint}.
     *
     * <p>The default implementation does nothing; concrete subclasses override this to draw their
     * body shape and, if applicable, punch a circular hole.
     *
     * @param flashPoint The world-space position at which the aperture origin is placed.
     * @param target     The output target that receives the drawing calls.
     * @param polarity   The effective polarity (DARK or CLEAR).
     */
    public void render(final Point2D flashPoint, final GerberOutputTarget target, final Polarity polarity) {
        // Default: nothing rendered (e.g. BlockAperture handled by the orchestrator)
    }

    /**
     * Render this aperture as a flash (D03), additionally supplying the aperture transformation from
     * the enclosing drawing operation.
     *
     * <p>For most aperture types this simply delegates to {@link #render(Point2D, GerberOutputTarget,
     * Polarity)}. {@link BlockAperture} overrides this to recursively render its child operations
     * through the injected {@code BlockRenderer} callback, passing the {@code transform} so that
     * block-aperture LM/LR/LS are honoured.
     *
     * @param flashPoint The world-space position at which the aperture origin is placed.
     * @param target     The output target that receives the drawing calls.
     * @param polarity   The effective polarity (DARK or CLEAR).
     * @param transform  The aperture transform recorded with the flash operation.
     */
    public void renderFlash(
            final Point2D flashPoint,
            final GerberOutputTarget target,
            final Polarity polarity,
            final ApertureTransform transform) {
        render(flashPoint, target, polarity);
    }

    /**
     * Expand {@code extents} to encompass this aperture when flashed at ({@code x}, {@code y}) with
     * the given block transform.
     *
     * <p>The default implementation translates {@link #getBounds()} by the flash point. When {@link
     * #getBounds()} returns {@code null} the flash point itself is used as a zero-size extent. {@link
     * BlockAperture} overrides this to recurse into its child operations via the injected {@code
     * BoundsExpander} callback.
     *
     * @param x         Flash X in world coordinates.
     * @param y         Flash Y in world coordinates.
     * @param transform Block-level affine transform already applied to produce ({@code x},{@code y}).
     * @param extents   Running [minX, maxX, minY, maxY] array updated in place.
     */
    public void expandFlashBounds(final double x, final double y, final AffineTransform transform, final double[] extents) {
        Rectangle2D localBounds = getBounds();
        if (localBounds != null) {
            extents[0] = Math.min(extents[0], x + localBounds.getMinX());
            extents[1] = Math.max(extents[1], x + localBounds.getMaxX());
            extents[2] = Math.min(extents[2], y + localBounds.getMinY());
            extents[3] = Math.max(extents[3], y + localBounds.getMaxY());
        } else {
            extents[0] = Math.min(extents[0], x);
            extents[1] = Math.max(extents[1], x);
            extents[2] = Math.min(extents[2], y);
            extents[3] = Math.max(extents[3], y);
        }
    }

    /**
     * Helper: return the inverted polarity (used when punching the hole).
     *
     * @param p the polarity
     * @return the inverted polarity
     */
    protected static Polarity invertPolarity(final Polarity p) {
        return p == Polarity.DARK ? Polarity.CLEAR : Polarity.DARK;
    }
}