DesignController.java

package org.fresnel.backend.api;

import jakarta.validation.Valid;
import org.fresnel.optics.DesignValidator;
import org.fresnel.optics.MultiFocusRenderer;
import org.fresnel.optics.PdfExporter;
import org.fresnel.optics.PngExporter;
import org.fresnel.optics.RenderResult;
import org.fresnel.optics.RgbZonePlateRenderer;
import org.fresnel.optics.SingleZonePlateParameters;
import org.fresnel.optics.SvgExporter;
import org.fresnel.optics.ValidationResult;
import org.fresnel.optics.HexMacroCellRenderer;
import org.fresnel.optics.WindowFoilRenderer;
import org.fresnel.optics.HexMacroCellParameters;
import org.fresnel.optics.WindowFoilParameters;
import org.fresnel.optics.MultiFocusParameters;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;
import java.util.Map;

/**
 * Synchronous endpoints for designing and previewing Fresnel zone plates and related
 * diffractive elements.
 *
 * <p>Synchronous renders are capped at {@link #MAX_PREVIEW_PX} per side; for larger
 * outputs (window foils, very high-DPI macro cells, holograms) clients should use
 * the async render-job endpoints in {@code RenderJobController} which stream
 * progress over Server-Sent Events.
 */
@RestController
@RequestMapping("/api/designs")
public class DesignController {

    /** Maximum image side (in pixels) allowed for synchronous PNG preview. */
    public static final long MAX_PREVIEW_PX = 4096;

    // -------- Single zone plate (Use Case A) --------

    @PostMapping(value = "/validate",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.APPLICATION_JSON_VALUE)
    public ValidationResponse validate(@Valid @RequestBody SingleZonePlateRequest req) {
        SingleZonePlateParameters params = req.toParameters();
        ValidationResult v = DesignValidator.validate(params);
        return ValidationResponse.from(v);
    }

    @PostMapping(value = "/preview.png",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.IMAGE_PNG_VALUE)
    public ResponseEntity<byte[]> previewPng(@Valid @RequestBody SingleZonePlateRequest req) throws IOException {
        SingleZonePlateParameters params = req.toParameters();
        long sizePx = estimateSizePx(params);
        if (sizePx > MAX_PREVIEW_PX) {
            return tooLarge(sizePx);
        }
        return renderSinglePng(params, "inline", "fresnel-zone-plate.png");
    }

    @PostMapping(value = "/export.png",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.IMAGE_PNG_VALUE)
    public ResponseEntity<byte[]> exportPng(@Valid @RequestBody SingleZonePlateRequest req) throws IOException {
        return renderSinglePng(req.toParameters(), "attachment", "fresnel-zone-plate.png");
    }

    @PostMapping(value = "/export.svg",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = "image/svg+xml")
    public ResponseEntity<byte[]> exportSvg(@Valid @RequestBody SingleZonePlateRequest req,
                                            @RequestParam(value = "vector", defaultValue = "true") boolean vector)
            throws IOException {
        SingleZonePlateParameters params = req.toParameters();
        boolean canVector = vector
                && (params.targetOffsetXmm() == 0.0)
                && (params.targetOffsetYmm() == 0.0)
                && params.maskType() == org.fresnel.optics.MaskType.BINARY_AMPLITUDE;
        byte[] svg = canVector
                ? SvgExporter.toSvgZonePlateBytes(params)
                : SvgExporter.toSvgRasterBytes(
                        org.fresnel.optics.ZonePlateRenderer.render(params), params.dpi());
        return svgResponse(svg, "fresnel-zone-plate.svg");
    }

    @PostMapping(value = "/export.pdf",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.APPLICATION_PDF_VALUE)
    public ResponseEntity<byte[]> exportPdf(@Valid @RequestBody SingleZonePlateRequest req,
                                            @RequestParam(value = "sheet", defaultValue = "FIT") String sheet)
            throws IOException {
        PdfExporter.SheetSize size = parseSheetSize(sheet);
        RenderResult r = org.fresnel.optics.ZonePlateRenderer.render(req.toParameters());
        byte[] pdf = PdfExporter.toPdfBytes(r, size);
        return pdfResponse(pdf, "fresnel-zone-plate.pdf");
    }

    @PostMapping(value = "/export.dxf",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = "application/dxf")
    public ResponseEntity<byte[]> exportDxf(@Valid @RequestBody SingleZonePlateRequest req) throws IOException {
        byte[] dxf = org.fresnel.optics.DxfExporter.toDxfBytes(req.toParameters());
        return vendorResponse(dxf, "application/dxf", "fresnel-zone-plate.dxf");
    }

    @PostMapping(value = "/export.gbr",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = "application/vnd.gerber")
    public ResponseEntity<byte[]> exportGerber(@Valid @RequestBody SingleZonePlateRequest req) throws IOException {
        byte[] gbr = org.fresnel.optics.GerberExporter.toGerberBytes(req.toParameters());
        return vendorResponse(gbr, "application/vnd.gerber", "fresnel-zone-plate.gbr");
    }

    // -------- Hex macro cell (Use Case B) --------

    @PostMapping(value = "/hex/info",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.APPLICATION_JSON_VALUE)
    public Map<String, Object> hexInfo(@Valid @RequestBody HexMacroCellRequest req) {
        HexMacroCellParameters p = req.toParameters();
        int n = HexMacroCellRenderer.countSubElements(p);
        long sidePx = (long) Math.ceil(2.0 * p.macroRadiusMm() / (25.4 / p.dpi()));
        return Map.of("subElements", n, "imageSidePx", sidePx);
    }

    @PostMapping(value = "/hex/preview.png",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.IMAGE_PNG_VALUE)
    public ResponseEntity<byte[]> hexPreview(@Valid @RequestBody HexMacroCellRequest req) throws IOException {
        HexMacroCellParameters p = req.toParameters();
        long sizePx = (long) Math.ceil(2.0 * p.macroRadiusMm() / (25.4 / p.dpi()));
        if (sizePx > MAX_PREVIEW_PX) return tooLarge(sizePx);
        RenderResult r = HexMacroCellRenderer.render(p);
        return pngResponse(PngExporter.toPngBytes(r, p.dpi()), "inline", "fresnel-hex-macro.png");
    }

    @PostMapping(value = "/hex/export.png",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.IMAGE_PNG_VALUE)
    public ResponseEntity<byte[]> hexExportPng(@Valid @RequestBody HexMacroCellRequest req) throws IOException {
        RenderResult r = HexMacroCellRenderer.render(req.toParameters());
        return pngResponse(PngExporter.toPngBytes(r, req.dpi()), "attachment", "fresnel-hex-macro.png");
    }

    @PostMapping(value = "/hex/export.svg",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = "image/svg+xml")
    public ResponseEntity<byte[]> hexExportSvg(@Valid @RequestBody HexMacroCellRequest req) throws IOException {
        RenderResult r = HexMacroCellRenderer.render(req.toParameters());
        return svgResponse(SvgExporter.toSvgRasterBytes(r, req.dpi()), "fresnel-hex-macro.svg");
    }

    @PostMapping(value = "/hex/export.pdf",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.APPLICATION_PDF_VALUE)
    public ResponseEntity<byte[]> hexExportPdf(@Valid @RequestBody HexMacroCellRequest req,
                                               @RequestParam(value = "sheet", defaultValue = "FIT") String sheet)
            throws IOException {
        RenderResult r = HexMacroCellRenderer.render(req.toParameters());
        return pdfResponse(PdfExporter.toPdfBytes(r, parseSheetSize(sheet)), "fresnel-hex-macro.pdf");
    }

    // -------- Window foil (Use Case C) --------

    @PostMapping(value = "/foil/info",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.APPLICATION_JSON_VALUE)
    public Map<String, Object> foilInfo(@Valid @RequestBody WindowFoilRequest req) {
        WindowFoilParameters p = req.toParameters();
        int n = WindowFoilRenderer.countCells(p);
        long wPx = (long) Math.ceil(p.sheetWidthMm() / (25.4 / p.dpi()));
        long hPx = (long) Math.ceil(p.sheetHeightMm() / (25.4 / p.dpi()));
        return Map.of("cells", n, "imageWidthPx", wPx, "imageHeightPx", hPx);
    }

    @PostMapping(value = "/foil/preview.png",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.IMAGE_PNG_VALUE)
    public ResponseEntity<byte[]> foilPreview(@Valid @RequestBody WindowFoilRequest req) throws IOException {
        WindowFoilParameters p = req.toParameters();
        long wPx = (long) Math.ceil(p.sheetWidthMm() / (25.4 / p.dpi()));
        long hPx = (long) Math.ceil(p.sheetHeightMm() / (25.4 / p.dpi()));
        if (wPx > MAX_PREVIEW_PX || hPx > MAX_PREVIEW_PX) return tooLarge(Math.max(wPx, hPx));
        RenderResult r = WindowFoilRenderer.render(p);
        return pngResponse(PngExporter.toPngBytes(r, p.dpi()), "inline", "fresnel-window-foil.png");
    }

    @PostMapping(value = "/foil/export.pdf",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.APPLICATION_PDF_VALUE)
    public ResponseEntity<byte[]> foilExportPdf(@Valid @RequestBody WindowFoilRequest req,
                                                @RequestParam(value = "sheet", defaultValue = "A4") String sheet)
            throws IOException {
        RenderResult r = WindowFoilRenderer.render(req.toParameters());
        return pdfResponse(PdfExporter.toPdfBytes(r, parseSheetSize(sheet)), "fresnel-window-foil.pdf");
    }

    // -------- Multi-focus (Mode 4) --------

    @PostMapping(value = "/multifocus/preview.png",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.IMAGE_PNG_VALUE)
    public ResponseEntity<byte[]> multiFocusPreview(@Valid @RequestBody MultiFocusRequest req) throws IOException {
        MultiFocusParameters p = req.toParameters();
        long sizePx = (long) Math.ceil(p.apertureDiameterMm() / (25.4 / p.dpi()));
        if (sizePx > MAX_PREVIEW_PX) return tooLarge(sizePx);
        RenderResult r = MultiFocusRenderer.render(p);
        return pngResponse(PngExporter.toPngBytes(r, p.dpi()), "inline", "fresnel-multifocus.png");
    }

    @PostMapping(value = "/multifocus/export.png",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.IMAGE_PNG_VALUE)
    public ResponseEntity<byte[]> multiFocusExport(@Valid @RequestBody MultiFocusRequest req) throws IOException {
        RenderResult r = MultiFocusRenderer.render(req.toParameters());
        return pngResponse(PngExporter.toPngBytes(r, req.dpi()), "attachment", "fresnel-multifocus.png");
    }

    // -------- RGB / multi-wavelength (Mode 5) --------

    @PostMapping(value = "/rgb/preview.png",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.IMAGE_PNG_VALUE)
    public ResponseEntity<byte[]> rgbPreview(@Valid @RequestBody RgbZonePlateRequest req) throws IOException {
        SingleZonePlateParameters base = req.base().toParameters();
        long sizePx = estimateSizePx(base);
        if (sizePx > MAX_PREVIEW_PX) return tooLarge(sizePx);
        RenderResult r = RgbZonePlateRenderer.render(base, req.redNm(), req.greenNm(), req.blueNm());
        return pngResponse(PngExporter.toPngBytes(r, base.dpi()), "inline", "fresnel-rgb.png");
    }

    @PostMapping(value = "/rgb/export.png",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.IMAGE_PNG_VALUE)
    public ResponseEntity<byte[]> rgbExport(@Valid @RequestBody RgbZonePlateRequest req) throws IOException {
        SingleZonePlateParameters base = req.base().toParameters();
        RenderResult r = RgbZonePlateRenderer.render(base, req.redNm(), req.greenNm(), req.blueNm());
        return pngResponse(PngExporter.toPngBytes(r, base.dpi()), "attachment", "fresnel-rgb.png");
    }

    // -------- Helpers --------

    private static long estimateSizePx(SingleZonePlateParameters p) {
        double pixelMm = 25.4 / p.dpi();
        return Math.round(p.apertureDiameterMm() / pixelMm);
    }

    private static ResponseEntity<byte[]> renderSinglePng(SingleZonePlateParameters params,
                                                          String disposition, String filename) throws IOException {
        RenderResult r = org.fresnel.optics.ZonePlateRenderer.render(params);
        return pngResponse(PngExporter.toPngBytes(r, params.dpi()), disposition, filename);
    }

    private static ResponseEntity<byte[]> pngResponse(byte[] body, String disposition, String filename) {
        HttpHeaders h = new HttpHeaders();
        h.setContentType(MediaType.IMAGE_PNG);
        h.setContentDisposition("inline".equalsIgnoreCase(disposition)
                ? org.springframework.http.ContentDisposition.inline().filename(filename).build()
                : org.springframework.http.ContentDisposition.attachment().filename(filename).build());
        return new ResponseEntity<>(body, h, 200);
    }

    private static ResponseEntity<byte[]> svgResponse(byte[] body, String filename) {
        HttpHeaders h = new HttpHeaders();
        h.setContentType(MediaType.parseMediaType("image/svg+xml"));
        h.setContentDisposition(org.springframework.http.ContentDisposition.attachment().filename(filename).build());
        return new ResponseEntity<>(body, h, 200);
    }

    private static ResponseEntity<byte[]> pdfResponse(byte[] body, String filename) {
        HttpHeaders h = new HttpHeaders();
        h.setContentType(MediaType.APPLICATION_PDF);
        h.setContentDisposition(org.springframework.http.ContentDisposition.attachment().filename(filename).build());
        return new ResponseEntity<>(body, h, 200);
    }

    private static ResponseEntity<byte[]> vendorResponse(byte[] body, String mime, String filename) {
        HttpHeaders h = new HttpHeaders();
        h.setContentType(MediaType.parseMediaType(mime));
        h.setContentDisposition(org.springframework.http.ContentDisposition.attachment().filename(filename).build());
        return new ResponseEntity<>(body, h, 200);
    }

    private static ResponseEntity<byte[]> tooLarge(long sizePx) {
        return ResponseEntity.status(413)
                .contentType(MediaType.TEXT_PLAIN)
                .body(("Image would be " + sizePx + " px wide; use async render jobs for > "
                        + MAX_PREVIEW_PX + " px.").getBytes());
    }

    private static PdfExporter.SheetSize parseSheetSize(String s) {
        try {
            return PdfExporter.SheetSize.valueOf(s.trim().toUpperCase(java.util.Locale.ROOT));
        } catch (RuntimeException e) {
            throw new IllegalArgumentException("unknown sheet size: " + s
                    + " (allowed: A0,A1,A2,A3,A4,FIT)");
        }
    }
}