"""API routes for the Kerf Engine trace and simplify endpoints.""" import json import time from fastapi import APIRouter, File, Form, HTTPException, UploadFile from fastapi.responses import Response from output import generate_dxf, generate_json, generate_svg from pipeline.postprocess import postprocess_svg from pipeline.preprocessing import VALID_MODES as VALID_CONVERSION_MODES, preprocess from pipeline.vectorize import potrace_trace, vtracer_trace from presets.loader import all_presets, preset_names, resolve_params router = APIRouter() @router.get("/engine/health") async def health(): """Healthcheck endpoint for container orchestration.""" return {"status": "ok"} VALID_VECTORIZER_MODES = {"potrace", "vtracer"} VALID_OUTPUT_FORMATS = {"svg", "dxf", "json"} def _format_response( result, output_format: str, warnings: list[str], processing_ms: float, *, units: str | None = None, scale_factor: float = 1.0, ): """Build a standardized response from a PostProcessResult and output format. SVG and JSON return a JSON envelope with output + metadata. DXF returns raw bytes with application/dxf content type. For DXF output, *units* and *scale_factor* are forwarded to :func:`generate_dxf` so the resulting file contains correct unit headers and real-world coordinate values. """ metadata = { "format": output_format, "path_count": len(result.paths), "node_count_total": result.total_nodes, "open_paths": result.open_path_count, "island_count": result.island_count, "warnings": warnings, "processing_ms": processing_ms, } if output_format == "dxf": dxf_kwargs: dict = {} if units is not None: dxf_kwargs["units"] = units if scale_factor != 1.0: dxf_kwargs["scale_factor"] = scale_factor dxf_bytes = generate_dxf(result, **dxf_kwargs) return Response( content=dxf_bytes, media_type="application/dxf", headers={ "Content-Disposition": "attachment; filename=output.dxf", "X-Kerf-Metadata": json.dumps(metadata), }, ) elif output_format == "json": return { "output": json.loads(generate_json(result)), "format": "json", "metadata": metadata, } else: return { "output": generate_svg(result), "format": "svg", "metadata": metadata, } @router.get("/engine/presets") async def list_presets(): """Return all available presets and their parameter values.""" return {"presets": all_presets()} @router.post("/engine/trace") async def trace( file: UploadFile = File(...), mode: str = Form(None), output_format: str = Form("svg"), preset: str = Form("sign"), params: str = Form("{}"), ): """Convert a raster image to vector via the preprocessing + vectorization + post-processing pipeline. Supports three output formats: svg (default), dxf, json. A preset provides default parameters for each pipeline stage. User params override preset defaults. The ``params`` JSON may include: - ``conversion_mode``: 'bw' (default), 'grayscale', or 'color' - ``invert``: bool (B&W mode only) - ``mask_regions``: list of {x, y, width, height} dicts for exclusion zones - Any preprocessing, vectorization, or postprocessing parameter In grayscale/color modes, vectorization is forced to vtracer regardless of the preset or mode param (potrace only handles binary images). """ if output_format not in VALID_OUTPUT_FORMATS: raise HTTPException( status_code=422, detail=f"Unsupported output_format '{output_format}'. Must be one of: {', '.join(sorted(VALID_OUTPUT_FORMATS))}", ) try: user_params = json.loads(params) except json.JSONDecodeError as exc: raise HTTPException(status_code=422, detail=f"Invalid params JSON: {exc}") # If mode is explicitly provided, inject it into user_params for resolve_params if mode is not None: user_params.setdefault("mode", mode) # Validate the preset name valid_presets = preset_names() if preset not in valid_presets: raise HTTPException( status_code=422, detail=f"Unknown preset '{preset}'. Must be one of: {', '.join(valid_presets)}", ) # Extract conversion_mode before resolve_params (it's a pipeline-level concern) conversion_mode = user_params.pop("conversion_mode", "bw") if conversion_mode not in VALID_CONVERSION_MODES: raise HTTPException( status_code=422, detail=f"Invalid conversion_mode '{conversion_mode}'. Must be one of: {', '.join(sorted(VALID_CONVERSION_MODES))}", ) invert = bool(user_params.pop("invert", False)) mask_regions = user_params.pop("mask_regions", None) # Resolve effective parameters: preset defaults + user overrides resolved = resolve_params(preset, user_params) effective_mode = resolved["vectorization_mode"] # Force vtracer for non-binary conversion modes (potrace can't handle color/gray) if conversion_mode in ("color", "grayscale") and effective_mode == "potrace": effective_mode = "vtracer" if effective_mode not in VALID_VECTORIZER_MODES: raise HTTPException( status_code=422, detail=f"Invalid mode '{effective_mode}'. Must be one of: {', '.join(sorted(VALID_VECTORIZER_MODES))}", ) raw_bytes = await file.read() if not raw_bytes: raise HTTPException(status_code=422, detail="Uploaded file is empty") warnings: list[str] = [] # Warn if potrace was requested but overridden if conversion_mode in ("color", "grayscale") and resolved["vectorization_mode"] == "potrace": warnings.append( f"Potrace does not support {conversion_mode} mode — switched to vtracer" ) start = time.perf_counter() # Inject conversion_mode, invert, and mask_regions into preprocessing params pre_params = dict(resolved["preprocessing"]) pre_params["conversion_mode"] = conversion_mode pre_params["invert"] = invert if mask_regions: pre_params["mask_regions"] = mask_regions try: preprocessed = preprocess(raw_bytes, params=pre_params) except ValueError as exc: raise HTTPException(status_code=422, detail=f"Preprocessing failed: {exc}") try: vec_params = resolved["vectorizer_params"] if effective_mode == "potrace": svg_output = potrace_trace(preprocessed, **{ k: v for k, v in vec_params.items() if k in ("turdsize", "alphamax", "opticurve", "opttolerance", "turnpolicy") }) else: # Set VTracer colormode based on conversion_mode vtracer_kwargs = { k: v for k, v in vec_params.items() if k in ( "colormode", "hierarchical", "filter_speckle", "color_precision", "layer_difference", "corner_threshold", "length_threshold", "splice_threshold", "mode", "path_precision", "max_iterations", ) } if conversion_mode == "color": vtracer_kwargs["colormode"] = "color" elif conversion_mode == "grayscale": # VTracer binary mode on grayscale input quantizes to tonal layers vtracer_kwargs["colormode"] = "color" # bw mode keeps whatever colormode was in preset (usually "binary") svg_output = vtracer_trace(preprocessed, **vtracer_kwargs) except Exception as exc: raise HTTPException(status_code=500, detail=f"Vectorization failed: {exc}") # Post-processing: RDP simplification, island detection, open path analysis post = resolved["postprocessing"] epsilon = float(post.get("epsilon", 1.0)) close_tolerance = float(post.get("close_tolerance", 1.0)) auto_close = bool(post.get("auto_close", False)) try: result = postprocess_svg( svg_output, epsilon=epsilon, close_tolerance=close_tolerance, auto_close=auto_close, ) except Exception as exc: raise HTTPException(status_code=500, detail=f"Post-processing failed: {exc}") processing_ms = round((time.perf_counter() - start) * 1000, 2) return _format_response(result, output_format, warnings, processing_ms) VALID_UNITS = {"inches", "mm"} @router.post("/engine/simplify") async def simplify( file: UploadFile = File(...), epsilon: float = Form(1.0), output_format: str = Form("svg"), units: str | None = Form(None), scale_factor: float = Form(1.0), ): """Simplify an existing SVG using RDP path simplification. Accepts an SVG file upload, applies Ramer-Douglas-Peucker simplification with the given epsilon, and returns the result in the requested format. For DXF output, *units* (``'inches'`` or ``'mm'``) and *scale_factor* control the unit metadata headers and coordinate scaling in the DXF file. """ if output_format not in VALID_OUTPUT_FORMATS: raise HTTPException( status_code=422, detail=f"Unsupported output_format '{output_format}'. Must be one of: {', '.join(sorted(VALID_OUTPUT_FORMATS))}", ) if units is not None and units not in VALID_UNITS: raise HTTPException( status_code=422, detail=f"Unsupported units '{units}'. Must be one of: {', '.join(sorted(VALID_UNITS))}", ) raw_bytes = await file.read() if not raw_bytes: raise HTTPException(status_code=422, detail="Uploaded file is empty") try: svg_str = raw_bytes.decode("utf-8") except UnicodeDecodeError: raise HTTPException(status_code=422, detail="File is not valid UTF-8 text") if "