ThermalMacroPrimitive.java

package com.varnernet.gerb4j.macro;

import com.varnernet.gerb4j.Polarity;
import com.varnernet.gerb4j.render.GerberOutputTarget;

import java.awt.geom.Area;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Path2D;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;

/**
 * Gerber macro primitive type 7 — Thermal relief. Format: {@code
 * 7,centerX,centerY,outerDiameter,innerDiameter,gapThickness,rotation}
 *
 * <p>Per spec §4.5.1.7, a thermal relief is an annular ring (outer circle minus inner circle) with
 * <em>four gap cutouts</em> arranged in a cross pattern. The cutouts are subtracted via {@link
 * Area} boolean operations — one at {@code rotation} degrees and one at {@code rotation + 90°} —
 * each spanning the full outer diameter with height {@code gapThickness}.
 *
 * <p>The thermal is constructed as a single composite {@link Area} and rendered in one {@code
 * drawRegion()} call. This avoids rasterization mismatches between {@link Ellipse2D} and {@link
 * Path2D} that caused sub-pixel dark-dot artifacts at the gap tips when the shape was previously
 * painted as a sequence of DARK circle → CLEAR circle → CLEAR rectangles.
 */
final class ThermalMacroPrimitive implements MacroPrimitive {

    private static final double NINETY_DEGREES = 90.0;
    private static final int FOUR_CORNERS = 4;

    private final double centerX;
    private final double centerY;
    private final double outerDiameter;
    private final double innerDiameter;
    private final double gapThickness;
    private final double rotation;

    ThermalMacroPrimitive(
            final double centerX,
            final double centerY,
            final double outerDiameter,
            final double innerDiameter,
            final double gapThickness,
            final double rotation) {
        this.centerX = centerX;
        this.centerY = centerY;
        this.outerDiameter = outerDiameter;
        this.innerDiameter = innerDiameter;
        this.gapThickness = gapThickness;
        this.rotation = rotation;
    }

    @Override
    public Rectangle2D getBounds() {
        double r = outerDiameter / 2.0;
        return new Rectangle2D.Double(centerX - r, centerY - r, outerDiameter, outerDiameter);
    }

    @Override
    public void render(final Point2D flashPoint, final GerberOutputTarget target, final Polarity polarity) {
        double cx = flashPoint.getX() + centerX;
        double cy = flashPoint.getY() + centerY;
        double outerR = outerDiameter / 2.0;
        double innerR = innerDiameter / 2.0;

        // Build the thermal as a single composite Area:
        //   outer circle – inner circle – gap rect 1 – gap rect 2
        // This avoids sub-pixel rasterization mismatches between Ellipse2D and
        // Path2D that caused dark-dot artifacts at gap tips.
        Area thermal =
                new Area(new Ellipse2D.Double(cx - outerR, cy - outerR, outerDiameter, outerDiameter));

        thermal.subtract(
                new Area(new Ellipse2D.Double(cx - innerR, cy - innerR, innerDiameter, innerDiameter)));

        thermal.subtract(new Area(createGapRect(cx, cy, outerDiameter, gapThickness, rotation)));
        thermal.subtract(new Area(createGapRect(cx, cy, outerDiameter, gapThickness, rotation + NINETY_DEGREES)));

        target.drawRegion(new Path2D.Double(thermal), polarity);
    }

    /**
     * Create a filled rectangle of size {@code length × gap} centred at {@code (cx, cy)}, rotated CCW
     * by {@code rotDeg} degrees.
     *
     * @param cx     the center x coordinate
     * @param cy     the center y coordinate
     * @param length the length of the rectangle
     * @param gap    the gap thickness
     * @param rotDeg the rotation in degrees
     * @return the created gap rectangle path
     */
    private static Path2D.Double createGapRect(
            final double cx, final double cy, final double length, final double gap, final double rotDeg) {
        double hw = length / 2.0;
        double hg = gap / 2.0;

        // Four corners in un-rotated local space
        double[][] local = {
                {-hw, -hg},
                {hw, -hg},
                {hw, hg},
                {-hw, hg},
        };

        Path2D.Double path = new Path2D.Double();
        for (int i = 0; i < FOUR_CORNERS; i++) {
            double[] rot = AbstractMacroPrimitive.rotatePoint(local[i][0], local[i][1], rotDeg);
            double wx = cx + rot[0];
            double wy = cy + rot[1];
            if (i == 0) {
                path.moveTo(wx, wy);
            } else {
                path.lineTo(wx, wy);
            }
        }
        path.closePath();
        return path;
    }
}