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