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