"""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 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_MODES = {"potrace", "vtracer"} VALID_OUTPUT_FORMATS = {"svg", "dxf", "json"} def _format_response( result, output_format: str, warnings: list[str], processing_ms: float, ): """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. """ 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_bytes = generate_dxf(result) 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. """ 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)}", ) # Resolve effective parameters: preset defaults + user overrides resolved = resolve_params(preset, user_params) effective_mode = resolved["vectorization_mode"] if effective_mode not in VALID_MODES: raise HTTPException( status_code=422, detail=f"Invalid mode '{effective_mode}'. Must be one of: {', '.join(sorted(VALID_MODES))}", ) raw_bytes = await file.read() if not raw_bytes: raise HTTPException(status_code=422, detail="Uploaded file is empty") warnings: list[str] = [] start = time.perf_counter() try: preprocessed = preprocess(raw_bytes, params=resolved["preprocessing"]) 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") }) else: svg_output = vtracer_trace(preprocessed, **{ 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", ) }) 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) @router.post("/engine/simplify") async def simplify( file: UploadFile = File(...), epsilon: float = Form(1.0), output_format: str = Form("svg"), ): """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. """ 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))}", ) 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 "