GerberContext.java
package com.varnernet.gerb4j;
import com.varnernet.gerb4j.render.BlockOperation;
import com.varnernet.gerb4j.render.DrawingOperation;
import com.varnernet.gerb4j.render.GerberOperation;
import com.varnernet.gerb4j.render.GerberOutputTarget;
import com.varnernet.gerb4j.render.RegionPath;
import java.awt.geom.AffineTransform;
import java.awt.geom.Path2D;
import java.awt.geom.PathIterator;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Stack;
import java.util.logging.Logger;
/**
* Parse-time accumulator for a single Gerber file.
*
* <p>Responsibilities (after refactoring):
*
* <ul>
* <li>Aperture and macro dictionaries
* <li>Format specification and unit
* <li>Current graphics state (point, aperture, polarity, transform)
* <li>Graphics-state save/restore stack
* <li>Operation recording and scoping (AB / SR blocks)
* <li>Region building (G36/G37)
* <li>Bounds computation
* <li>Rendering orchestration
* </ul>
*/
public final class GerberContext {
private static final Logger LOG = Logger.getLogger(GerberContext.class.getName());
// Bounds computation constants
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;
private static final int PATH_ITERATOR_COORDS = 6;
// ── Parse-time mutable state ──────────────────────────────────────────────
private Mode unit;
private FormatSpecification format;
private Point2D currentPoint;
private String currentApertureId;
private Polarity polarity;
private Mirror mirroring;
private Double rotation;
private Double scaling;
/**
* Global aperture dictionary. Aperture definitions (AD, AB) accumulate here across the entire
* file and are <strong>not</strong> affected by graphics-state save/restore.
*/
private final Map<String, Aperture> apertureDictionary;
private final Map<String, MacroDefinition> macroDictionary;
/**
* Graphics state stack (per Ucamco specification page 15).
*/
private final Stack<GraphicsState> stateStack;
/**
* Top-level operation list — DrawingOperation, RegionPath, or BlockOperation.
*/
private final List<GerberOperation> topLevelOperations;
/**
* Shadow list of all RegionPath objects (in source order). Kept separately so that {@link
* #getRegions()} can return them without iterating the full operation tree.
*/
private final List<RegionPath> regions;
/**
* In-progress region path (null when not inside G36…G37).
*/
private Path2D.Double currentRegion;
private Polarity regionPolarity;
private Mirror regionMirroring;
private Double regionRotation;
private Double regionScaling;
/**
* Operation scope stack. While parsing an AB or SR block, a new scope is pushed here; all
* recorded operations go to the top scope rather than {@link #topLevelOperations}. When the block
* ends the scope is popped.
*/
private final Deque<List<GerberOperation>> operationScopeStack;
/**
* In-region flag for G36/G37 commands.
*/
private boolean inRegion;
/**
* Interpolation mode tracking (for G01/G02/G03).
*/
private InterpolationMode interpolationMode;
/**
* Quadrant mode tracking (G74 = single, G75 = multi).
*/
private QuadrantMode quadrantMode;
// ── Attribute dictionaries (§5) ───────────────────────────────────────────
/**
* File attributes set via %TF commands.
*/
private final Map<String, List<String>> fileAttributes;
/**
* Aperture attributes set via %TA commands (current dictionary; snapshots are attached to
* apertures at AD time).
*/
private final Map<String, List<String>> apertureAttributes;
/**
* Object attributes set via %TO commands.
*/
private final Map<String, List<String>> objectAttributes;
// ── Constructor ───────────────────────────────────────────────────────────
/**
* Creates a GerberContext in the Gerber power-on default state.
*
* <p>See Ucamco NV Format Specification page 14.
*/
public GerberContext() {
this.format = null;
this.unit = null;
this.currentPoint = null;
this.currentApertureId = null;
this.polarity = Polarity.DARK;
this.mirroring = Mirror.NONE;
this.rotation = null;
this.scaling = null;
this.apertureDictionary = new HashMap<>();
this.macroDictionary = new HashMap<>();
this.stateStack = new Stack<>();
this.topLevelOperations = new ArrayList<>();
this.regions = new ArrayList<>();
this.operationScopeStack = new ArrayDeque<>();
this.inRegion = false;
this.interpolationMode = InterpolationMode.LINEAR;
this.quadrantMode = QuadrantMode.MULTI;
this.fileAttributes = new HashMap<>();
this.apertureAttributes = new HashMap<>();
this.objectAttributes = new HashMap<>();
}
// ── Scope helpers ─────────────────────────────────────────────────────────
private List<GerberOperation> currentOperationScope() {
return operationScopeStack.isEmpty() ? topLevelOperations : operationScopeStack.peek();
}
/**
* Push a new empty scope (called at the start of an AB or SR block).
*/
public void pushOperationScope() {
operationScopeStack.push(new ArrayList<>());
}
/**
* Pop the current scope and return its contents.
*
* @return the popped operations, or an empty list if the stack is empty
*/
public List<GerberOperation> popOperationScope() {
if (operationScopeStack.isEmpty()) {
return new ArrayList<>();
}
return operationScopeStack.pop();
}
// ── Unit / format ─────────────────────────────────────────────────────────
/**
* Sets the measurement mode for the Gerber file.
*
* @param mode the measurement mode (MM or IN)
* @throws IllegalStateException if the mode has already been set
*/
public void setMode(final Mode mode) {
if (this.unit != null) {
throw new IllegalStateException("Mode may not be changed once set.");
}
this.unit = mode;
}
/**
* Returns the measurement mode for the Gerber file.
*
* @return the measurement mode, or null if not set
*/
public Mode getUnit() {
return unit;
}
/**
* Sets the format specification for coordinate parsing.
*
* @param formatSpec the format specification
* @throws IllegalStateException if the format has already been set
*/
public void setFormat(final FormatSpecification formatSpec) {
if (this.format != null) {
throw new IllegalStateException("FormatSpecification may not be changed once set.");
}
this.format = formatSpec;
}
/**
* Returns the format specification for coordinate parsing.
*
* @return the format specification, or null if not set
*/
public FormatSpecification getFormat() {
return format;
}
/**
* Returns the format specification, or throws if it hasn't been set.
*
* <p>Per the Gerber spec §4.1, {@code %FS…%} must appear before any coordinate command
* (D01/D02/D03). This method provides a clear error when a malformed file omits it, instead of a
* raw NPE.
*
* @return the format specification
*/
public FormatSpecification requireFormat() {
if (format == null) {
throw new IllegalStateException(
"Format specification (FS) has not been set. "
+ "The FS command must appear before any coordinate commands.");
}
return format;
}
// ── Graphics state ────────────────────────────────────────────────────────
/**
* Sets the mirroring mode.
*
* @param mirror the mirroring mode
*/
public void setMirroring(final Mirror mirror) {
this.mirroring = mirror;
}
/**
* Sets the rotation angle.
*
* @param angle the rotation angle in degrees
*/
public void setRotation(final Double angle) {
this.rotation = angle;
}
/**
* Sets the scaling factor.
*
* @param factor the scaling factor
*/
public void setScaling(final Double factor) {
this.scaling = factor;
}
/**
* Sets the polarity.
*
* @param polarityParam the polarity
*/
public void setPolarity(final Polarity polarityParam) {
this.polarity = polarityParam;
}
/**
* Returns the mirroring mode.
*
* @return the mirroring mode
*/
public Mirror getMirroring() {
return mirroring;
}
/**
* Returns the rotation angle.
*
* @return the rotation angle in degrees
*/
public Double getRotation() {
return rotation;
}
/**
* Returns the scaling factor.
*
* @return the scaling factor
*/
public Double getScaling() {
return scaling;
}
/**
* Returns the polarity.
*
* @return the polarity
*/
public Polarity getPolarity() {
return polarity;
}
/**
* Sets the current aperture ID.
*
* @param apertureId the aperture ID
*/
public void setCurrentApertureId(final String apertureId) {
this.currentApertureId = apertureId;
}
/**
* Returns the current aperture ID.
*
* @return the current aperture ID
*/
public String getCurrentApertureId() {
return currentApertureId;
}
/**
* Returns the current point.
*
* @return the current point
*/
public Point2D getCurrentPoint() {
return currentPoint != null ? (Point2D) currentPoint.clone() : null;
}
/**
* Sets the current point.
*
* @param x the x coordinate
* @param y the y coordinate
*/
public void setCurrentPoint(final double x, final double y) {
if (this.currentPoint == null) {
this.currentPoint = new Point2D.Double(x, y);
} else {
this.currentPoint.setLocation(x, y);
}
}
// ── Interpolation mode ────────────────────────────────────────────────────
/**
* Sets the interpolation mode.
*
* @param mode the interpolation mode
*/
public void setInterpolationMode(final InterpolationMode mode) {
this.interpolationMode = mode;
}
/**
* Returns the interpolation mode.
*
* @return the interpolation mode
*/
public InterpolationMode getInterpolationMode() {
return interpolationMode;
}
// ── Quadrant mode ─────────────────────────────────────────────────────────
/**
* Sets the quadrant mode.
*
* @param mode the quadrant mode
*/
public void setQuadrantMode(final QuadrantMode mode) {
this.quadrantMode = mode;
}
/**
* Returns the quadrant mode.
*
* @return the quadrant mode
*/
public QuadrantMode getQuadrantMode() {
return quadrantMode;
}
// ── Attribute dictionaries (§5) ───────────────────────────────────────────
/**
* Set a file attribute (%TF).
*
* @param name the attribute name
* @param values the attribute values
*/
public void setFileAttribute(final String name, final List<String> values) {
fileAttributes.put(name, values);
}
/**
* Get a specific file attribute, or null if not present.
*
* @param name the attribute name
* @return the attribute values, or null if not present
*/
public List<String> getFileAttribute(final String name) {
return fileAttributes.getOrDefault(name, null);
}
/**
* Get all file attributes (unmodifiable view).
*
* @return an unmodifiable map of file attributes
*/
public Map<String, List<String>> getFileAttributes() {
return java.util.Collections.unmodifiableMap(fileAttributes);
}
/**
* Set an aperture attribute (%TA).
*
* @param name the attribute name
* @param values the attribute values
*/
public void setApertureAttribute(final String name, final List<String> values) {
apertureAttributes.put(name, values);
}
/**
* Get a specific aperture attribute, or null if not present.
*
* @param name the attribute name
* @return the attribute values, or null if not present
*/
public List<String> getApertureAttribute(final String name) {
return apertureAttributes.getOrDefault(name, null);
}
/**
* Get all aperture attributes (unmodifiable view).
*
* @return an unmodifiable map of aperture attributes
*/
public Map<String, List<String>> getApertureAttributes() {
return java.util.Collections.unmodifiableMap(apertureAttributes);
}
/**
* Take a snapshot of the current aperture attributes. Returns a deep copy so that subsequent
* TA/TD commands don't affect it.
*
* @return a deep copy of the aperture attributes
*/
public Map<String, List<String>> snapshotApertureAttributes() {
Map<String, List<String>> snapshot = new HashMap<>();
for (Map.Entry<String, List<String>> e : apertureAttributes.entrySet()) {
snapshot.put(e.getKey(), new ArrayList<>(e.getValue()));
}
return snapshot;
}
/**
* Set an object attribute (%TO).
*
* @param name the attribute name
* @param values the attribute values
*/
public void setObjectAttribute(final String name, final List<String> values) {
objectAttributes.put(name, values);
}
/**
* Get a specific object attribute, or null if not present.
*
* @param name the attribute name
* @return the attribute values, or null if not present
*/
public List<String> getObjectAttribute(final String name) {
return objectAttributes.getOrDefault(name, null);
}
/**
* Get all object attributes (unmodifiable view).
*
* @return an unmodifiable map of object attributes
*/
public Map<String, List<String>> getObjectAttributes() {
return java.util.Collections.unmodifiableMap(objectAttributes);
}
/**
* Delete a specific attribute by name from whichever dictionary it appears in. Used by
* %TD.Name*%.
*
* @param name the attribute name
*/
public void deleteAttribute(final String name) {
fileAttributes.remove(name);
apertureAttributes.remove(name);
objectAttributes.remove(name);
}
/**
* Delete all aperture and object attributes. Used by a bare %TD*% (no attribute name). Per spec
* §5.5, file attributes are NOT affected.
*/
public void deleteAllApertureAndObjectAttributes() {
apertureAttributes.clear();
objectAttributes.clear();
}
// ── Dictionaries ──────────────────────────────────────────────────────────
/**
* Returns the macro definition for the given name.
*
* @param macroName the macro name
* @return the macro definition, or null if not found
*/
public MacroDefinition getMacro(final String macroName) {
return macroDictionary.get(macroName);
}
/**
* Adds a macro definition.
*
* @param macroName the macro name
* @param macro the macro definition
*/
public void addMacro(final String macroName, final MacroDefinition macro) {
macroDictionary.put(macroName, macro);
}
/**
* Returns the aperture for the given ID.
*
* @param apertureId the aperture ID
* @return the aperture, or null if not found
*/
public Aperture getAperture(final String apertureId) {
return apertureId != null ? apertureDictionary.get(apertureId) : null;
}
/**
* Adds an aperture.
*
* @param apertureId the aperture ID
* @param aperture the aperture
*/
public void addAperture(final String apertureId, final Aperture aperture) {
apertureDictionary.put(apertureId, aperture);
if (aperture instanceof BlockAperture ba) {
ba.setBlockRenderer(this::renderItems);
ba.setBoundsExpander(this::expandBounds);
}
}
/**
* Returns the number of apertures.
*
* @return the aperture count
*/
public int getApertureCount() {
return apertureDictionary.size();
}
// ── Operation recording ───────────────────────────────────────────────────
/**
* Records a drawing operation.
*
* @param op the drawing operation
*/
public void recordOperation(final DrawingOperation op) {
currentOperationScope().add(op);
}
/**
* Records a block operation.
*
* @param block the block operation
*/
public void recordBlockOperation(final BlockOperation block) {
currentOperationScope().add(block);
}
/**
* Returns a defensive copy of the top-level operation list.
*
* @return a defensive copy of the top-level operation list
*/
public List<GerberOperation> getTopLevelOperations() {
return new ArrayList<>(topLevelOperations);
}
// ── Graphics state stack ──────────────────────────────────────────────────
/**
* Push graphics state onto the stack (for block entry).
*/
public void pushGraphicsState() {
stateStack.push(snapshotState());
}
/**
* Pop graphics state from the stack (for block exit).
*/
public void popGraphicsState() {
if (!stateStack.isEmpty()) {
restoreFromState(stateStack.pop());
}
}
private GraphicsState snapshotState() {
return new GraphicsState(
currentPoint, currentApertureId, polarity, mirroring, rotation, scaling);
}
private void restoreFromState(final GraphicsState state) {
Point2D sp = state.currentPoint();
this.currentPoint = sp != null ? new Point2D.Double(sp.getX(), sp.getY()) : null;
this.currentApertureId = state.currentApertureId();
this.polarity = state.polarity();
this.mirroring = state.mirroring();
this.rotation = state.rotation();
this.scaling = state.scaling();
}
// ── Region building ───────────────────────────────────────────────────────
/**
* Returns whether currently inside a region.
*
* @return true if in region
*/
public boolean isInRegion() {
return inRegion;
}
/**
* Start a region (G36 command).
*/
public void startRegion() {
inRegion = true;
currentRegion = new Path2D.Double();
regionPolarity = this.polarity;
regionMirroring = this.mirroring;
regionRotation = this.rotation;
regionScaling = this.scaling;
}
/**
* Start a new contour inside the current region (D02 within G36…G37). Closes any previously open
* contour before starting the new one.
*
* @param point the starting point
*/
public void startContourInRegion(final Point2D point) {
if (currentRegion != null) {
if (currentRegion.getCurrentPoint() != null) {
currentRegion.closePath();
}
currentRegion.moveTo(point.getX(), point.getY());
}
}
/**
* Add a linear point to the current region path (D01 linear segment inside G36…G37).
*
* @param point the point to add
*/
public void addPointToRegion(final Point2D point) {
if (currentRegion != null) {
if (currentRegion.getCurrentPoint() == null) {
currentRegion.moveTo(point.getX(), point.getY());
} else {
currentRegion.lineTo(point.getX(), point.getY());
}
}
}
/**
* Add an arc segment to the current region path. Delegates arc math to {@link ArcGeometry}.
*
* @param startPoint the start point
* @param endPoint the end point
* @param centerOffset the center offset
* @param clockwise whether clockwise
* @param quadrantModeParam the quadrant mode
*/
public void addArcToRegion(
final Point2D startPoint,
final Point2D endPoint,
final Point2D centerOffset,
final boolean clockwise,
final QuadrantMode quadrantModeParam) {
if (currentRegion == null) {
return;
}
ArcGeometry arc = new ArcGeometry(startPoint, endPoint, centerOffset, clockwise, quadrantModeParam);
int segments = arc.approximationSegments();
double step = arc.angularExtent / segments;
if (currentRegion.getCurrentPoint() == null) {
currentRegion.moveTo(startPoint.getX(), startPoint.getY());
}
for (int i = 1; i <= segments; i++) {
double angle = arc.startAngle + step * i;
currentRegion.lineTo(
arc.centerX + arc.radius * Math.cos(angle), arc.centerY + arc.radius * Math.sin(angle));
}
}
/**
* End the current region (G37 command).
*/
public void endRegion() {
if (currentRegion != null) {
currentRegion.closePath();
RegionPath region =
new RegionPath(
currentRegion, regionPolarity, regionMirroring, regionRotation, regionScaling);
regions.add(region);
currentOperationScope().add(region);
currentRegion = null;
inRegion = false;
}
}
/**
* Returns a defensive copy of all recorded regions.
*
* @return the regions
*/
public List<RegionPath> getRegions() {
return new ArrayList<>(regions);
}
// ── Standard file-attribute convenience accessors (§5.6) ──────────────────
//
// The Gerber X2/X3 spec defines well-known %TF attributes whose values are
// open-ended strings. These helpers expose common lookups so a visualiser
// doesn't have to hard-code attribute key names and token-index arithmetic.
// All values are returned exactly as the file defined them — no enums.
private static final int COPPER_LAYER_MIN_TOKENS = 3;
/**
* Returns the file-function token list, or {@code null} if {@code %TF.FileFunction} was not
* present.
*
* <p>Examples of returned lists:
*
* <ul>
* <li>{@code ["Copper", "L1", "Top"]}
* <li>{@code ["Soldermask", "Top"]}
* <li>{@code ["Plated", "1", "8", "PTH"]}
* <li>{@code ["Profile", "NP"]}
* </ul>
*
* @return unmodifiable token list, or {@code null}
*/
public List<String> getFileFunction() {
List<String> v = fileAttributes.get(".FileFunction");
return v != null ? List.copyOf(v) : null;
}
/**
* Returns the primary file-function keyword (first token), or {@code null}.
*
* <p>Typical values: {@code "Copper"}, {@code "Soldermask"}, {@code "Solderpaste"}, {@code
* "Legend"}, {@code "Plated"}, {@code "NonPlated"}, {@code "Profile"}, {@code "Other"}, etc.
*
* @return the primary file-function keyword or null
*/
public String getFileFunctionType() {
List<String> v = fileAttributes.get(".FileFunction");
return (v != null && !v.isEmpty()) ? v.getFirst() : null;
}
/**
* Returns the board-side token from the {@code %TF.FileFunction} attribute, or {@code null} if
* not present.
*
* <p>For copper layers the side is the third token ({@code ["Copper","L1","Top"]} → {@code
* "Top"}). For mask / paste / legend / etc. the side is the second token ({@code
* ["Soldermask","Top"]} → {@code "Top"}). Typical values: {@code "Top"}, {@code "Bot"}, {@code
* "Inr"}.
*
* @return the board-side token or null
*/
public String getFileFunctionSide() {
List<String> v = fileAttributes.get(".FileFunction");
if (v == null || v.isEmpty()) {
return null;
}
String primary = v.getFirst();
// Copper: side is 3rd token (index 2)
if ("Copper".equalsIgnoreCase(primary)) {
return v.size() >= COPPER_LAYER_MIN_TOKENS ? v.get(2) : null;
}
// Everything else: side is 2nd token (index 1), if it looks like a side
if (v.size() >= 2) {
String candidate = v.get(1);
if ("Top".equalsIgnoreCase(candidate)
|| "Bot".equalsIgnoreCase(candidate)
|| "Inr".equalsIgnoreCase(candidate)
|| "Both".equalsIgnoreCase(candidate)) {
return candidate;
}
}
return null;
}
/**
* Returns the copper layer number, or {@code null} if this is not a copper layer or the token is
* missing.
*
* <p>Parsed from the second token of {@code %TF.FileFunction,Copper,L<em>n</em>,…*%}.
*
* @return the copper layer number or null
*/
public Integer getCopperLayerNumber() {
List<String> v = fileAttributes.get(".FileFunction");
if (v == null || v.size() < 2) {
return null;
}
if (!"Copper".equalsIgnoreCase(v.getFirst())) {
return null;
}
String token = v.get(1); // e.g. "L1", "L12"
if (token.length() > 1 && (token.charAt(0) == 'L' || token.charAt(0) == 'l')) {
try {
return Integer.parseInt(token.substring(1));
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
/**
* Returns the file-polarity string, or {@code null} if {@code %TF.FilePolarity} was not present.
*
* <p>Typical values: {@code "Positive"}, {@code "Negative"}.
*
* @return the file-polarity string, or {@code null} if {@code %TF.FilePolarity} was not present
*/
public String getFilePolarity() {
List<String> v = fileAttributes.get(".FilePolarity");
return (v != null && !v.isEmpty()) ? v.getFirst() : null;
}
/**
* Returns the part-type string, or {@code null} if {@code %TF.Part} was not present.
*
* <p>Typical values: {@code "Single"}, {@code "Array"}, {@code "FabricationPanel"}, {@code
* "Other"}.
*
* @return the part-type string, or {@code null} if {@code %TF.Part} was not present
*/
public String getPartType() {
List<String> v = fileAttributes.get(".Part");
return (v != null && !v.isEmpty()) ? v.getFirst() : null;
}
/**
* Returns the generation-software token list, or {@code null}.
*
* <p>Example: {@code ["KiCad", "Pcbnew", "6.0.0"]}.
*
* @return the generation-software token list, or {@code null}
*/
public List<String> getGenerationSoftware() {
List<String> v = fileAttributes.get(".GenerationSoftware");
return v != null ? List.copyOf(v) : null;
}
/**
* Returns the file creation-date string (ISO-8601), or {@code null}.
*
* @return the file creation-date string (ISO-8601), or {@code null}
*/
public String getCreationDate() {
List<String> v = fileAttributes.get(".CreationDate");
return (v != null && !v.isEmpty()) ? v.getFirst() : null;
}
/**
* Returns the project-ID token list, or {@code null}.
*
* <p>Example: {@code ["MyBoard", "{guid}", "rev1"]}.
*
* @return the project-ID token list, or {@code null}
*/
public List<String> getProjectId() {
List<String> v = fileAttributes.get(".ProjectId");
return v != null ? List.copyOf(v) : null;
}
/**
* Returns the MD5 checksum string, or {@code null}.
*
* @return the MD5 checksum string, or {@code null}
*/
public String getMD5() {
List<String> v = fileAttributes.get(".MD5");
return (v != null && !v.isEmpty()) ? v.getFirst() : null;
}
/**
* Returns {@code true} if the {@code %TF.SameCoordinates} attribute is present, indicating that
* all layers in this job share the same coordinate origin and can be composited by simple
* overlay.
*
* @return {@code true} if the {@code %TF.SameCoordinates} attribute is present
*/
public boolean hasSameCoordinates() {
return fileAttributes.containsKey(".SameCoordinates");
}
/**
* Returns the same-coordinates identifier string, or {@code null}.
*
* <p>When present, all files sharing the same identifier value use the same coordinate system and
* can be overlaid directly.
*
* @return the same-coordinates identifier string, or {@code null}
*/
public String getSameCoordinatesId() {
List<String> v = fileAttributes.get(".SameCoordinates");
return (v != null && !v.isEmpty()) ? v.getFirst() : null;
}
// ── Bounds computation ────────────────────────────────────────────────────
/**
* Returns the bounding box of all drawing operations in Gerber coordinate space.
*
* @return the bounding box of all drawing operations in Gerber coordinate space
*/
public Rectangle2D.Double getBounds() {
double[] extents = {
Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY,
Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY
};
expandBounds(topLevelOperations, 0.0, 0.0, new AffineTransform(), extents);
if (extents[EXTENTS_MIN_X] > extents[EXTENTS_MAX_X] || extents[EXTENTS_MIN_Y] > extents[EXTENTS_MAX_Y]) {
return new Rectangle2D.Double(0, 0, 0, 0);
}
return new Rectangle2D.Double(
extents[EXTENTS_MIN_X],
extents[EXTENTS_MIN_Y],
extents[EXTENTS_MAX_X] - extents[EXTENTS_MIN_X],
extents[EXTENTS_MAX_Y] - extents[EXTENTS_MIN_Y]);
}
private void expandBounds(
final List<GerberOperation> items,
final double dx,
final double dy,
final AffineTransform blockTransform,
final double[] extents) {
Point2D.Double penPos = null;
for (GerberOperation item : items) {
switch (item) {
case DrawingOperation op -> {
if (op.getPoint() == null) {
continue;
}
Point2D transformed = new Point2D.Double();
blockTransform.transform(op.getPoint(), transformed);
double x = transformed.getX() + dx;
double y = transformed.getY() + dy;
Aperture aperture = getAperture(op.getApertureId());
if (op.getType() == DrawingOperation.Type.FLASH) {
if (aperture != null) {
aperture.expandFlashBounds(
x, y, op.getApertureTransform().toAffineTransform(), extents);
} else {
expandPointExtent(x, y, 0, extents);
}
penPos = new Point2D.Double(x, y);
continue;
}
double strokeRadius = (aperture != null) ? aperture.getStrokeWidth() / 2.0 : 0.0;
if (op.getType() == DrawingOperation.Type.DRAW
&& op.getInterpolationPoint() != null
&& penPos != null) {
Point2D transformedInterp = new Point2D.Double();
blockTransform.deltaTransform(op.getInterpolationPoint(), transformedInterp);
ArcGeometry arc =
new ArcGeometry(
penPos,
new Point2D.Double(x, y),
transformedInterp,
op.isClockwise(),
op.getQuadrantMode());
arc.expandBounds(strokeRadius, extents);
} else if (op.getType() == DrawingOperation.Type.DRAW) {
expandPointExtent(x, y, strokeRadius, extents);
if (penPos != null) {
expandPointExtent(penPos.x, penPos.y, strokeRadius, extents);
}
}
penPos = new Point2D.Double(x, y);
}
case RegionPath region -> {
AffineTransform combined = new AffineTransform();
combined.translate(dx, dy);
combined.concatenate(blockTransform);
PathIterator pi = region.getPath().getPathIterator(combined);
double[] coords = new double[PATH_ITERATOR_COORDS];
while (!pi.isDone()) {
int type = pi.currentSegment(coords);
if (type != PathIterator.SEG_CLOSE) {
extents[EXTENTS_MIN_X] = Math.min(extents[EXTENTS_MIN_X], coords[0]);
extents[EXTENTS_MAX_X] = Math.max(extents[EXTENTS_MAX_X], coords[0]);
extents[EXTENTS_MIN_Y] = Math.min(extents[EXTENTS_MIN_Y], coords[1]);
extents[EXTENTS_MAX_Y] = Math.max(extents[EXTENTS_MAX_Y], coords[1]);
}
pi.next();
}
penPos = null;
}
case BlockOperation block -> {
int rX = block.getRepeatX() != null ? block.getRepeatX() : 1;
int rY = block.getRepeatY() != null ? block.getRepeatY() : 1;
double stepI = block.getOffsetI() != null ? block.getOffsetI() : 0;
double stepJ = block.getOffsetJ() != null ? block.getOffsetJ() : 0;
for (int iy = 0; iy < rY; iy++) {
for (int ix = 0; ix < rX; ix++) {
expandBounds(
block.getOperations(), dx + ix * stepI, dy + iy * stepJ, blockTransform, extents);
}
}
penPos = null;
}
}
}
}
private static void expandPointExtent(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);
}
// ── Rendering ─────────────────────────────────────────────────────────────
/**
* Render this context to a GerberOutputTarget. Entry point: renders all top-level operations with
* zero offset.
*
* @param target the target to render to
*/
public void render(final GerberOutputTarget target) {
renderItems(topLevelOperations, 0.0, 0.0, target);
}
/**
* Composes all Gerber operations into a single {@link java.awt.geom.Area} in Gerber coordinate
* space (Y-up, no pixel scale, no axis flip).
*
* <p>DARK polarity adds material; CLEAR polarity subtracts it. The resulting area is suitable
* for:
*
* <ul>
* <li>Rendering to any {@link java.awt.Graphics2D} with a single {@code g2d.fill(area)} call
* (after applying scale / Y-flip).
* <li>Edge detection and tool-path planning for CNC/CAM consumers.
* </ul>
*
* @return a new {@link java.awt.geom.Area} representing the composite of all Gerber operations
*/
public java.awt.geom.Area toArea() {
com.varnernet.gerb4j.render.AreaTarget areaTarget =
new com.varnernet.gerb4j.render.AreaTarget();
render(areaTarget);
return areaTarget.getArea();
}
/**
* Composes all Gerber operations and returns the individual {@link
* com.varnernet.gerb4j.render.PolarizedShape} objects in emission order. Useful for CAM consumers
* that need per-feature geometry.
*
* @return an unmodifiable list of polarized shapes in Gerber coordinate space
*/
public java.util.List<com.varnernet.gerb4j.render.PolarizedShape> toPolarizedShapes() {
com.varnernet.gerb4j.render.AreaTarget areaTarget =
new com.varnernet.gerb4j.render.AreaTarget();
render(areaTarget);
return areaTarget.getShapes();
}
private void renderItems(
final List<GerberOperation> items, final double dx, final double dy, final GerberOutputTarget target) {
renderItems(items, dx, dy, target, ApertureTransform.IDENTITY);
}
/**
* Render a list of items with an outer block-aperture transformation.
*
* @param items the list of operations to render
* @param dx the x offset
* @param dy the y offset
* @param target the target to render to
* @param outerTransform the outer transform
*/
private void renderItems(
final List<GerberOperation> items,
final double dx,
final double dy,
final GerberOutputTarget target,
final ApertureTransform outerTransform) {
AffineTransform blockTransform = outerTransform.toAffineTransform();
boolean flipArcDirection = outerTransform.shouldFlipArcDirection();
List<GerberOperation> allItems = new ArrayList<>();
flattenTopLevel(items, allItems, dx, dy, blockTransform, flipArcDirection);
Path2D.Double currentPath = new Path2D.Double();
String lastApertureId = null;
Polarity currentPathPol = Polarity.DARK;
Point2D renderCurrentPt = new Point2D.Double(dx, dy);
for (GerberOperation item : allItems) {
switch (item) {
case RegionPath region -> {
flushPath(currentPath, lastApertureId, currentPathPol, target);
currentPath = new Path2D.Double();
lastApertureId = null;
target.setTransformation(
region.getRotation(), region.getScaling(), region.getMirroring());
target.drawRegion(region.getPath(), region.getPolarity());
}
case BlockOperation ignored -> throw new IllegalStateException(
"BlockOperation should have been flattened before rendering");
case DrawingOperation op -> {
if (op.getApertureId() == null) {
LOG.warning("Drawing operation with no aperture selected (Dnn not issued) — skipping.");
continue;
}
Aperture aperture = getAperture(op.getApertureId());
if (aperture == null) {
LOG.warning(
"Aperture D" + op.getApertureId() + " referenced but never defined — skipping.");
continue;
}
Point2D rawPoint = op.getPoint();
if (rawPoint == null) {
continue;
}
Point2D point = new Point2D.Double(rawPoint.getX(), rawPoint.getY());
target.setTransformation(op.getRotation(), op.getScaling(), op.getMirroring());
switch (op.getType()) {
case FLASH:
flushPath(currentPath, lastApertureId, currentPathPol, target);
currentPath = new Path2D.Double();
lastApertureId = null;
aperture.renderFlash(point, target, op.getPolarity(), op.getApertureTransform());
renderCurrentPt = new Point2D.Double(point.getX(), point.getY());
break;
case DRAW:
if (op.getInterpolationPoint() != null) {
flushPath(currentPath, lastApertureId, currentPathPol, target);
currentPath = new Path2D.Double();
lastApertureId = null;
target.drawArc(
new Point2D.Double(renderCurrentPt.getX(), renderCurrentPt.getY()),
point,
op.getInterpolationPoint(),
aperture,
op.getPolarity(),
op.isClockwise(),
op.getQuadrantMode());
renderCurrentPt = new Point2D.Double(point.getX(), point.getY());
} else {
if (currentPath.getCurrentPoint() == null) {
currentPath.moveTo(renderCurrentPt.getX(), renderCurrentPt.getY());
currentPathPol = op.getPolarity();
}
currentPath.lineTo(point.getX(), point.getY());
renderCurrentPt = new Point2D.Double(point.getX(), point.getY());
lastApertureId = op.getApertureId();
}
break;
case MOVE:
flushPath(currentPath, lastApertureId, currentPathPol, target);
currentPath = new Path2D.Double();
currentPath.moveTo(point.getX(), point.getY());
currentPathPol = op.getPolarity();
renderCurrentPt = new Point2D.Double(point.getX(), point.getY());
lastApertureId = op.getApertureId();
break;
default:
throw new IllegalStateException("Unknown operation type: " + op.getType());
}
}
}
}
flushPath(currentPath, lastApertureId, currentPathPol, target);
target.clearTransformation();
}
private void flushPath(
final Path2D.Double path, final String apertureId, final Polarity polarityParam, final GerberOutputTarget target) {
if (path.getCurrentPoint() != null && apertureId != null) {
Aperture aperture = getAperture(apertureId);
if (aperture != null) {
target.drawPath(path, aperture, polarityParam);
}
}
}
/**
* Flatten the source item list into {@code result}, expanding SR BlockOperations by translating
* each repetition's coordinates. AB blocks (BlockAperture) are handled at render time via
* recursive renderItems().
*
* @param source the source list of operations
* @param result the result list to append to
* @param baseDx the base x offset
* @param baseDy the base y offset
* @param blockTransform the block transform
* @param flipArcDir whether to flip arc direction
*/
private void flattenTopLevel(
final List<GerberOperation> source,
final List<GerberOperation> result,
final double baseDx,
final double baseDy,
final AffineTransform blockTransform,
final boolean flipArcDir) {
for (GerberOperation obj : source) {
switch (obj) {
case DrawingOperation op -> {
Point2D orig = op.getPoint();
if (orig != null) {
Point2D transformed = new Point2D.Double();
blockTransform.transform(orig, transformed);
Point2D interpPoint = op.getInterpolationPoint();
Point2D transformedInterp = null;
if (interpPoint != null) {
transformedInterp = new Point2D.Double();
blockTransform.deltaTransform(interpPoint, transformedInterp);
}
boolean clockwise = op.isClockwise();
if (flipArcDir && op.getType() == DrawingOperation.Type.DRAW && interpPoint != null) {
clockwise = !clockwise;
}
result.add(
new DrawingOperation(
op.getType(),
op.getApertureId(),
new Point2D.Double(transformed.getX() + baseDx, transformed.getY() + baseDy),
op.getPolarity(),
op.getApertureTransform(),
transformedInterp,
clockwise,
op.getQuadrantMode()));
}
}
case RegionPath rp -> {
AffineTransform combined = new AffineTransform();
combined.translate(baseDx, baseDy);
combined.concatenate(blockTransform);
Path2D.Double transformedPath = new Path2D.Double(rp.getPath(), combined);
result.add(new RegionPath(transformedPath, rp.getPolarity(), rp.getApertureTransform()));
}
case BlockOperation block -> {
int rX = block.getRepeatX() != null ? block.getRepeatX() : 1;
int rY = block.getRepeatY() != null ? block.getRepeatY() : 1;
double stepI = block.getOffsetI() != null ? block.getOffsetI() : 0;
double stepJ = block.getOffsetJ() != null ? block.getOffsetJ() : 0;
for (int y = 0; y < rY; y++) {
for (int x = 0; x < rX; x++) {
flattenTopLevel(
block.getOperations(),
result,
baseDx + x * stepI,
baseDy + y * stepJ,
blockTransform,
flipArcDir);
}
}
}
}
}
}
}