ArcGeometry.java

package com.varnernet.gerb4j;

import java.awt.geom.Point2D;

/**
 * Utility class encapsulating the arc geometry computations shared by region building, bounds
 * calculation, and rendering.
 *
 * <p>Previously the arc angle math was duplicated across three call sites: {@code
 * GerberContext.addArcToRegion()}, {@code GerberContext.expandArcBounds()}, and {@code
 * Graphics2DTarget.drawArc()}. All three now delegate to this class.
 *
 * <p>All angles follow the Gerber / mathematical convention: Y-axis up, angles measured
 * counter-clockwise from the positive X axis, in radians.
 */
public final class ArcGeometry {

    private static final double RADIUS_TOLERANCE = 1e-9;
    private static final double RADIUS_MATCH_TOLERANCE = 0.05;
    private static final double TWO_PI = 2 * Math.PI;
    private static final double HALF_PI = Math.PI / 2;
    private static final double ANGLE_TOLERANCE = 1e-6;
    private static final int MIN_SEGMENTS = 4;
    private static final int SEGMENTS_PER_DEGREE = 1;
    private static final int CARDINAL_DIRECTIONS = 4;
    private static final int EXTENTS_MIN_X = 0;
    private static final int EXTENTS_MAX_X = 1;
    private static final int EXTENTS_MIN_Y = 2;
    private static final int EXTENTS_MAX_Y = 3;

    /**
     * Arc centre X in Gerber units.
     */
    public final double centerX;

    /**
     * Arc centre Y in Gerber units.
     */
    public final double centerY;

    /**
     * Arc radius in Gerber units.
     */
    public final double radius;

    /**
     * Start angle (radians, Y-up, CCW positive). This is the angle from the centre to the start
     * point.
     */
    public final double startAngle;

    /**
     * End angle (radians, Y-up, CCW positive). This is the angle from the centre to the end point.
     */
    public final double endAngle;

    /**
     * Signed angular extent of the arc (radians). Negative for CW (G02), positive for CCW (G03).
     */
    public final double angularExtent;

    /**
     * {@code true} = G02 clockwise, {@code false} = G03 counter-clockwise.
     */
    public final boolean clockwise;

    /**
     * Compute arc geometry from the raw Gerber parameters.
     *
     * @param startPoint   Arc start in Gerber coordinates (the current point <em>before</em> the D01
     *                     command).
     * @param endPoint     Arc end in Gerber coordinates (the D01 destination).
     * @param centerOffset Relative offset from {@code startPoint} to the arc centre (the I, J
     *                     parameters).
     * @param clockwise    {@code true} for G02 (clockwise), {@code false} for G03.
     */
    public ArcGeometry(
            final Point2D startPoint, final Point2D endPoint, final Point2D centerOffset, final boolean clockwise) {
        this(startPoint, endPoint, centerOffset, clockwise, QuadrantMode.MULTI);
    }

    /**
     * Compute arc geometry from the raw Gerber parameters, respecting single- vs multi-quadrant mode.
     *
     * <p>In <b>multi-quadrant mode</b> (G75) the I/J offset is a signed vector from the start point
     * to the arc centre. Arcs may span 0–360°.
     *
     * <p>In <b>single-quadrant mode</b> (G74) the absolute values of I and J give the offset
     * magnitude, and their signs encode which quadrant the arc centre lies in relative to the start
     * point. The arc must not exceed 90°. The correct centre is determined by trying all four sign
     * combinations of (|I|, |J|) and choosing the one that produces a valid arc (radius matches at
     * both endpoints, extent ≤ 90°, and correct CW/CCW direction).
     *
     * @param startPoint   Arc start in Gerber coordinates.
     * @param endPoint     Arc end in Gerber coordinates.
     * @param centerOffset Relative offset from startPoint to the arc centre.
     * @param clockwise    {@code true} for G02, {@code false} for G03.
     * @param quadrantMode {@link QuadrantMode#SINGLE} for G74, {@link QuadrantMode#MULTI} for G75.
     */
    public ArcGeometry(
            final Point2D startPoint,
            final Point2D endPoint,
            final Point2D centerOffset,
            final boolean clockwise,
            final QuadrantMode quadrantMode) {
        this.clockwise = clockwise;

        if (quadrantMode == QuadrantMode.SINGLE) {
            // ── Single-quadrant mode (G74) ──────────────────────────────────
            // I/J magnitudes are unsigned; try all four sign permutations to
            // find the centre that produces a valid ≤ 90° arc.
            double absI = Math.abs(centerOffset.getX());
            double absJ = Math.abs(centerOffset.getY());
            int[][] signs = {{1, 1}, {1, -1}, {-1, 1}, {-1, -1}};

            double bestCx = startPoint.getX() + absI;
            double bestCy = startPoint.getY() + absJ;
            double bestR = startPoint.distance(bestCx, bestCy);
            double bestSA = 0;
            double bestEA = 0;
            double bestExt = Double.MAX_VALUE;
            boolean found = false;

            for (int[] s : signs) {
                double cx = startPoint.getX() + s[0] * absI;
                double cy = startPoint.getY() + s[1] * absJ;
                double rStart = startPoint.distance(cx, cy);
                double rEnd = endPoint.distance(cx, cy);

                // Radii must match within tolerance
                if (rStart < RADIUS_TOLERANCE) {
                    continue;
                }
                if (Math.abs(rStart - rEnd) / rStart > RADIUS_MATCH_TOLERANCE) {
                    continue;
                }

                double sa = Math.atan2(startPoint.getY() - cy, startPoint.getX() - cx);
                double ea = Math.atan2(endPoint.getY() - cy, endPoint.getX() - cx);

                double ext = ea - sa;
                if (clockwise) {
                    if (ext > 0) {
                        ext -= TWO_PI;
                    }
                    if (ext == 0) {
                        ext = -TWO_PI;
                    }
                } else {
                    if (ext < 0) {
                        ext += TWO_PI;
                    }
                    if (ext == 0) {
                        ext = TWO_PI;
                    }
                }

                // Must be ≤ 90° (π/2) with a small tolerance
                double absExt = Math.abs(ext);
                if (absExt <= HALF_PI + ANGLE_TOLERANCE && absExt < Math.abs(bestExt)) {
                    bestCx = cx;
                    bestCy = cy;
                    bestR = rStart;
                    bestSA = sa;
                    bestEA = ea;
                    bestExt = ext;
                    found = true;
                }
            }

            this.centerX = bestCx;
            this.centerY = bestCy;
            this.radius = bestR;
            this.startAngle =
                    found ? bestSA : Math.atan2(startPoint.getY() - bestCy, startPoint.getX() - bestCx);
            this.endAngle =
                    found ? bestEA : Math.atan2(endPoint.getY() - bestCy, endPoint.getX() - bestCx);
            this.angularExtent =
                    found ? bestExt : clampSingleQuadrant(this.endAngle - this.startAngle, clockwise);
        } else {
            // ── Multi-quadrant mode (G75, default) ──────────────────────────
            this.centerX = startPoint.getX() + centerOffset.getX();
            this.centerY = startPoint.getY() + centerOffset.getY();
            this.radius = startPoint.distance(centerX, centerY);

            this.startAngle = Math.atan2(startPoint.getY() - centerY, startPoint.getX() - centerX);
            this.endAngle = Math.atan2(endPoint.getY() - centerY, endPoint.getX() - centerX);

            double extent = endAngle - startAngle;
            if (clockwise) {
                if (extent >= 0) {
                    extent -= TWO_PI;
                }
                if (extent == 0) {
                    extent = -TWO_PI;
                }
            } else {
                if (extent <= 0) {
                    extent += TWO_PI;
                }
                if (extent == 0) {
                    extent = TWO_PI;
                }
            }
            this.angularExtent = extent;
        }
    }

    /**
     * Clamp extent to ≤ 90° for single-quadrant fallback.
     *
     * @param extent    the extent to clamp
     * @param clockwise true if clockwise
     * @return the clamped extent
     */
    private static double clampSingleQuadrant(final double extent, final boolean clockwise) {
        if (clockwise) {
            if (extent > 0) {
                return -HALF_PI;
            }
            if (extent < -HALF_PI) {
                return -HALF_PI;
            }
        } else {
            if (extent < 0) {
                return HALF_PI;
            }
            if (extent > HALF_PI) {
                return HALF_PI;
            }
        }
        return extent;
    }

    /**
     * Number of line segments to use when approximating this arc. Uses at least 4 segments and 1 per
     * degree of arc.
     *
     * @return the number of segments
     */
    public int approximationSegments() {
        return Math.max(MIN_SEGMENTS, (int) Math.ceil(Math.abs(Math.toDegrees(angularExtent))));
    }

    /**
     * Expands the extents array {@code [minX, maxX, minY, maxY]} to enclose this arc including a
     * stroke half-width padding on all sides.
     *
     * <p>The extremes occur at the start/end points and at any cardinal angles (0°, 90°, 180°, 270°)
     * that fall within the swept arc.
     *
     * @param strokeRadius the half-width of the stroke to add as padding
     * @param extents      the extents array to expand: [minX, maxX, minY, maxY]
     */
    public void expandBounds(final double strokeRadius, final double[] extents) {
        // Always include start and end with stroke
        expandPoint(
                centerX + radius * Math.cos(startAngle),
                centerY + radius * Math.sin(startAngle),
                strokeRadius,
                extents);
        expandPoint(
                centerX + radius * Math.cos(endAngle),
                centerY + radius * Math.sin(endAngle),
                strokeRadius,
                extents);

        boolean fullCircle = (radius < RADIUS_TOLERANCE) || (Math.abs(angularExtent) >= TWO_PI - RADIUS_TOLERANCE);

        // Cardinal directions: 0° → +X, 90° → +Y, 180° → −X, 270° → −Y
        double[] cardinalAngles = {0, Math.PI / 2, Math.PI, -Math.PI / 2};
        double[][] cardinalOffsets = {{radius, 0}, {0, radius}, {-radius, 0}, {0, -radius}};
        for (int i = 0; i < CARDINAL_DIRECTIONS; i++) {
            if (fullCircle || angleInArc(cardinalAngles[i])) {
                expandPoint(
                        centerX + cardinalOffsets[i][0],
                        centerY + cardinalOffsets[i][1],
                        strokeRadius,
                        extents);
            }
        }
    }

    /**
     * Tests whether {@code angle} (radians, un-normalised) lies within the arc swept by this
     * geometry.
     *
     * @param angle the angle to test in radians
     * @return true if the angle is within the arc
     */
    public boolean angleInArc(final double angle) {
        double a = normalizeAngle(angle);
        double sa = normalizeAngle(startAngle);
        double ea = normalizeAngle(endAngle);

        if (clockwise) {
            // CW sweep: angles decrease from start to end
            if (sa >= ea) {
                return a <= sa && a >= ea;
            } else {
                return a <= sa || a >= ea;
            }
        } else {
            // CCW sweep: angles increase from start to end
            if (ea >= sa) {
                return a >= sa && a <= ea;
            } else {
                return a >= sa || a <= ea;
            }
        }
    }

    // ── Static helpers ─────────────────────────────────────────────────────────

    /**
     * Normalise an angle (radians) to [0, 2π).
     *
     * @param a the angle to normalize
     * @return the normalized angle
     */
    public static double normalizeAngle(final double a) {
        double normalized = a % TWO_PI;
        if (normalized < 0) {
            normalized += TWO_PI;
        }
        return normalized;
    }

    private static void expandPoint(final double x, final double y, final double r, final double[] extents) {
        extents[EXTENTS_MIN_X] = Math.min(extents[EXTENTS_MIN_X], x - r);
        extents[EXTENTS_MAX_X] = Math.max(extents[EXTENTS_MAX_X], x + r);
        extents[EXTENTS_MIN_Y] = Math.min(extents[EXTENTS_MIN_Y], y - r);
        extents[EXTENTS_MAX_Y] = Math.max(extents[EXTENTS_MAX_Y], y + r);
    }
}