test: Wire post-processing into /engine/trace, add output_format routin…

- "engine/api/routes.py"
- "engine/tests/test_api.py"

GSD-Task: S02/T03
This commit is contained in:
jlightner 2026-03-26 04:39:52 +00:00
parent 4539a488bc
commit 9540f37f70
14 changed files with 1082 additions and 143 deletions

View file

@ -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"}

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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.

File diff suppressed because one or more lines are too long

View file

@ -1,40 +1,66 @@
"""API routes for the Kerf Engine trace endpoint."""
"""API routes for the Kerf Engine trace and simplify endpoints."""
import json
import re
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
router = APIRouter()
VALID_MODES = {"potrace", "vtracer"}
VALID_OUTPUT_FORMATS = {"svg", "dxf", "json"}
def _extract_svg_metadata(svg: str) -> dict:
"""Extract basic metadata from an SVG string."""
path_matches = re.findall(r'<path\b[^>]*\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 "<svg" not in svg_str.lower():
raise HTTPException(status_code=422, detail="File does not appear to be a valid SVG")
warnings: list[str] = []
start = time.perf_counter()
try:
result = postprocess_svg(svg_str, epsilon=epsilon)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Simplification failed: {exc}")
processing_ms = round((time.perf_counter() - start) * 1000, 2)
return _format_response(result, output_format, warnings, processing_ms)

View file

@ -0,0 +1,7 @@
"""Output format generators for the Kerf Engine pipeline."""
from output.dxf import generate_dxf
from output.json_output import generate_json
from output.svg import generate_svg
__all__ = ["generate_dxf", "generate_json", "generate_svg"]

66
engine/output/dxf.py Normal file
View file

@ -0,0 +1,66 @@
"""DXF output generator — AC1015+ DXF from PostProcessResult using ezdxf."""
from __future__ import annotations
import io
import ezdxf
from pipeline.postprocess import PathInfo, PostProcessResult
def _add_path_to_msp(
msp: ezdxf.layouts.BaseLayout,
path: PathInfo,
layer: str = "0",
) -> 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")

View file

@ -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)

22
engine/output/svg.py Normal file
View file

@ -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

View file

@ -15,6 +15,7 @@ dependencies = [
"vtracer>=0.6",
"python-multipart>=0.0.9",
"Pillow>=10.2",
"ezdxf>=1.0",
]
[tool.setuptools.packages.find]

View file

@ -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 (
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
'<path d="M 10,10 L 90,10 L 90,90 L 10,90 Z" fill="black"/>'
"</svg>"
)
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 (
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
f'<path d="M 10,10 {points} L 90,90 L 10,90 Z" fill="black"/>'
"</svg>"
)
@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 "<svg" in resp.json()["output"]
class TestTraceEndpointVtracer:
"""Tests for /engine/trace with mode=vtracer."""
def test_basic_trace(self, test_png):
def test_defaults_to_potrace_svg(self, test_png):
"""Mode defaults to potrace, format defaults to svg."""
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={"mode": "vtracer"},
)
assert resp.status_code == 200
body = resp.json()
assert body["format"] == "svg"
assert "<svg" in body["output"].lower() or "svg" in body["output"]
assert "<svg" in body["output"]
def test_metadata_present(self, test_png):
# -----------------------------------------------------------------------
# /engine/trace — JSON output
# -----------------------------------------------------------------------
class TestTraceEndpointJSON:
"""Tests for /engine/trace with JSON output format."""
def test_json_output(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={"mode": "vtracer"},
)
body = resp.json()
meta = body["metadata"]
assert isinstance(meta["path_count"], int)
assert isinstance(meta["processing_ms"], float)
def test_custom_params(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={
"mode": "vtracer",
"params": json.dumps({"filter_speckle": 10, "corner_threshold": 90}),
},
data={"mode": "potrace", "output_format": "json"},
)
assert resp.status_code == 200
body = resp.json()
assert body["format"] == "json"
assert "output" in body
assert "paths" in body["output"]
assert "metadata" in body["output"]
def test_json_metadata_fields(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={"mode": "potrace", "output_format": "json"},
)
body = resp.json()
# Envelope metadata
assert body["metadata"]["format"] == "json"
assert isinstance(body["metadata"]["node_count_total"], int)
# Inline JSON metadata
inner_meta = body["output"]["metadata"]
assert "path_count" in inner_meta
assert "total_nodes" in inner_meta
def test_json_path_structure(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={"mode": "potrace", "output_format": "json"},
)
body = resp.json()
paths = body["output"]["paths"]
assert len(paths) >= 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 "<svg" in resp.json()["output"]
def test_preset_ignored(self, test_png):
"""Preset param is accepted but ignored for now."""
resp = client.post(
@ -176,3 +301,215 @@ class TestTraceEndpointValidation:
data={"mode": "potrace", "preset": "logo"},
)
assert resp.status_code == 200
# -----------------------------------------------------------------------
# /engine/trace — VTracer mode
# -----------------------------------------------------------------------
class TestTraceEndpointVtracer:
"""Tests for /engine/trace with mode=vtracer."""
def test_basic_trace(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={"mode": "vtracer"},
)
assert resp.status_code == 200
body = resp.json()
assert body["format"] == "svg"
def test_metadata_present(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={"mode": "vtracer"},
)
body = resp.json()
meta = body["metadata"]
assert isinstance(meta["path_count"], int)
assert isinstance(meta["processing_ms"], float)
def test_custom_params(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={
"mode": "vtracer",
"params": json.dumps({"filter_speckle": 10, "corner_threshold": 90}),
},
)
assert resp.status_code == 200
# -----------------------------------------------------------------------
# /engine/simplify — SVG output
# -----------------------------------------------------------------------
class TestSimplifyEndpointSVG:
"""Tests for /engine/simplify with SVG output format."""
def test_basic_simplify(self, test_svg):
resp = client.post(
"/engine/simplify",
files={"file": ("test.svg", test_svg, "image/svg+xml")},
data={"epsilon": "1.0"},
)
assert resp.status_code == 200
body = resp.json()
assert body["format"] == "svg"
assert "<svg" in body["output"]
def test_simplify_metadata(self, test_svg):
resp = client.post(
"/engine/simplify",
files={"file": ("test.svg", test_svg, "image/svg+xml")},
data={"epsilon": "1.0"},
)
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 "processing_ms" in meta
def test_simplify_reduces_nodes(self, complex_svg):
"""Higher epsilon should reduce node count on complex paths."""
resp_low = client.post(
"/engine/simplify",
files={"file": ("test.svg", complex_svg, "image/svg+xml")},
data={"epsilon": "0.01"},
)
resp_high = client.post(
"/engine/simplify",
files={"file": ("test.svg", complex_svg, "image/svg+xml")},
data={"epsilon": "10.0"},
)
low_nodes = resp_low.json()["metadata"]["node_count_total"]
high_nodes = resp_high.json()["metadata"]["node_count_total"]
assert high_nodes <= low_nodes
# -----------------------------------------------------------------------
# /engine/simplify — JSON output
# -----------------------------------------------------------------------
class TestSimplifyEndpointJSON:
"""Tests for /engine/simplify with JSON output format."""
def test_json_output(self, test_svg):
resp = client.post(
"/engine/simplify",
files={"file": ("test.svg", test_svg, "image/svg+xml")},
data={"epsilon": "1.0", "output_format": "json"},
)
assert resp.status_code == 200
body = resp.json()
assert body["format"] == "json"
assert "paths" in body["output"]
def test_json_path_commands(self, test_svg):
resp = client.post(
"/engine/simplify",
files={"file": ("test.svg", test_svg, "image/svg+xml")},
data={"epsilon": "1.0", "output_format": "json"},
)
body = resp.json()
paths = body["output"]["paths"]
assert len(paths) >= 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

274
engine/tests/test_output.py Normal file
View file

@ -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 xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
'<path d="M 10,10 L 90,10 L 90,90 L 10,90 Z" fill="black"/>'
"</svg>"
)
# SVG with an outer CCW rectangle and an inner CW rectangle (island/hole)
SVG_WITH_ISLAND = (
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
'<path d="M 0,0 L 100,0 L 100,100 L 0,100 Z" fill="black"/>'
'<path d="M 30,30 L 30,70 L 70,70 L 70,30 Z" fill="black"/>'
"</svg>"
)
# SVG with an open path (no Z close)
SVG_OPEN_PATH = (
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
'<path d="M 10,10 L 50,10 L 50,50" fill="none" stroke="black"/>'
"</svg>"
)
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 "<svg" in svg
assert "</svg>" in svg
def test_contains_path_element(self):
result = _make_result()
svg = generate_svg(result)
assert "<path" in svg
def test_valid_xml(self):
import xml.etree.ElementTree as ET
result = _make_result()
svg = generate_svg(result)
root = ET.fromstring(svg)
assert root.tag == "{http://www.w3.org/2000/svg}svg"
# ---------------------------------------------------------------------------
# JSON output tests
# ---------------------------------------------------------------------------
class TestJSONOutput:
def test_returns_valid_json(self):
result = _make_result()
output = generate_json(result)
parsed = json.loads(output)
assert isinstance(parsed, dict)
def test_has_paths_and_metadata(self):
result = _make_result()
parsed = json.loads(generate_json(result))
assert "paths" in parsed
assert "metadata" in parsed
def test_path_has_commands_and_properties(self):
result = _make_result()
parsed = json.loads(generate_json(result))
assert len(parsed["paths"]) > 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 "<svg" in svg
assert isinstance(json_out, str) and json.loads(json_out)
assert isinstance(dxf, bytes) and len(dxf) > 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)