ApertureTransform.java
package com.varnernet.gerb4j;
import java.awt.geom.AffineTransform;
/**
* Immutable value object that groups the three load-transform parameters — mirroring (LM), rotation
* (LR), and scaling (LS) — that are applied together when a flash or block is rendered.
*
* <p>Replaces the three scattered {@code Mirror mirroring}, {@code Double rotation}, {@code Double
* scaling} fields that previously appeared independently in {@link
* com.varnernet.gerb4j.render.DrawingOperation}, {@link com.varnernet.gerb4j.render.RegionPath},
* and {@link GraphicsState}.
*
* @param mirroring the mirroring
* @param rotation degrees, CCW positive (Gerber convention)
* @param scaling 1.0 = no scaling
*/
public record ApertureTransform(Mirror mirroring, double rotation, double scaling) {
/**
* Compact canonical constructor — normalises {@code null} mirroring to {@link Mirror#NONE}.
*/
public ApertureTransform {
mirroring = (mirroring != null) ? mirroring : Mirror.NONE;
}
/**
* The identity transform (no mirror, no rotation, no scaling).
*/
public static final ApertureTransform IDENTITY = new ApertureTransform(Mirror.NONE, 0.0, 1.0);
/**
* Constructor with nullable parameters.
*
* @param mirroring the mirroring
* @param rotation the rotation
* @param scaling the scaling
*/
public ApertureTransform(final Mirror mirroring, final Double rotation, final Double scaling) {
this(mirroring != null ? mirroring : Mirror.NONE, rotation != null ? rotation : 0.0, scaling != null ? scaling : 1.0);
}
// ── Accessors ─────────────────────────────────────────────────────────────
/**
* Returns {@code true} if this transform is effectively the identity — no mirror, no rotation (or
* 0°), and scale = 1.0.
*
* @return {@code true} if this transform is the identity
*/
public boolean isIdentity() {
return mirroring == Mirror.NONE && rotation == 0.0 && scaling == 1.0;
}
// ── Transform construction ─────────────────────────────────────────────────
/**
* Builds an {@link AffineTransform} that applies mirror → rotation → scaling about the origin, in
* that order on the coordinates.
*
* <p>This is the canonical implementation that previously appeared in both {@code
* GerberContext.buildBlockTransform()} and {@code Graphics2DTarget.setTransformation()} —
* consolidated here so it cannot diverge.
*
* @return an {@link AffineTransform} encoding this aperture transform
*/
public AffineTransform toAffineTransform() {
AffineTransform t = new AffineTransform();
// AffineTransform concatenates in reverse: the last pre-concatenated
// operation is applied first to coordinates.
if (scaling != 1.0) {
t.scale(scaling, scaling);
}
if (rotation != 0.0) {
t.rotate(Math.toRadians(rotation));
}
if (mirroring != null && mirroring != Mirror.NONE) {
switch (mirroring) {
case X:
t.scale(-1, 1);
break;
case Y:
t.scale(1, -1);
break;
case XY:
t.scale(-1, -1);
break;
default:
break;
}
}
return t;
}
/**
* A single mirror axis reverses handedness, flipping CW arcs to CCW and vice-versa. Two mirrors
* (XY) cancel out.
*
* @return {@code true} if arc direction should be flipped due to mirroring
*/
public boolean shouldFlipArcDirection() {
return mirroring == Mirror.X || mirroring == Mirror.Y;
}
@Override
public String toString() {
return String.format(
"ApertureTransform[mirror=%s, rot=%.2f°, scale=%.4f]", mirroring, rotation, scaling);
}
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (!(o instanceof ApertureTransform(Mirror mirroring1, double rotation1, double scaling1))) {
return false;
}
return mirroring == mirroring1
&& Double.compare(rotation, rotation1) == 0
&& Double.compare(scaling, scaling1) == 0;
}
@Override
public int hashCode() {
int result = mirroring.hashCode();
result = 31 * result + Double.hashCode(rotation);
result = 31 * result + Double.hashCode(scaling);
return result;
}
}