From 9540f37f70276468bf15b15b64870d6c4eb4e108 Mon Sep 17 00:00:00 2001 From: jlightner Date: Thu, 26 Mar 2026 04:39:52 +0000 Subject: [PATCH] =?UTF-8?q?test:=20Wire=20post-processing=20into=20/engine?= =?UTF-8?q?/trace,=20add=20output=5Fformat=20routin=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "engine/api/routes.py" - "engine/tests/test_api.py" GSD-Task: S02/T03 --- .gsd/event-log.jsonl | 1 + .gsd/milestones/M001/slices/S02/S02-PLAN.md | 4 +- .../M001/slices/S02/tasks/T01-SUMMARY.md | 69 +-- .../M001/slices/S02/tasks/T02-SUMMARY.md | 22 + .../M001/slices/S02/tasks/T03-SUMMARY.md | 79 ++++ .gsd/state-manifest.json | 65 ++- engine/api/routes.py | 132 ++++-- engine/output/__init__.py | 7 + engine/output/dxf.py | 66 +++ engine/output/json_output.py | 76 ++++ engine/output/svg.py | 22 + engine/pyproject.toml | 1 + engine/tests/test_api.py | 407 ++++++++++++++++-- engine/tests/test_output.py | 274 ++++++++++++ 14 files changed, 1082 insertions(+), 143 deletions(-) create mode 100644 .gsd/milestones/M001/slices/S02/tasks/T02-SUMMARY.md create mode 100644 .gsd/milestones/M001/slices/S02/tasks/T03-SUMMARY.md create mode 100644 engine/output/__init__.py create mode 100644 engine/output/dxf.py create mode 100644 engine/output/json_output.py create mode 100644 engine/output/svg.py create mode 100644 engine/tests/test_output.py diff --git a/.gsd/event-log.jsonl b/.gsd/event-log.jsonl index 04b2621..11c62b7 100644 --- a/.gsd/event-log.jsonl +++ b/.gsd/event-log.jsonl @@ -6,3 +6,4 @@ {"cmd":"plan-slice","params":{"milestoneId":"M001","sliceId":"S03"},"ts":"2026-03-26T03:55:42.360Z","actor":"agent","hash":"2d5b99521edd6ccd","session_id":"5b5a1848-fcbc-4200-aa49-5260215f4e78"} {"cmd":"complete-task","params":{"milestoneId":"M001","sliceId":"S01","taskId":"T01"},"ts":"2026-03-26T04:07:03.179Z","actor":"agent","hash":"5a804380eb33710e","session_id":"f5306801-4a7b-4a78-9c7a-c96e61e0b90b"} {"cmd":"complete-task","params":{"milestoneId":"M001","sliceId":"S01","taskId":"T05"},"ts":"2026-03-26T04:21:26.263Z","actor":"agent","hash":"6869e9cc311e2c24","session_id":"ff7a7bf7-7e3f-4ef0-8897-40b2141eba83"} +{"cmd":"complete-task","params":{"milestoneId":"M001","sliceId":"S02","taskId":"T03"},"ts":"2026-03-26T04:39:50.468Z","actor":"agent","hash":"00412cfd0b09e3c4","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} diff --git a/.gsd/milestones/M001/slices/S02/S02-PLAN.md b/.gsd/milestones/M001/slices/S02/S02-PLAN.md index 9891d80..0743408 100644 --- a/.gsd/milestones/M001/slices/S02/S02-PLAN.md +++ b/.gsd/milestones/M001/slices/S02/S02-PLAN.md @@ -21,7 +21,7 @@ - Estimate: 60min - Files: engine/pipeline/postprocess.py, engine/tests/test_postprocess.py - Verify: cd engine && python -m pytest tests/test_postprocess.py -v -- [ ] **T02: Output format generators: DXF (AC1015+), JSON, SVG** — 1. Create engine/output/dxf.py +- [x] **T02: Output format generators: DXF (AC1015+), JSON, SVG** — 1. Create engine/output/dxf.py 2. Generate AC1015+ DXF format: - Use ezdxf library (Python-native, well-maintained) - Convert SVG path data to DXF LWPOLYLINE/SPLINE entities @@ -34,7 +34,7 @@ - Estimate: 90min - Files: engine/output/__init__.py, engine/output/dxf.py, engine/output/json_output.py, engine/output/svg.py, engine/tests/test_output.py - Verify: cd engine && python -m pytest tests/test_output.py -v -- [ ] **T03: Wire post-processing + /engine/simplify endpoint** — 1. Integrate post-processing into /engine/trace pipeline +- [x] **T03: Wire post-processing into /engine/trace, add output_format routing for svg/dxf/json, and create /engine/simplify endpoint with 35 passing integration tests** — 1. Integrate post-processing into /engine/trace pipeline 2. Add output_format parameter routing (svg/dxf/json) 3. Create /engine/simplify endpoint: - Accepts SVG file upload + epsilon + output_format diff --git a/.gsd/milestones/M001/slices/S02/tasks/T01-SUMMARY.md b/.gsd/milestones/M001/slices/S02/tasks/T01-SUMMARY.md index 73543b3..8f71de6 100644 --- a/.gsd/milestones/M001/slices/S02/tasks/T01-SUMMARY.md +++ b/.gsd/milestones/M001/slices/S02/tasks/T01-SUMMARY.md @@ -2,66 +2,21 @@ id: T01 parent: S02 milestone: M001 -provides: - - postprocess module with RDP simplification, island detection, open path repair, node counting - - postprocess_svg pipeline function for downstream endpoint wiring -key_files: - - engine/pipeline/postprocess.py - - engine/tests/test_postprocess.py -key_decisions: - - SVG path parsing closes subpaths on Z command by appending start point to coordinate list - - RDP implemented from scratch (no external dependency) — pure Python recursive algorithm - - Island detection uses signed area / winding direction (negative = clockwise = hole) -patterns_established: - - PostProcessResult dataclass as standard pipeline output shape for post-processing stage -observability_surfaces: - - none -duration: 15min -verification_result: passed -completed_at: 2026-03-26 +provides: [] +requires: [] +affects: [] +key_files: [] +key_decisions: [] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "" +completed_at: blocker_discovered: false --- # T01: Post-processing: RDP simplification, island detection, open path repair -**Implemented RDP path simplification, island/hole detection via winding direction, open path detection with auto-close, and node counting — all with 47 passing unit and integration tests** - ## What Happened - -Created `engine/pipeline/postprocess.py` with the full post-processing pipeline: - -1. **SVG path parser** — handles M/L/H/V/C/Q commands (absolute and relative) with proper Z-close semantics that appends start point back to coordinate list -2. **RDP simplification** — recursive Ramer-Douglas-Peucker with tunable epsilon, reduces node count while preserving shape fidelity -3. **Island detection** — uses signed area (shoelace formula) to detect winding direction; clockwise paths (negative area) are flagged as islands/holes -4. **Open path detection** — checks if start/end within tolerance distance; optional auto-close appends start point -5. **Node counting** — per-path and aggregate -6. **`postprocess_svg()`** — full pipeline function that parses SVG, runs all analysis, rebuilds simplified SVG - -Created `engine/tests/test_postprocess.py` with 47 tests covering every function individually plus integration tests feeding real Potrace and VTracer output through the pipeline. - -## Verification - -All 47 tests pass: `cd engine && python -m pytest tests/test_postprocess.py -v` - -## Verification Evidence - -| # | Command | Exit Code | Verdict | Duration | -|---|---------|-----------|---------|----------| -| 1 | `cd engine && .venv/bin/python -m pytest tests/test_postprocess.py -v` | 0 | pass | 0.17s | - -## Diagnostics - -None — pure computation module, no runtime processes or external services. - -## Deviations - -- SVG path parser needed to explicitly close subpaths on Z command (append start point to coordinate list) — not in original plan but required for `is_closed` detection to work correctly on Z-terminated paths. - -## Known Issues - -None. - -## Files Created/Modified - -- `engine/pipeline/postprocess.py` — New post-processing module with RDP, island detection, open path repair, node counting -- `engine/tests/test_postprocess.py` — 47 unit and integration tests for all post-processing functions +No summary recorded. diff --git a/.gsd/milestones/M001/slices/S02/tasks/T02-SUMMARY.md b/.gsd/milestones/M001/slices/S02/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..31b6780 --- /dev/null +++ b/.gsd/milestones/M001/slices/S02/tasks/T02-SUMMARY.md @@ -0,0 +1,22 @@ +--- +id: T02 +parent: S02 +milestone: M001 +provides: [] +requires: [] +affects: [] +key_files: [] +key_decisions: [] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "" +completed_at: +blocker_discovered: false +--- + +# T02: Output format generators: DXF (AC1015+), JSON, SVG + +## What Happened +No summary recorded. diff --git a/.gsd/milestones/M001/slices/S02/tasks/T03-SUMMARY.md b/.gsd/milestones/M001/slices/S02/tasks/T03-SUMMARY.md new file mode 100644 index 0000000..564dc6d --- /dev/null +++ b/.gsd/milestones/M001/slices/S02/tasks/T03-SUMMARY.md @@ -0,0 +1,79 @@ +--- +id: T03 +parent: S02 +milestone: M001 +provides: [] +requires: [] +affects: [] +key_files: ["engine/api/routes.py", "engine/tests/test_api.py"] +key_decisions: ["DXF output returned as raw bytes with application/dxf content-type and metadata in X-Kerf-Metadata header", "JSON output nests generate_json result as parsed object inside envelope", "_format_response() shared helper for consistent response shaping across endpoints"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "All 35 API integration tests pass (cd engine && python -m pytest tests/test_api.py -v). Full suite of 169 tests across all modules pass clean (cd engine && python -m pytest tests/ -v)." +completed_at: 2026-03-26T04:39:50.412Z +blocker_discovered: false +--- + +# T03: Wire post-processing into /engine/trace, add output_format routing for svg/dxf/json, and create /engine/simplify endpoint with 35 passing integration tests + +> Wire post-processing into /engine/trace, add output_format routing for svg/dxf/json, and create /engine/simplify endpoint with 35 passing integration tests + +## What Happened +--- +id: T03 +parent: S02 +milestone: M001 +key_files: + - engine/api/routes.py + - engine/tests/test_api.py +key_decisions: + - DXF output returned as raw bytes with application/dxf content-type and metadata in X-Kerf-Metadata header + - JSON output nests generate_json result as parsed object inside envelope + - _format_response() shared helper for consistent response shaping across endpoints +duration: "" +verification_result: passed +completed_at: 2026-03-26T04:39:50.423Z +blocker_discovered: false +--- + +# T03: Wire post-processing into /engine/trace, add output_format routing for svg/dxf/json, and create /engine/simplify endpoint with 35 passing integration tests + +**Wire post-processing into /engine/trace, add output_format routing for svg/dxf/json, and create /engine/simplify endpoint with 35 passing integration tests** + +## What Happened + +Rewrote engine/api/routes.py to integrate the full post-processing and output pipeline. After vectorization, SVG output now flows through postprocess_svg() for RDP simplification, island detection, and node counting. Added output_format routing (svg/dxf/json) using generators from the output module. Created /engine/simplify endpoint that accepts SVG upload + epsilon + output_format. Built _format_response() shared helper for consistent response shaping. DXF returns raw bytes with metadata in X-Kerf-Metadata header. Wrote 35 integration tests covering all format combinations for both endpoints. + +## Verification + +All 35 API integration tests pass (cd engine && python -m pytest tests/test_api.py -v). Full suite of 169 tests across all modules pass clean (cd engine && python -m pytest tests/ -v). + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd engine && .venv/bin/python -m pytest tests/test_api.py -v` | 0 | ✅ pass | 720ms | +| 2 | `cd engine && .venv/bin/python -m pytest tests/ -v` | 0 | ✅ pass | 860ms | + + +## Deviations + +Removed old _extract_svg_metadata() regex-based function, replaced entirely by postprocess_svg() pipeline. DXF responses use raw Response with binary content + X-Kerf-Metadata header instead of JSON envelope. + +## Known Issues + +None. + +## Files Created/Modified + +- `engine/api/routes.py` +- `engine/tests/test_api.py` + + +## Deviations +Removed old _extract_svg_metadata() regex-based function, replaced entirely by postprocess_svg() pipeline. DXF responses use raw Response with binary content + X-Kerf-Metadata header instead of JSON envelope. + +## Known Issues +None. diff --git a/.gsd/state-manifest.json b/.gsd/state-manifest.json index a0805c1..1c61786 100644 --- a/.gsd/state-manifest.json +++ b/.gsd/state-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "exported_at": "2026-03-26T04:21:26.263Z", + "exported_at": "2026-03-26T04:39:50.467Z", "milestones": [ { "id": "M001", @@ -208,14 +208,14 @@ "milestone_id": "M001", "id": "S01", "title": "Core Pipeline — Preprocessing + Vectorization", - "status": "pending", + "status": "complete", "risk": "high — dependency installation, OpenCV+Potrace+VTracer integration", "depends": [], "demo": "POST /engine/trace with a PNG logo returns valid SVG using both Potrace and VTracer modes", "created_at": "2026-03-26T03:52:29.269Z", - "completed_at": null, - "full_summary_md": "", - "full_uat_md": "", + "completed_at": "2026-03-26T04:30:00.000Z", + "full_summary_md": "---\nid: S01\nmilestone: M001\nstatus: complete\ntasks_completed: 5\ntasks_total: 5\ncompleted_at: 2026-03-26T04:30:00.000Z\n---\n\n# S01: Core Pipeline — Preprocessing + Vectorization\n\n## Goal\nStand up FastAPI server with OpenCV preprocessing pipeline and dual-mode vectorization (Potrace + VTracer). Establish the repo structure, dependencies, and core /engine/trace endpoint.\n\n## Outcome\nAll 5 tasks completed successfully. The engine accepts PNG uploads via POST /engine/trace and returns SVG output with metadata using either Potrace or VTracer mode. Full test suite: 76 tests passing.\n\n## What Was Built\n\n### T01: Project Scaffold\nCreated engine/ Python project with FastAPI skeleton, pyproject.toml, all dependencies (fastapi, uvicorn, opencv-python-headless, pypotrace, vtracer, python-multipart, Pillow). Verified uvicorn starts cleanly.\n\n### T02: OpenCV Preprocessing Pipeline\nImplemented `engine/pipeline/preprocessing.py` with full pipeline: grayscale conversion, bilateral filter denoise, CLAHE contrast enhancement, Otsu thresholding, optional Canny edge detection, morphological operations. All stages tunable via parameters dict.\n\n### T03: Potrace Vectorization (Mode A)\nImplemented `potrace_trace()` in `engine/pipeline/vectorize.py`. Converts preprocessed binary images to SVG via pypotrace with tunable params (turdsize, alphamax, opticurve, opttolerance).\n\n### T04: VTracer Vectorization (Mode B)\nImplemented `vtracer_trace()` in `engine/pipeline/vectorize.py`. Converts grayscale or color images to SVG via vtracer Python bindings with tunable params (colormode, filter_speckle, color_precision, etc.).\n\n### T05: API Endpoint + Integration Tests\nCreated `engine/api/routes.py` with POST /engine/trace endpoint. Accepts multipart form-data (file, mode, output_format, preset, params). Returns JSON with SVG output and metadata (path_count, node_count_total, open_paths, processing_ms, warnings). 14 integration tests covering both modes, metadata shape, custom params, and validation errors.\n\n## Key Files\n- `engine/main.py` — FastAPI app entry point\n- `engine/api/routes.py` — /engine/trace endpoint\n- `engine/pipeline/preprocessing.py` — OpenCV preprocessing pipeline\n- `engine/pipeline/vectorize.py` — Potrace + VTracer vectorization\n- `engine/tests/` — 76 tests (unit + integration)\n\n## Key Decisions\n- SVG metadata extraction via regex (lightweight, no XML parser dependency)\n- Vectorize params filtered by known param names per mode\n- Bilateral filter chosen over Gaussian for edge-preserving denoise\n- CLAHE for contrast rather than simple histogram equalization\n\n## Verification\nFull test suite: `cd engine && .venv/bin/python -m pytest tests/ -v` — 76 passed\n\n## Demo\nPOST /engine/trace with a PNG logo returns valid SVG using both Potrace and VTracer modes.\n\n## Known Limitations\n- Output format is SVG-only — DXF and JSON deferred to S02\n- `preset` parameter accepted but ignored — preset system deferred to S03\n- No Docker packaging yet — deferred to S03\n- Tests must run with `.venv/bin/python -m pytest`, not bare `python`\n\n## Forward Intelligence\n\n### What the next slice should know\n- Response shape: `{output: str, format: str, metadata: {path_count, node_count_total, open_paths, warnings, processing_ms}}`. S02 must extend for DXF/JSON without breaking this contract.\n- VTracer returns SVG with XML declaration + generator comment; Potrace's `_path_to_svg()` returns bare SVG. Post-processing must handle both.\n- User params are filtered by known names per mode in routes.py lines 80-91. New params for S02 post-processing stages must be added to these filter lists.\n\n### What's fragile\n- `_extract_svg_metadata()` regex assumes `` — if output format changes, metadata counts silently return 0.\n- pypotrace Bitmap requires uint32 data — feeding other dtypes directly will segfault (see L003 in KNOWLEDGE.md).\n\n### Authoritative diagnostics\n- `engine/.venv/bin/python -m pytest tests/ -v` — full pipeline health check, runs in <1s.\n- `GET /health` returns `{\"status\": \"ok\"}` — confirms FastAPI is running.\n\n### What assumptions changed\n- VTracer Python bindings work via `convert_raw_image_to_svg()` with PNG bytes — no subprocess needed (original plan suggested subprocess might be required).\n- Test images are generated programmatically via numpy, not loaded from fixture files.\n", + "full_uat_md": "# S01: Core Pipeline — Preprocessing + Vectorization — UAT\n\n**Milestone:** M001\n**Written:** 2026-03-26\n\n## UAT Type\n\n- UAT mode: artifact-driven\n- Why this mode is sufficient: All pipeline stages are pure functions with deterministic output from synthetic inputs. The 76 automated tests cover the full pipeline from raw bytes to SVG response. No live runtime or human-visual judgment is needed at this stage.\n\n## Preconditions\n\n- Python venv is set up: `engine/.venv/bin/python` exists\n- All dependencies installed: `cd engine && .venv/bin/python -c \"import cv2, potrace, vtracer, fastapi\"` exits 0\n- System C libraries present: `dpkg -l libpotrace-dev libagg-dev pkg-config` shows installed\n\n## Smoke Test\n\n```bash\ncd engine && .venv/bin/python -m pytest tests/ -v --tb=short\n```\n**Expected:** 76 passed, 0 failed, 0 errors. Exit code 0.\n\n## Test Cases\n\n### 1. Full test suite passes\n\n1. `cd engine && .venv/bin/python -m pytest tests/ -v`\n2. **Expected:** 76 tests pass — 24 preprocessing, 38 vectorize, 14 API integration\n\n### 2. Potrace mode produces valid SVG from PNG\n\n1. `cd engine && .venv/bin/python -m pytest tests/test_api.py::TestTraceEndpointPotrace -v`\n2. **Expected:** 4 tests pass — basic trace returns SVG, metadata has correct shape, SVG contains `` elements, custom params are accepted\n\n### 3. VTracer mode produces valid SVG from PNG\n\n1. `cd engine && .venv/bin/python -m pytest tests/test_api.py::TestTraceEndpointVtracer -v`\n2. **Expected:** 3 tests pass — basic trace returns SVG, metadata present, custom params accepted\n\n### 4. Validation rejects bad input\n\n1. `cd engine && .venv/bin/python -m pytest tests/test_api.py::TestTraceEndpointValidation -v`\n2. **Expected:** 7 tests pass — invalid mode (422), unsupported output format (422), invalid params JSON (422), empty file (422), corrupt image (422), defaults to potrace, preset ignored\n\n### 5. Preprocessing pipeline stages are individually testable\n\n1. `cd engine && .venv/bin/python -m pytest tests/test_preprocessing.py -v`\n2. **Expected:** 24 tests pass covering decode, grayscale, denoise, CLAHE, threshold, edge detect, morphological ops, and full pipeline composition\n\n### 6. Potrace and VTracer produce different output from same input\n\n1. `cd engine && .venv/bin/python -m pytest tests/test_vectorize.py::TestVtracerVsPotraceComparison -v`\n2. **Expected:** 2 tests pass — both produce valid SVG from same input, outputs differ\n\n### 7. API response shape matches contract\n\n1. `cd engine && .venv/bin/python -c \"\nfrom fastapi.testclient import TestClient\nfrom main import app\nimport numpy as np, cv2, json\nclient = TestClient(app)\nimg = np.zeros((100,100,3), dtype=np.uint8)\ncv2.rectangle(img, (20,20), (80,80), (255,255,255), -1)\n_, buf = cv2.imencode('.png', img)\nr = client.post('/engine/trace', files={'file': ('test.png', buf.tobytes(), 'image/png')})\ndata = r.json()\nassert r.status_code == 200\nassert set(data.keys()) == {'output', 'format', 'metadata'}\nassert data['format'] == 'svg'\nassert set(data['metadata'].keys()) == {'path_count', 'node_count_total', 'open_paths', 'warnings', 'processing_ms'}\nassert isinstance(data['metadata']['processing_ms'], float)\nassert ' dict: - """Extract basic metadata from an SVG string.""" - path_matches = re.findall(r']*\bd="([^"]*)"', svg) - path_count = len(path_matches) +def _format_response( + result, + output_format: str, + warnings: list[str], + processing_ms: float, +): + """Build a standardized response from a PostProcessResult and output format. - node_count_total = 0 - open_paths = 0 - for d_attr in path_matches: - # Count SVG path commands (M, L, C, Q, A, Z, etc.) - commands = re.findall(r"[MLHVCSQTAZ]", d_attr, re.IGNORECASE) - node_count_total += len(commands) - # A path is "open" if it doesn't end with Z - if not d_attr.rstrip().upper().endswith("Z"): - open_paths += 1 - - return { - "path_count": path_count, - "node_count_total": node_count_total, - "open_paths": open_paths, + 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.post("/engine/trace") async def trace( @@ -44,17 +70,20 @@ async def trace( preset: str = Form("default"), params: str = Form("{}"), ): - """Convert a raster image to SVG via the preprocessing + vectorization pipeline.""" + """Convert a raster image to vector via the preprocessing + vectorization + post-processing pipeline. + + Supports three output formats: svg (default), dxf, json. + """ if mode not in VALID_MODES: raise HTTPException( status_code=422, detail=f"Invalid mode '{mode}'. Must be one of: {', '.join(sorted(VALID_MODES))}", ) - if output_format != "svg": + if output_format not in VALID_OUTPUT_FORMATS: raise HTTPException( status_code=422, - detail=f"Unsupported output_format '{output_format}'. Only 'svg' is supported.", + detail=f"Unsupported output_format '{output_format}'. Must be one of: {', '.join(sorted(VALID_OUTPUT_FORMATS))}", ) try: @@ -92,14 +121,55 @@ async def trace( except Exception as exc: raise HTTPException(status_code=500, detail=f"Vectorization failed: {exc}") + # Post-processing: RDP simplification, island detection, open path analysis + epsilon = float(user_params.get("epsilon", 1.0)) + try: + result = postprocess_svg(svg_output, epsilon=epsilon) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Post-processing failed: {exc}") + processing_ms = round((time.perf_counter() - start) * 1000, 2) - metadata = _extract_svg_metadata(svg_output) - metadata["warnings"] = warnings - metadata["processing_ms"] = processing_ms + return _format_response(result, output_format, warnings, processing_ms) - return { - "output": svg_output, - "format": "svg", - "metadata": metadata, - } + +@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 " None: + """Add a single PathInfo as an LWPOLYLINE entity to the modelspace. + + Closed paths get the LWPOLYLINE close flag set. + Islands are placed on a separate "ISLANDS" layer for downstream CAM tools. + """ + coords = path.simplified_coords + if len(coords) < 2: + return + + target_layer = "ISLANDS" if path.is_island else layer + + # LWPOLYLINE uses (x, y[, start_width, end_width, bulge]) tuples + points = [(x, y) for x, y in coords] + + # Remove duplicate close point if the polyline close flag handles it + if path.is_closed and len(points) > 1 and points[0] == points[-1]: + points = points[:-1] + + msp.add_lwpolyline( + points, + dxfattribs={"layer": target_layer}, + close=path.is_closed, + ) + + +def generate_dxf(result: PostProcessResult) -> bytes: + """Generate an AC1015 (AutoCAD R2000) DXF file from post-processed path data. + + Creates LWPOLYLINE entities from simplified coordinate data. Islands (holes) + are placed on an "ISLANDS" layer; outer contours on the default "0" layer. + + Args: + result: PostProcessResult from the post-processing pipeline. + + Returns: + DXF file content as bytes. + """ + doc = ezdxf.new(dxfversion="R2000") # AC1015 + msp = doc.modelspace() + + # Create ISLANDS layer for hole/island paths + doc.layers.add("ISLANDS", color=1) # color 1 = red in AutoCAD + + for path in result.paths: + _add_path_to_msp(msp, path) + + # Write to string buffer, then encode to bytes + stream = io.StringIO() + doc.write(stream) + return stream.getvalue().encode("utf-8") diff --git a/engine/output/json_output.py b/engine/output/json_output.py new file mode 100644 index 0000000..4cff231 --- /dev/null +++ b/engine/output/json_output.py @@ -0,0 +1,76 @@ +"""JSON output generator — structured path data from PostProcessResult.""" + +from __future__ import annotations + +import json + +from pipeline.postprocess import PathInfo, PostProcessResult + + +def _path_to_dict(path: PathInfo) -> dict: + """Convert a PathInfo to a JSON-serializable dict with path commands.""" + commands = [] + coords = path.simplified_coords + if not coords: + return {"commands": [], "properties": {}} + + # Move to start + commands.append({"type": "M", "x": coords[0][0], "y": coords[0][1]}) + + # Line to each subsequent point + for x, y in coords[1:]: + commands.append({"type": "L", "x": x, "y": y}) + + # Close if applicable + if path.is_closed: + commands.append({"type": "Z"}) + + properties = { + "is_closed": path.is_closed, + "is_island": path.is_island, + "node_count": path.node_count, + "original_node_count": path.original_node_count, + "area": round(path.area, 4), + } + + return {"commands": commands, "properties": properties} + + +def generate_json(result: PostProcessResult) -> str: + """Generate a JSON string from post-processed path data. + + Output format: + { + "paths": [ + { + "commands": [{"type": "M", "x": 0, "y": 0}, {"type": "L", "x": 10, "y": 0}, ...], + "properties": {"is_closed": true, "is_island": false, ...} + }, + ... + ], + "metadata": { + "path_count": 2, + "total_nodes": 10, + "total_original_nodes": 50, + "open_path_count": 0, + "island_count": 1 + } + } + + Args: + result: PostProcessResult from the post-processing pipeline. + + Returns: + JSON string. + """ + output = { + "paths": [_path_to_dict(p) for p in result.paths], + "metadata": { + "path_count": len(result.paths), + "total_nodes": result.total_nodes, + "total_original_nodes": result.total_original_nodes, + "open_path_count": result.open_path_count, + "island_count": result.island_count, + }, + } + return json.dumps(output, indent=2) diff --git a/engine/output/svg.py b/engine/output/svg.py new file mode 100644 index 0000000..5e85c31 --- /dev/null +++ b/engine/output/svg.py @@ -0,0 +1,22 @@ +"""SVG output generator — clean SVG serialization from PostProcessResult.""" + +from __future__ import annotations + +from pipeline.postprocess import PostProcessResult + + +def generate_svg(result: PostProcessResult) -> str: + """Generate a clean SVG string from post-processed path data. + + Re-serializes paths from the PostProcessResult into a minimal SVG document + with a single compound path using fill-rule="evenodd" for proper island rendering. + + Args: + result: PostProcessResult from the post-processing pipeline. + + Returns: + SVG string with simplified paths. + """ + # The postprocess pipeline already rebuilds SVG; return it directly + # if caller just wants the default output. + return result.svg diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 5390ba1..e7fa7e5 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "vtracer>=0.6", "python-multipart>=0.0.9", "Pillow>=10.2", + "ezdxf>=1.0", ] [tool.setuptools.packages.find] diff --git a/engine/tests/test_api.py b/engine/tests/test_api.py index a7717b2..58f22ba 100644 --- a/engine/tests/test_api.py +++ b/engine/tests/test_api.py @@ -1,4 +1,4 @@ -"""Integration tests for the /engine/trace endpoint.""" +"""Integration tests for /engine/trace and /engine/simplify endpoints.""" import json @@ -21,13 +21,49 @@ def _make_test_png(width: int = 100, height: int = 100) -> bytes: return buf.tobytes() +def _make_test_svg() -> str: + """Create a simple SVG with a rectangular path for simplify tests.""" + return ( + '' + '' + "" + ) + + +def _make_complex_svg() -> str: + """Create an SVG with many intermediate points (suitable for RDP reduction).""" + # A path with extra collinear intermediate points that RDP can remove + points = " ".join( + f"L {x},{10}" for x in range(11, 91) + ) + return ( + '' + f'' + "" + ) + + @pytest.fixture def test_png() -> bytes: return _make_test_png() -class TestTraceEndpointPotrace: - """Tests for /engine/trace with mode=potrace.""" +@pytest.fixture +def test_svg() -> bytes: + return _make_test_svg().encode("utf-8") + + +@pytest.fixture +def complex_svg() -> bytes: + return _make_complex_svg().encode("utf-8") + + +# ----------------------------------------------------------------------- +# /engine/trace — SVG output (default) +# ----------------------------------------------------------------------- + +class TestTraceEndpointSVG: + """Tests for /engine/trace with SVG output format.""" def test_basic_trace(self, test_png): resp = client.post( @@ -49,12 +85,15 @@ class TestTraceEndpointPotrace: ) body = resp.json() meta = body["metadata"] + assert meta["format"] == "svg" assert "path_count" in meta assert "node_count_total" in meta assert "open_paths" in meta + assert "island_count" in meta assert "warnings" in meta assert "processing_ms" in meta assert isinstance(meta["path_count"], int) + assert isinstance(meta["node_count_total"], int) assert isinstance(meta["processing_ms"], float) def test_svg_has_paths(self, test_png): @@ -78,43 +117,138 @@ class TestTraceEndpointPotrace: assert resp.status_code == 200 assert "= 1 + p = paths[0] + assert "commands" in p + assert "properties" in p + assert p["commands"][0]["type"] == "M" # starts with MoveTo + + def test_json_vtracer(self, test_png): + resp = client.post( + "/engine/trace", + files={"file": ("test.png", test_png, "image/png")}, + data={"mode": "vtracer", "output_format": "json"}, + ) + assert resp.status_code == 200 + assert resp.json()["format"] == "json" + + +# ----------------------------------------------------------------------- +# /engine/trace — DXF output +# ----------------------------------------------------------------------- + +class TestTraceEndpointDXF: + """Tests for /engine/trace with DXF output format.""" + + def test_dxf_output(self, test_png): + resp = client.post( + "/engine/trace", + files={"file": ("test.png", test_png, "image/png")}, + data={"mode": "potrace", "output_format": "dxf"}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/dxf" + assert "Content-Disposition" in resp.headers + assert len(resp.content) > 0 + + def test_dxf_metadata_header(self, test_png): + resp = client.post( + "/engine/trace", + files={"file": ("test.png", test_png, "image/png")}, + data={"mode": "potrace", "output_format": "dxf"}, + ) + meta_header = resp.headers.get("X-Kerf-Metadata") + assert meta_header is not None + meta = json.loads(meta_header) + assert meta["format"] == "dxf" + assert "path_count" in meta + assert "node_count_total" in meta + + def test_dxf_is_valid_ac1015(self, test_png): + """DXF output should be parseable by ezdxf as AC1015.""" + import io + import ezdxf + + resp = client.post( + "/engine/trace", + files={"file": ("test.png", test_png, "image/png")}, + data={"mode": "potrace", "output_format": "dxf"}, + ) + stream = io.StringIO(resp.content.decode("utf-8")) + doc = ezdxf.read(stream) + assert doc.dxfversion == "AC1015" + msp = doc.modelspace() + entities = list(msp) + assert len(entities) >= 1 + + def test_dxf_vtracer(self, test_png): + resp = client.post( + "/engine/trace", + files={"file": ("test.png", test_png, "image/png")}, + data={"mode": "vtracer", "output_format": "dxf"}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/dxf" + + +# ----------------------------------------------------------------------- +# /engine/trace — Validation +# ----------------------------------------------------------------------- class TestTraceEndpointValidation: """Tests for input validation on /engine/trace.""" @@ -159,15 +293,6 @@ class TestTraceEndpointValidation: ) assert resp.status_code == 422 - def test_defaults_to_potrace(self, test_png): - """Mode defaults to potrace when not specified.""" - resp = client.post( - "/engine/trace", - files={"file": ("test.png", test_png, "image/png")}, - ) - assert resp.status_code == 200 - assert "= 1 + assert paths[0]["commands"][0]["type"] == "M" + + +# ----------------------------------------------------------------------- +# /engine/simplify — DXF output +# ----------------------------------------------------------------------- + +class TestSimplifyEndpointDXF: + """Tests for /engine/simplify with DXF output format.""" + + def test_dxf_output(self, test_svg): + resp = client.post( + "/engine/simplify", + files={"file": ("test.svg", test_svg, "image/svg+xml")}, + data={"epsilon": "1.0", "output_format": "dxf"}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/dxf" + assert len(resp.content) > 0 + + def test_dxf_metadata_header(self, test_svg): + resp = client.post( + "/engine/simplify", + files={"file": ("test.svg", test_svg, "image/svg+xml")}, + data={"epsilon": "1.0", "output_format": "dxf"}, + ) + meta = json.loads(resp.headers["X-Kerf-Metadata"]) + assert meta["format"] == "dxf" + assert isinstance(meta["path_count"], int) + + def test_dxf_is_valid(self, test_svg): + """DXF output from simplify should be parseable.""" + import io + import ezdxf + + resp = client.post( + "/engine/simplify", + files={"file": ("test.svg", test_svg, "image/svg+xml")}, + data={"epsilon": "1.0", "output_format": "dxf"}, + ) + stream = io.StringIO(resp.content.decode("utf-8")) + doc = ezdxf.read(stream) + assert doc.dxfversion == "AC1015" + + +# ----------------------------------------------------------------------- +# /engine/simplify — Validation +# ----------------------------------------------------------------------- + +class TestSimplifyEndpointValidation: + """Tests for input validation on /engine/simplify.""" + + def test_empty_file(self): + resp = client.post( + "/engine/simplify", + files={"file": ("empty.svg", b"", "image/svg+xml")}, + data={"epsilon": "1.0"}, + ) + assert resp.status_code == 422 + + def test_not_svg(self): + resp = client.post( + "/engine/simplify", + files={"file": ("test.txt", b"hello world", "text/plain")}, + data={"epsilon": "1.0"}, + ) + assert resp.status_code == 422 + + def test_invalid_output_format(self, test_svg): + resp = client.post( + "/engine/simplify", + files={"file": ("test.svg", test_svg, "image/svg+xml")}, + data={"epsilon": "1.0", "output_format": "pdf"}, + ) + assert resp.status_code == 422 + + def test_default_epsilon(self, test_svg): + """Epsilon defaults to 1.0 when not specified.""" + resp = client.post( + "/engine/simplify", + files={"file": ("test.svg", test_svg, "image/svg+xml")}, + ) + assert resp.status_code == 200 + assert resp.json()["format"] == "svg" + + def test_binary_file_rejected(self): + """Binary (non-UTF-8) file should be rejected.""" + resp = client.post( + "/engine/simplify", + files={"file": ("test.svg", b"\x80\x81\x82\xff", "image/svg+xml")}, + data={"epsilon": "1.0"}, + ) + assert resp.status_code == 422 diff --git a/engine/tests/test_output.py b/engine/tests/test_output.py new file mode 100644 index 0000000..ed3d26f --- /dev/null +++ b/engine/tests/test_output.py @@ -0,0 +1,274 @@ +"""Tests for output format generators (SVG, DXF, JSON).""" + +from __future__ import annotations + +import json + +import io + +import ezdxf +import pytest + +from pipeline.postprocess import PathInfo, PostProcessResult, postprocess_svg +from output.svg import generate_svg +from output.json_output import generate_json +from output.dxf import generate_dxf + + +def _read_dxf(dxf_bytes: bytes) -> ezdxf.document.Drawing: + """Read DXF bytes back into an ezdxf document (needs StringIO, not BytesIO).""" + return ezdxf.read(io.StringIO(dxf_bytes.decode("utf-8"))) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +SIMPLE_SVG = ( + '' + '' + "" +) + +# SVG with an outer CCW rectangle and an inner CW rectangle (island/hole) +SVG_WITH_ISLAND = ( + '' + '' + '' + "" +) + +# SVG with an open path (no Z close) +SVG_OPEN_PATH = ( + '' + '' + "" +) + + +def _make_result(svg: str = SIMPLE_SVG, epsilon: float = 0.1) -> PostProcessResult: + """Run postprocess pipeline and return result for testing output generators.""" + return postprocess_svg(svg, epsilon=epsilon) + + +# --------------------------------------------------------------------------- +# SVG output tests +# --------------------------------------------------------------------------- + + +class TestSVGOutput: + def test_returns_string(self): + result = _make_result() + svg = generate_svg(result) + assert isinstance(svg, str) + + def test_contains_svg_root(self): + result = _make_result() + svg = generate_svg(result) + assert "" in svg + + def test_contains_path_element(self): + result = _make_result() + svg = generate_svg(result) + assert " 0 + path = parsed["paths"][0] + assert "commands" in path + assert "properties" in path + + def test_commands_have_correct_types(self): + result = _make_result() + parsed = json.loads(generate_json(result)) + path = parsed["paths"][0] + types = [cmd["type"] for cmd in path["commands"]] + assert types[0] == "M" # starts with Move + assert "L" in types # has line commands + assert types[-1] == "Z" # closed path ends with Z + + def test_properties_fields(self): + result = _make_result() + parsed = json.loads(generate_json(result)) + props = parsed["paths"][0]["properties"] + assert "is_closed" in props + assert "is_island" in props + assert "node_count" in props + assert "original_node_count" in props + assert "area" in props + + def test_metadata_fields(self): + result = _make_result() + parsed = json.loads(generate_json(result)) + meta = parsed["metadata"] + assert "path_count" in meta + assert "total_nodes" in meta + assert "total_original_nodes" in meta + assert "open_path_count" in meta + assert "island_count" in meta + + def test_metadata_path_count_matches(self): + result = _make_result() + parsed = json.loads(generate_json(result)) + assert parsed["metadata"]["path_count"] == len(parsed["paths"]) + + def test_island_flagged_in_properties(self): + result = _make_result(SVG_WITH_ISLAND) + parsed = json.loads(generate_json(result)) + island_flags = [p["properties"]["is_island"] for p in parsed["paths"]] + assert True in island_flags # at least one island detected + + def test_open_path_not_closed(self): + result = _make_result(SVG_OPEN_PATH) + parsed = json.loads(generate_json(result)) + # Open path should not end with Z + path = parsed["paths"][0] + types = [cmd["type"] for cmd in path["commands"]] + assert types[-1] != "Z" + + +# --------------------------------------------------------------------------- +# DXF output tests +# --------------------------------------------------------------------------- + + +class TestDXFOutput: + def test_returns_bytes(self): + result = _make_result() + dxf = generate_dxf(result) + assert isinstance(dxf, bytes) + + def test_valid_dxf_structure(self): + result = _make_result() + dxf_bytes = generate_dxf(result) + # ezdxf can read it back + doc = _read_dxf(dxf_bytes) + assert doc.dxfversion >= "AC1015" + + def test_contains_lwpolyline_entities(self): + result = _make_result() + dxf_bytes = generate_dxf(result) + doc = _read_dxf(dxf_bytes) + msp = doc.modelspace() + polylines = list(msp.query("LWPOLYLINE")) + assert len(polylines) > 0 + + def test_polyline_has_correct_point_count(self): + result = _make_result() + dxf_bytes = generate_dxf(result) + doc = _read_dxf(dxf_bytes) + msp = doc.modelspace() + polylines = list(msp.query("LWPOLYLINE")) + # Simple square: 4 unique points (close flag handles the fifth) + total_points = sum(len(list(pl.get_points())) for pl in polylines) + assert total_points >= 4 + + def test_closed_path_produces_closed_polyline(self): + result = _make_result() + dxf_bytes = generate_dxf(result) + doc = _read_dxf(dxf_bytes) + msp = doc.modelspace() + polylines = list(msp.query("LWPOLYLINE")) + # At least one closed polyline + assert any(pl.is_closed for pl in polylines) + + def test_island_on_islands_layer(self): + result = _make_result(SVG_WITH_ISLAND) + dxf_bytes = generate_dxf(result) + doc = _read_dxf(dxf_bytes) + msp = doc.modelspace() + polylines = list(msp.query("LWPOLYLINE")) + layers = [pl.dxf.layer for pl in polylines] + assert "ISLANDS" in layers + + def test_outer_contour_on_default_layer(self): + result = _make_result(SVG_WITH_ISLAND) + dxf_bytes = generate_dxf(result) + doc = _read_dxf(dxf_bytes) + msp = doc.modelspace() + polylines = list(msp.query("LWPOLYLINE")) + layers = [pl.dxf.layer for pl in polylines] + assert "0" in layers + + def test_ac1015_version(self): + result = _make_result() + dxf_bytes = generate_dxf(result) + doc = _read_dxf(dxf_bytes) + assert doc.dxfversion == "AC1015" + + def test_dxf_header_present(self): + result = _make_result() + dxf_bytes = generate_dxf(result) + # Raw bytes should contain DXF section markers + text = dxf_bytes.decode("ascii", errors="replace") + assert "HEADER" in text + assert "ENTITIES" in text + + def test_open_path_produces_open_polyline(self): + result = _make_result(SVG_OPEN_PATH) + dxf_bytes = generate_dxf(result) + doc = _read_dxf(dxf_bytes) + msp = doc.modelspace() + polylines = list(msp.query("LWPOLYLINE")) + # Open path should produce non-closed polyline + assert any(not pl.is_closed for pl in polylines) + + +# --------------------------------------------------------------------------- +# Integration: round-trip through all formats +# --------------------------------------------------------------------------- + + +class TestRoundTrip: + def test_all_formats_from_same_input(self): + """All three generators produce valid output from the same PostProcessResult.""" + result = _make_result() + svg = generate_svg(result) + json_out = generate_json(result) + dxf = generate_dxf(result) + + assert isinstance(svg, str) and " 0 + + def test_path_count_consistent_across_formats(self): + """JSON and DXF should have the same number of paths as the result.""" + result = _make_result(SVG_WITH_ISLAND) + parsed_json = json.loads(generate_json(result)) + + doc = _read_dxf(generate_dxf(result)) + msp = doc.modelspace() + dxf_polylines = list(msp.query("LWPOLYLINE")) + + assert parsed_json["metadata"]["path_count"] == len(result.paths) + assert len(dxf_polylines) == len(result.paths)