GerberExporter.java

package org.fresnel.optics;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.Locale;

/**
 * Gerber RS-274X export for PCB-style fabrication of binary zone plates.
 *
 * <p>Emits a single-layer Gerber file using a circular aperture flashed at the
 * origin with alternating layer polarity ({@code %LPD*%} / {@code %LPC*%}) to
 * paint each Fresnel zone. The "ON" zones become copper (dark), the "OFF"
 * zones become absence-of-copper (clear). A photoplotter or PCB house can use
 * the file directly.
 *
 * <p>Conventions:
 * <ul>
 *   <li>Units: millimetres ({@code %MOMM*%}).</li>
 *   <li>Coordinate format: 4 integer / 6 fractional digits, leading zeros omitted
 *       ({@code %FSLAX46Y46*%}).</li>
 *   <li>One {@code C} (circle) aperture per zone radius.</li>
 *   <li>Single flash {@code D03} at {@code 0,0} per aperture; layer polarity is
 *       toggled before each flash so successive disks paint the alternating
 *       Fresnel rings.</li>
 * </ul>
 *
 * <p>For arbitrary masks (off-axis, hex, foil, hologram) this exporter is not
 * applicable — use SVG or PDF instead.
 */
public final class GerberExporter {

    private GerberExporter() {}

    /** Write an on-axis circular zone plate as a single-layer RS-274X Gerber file. */
    public static void writeZonePlate(SingleZonePlateParameters p, OutputStream out) throws IOException {
        double D = p.apertureDiameterMm();
        double f = p.focalLengthMm();
        double lambdaMm = Units.nmToMm(p.wavelengthNm());
        double R = D / 2.0;
        boolean positive = p.polarity() == Polarity.POSITIVE;
        int nMax = (int) Math.floor((R * R) / (lambdaMm * f));

        try (Writer w = new OutputStreamWriter(out, StandardCharsets.UTF_8)) {
            // Header: format spec, units, generator comment.
            w.write("G04 Generated by Fresnel Zone Plate Designer*\n");
            w.write("%FSLAX46Y46*%\n");
            w.write("%MOMM*%\n");
            // Aperture macro not needed — use plain circle apertures.

            // Define apertures: D10 is the aperture-clipping disk; D11..D(10+nMax)
            // are one circle per zone radius. Aperture index 10+n is therefore zone n.
            w.write(String.format(Locale.ROOT,
                    "%%ADD10C,%.6f*%%\n", 2.0 * R));
            for (int n = 1; n <= nMax; n++) {
                double rn = Math.sqrt(n * lambdaMm * f);
                if (rn > R) rn = R;
                w.write(String.format(Locale.ROOT,
                        "%%ADD%dC,%.6f*%%\n", 10 + n, 2.0 * rn));
            }

            // The outermost disk is the "background". For positive polarity the
            // OFF colour is dark (copper present outside the ON zones) and ON zones
            // clear it; for negative polarity it is reversed.
            //
            // Strategy: paint outer disk with the OFF colour (LPD if OFF=dark),
            // then for each zone n from largest to smallest, alternate polarity so
            // successive disks "carve" the rings.
            //
            // Zone n is bounded by r_{n-1} and r_n; ON for odd n in positive polarity.
            // Painting from largest to smallest with toggling polarity yields the
            // correct alternating annuli regardless of starting polarity.

            // Start with the OFF colour for the clipping disk.
            String offPolarity = positive ? "D" : "C"; // dark vs clear
            w.write("%LP" + offPolarity + "*%\n");
            w.write("D10*\n");
            w.write("X0Y0D03*\n");

            // Now paint zones nMax..1 alternating polarity.
            // Zone nMax has the OPPOSITE polarity of the background if nMax is odd
            // (positive polarity, ON for odd n) — but because we flip on every step,
            // we just pick the correct starting polarity for n=nMax.
            // ON for odd n iff positive polarity; OFF for even n.
            for (int n = nMax; n >= 1; n--) {
                boolean on = ((n % 2) == 1) == positive;
                String pol = on ? "D" : "C";
                w.write("%LP" + pol + "*%\n");
                w.write(String.format(Locale.ROOT, "D%d*\n", 10 + n));
                w.write("X0Y0D03*\n");
            }

            // End-of-file.
            w.write("M02*\n");
        }
    }

    public static byte[] toGerberBytes(SingleZonePlateParameters p) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        writeZonePlate(p, baos);
        return baos.toByteArray();
    }
}