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
6d51628ce8
commit
0c197f5497
8 changed files with 919 additions and 66 deletions
|
|
@ -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 json
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
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.preprocessing import preprocess
|
||||||
from pipeline.vectorize import potrace_trace, vtracer_trace
|
from pipeline.vectorize import potrace_trace, vtracer_trace
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
VALID_MODES = {"potrace", "vtracer"}
|
VALID_MODES = {"potrace", "vtracer"}
|
||||||
|
VALID_OUTPUT_FORMATS = {"svg", "dxf", "json"}
|
||||||
|
|
||||||
|
|
||||||
def _extract_svg_metadata(svg: str) -> dict:
|
def _format_response(
|
||||||
"""Extract basic metadata from an SVG string."""
|
result,
|
||||||
path_matches = re.findall(r'<path\b[^>]*\bd="([^"]*)"', svg)
|
output_format: str,
|
||||||
path_count = len(path_matches)
|
warnings: list[str],
|
||||||
|
processing_ms: float,
|
||||||
|
):
|
||||||
|
"""Build a standardized response from a PostProcessResult and output format.
|
||||||
|
|
||||||
node_count_total = 0
|
SVG and JSON return a JSON envelope with output + metadata.
|
||||||
open_paths = 0
|
DXF returns raw bytes with application/dxf content type.
|
||||||
for d_attr in path_matches:
|
"""
|
||||||
# Count SVG path commands (M, L, C, Q, A, Z, etc.)
|
metadata = {
|
||||||
commands = re.findall(r"[MLHVCSQTAZ]", d_attr, re.IGNORECASE)
|
"format": output_format,
|
||||||
node_count_total += len(commands)
|
"path_count": len(result.paths),
|
||||||
# A path is "open" if it doesn't end with Z
|
"node_count_total": result.total_nodes,
|
||||||
if not d_attr.rstrip().upper().endswith("Z"):
|
"open_paths": result.open_path_count,
|
||||||
open_paths += 1
|
"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 {
|
return {
|
||||||
"path_count": path_count,
|
"output": json.loads(generate_json(result)),
|
||||||
"node_count_total": node_count_total,
|
"format": "json",
|
||||||
"open_paths": open_paths,
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"output": generate_svg(result),
|
||||||
|
"format": "svg",
|
||||||
|
"metadata": metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -44,17 +70,20 @@ async def trace(
|
||||||
preset: str = Form("default"),
|
preset: str = Form("default"),
|
||||||
params: str = Form("{}"),
|
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:
|
if mode not in VALID_MODES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
detail=f"Invalid mode '{mode}'. Must be one of: {', '.join(sorted(VALID_MODES))}",
|
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(
|
raise HTTPException(
|
||||||
status_code=422,
|
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:
|
try:
|
||||||
|
|
@ -92,14 +121,55 @@ async def trace(
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Vectorization failed: {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)
|
processing_ms = round((time.perf_counter() - start) * 1000, 2)
|
||||||
|
|
||||||
metadata = _extract_svg_metadata(svg_output)
|
return _format_response(result, output_format, warnings, processing_ms)
|
||||||
metadata["warnings"] = warnings
|
|
||||||
metadata["processing_ms"] = processing_ms
|
|
||||||
|
|
||||||
return {
|
|
||||||
"output": svg_output,
|
@router.post("/engine/simplify")
|
||||||
"format": "svg",
|
async def simplify(
|
||||||
"metadata": metadata,
|
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",
|
"vtracer>=0.6",
|
||||||
"python-multipart>=0.0.9",
|
"python-multipart>=0.0.9",
|
||||||
"Pillow>=10.2",
|
"Pillow>=10.2",
|
||||||
|
"ezdxf>=1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[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
|
import json
|
||||||
|
|
||||||
|
|
@ -21,13 +21,49 @@ def _make_test_png(width: int = 100, height: int = 100) -> bytes:
|
||||||
return buf.tobytes()
|
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
|
@pytest.fixture
|
||||||
def test_png() -> bytes:
|
def test_png() -> bytes:
|
||||||
return _make_test_png()
|
return _make_test_png()
|
||||||
|
|
||||||
|
|
||||||
class TestTraceEndpointPotrace:
|
@pytest.fixture
|
||||||
"""Tests for /engine/trace with mode=potrace."""
|
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):
|
def test_basic_trace(self, test_png):
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
|
|
@ -49,12 +85,15 @@ class TestTraceEndpointPotrace:
|
||||||
)
|
)
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
meta = body["metadata"]
|
meta = body["metadata"]
|
||||||
|
assert meta["format"] == "svg"
|
||||||
assert "path_count" in meta
|
assert "path_count" in meta
|
||||||
assert "node_count_total" in meta
|
assert "node_count_total" in meta
|
||||||
assert "open_paths" in meta
|
assert "open_paths" in meta
|
||||||
|
assert "island_count" in meta
|
||||||
assert "warnings" in meta
|
assert "warnings" in meta
|
||||||
assert "processing_ms" in meta
|
assert "processing_ms" in meta
|
||||||
assert isinstance(meta["path_count"], int)
|
assert isinstance(meta["path_count"], int)
|
||||||
|
assert isinstance(meta["node_count_total"], int)
|
||||||
assert isinstance(meta["processing_ms"], float)
|
assert isinstance(meta["processing_ms"], float)
|
||||||
|
|
||||||
def test_svg_has_paths(self, test_png):
|
def test_svg_has_paths(self, test_png):
|
||||||
|
|
@ -78,43 +117,138 @@ class TestTraceEndpointPotrace:
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "<svg" in resp.json()["output"]
|
assert "<svg" in resp.json()["output"]
|
||||||
|
|
||||||
|
def test_defaults_to_potrace_svg(self, test_png):
|
||||||
class TestTraceEndpointVtracer:
|
"""Mode defaults to potrace, format defaults to svg."""
|
||||||
"""Tests for /engine/trace with mode=vtracer."""
|
|
||||||
|
|
||||||
def test_basic_trace(self, test_png):
|
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/engine/trace",
|
"/engine/trace",
|
||||||
files={"file": ("test.png", test_png, "image/png")},
|
files={"file": ("test.png", test_png, "image/png")},
|
||||||
data={"mode": "vtracer"},
|
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["format"] == "svg"
|
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(
|
resp = client.post(
|
||||||
"/engine/trace",
|
"/engine/trace",
|
||||||
files={"file": ("test.png", test_png, "image/png")},
|
files={"file": ("test.png", test_png, "image/png")},
|
||||||
data={"mode": "vtracer"},
|
data={"mode": "potrace", "output_format": "json"},
|
||||||
)
|
|
||||||
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
|
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:
|
class TestTraceEndpointValidation:
|
||||||
"""Tests for input validation on /engine/trace."""
|
"""Tests for input validation on /engine/trace."""
|
||||||
|
|
@ -159,15 +293,6 @@ class TestTraceEndpointValidation:
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
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):
|
def test_preset_ignored(self, test_png):
|
||||||
"""Preset param is accepted but ignored for now."""
|
"""Preset param is accepted but ignored for now."""
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
|
|
@ -176,3 +301,215 @@ class TestTraceEndpointValidation:
|
||||||
data={"mode": "potrace", "preset": "logo"},
|
data={"mode": "potrace", "preset": "logo"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
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