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