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:
parent
4539a488bc
commit
9540f37f70
14 changed files with 1082 additions and 143 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
22
.gsd/milestones/M001/slices/S02/tasks/T02-SUMMARY.md
Normal file
22
.gsd/milestones/M001/slices/S02/tasks/T02-SUMMARY.md
Normal 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.
|
||||
79
.gsd/milestones/M001/slices/S02/tasks/T03-SUMMARY.md
Normal file
79
.gsd/milestones/M001/slices/S02/tasks/T03-SUMMARY.md
Normal 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
|
|
@ -1,38 +1,64 @@
|
|||
"""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
|
||||
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 {
|
||||
"path_count": path_count,
|
||||
"node_count_total": node_count_total,
|
||||
"open_paths": open_paths,
|
||||
"output": json.loads(generate_json(result)),
|
||||
"format": "json",
|
||||
"metadata": metadata,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"output": generate_svg(result),
|
||||
"format": "svg",
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
7
engine/output/__init__.py
Normal file
7
engine/output/__init__.py
Normal 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
66
engine/output/dxf.py
Normal 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")
|
||||
76
engine/output/json_output.py
Normal file
76
engine/output/json_output.py
Normal 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
22
engine/output/svg.py
Normal 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
|
||||
|
|
@ -15,6 +15,7 @@ dependencies = [
|
|||
"vtracer>=0.6",
|
||||
"python-multipart>=0.0.9",
|
||||
"Pillow>=10.2",
|
||||
"ezdxf>=1.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
|
|
|||
|
|
@ -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
274
engine/tests/test_output.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue