TemplateAperture.java

package com.varnernet.gerb4j;

import com.varnernet.gerb4j.macro.MacroPrimitive;
import com.varnernet.gerb4j.macro.MacroPrimitiveFactory;
import com.varnernet.gerb4j.render.AreaTarget;
import com.varnernet.gerb4j.render.GerberOutputTarget;

import java.awt.geom.Area;
import java.awt.geom.Path2D;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * A macro-based aperture (Gerber {@code %ADD…macroName…%}).
 *
 * <p>At construction time the macro's primitives are fully evaluated and parsed into typed {@link
 * MacroPrimitive} objects. Subsequent calls to {@link #getBounds()} and {@link #render(Point2D,
 * GerberOutputTarget, Polarity)} delegate directly to those objects — no raw string processing
 * occurs at render time and no external helper class is needed.
 *
 * <h2>Area-based composition</h2>
 *
 * <p>Macro primitives are <em>not</em> rendered individually to the output target. Instead, an
 * internal {@link com.varnernet.gerb4j.render.AreaTarget} intercepts every draw call and composes the primitives into a
 * single {@link Area} using boolean add/subtract based on each primitive's effective polarity. The
 * final composite shape is then sent to the real target as <b>one</b> {@code drawRegion()} call.
 * This eliminates sub-pixel rasterisation mismatches between different shape types ({@code
 * Ellipse2D} vs {@code Path2D}) that caused dark-dot artefacts at shape boundaries.
 */
public final class TemplateAperture extends Aperture {

    private static final double EMPTY_APERTURE_RADIUS = 0.1;

    private final String templateName;
    private final List<String> parameters;
    private final MacroDefinition macro;
    private final Map<Integer, Double> evaluatedVariables;

    /**
     * Pre-parsed, fully-resolved primitives ready for bounds and rendering.
     */
    private final List<MacroPrimitive> resolvedPrimitives;

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

    /**
     * Full constructor — both macro and evaluated variables known at construction. This is the
     * preferred constructor; it produces a fully initialised object.
     *
     * @param id                 the aperture identifier
     * @param templateName       the name of the macro template
     * @param parameters         the list of parameter strings
     * @param macro              the macro definition
     * @param evaluatedVariables the evaluated variable values
     */
    public TemplateAperture(
            final String id,
            final String templateName,
            final List<String> parameters,
            final MacroDefinition macro,
            final Map<Integer, Double> evaluatedVariables) {
        super(id);
        this.templateName = templateName;
        this.parameters = parameters;
        this.macro = macro;
        this.evaluatedVariables =
                evaluatedVariables != null
                        ? Collections.unmodifiableMap(new HashMap<>(evaluatedVariables))
                        : Collections.emptyMap();
        this.resolvedPrimitives =
                MacroPrimitiveFactory.parse(macro, this.evaluatedVariables, parameters);
    }

    // ── Accessors ─────────────────────────────────────────────────────────────

    /**
     * Returns the name of the macro template.
     *
     * @return the template name
     */
    public String getTemplateName() {
        return templateName;
    }

    /**
     * Returns the list of parameter strings.
     *
     * @return the parameters
     */
    public List<String> getParameters() {
        return parameters;
    }

    /**
     * Returns the macro definition.
     *
     * @return the macro
     */
    public MacroDefinition getMacro() {
        return macro;
    }

    /**
     * Returns the evaluated variable values.
     *
     * @return the evaluated variables
     */
    public Map<Integer, Double> getEvaluatedVariables() {
        return evaluatedVariables;
    }

    /**
     * Returns the pre-parsed, resolved primitives (immutable list).
     *
     * @return the resolved primitives
     */
    public List<MacroPrimitive> getResolvedPrimitives() {
        return resolvedPrimitives;
    }

    // ── Geometry ──────────────────────────────────────────────────────────────

    @Override
    public Rectangle2D getBounds() {
        Rectangle2D result = null;
        for (MacroPrimitive p : resolvedPrimitives) {
            Rectangle2D b = p.getBounds();
            if (b != null) {
                result = (result == null) ? b : result.createUnion(b);
            }
        }
        return result;
    }

    // ── Rendering ─────────────────────────────────────────────────────────────

    /**
     * Renders this macro aperture by composing all primitives into a single {@link Area} via {@link
     * AreaTarget} and emitting one {@code drawRegion()} call.
     *
     * <p>Each primitive's draw calls are captured by an {@link AreaTarget} which adds or subtracts
     * each shape depending on whether the primitive's effective polarity matches the base polarity:
     *
     * <ul>
     *   <li>Same polarity → {@code Area.add()}
     *   <li>Opposite polarity → {@code Area.subtract()}
     * </ul>
     */
    @Override
    public void render(final Point2D flashPoint, final GerberOutputTarget target, final Polarity polarity) {
        if (resolvedPrimitives.isEmpty()) {
            target.drawCircle(flashPoint, EMPTY_APERTURE_RADIUS, 0.0, polarity);
            return;
        }

        AreaTarget collector = new AreaTarget(polarity);
        for (MacroPrimitive p : resolvedPrimitives) {
            p.render(flashPoint, collector, polarity);
        }

        Area result = collector.getArea();
        if (!result.isEmpty()) {
            target.drawRegion(new Path2D.Double(result), polarity);
        }
    }
}