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