WindowFoilRenderer.java
package org.fresnel.optics;
import java.awt.image.BufferedImage;
import java.awt.image.WritableRaster;
import java.util.ArrayList;
import java.util.List;
/**
* Renders a window-foil layout: a rectangular sheet of W × H tiled with hexagonal
* macro cells on a flat-top hex grid (gap-less). Each cell uses
* {@link HexMacroCellRenderer} semantics with its own focal length and target offset.
*
* <p>For very large sheets the resulting image can be huge (e.g. 1 m² @ 600 dpi
* ≈ 23k × 23k px). Callers that drive printable PDFs are expected to render at the
* target DPI and then split into pages — see {@code PdfExporter}.
*/
public final class WindowFoilRenderer {
private WindowFoilRenderer() {}
public static RenderResult render(WindowFoilParameters p) {
double pixelSizeMm = Units.pixelSizeMm(p.dpi());
int wPx = Math.max(2, (int) Math.ceil(p.sheetWidthMm() / pixelSizeMm));
int hPx = Math.max(2, (int) Math.ceil(p.sheetHeightMm() / pixelSizeMm));
BufferedImage img = new BufferedImage(wPx, hPx, BufferedImage.TYPE_BYTE_GRAY);
WritableRaster raster = img.getRaster();
List<double[]> cellCentres = cellCentresInsideSheet(
p.sheetWidthMm(), p.sheetHeightMm(), p.macroRadiusMm());
double R = p.macroRadiusMm();
double sqrt3 = Math.sqrt(3.0);
double halfFlat = R * sqrt3 / 2.0;
double invSqrt3 = 1.0 / sqrt3;
double lambdaMm = Units.nmToMm(p.wavelengthNm());
double k = 2.0 * Math.PI / lambdaMm;
double subRadiusMm = p.subDiameterMm() / 2.0;
double subRadiusMmSq = subRadiusMm * subRadiusMm;
double subPitchMm = p.subPitchMm();
boolean binary = p.maskType() == MaskType.BINARY_AMPLITUDE;
boolean positive = p.polarity() == Polarity.POSITIVE;
// Sub-element centres within one cell are derived analytically per pixel via
// HexMacroCellRenderer.nearestLatticeContaining (O(1) lattice inversion),
// avoiding the previous O(numSubCentres) scan per pixel.
// Render row by row.
int[] rowBuf = new int[wPx];
for (int yPx = 0; yPx < hPx; yPx++) {
// Sheet coords (mm): origin at top-left, +x right, +y down.
double yMm = (yPx + 0.5) * pixelSizeMm;
for (int xPx = 0; xPx < wPx; xPx++) rowBuf[xPx] = 0;
// For each cell whose vertical extent intersects this row, render its slice.
for (int cellIdx = 0; cellIdx < cellCentres.size(); cellIdx++) {
double[] c = cellCentres.get(cellIdx);
double cy = c[1];
if (Math.abs(yMm - cy) > halfFlat) continue;
double cx = c[0];
WindowFoilParameters.CellSpec spec = p.specForCell(cellIdx);
double zfMm = spec.focalLengthMm();
double zfSqMm = zfMm * zfMm;
// Determine x range covered by this cell at this row.
double dyLocal = yMm - cy;
// Hex (flat-top) constraint at given y: |x_local| ≤ R - |y_local|/√3
double xHalfMm = R - Math.abs(dyLocal) * invSqrt3;
if (xHalfMm <= 0) continue;
int xMinPx = Math.max(0, (int) Math.floor((cx - xHalfMm) / pixelSizeMm));
int xMaxPx = Math.min(wPx - 1, (int) Math.ceil((cx + xHalfMm) / pixelSizeMm));
for (int xPx = xMinPx; xPx <= xMaxPx; xPx++) {
double xMm = (xPx + 0.5) * pixelSizeMm;
double dxLocal = xMm - cx;
if (Math.abs(dxLocal) + Math.abs(dyLocal) * invSqrt3 > R) continue;
// Find sub-element containing (dxLocal, dyLocal) in O(1) via the
// analytical lattice inverse instead of scanning the centre list.
double[] sub = HexMacroCellRenderer.nearestLatticeContaining(
dxLocal, dyLocal, subPitchMm, subRadiusMmSq, R);
if (sub == null) continue;
double sx = sub[0];
double sy = sub[1];
double xfLocal = spec.targetOffsetXmm() - sx;
double yfLocal = spec.targetOffsetYmm() - sy;
double dxF = (dxLocal - sx) - xfLocal;
double dyF = (dyLocal - sy) - yfLocal;
double L = Math.sqrt(dxF * dxF + dyF * dyF + zfSqMm);
double phi = k * L;
int v;
if (binary) {
double cv = Math.cos(phi);
boolean transparent = positive ? (cv >= 0.0) : (cv < 0.0);
v = transparent ? 255 : 0;
} else {
double wrapped = phi - 2.0 * Math.PI * Math.floor(phi / (2.0 * Math.PI));
v = (int) Math.min(255, Math.max(0, Math.round(wrapped * (255.0 / (2.0 * Math.PI)))));
if (!positive) v = 255 - v;
}
rowBuf[xPx] = v;
}
}
raster.setSamples(0, yPx, wPx, 1, 0, rowBuf);
}
if (p.drawCropMarks()) {
drawCropMarks(img, p, cellCentres, pixelSizeMm);
}
return new RenderResult(img, pixelSizeMm);
}
/**
* Compute hex-cell centres on a flat-top tiling whose centres lie inside
* the rectangular sheet. Pitch in x = √3·R; pitch in y = 1.5·R, with
* alternating-row x-offset of √3·R/2 (gap-less hex tiling).
*/
static List<double[]> cellCentresInsideSheet(double wMm, double hMm, double R) {
List<double[]> out = new ArrayList<>();
double sqrt3 = Math.sqrt(3.0);
double pitchX = sqrt3 * R;
double pitchY = 1.5 * R;
int nRows = (int) Math.ceil(hMm / pitchY) + 2;
int nCols = (int) Math.ceil(wMm / pitchX) + 2;
for (int j = 0; j <= nRows; j++) {
double cy = j * pitchY + R; // first row centre offset by R from top
if (cy > hMm) break;
double xOff = (j & 1) == 0 ? pitchX / 2.0 : pitchX;
for (int i = 0; i <= nCols; i++) {
double cx = i * pitchX + xOff - pitchX / 2.0;
if (cx > wMm) break;
if (cx < 0 || cy < 0) continue;
out.add(new double[]{cx, cy});
}
}
return out;
}
/** Draw 5 mm tick marks at the four sheet corners and small marks at each cell top. */
private static void drawCropMarks(BufferedImage img, WindowFoilParameters p,
List<double[]> cellCentres, double pixelMm) {
WritableRaster raster = img.getRaster();
int w = img.getWidth();
int h = img.getHeight();
int tickPx = Math.max(4, (int) Math.round(5.0 / pixelMm));
// Sheet corners: short ticks pointing inward
drawHLine(raster, 0, 0, tickPx);
drawVLine(raster, 0, 0, tickPx);
drawHLine(raster, w - tickPx, 0, tickPx);
drawVLine(raster, w - 1, 0, tickPx);
drawHLine(raster, 0, h - 1, tickPx);
drawVLine(raster, 0, h - tickPx, tickPx);
drawHLine(raster, w - tickPx, h - 1, tickPx);
drawVLine(raster, w - 1, h - tickPx, tickPx);
// Small cross at each cell centre (1 mm long).
int crossPx = Math.max(2, (int) Math.round(1.0 / pixelMm));
for (double[] c : cellCentres) {
int cx = (int) Math.round(c[0] / pixelMm);
int cy = (int) Math.round(c[1] / pixelMm);
drawHLine(raster, Math.max(0, cx - crossPx), cy, Math.min(w - cx + crossPx, 2 * crossPx + 1));
drawVLine(raster, cx, Math.max(0, cy - crossPx), Math.min(h - cy + crossPx, 2 * crossPx + 1));
}
}
private static void drawHLine(WritableRaster r, int x, int y, int len) {
if (y < 0 || y >= r.getHeight()) return;
for (int i = 0; i < len; i++) {
int xx = x + i;
if (xx < 0 || xx >= r.getWidth()) continue;
r.setSample(xx, y, 0, 255);
}
}
private static void drawVLine(WritableRaster r, int x, int y, int len) {
if (x < 0 || x >= r.getWidth()) return;
for (int i = 0; i < len; i++) {
int yy = y + i;
if (yy < 0 || yy >= r.getHeight()) continue;
r.setSample(x, yy, 0, 255);
}
}
/** Number of cells that would be tiled across the sheet. */
public static int countCells(WindowFoilParameters p) {
return cellCentresInsideSheet(p.sheetWidthMm(), p.sheetHeightMm(), p.macroRadiusMm()).size();
}
}