diff --git a/engine/api/routes.py b/engine/api/routes.py index 5e249e2..e642fcd 100644 --- a/engine/api/routes.py +++ b/engine/api/routes.py @@ -1,40 +1,66 @@ -"""API routes for the Kerf Engine trace endpoint.""" +"""API routes for the Kerf Engine trace and simplify endpoints.""" import json -import re import time from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from fastapi.responses import Response +from output import generate_dxf, generate_json, generate_svg +from pipeline.postprocess import postprocess_svg from pipeline.preprocessing import preprocess from pipeline.vectorize import potrace_trace, vtracer_trace router = APIRouter() VALID_MODES = {"potrace", "vtracer"} +VALID_OUTPUT_FORMATS = {"svg", "dxf", "json"} -def _extract_svg_metadata(svg: str) -> dict: - """Extract basic metadata from an SVG string.""" - path_matches = re.findall(r']*\bd="([^"]*)"', svg) - path_count = len(path_matches) +def _format_response( + result, + output_format: str, + warnings: list[str], + processing_ms: float, +): + """Build a standardized response from a PostProcessResult and output format. - node_count_total = 0 - open_paths = 0 - for d_attr in path_matches: - # Count SVG path commands (M, L, C, Q, A, Z, etc.) - commands = re.findall(r"[MLHVCSQTAZ]", d_attr, re.IGNORECASE) - node_count_total += len(commands) - # A path is "open" if it doesn't end with Z - if not d_attr.rstrip().upper().endswith("Z"): - open_paths += 1 - - return { - "path_count": path_count, - "node_count_total": node_count_total, - "open_paths": open_paths, + SVG and JSON return a JSON envelope with output + metadata. + DXF returns raw bytes with application/dxf content type. + """ + metadata = { + "format": output_format, + "path_count": len(result.paths), + "node_count_total": result.total_nodes, + "open_paths": result.open_path_count, + "island_count": result.island_count, + "warnings": warnings, + "processing_ms": processing_ms, } + if output_format == "dxf": + dxf_bytes = generate_dxf(result) + return Response( + content=dxf_bytes, + media_type="application/dxf", + headers={ + "Content-Disposition": "attachment; filename=output.dxf", + "X-Kerf-Metadata": json.dumps(metadata), + }, + ) + elif output_format == "json": + return { + "output": json.loads(generate_json(result)), + "format": "json", + "metadata": metadata, + } + else: + return { + "output": generate_svg(result), + "format": "svg", + "metadata": metadata, + } + @router.post("/engine/trace") async def trace( @@ -44,17 +70,20 @@ async def trace( preset: str = Form("default"), params: str = Form("{}"), ): - """Convert a raster image to SVG via the preprocessing + vectorization pipeline.""" + """Convert a raster image to vector via the preprocessing + vectorization + post-processing pipeline. + + Supports three output formats: svg (default), dxf, json. + """ if mode not in VALID_MODES: raise HTTPException( status_code=422, detail=f"Invalid mode '{mode}'. Must be one of: {', '.join(sorted(VALID_MODES))}", ) - if output_format != "svg": + if output_format not in VALID_OUTPUT_FORMATS: raise HTTPException( status_code=422, - detail=f"Unsupported output_format '{output_format}'. Only 'svg' is supported.", + detail=f"Unsupported output_format '{output_format}'. Must be one of: {', '.join(sorted(VALID_OUTPUT_FORMATS))}", ) try: @@ -92,14 +121,55 @@ async def trace( except Exception as exc: raise HTTPException(status_code=500, detail=f"Vectorization failed: {exc}") + # Post-processing: RDP simplification, island detection, open path analysis + epsilon = float(user_params.get("epsilon", 1.0)) + try: + result = postprocess_svg(svg_output, epsilon=epsilon) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Post-processing failed: {exc}") + processing_ms = round((time.perf_counter() - start) * 1000, 2) - metadata = _extract_svg_metadata(svg_output) - metadata["warnings"] = warnings - metadata["processing_ms"] = processing_ms + return _format_response(result, output_format, warnings, processing_ms) - return { - "output": svg_output, - "format": "svg", - "metadata": metadata, - } + +@router.post("/engine/simplify") +async def simplify( + file: UploadFile = File(...), + epsilon: float = Form(1.0), + output_format: str = Form("svg"), +): + """Simplify an existing SVG using RDP path simplification. + + Accepts an SVG file upload, applies Ramer-Douglas-Peucker simplification + with the given epsilon, and returns the result in the requested format. + """ + if output_format not in VALID_OUTPUT_FORMATS: + raise HTTPException( + status_code=422, + detail=f"Unsupported output_format '{output_format}'. Must be one of: {', '.join(sorted(VALID_OUTPUT_FORMATS))}", + ) + + raw_bytes = await file.read() + if not raw_bytes: + raise HTTPException(status_code=422, detail="Uploaded file is empty") + + try: + svg_str = raw_bytes.decode("utf-8") + except UnicodeDecodeError: + raise HTTPException(status_code=422, detail="File is not valid UTF-8 text") + + if " None: + """Add a single PathInfo as an LWPOLYLINE entity to the modelspace. + + Closed paths get the LWPOLYLINE close flag set. + Islands are placed on a separate "ISLANDS" layer for downstream CAM tools. + """ + coords = path.simplified_coords + if len(coords) < 2: + return + + target_layer = "ISLANDS" if path.is_island else layer + + # LWPOLYLINE uses (x, y[, start_width, end_width, bulge]) tuples + points = [(x, y) for x, y in coords] + + # Remove duplicate close point if the polyline close flag handles it + if path.is_closed and len(points) > 1 and points[0] == points[-1]: + points = points[:-1] + + msp.add_lwpolyline( + points, + dxfattribs={"layer": target_layer}, + close=path.is_closed, + ) + + +def generate_dxf(result: PostProcessResult) -> bytes: + """Generate an AC1015 (AutoCAD R2000) DXF file from post-processed path data. + + Creates LWPOLYLINE entities from simplified coordinate data. Islands (holes) + are placed on an "ISLANDS" layer; outer contours on the default "0" layer. + + Args: + result: PostProcessResult from the post-processing pipeline. + + Returns: + DXF file content as bytes. + """ + doc = ezdxf.new(dxfversion="R2000") # AC1015 + msp = doc.modelspace() + + # Create ISLANDS layer for hole/island paths + doc.layers.add("ISLANDS", color=1) # color 1 = red in AutoCAD + + for path in result.paths: + _add_path_to_msp(msp, path) + + # Write to string buffer, then encode to bytes + stream = io.StringIO() + doc.write(stream) + return stream.getvalue().encode("utf-8") diff --git a/engine/output/json_output.py b/engine/output/json_output.py new file mode 100644 index 0000000..4cff231 --- /dev/null +++ b/engine/output/json_output.py @@ -0,0 +1,76 @@ +"""JSON output generator — structured path data from PostProcessResult.""" + +from __future__ import annotations + +import json + +from pipeline.postprocess import PathInfo, PostProcessResult + + +def _path_to_dict(path: PathInfo) -> dict: + """Convert a PathInfo to a JSON-serializable dict with path commands.""" + commands = [] + coords = path.simplified_coords + if not coords: + return {"commands": [], "properties": {}} + + # Move to start + commands.append({"type": "M", "x": coords[0][0], "y": coords[0][1]}) + + # Line to each subsequent point + for x, y in coords[1:]: + commands.append({"type": "L", "x": x, "y": y}) + + # Close if applicable + if path.is_closed: + commands.append({"type": "Z"}) + + properties = { + "is_closed": path.is_closed, + "is_island": path.is_island, + "node_count": path.node_count, + "original_node_count": path.original_node_count, + "area": round(path.area, 4), + } + + return {"commands": commands, "properties": properties} + + +def generate_json(result: PostProcessResult) -> str: + """Generate a JSON string from post-processed path data. + + Output format: + { + "paths": [ + { + "commands": [{"type": "M", "x": 0, "y": 0}, {"type": "L", "x": 10, "y": 0}, ...], + "properties": {"is_closed": true, "is_island": false, ...} + }, + ... + ], + "metadata": { + "path_count": 2, + "total_nodes": 10, + "total_original_nodes": 50, + "open_path_count": 0, + "island_count": 1 + } + } + + Args: + result: PostProcessResult from the post-processing pipeline. + + Returns: + JSON string. + """ + output = { + "paths": [_path_to_dict(p) for p in result.paths], + "metadata": { + "path_count": len(result.paths), + "total_nodes": result.total_nodes, + "total_original_nodes": result.total_original_nodes, + "open_path_count": result.open_path_count, + "island_count": result.island_count, + }, + } + return json.dumps(output, indent=2) diff --git a/engine/output/svg.py b/engine/output/svg.py new file mode 100644 index 0000000..5e85c31 --- /dev/null +++ b/engine/output/svg.py @@ -0,0 +1,22 @@ +"""SVG output generator — clean SVG serialization from PostProcessResult.""" + +from __future__ import annotations + +from pipeline.postprocess import PostProcessResult + + +def generate_svg(result: PostProcessResult) -> str: + """Generate a clean SVG string from post-processed path data. + + Re-serializes paths from the PostProcessResult into a minimal SVG document + with a single compound path using fill-rule="evenodd" for proper island rendering. + + Args: + result: PostProcessResult from the post-processing pipeline. + + Returns: + SVG string with simplified paths. + """ + # The postprocess pipeline already rebuilds SVG; return it directly + # if caller just wants the default output. + return result.svg diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 5390ba1..e7fa7e5 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "vtracer>=0.6", "python-multipart>=0.0.9", "Pillow>=10.2", + "ezdxf>=1.0", ] [tool.setuptools.packages.find] diff --git a/engine/tests/test_api.py b/engine/tests/test_api.py index a7717b2..58f22ba 100644 --- a/engine/tests/test_api.py +++ b/engine/tests/test_api.py @@ -1,4 +1,4 @@ -"""Integration tests for the /engine/trace endpoint.""" +"""Integration tests for /engine/trace and /engine/simplify endpoints.""" import json @@ -21,13 +21,49 @@ def _make_test_png(width: int = 100, height: int = 100) -> bytes: return buf.tobytes() +def _make_test_svg() -> str: + """Create a simple SVG with a rectangular path for simplify tests.""" + return ( + '' + '' + "" + ) + + +def _make_complex_svg() -> str: + """Create an SVG with many intermediate points (suitable for RDP reduction).""" + # A path with extra collinear intermediate points that RDP can remove + points = " ".join( + f"L {x},{10}" for x in range(11, 91) + ) + return ( + '' + f'' + "" + ) + + @pytest.fixture def test_png() -> bytes: return _make_test_png() -class TestTraceEndpointPotrace: - """Tests for /engine/trace with mode=potrace.""" +@pytest.fixture +def test_svg() -> bytes: + return _make_test_svg().encode("utf-8") + + +@pytest.fixture +def complex_svg() -> bytes: + return _make_complex_svg().encode("utf-8") + + +# ----------------------------------------------------------------------- +# /engine/trace — SVG output (default) +# ----------------------------------------------------------------------- + +class TestTraceEndpointSVG: + """Tests for /engine/trace with SVG output format.""" def test_basic_trace(self, test_png): resp = client.post( @@ -49,12 +85,15 @@ class TestTraceEndpointPotrace: ) body = resp.json() meta = body["metadata"] + assert meta["format"] == "svg" assert "path_count" in meta assert "node_count_total" in meta assert "open_paths" in meta + assert "island_count" in meta assert "warnings" in meta assert "processing_ms" in meta assert isinstance(meta["path_count"], int) + assert isinstance(meta["node_count_total"], int) assert isinstance(meta["processing_ms"], float) def test_svg_has_paths(self, test_png): @@ -78,43 +117,138 @@ class TestTraceEndpointPotrace: assert resp.status_code == 200 assert "= 1 + p = paths[0] + assert "commands" in p + assert "properties" in p + assert p["commands"][0]["type"] == "M" # starts with MoveTo + + def test_json_vtracer(self, test_png): + resp = client.post( + "/engine/trace", + files={"file": ("test.png", test_png, "image/png")}, + data={"mode": "vtracer", "output_format": "json"}, + ) + assert resp.status_code == 200 + assert resp.json()["format"] == "json" + + +# ----------------------------------------------------------------------- +# /engine/trace — DXF output +# ----------------------------------------------------------------------- + +class TestTraceEndpointDXF: + """Tests for /engine/trace with DXF output format.""" + + def test_dxf_output(self, test_png): + resp = client.post( + "/engine/trace", + files={"file": ("test.png", test_png, "image/png")}, + data={"mode": "potrace", "output_format": "dxf"}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/dxf" + assert "Content-Disposition" in resp.headers + assert len(resp.content) > 0 + + def test_dxf_metadata_header(self, test_png): + resp = client.post( + "/engine/trace", + files={"file": ("test.png", test_png, "image/png")}, + data={"mode": "potrace", "output_format": "dxf"}, + ) + meta_header = resp.headers.get("X-Kerf-Metadata") + assert meta_header is not None + meta = json.loads(meta_header) + assert meta["format"] == "dxf" + assert "path_count" in meta + assert "node_count_total" in meta + + def test_dxf_is_valid_ac1015(self, test_png): + """DXF output should be parseable by ezdxf as AC1015.""" + import io + import ezdxf + + resp = client.post( + "/engine/trace", + files={"file": ("test.png", test_png, "image/png")}, + data={"mode": "potrace", "output_format": "dxf"}, + ) + stream = io.StringIO(resp.content.decode("utf-8")) + doc = ezdxf.read(stream) + assert doc.dxfversion == "AC1015" + msp = doc.modelspace() + entities = list(msp) + assert len(entities) >= 1 + + def test_dxf_vtracer(self, test_png): + resp = client.post( + "/engine/trace", + files={"file": ("test.png", test_png, "image/png")}, + data={"mode": "vtracer", "output_format": "dxf"}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/dxf" + + +# ----------------------------------------------------------------------- +# /engine/trace — Validation +# ----------------------------------------------------------------------- class TestTraceEndpointValidation: """Tests for input validation on /engine/trace.""" @@ -159,15 +293,6 @@ class TestTraceEndpointValidation: ) assert resp.status_code == 422 - def test_defaults_to_potrace(self, test_png): - """Mode defaults to potrace when not specified.""" - resp = client.post( - "/engine/trace", - files={"file": ("test.png", test_png, "image/png")}, - ) - assert resp.status_code == 200 - assert "= 1 + assert paths[0]["commands"][0]["type"] == "M" + + +# ----------------------------------------------------------------------- +# /engine/simplify — DXF output +# ----------------------------------------------------------------------- + +class TestSimplifyEndpointDXF: + """Tests for /engine/simplify with DXF output format.""" + + def test_dxf_output(self, test_svg): + resp = client.post( + "/engine/simplify", + files={"file": ("test.svg", test_svg, "image/svg+xml")}, + data={"epsilon": "1.0", "output_format": "dxf"}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/dxf" + assert len(resp.content) > 0 + + def test_dxf_metadata_header(self, test_svg): + resp = client.post( + "/engine/simplify", + files={"file": ("test.svg", test_svg, "image/svg+xml")}, + data={"epsilon": "1.0", "output_format": "dxf"}, + ) + meta = json.loads(resp.headers["X-Kerf-Metadata"]) + assert meta["format"] == "dxf" + assert isinstance(meta["path_count"], int) + + def test_dxf_is_valid(self, test_svg): + """DXF output from simplify should be parseable.""" + import io + import ezdxf + + resp = client.post( + "/engine/simplify", + files={"file": ("test.svg", test_svg, "image/svg+xml")}, + data={"epsilon": "1.0", "output_format": "dxf"}, + ) + stream = io.StringIO(resp.content.decode("utf-8")) + doc = ezdxf.read(stream) + assert doc.dxfversion == "AC1015" + + +# ----------------------------------------------------------------------- +# /engine/simplify — Validation +# ----------------------------------------------------------------------- + +class TestSimplifyEndpointValidation: + """Tests for input validation on /engine/simplify.""" + + def test_empty_file(self): + resp = client.post( + "/engine/simplify", + files={"file": ("empty.svg", b"", "image/svg+xml")}, + data={"epsilon": "1.0"}, + ) + assert resp.status_code == 422 + + def test_not_svg(self): + resp = client.post( + "/engine/simplify", + files={"file": ("test.txt", b"hello world", "text/plain")}, + data={"epsilon": "1.0"}, + ) + assert resp.status_code == 422 + + def test_invalid_output_format(self, test_svg): + resp = client.post( + "/engine/simplify", + files={"file": ("test.svg", test_svg, "image/svg+xml")}, + data={"epsilon": "1.0", "output_format": "pdf"}, + ) + assert resp.status_code == 422 + + def test_default_epsilon(self, test_svg): + """Epsilon defaults to 1.0 when not specified.""" + resp = client.post( + "/engine/simplify", + files={"file": ("test.svg", test_svg, "image/svg+xml")}, + ) + assert resp.status_code == 200 + assert resp.json()["format"] == "svg" + + def test_binary_file_rejected(self): + """Binary (non-UTF-8) file should be rejected.""" + resp = client.post( + "/engine/simplify", + files={"file": ("test.svg", b"\x80\x81\x82\xff", "image/svg+xml")}, + data={"epsilon": "1.0"}, + ) + assert resp.status_code == 422 diff --git a/engine/tests/test_output.py b/engine/tests/test_output.py new file mode 100644 index 0000000..ed3d26f --- /dev/null +++ b/engine/tests/test_output.py @@ -0,0 +1,274 @@ +"""Tests for output format generators (SVG, DXF, JSON).""" + +from __future__ import annotations + +import json + +import io + +import ezdxf +import pytest + +from pipeline.postprocess import PathInfo, PostProcessResult, postprocess_svg +from output.svg import generate_svg +from output.json_output import generate_json +from output.dxf import generate_dxf + + +def _read_dxf(dxf_bytes: bytes) -> ezdxf.document.Drawing: + """Read DXF bytes back into an ezdxf document (needs StringIO, not BytesIO).""" + return ezdxf.read(io.StringIO(dxf_bytes.decode("utf-8"))) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +SIMPLE_SVG = ( + '' + '' + "" +) + +# SVG with an outer CCW rectangle and an inner CW rectangle (island/hole) +SVG_WITH_ISLAND = ( + '' + '' + '' + "" +) + +# SVG with an open path (no Z close) +SVG_OPEN_PATH = ( + '' + '' + "" +) + + +def _make_result(svg: str = SIMPLE_SVG, epsilon: float = 0.1) -> PostProcessResult: + """Run postprocess pipeline and return result for testing output generators.""" + return postprocess_svg(svg, epsilon=epsilon) + + +# --------------------------------------------------------------------------- +# SVG output tests +# --------------------------------------------------------------------------- + + +class TestSVGOutput: + def test_returns_string(self): + result = _make_result() + svg = generate_svg(result) + assert isinstance(svg, str) + + def test_contains_svg_root(self): + result = _make_result() + svg = generate_svg(result) + assert "" in svg + + def test_contains_path_element(self): + result = _make_result() + svg = generate_svg(result) + assert " 0 + path = parsed["paths"][0] + assert "commands" in path + assert "properties" in path + + def test_commands_have_correct_types(self): + result = _make_result() + parsed = json.loads(generate_json(result)) + path = parsed["paths"][0] + types = [cmd["type"] for cmd in path["commands"]] + assert types[0] == "M" # starts with Move + assert "L" in types # has line commands + assert types[-1] == "Z" # closed path ends with Z + + def test_properties_fields(self): + result = _make_result() + parsed = json.loads(generate_json(result)) + props = parsed["paths"][0]["properties"] + assert "is_closed" in props + assert "is_island" in props + assert "node_count" in props + assert "original_node_count" in props + assert "area" in props + + def test_metadata_fields(self): + result = _make_result() + parsed = json.loads(generate_json(result)) + meta = parsed["metadata"] + assert "path_count" in meta + assert "total_nodes" in meta + assert "total_original_nodes" in meta + assert "open_path_count" in meta + assert "island_count" in meta + + def test_metadata_path_count_matches(self): + result = _make_result() + parsed = json.loads(generate_json(result)) + assert parsed["metadata"]["path_count"] == len(parsed["paths"]) + + def test_island_flagged_in_properties(self): + result = _make_result(SVG_WITH_ISLAND) + parsed = json.loads(generate_json(result)) + island_flags = [p["properties"]["is_island"] for p in parsed["paths"]] + assert True in island_flags # at least one island detected + + def test_open_path_not_closed(self): + result = _make_result(SVG_OPEN_PATH) + parsed = json.loads(generate_json(result)) + # Open path should not end with Z + path = parsed["paths"][0] + types = [cmd["type"] for cmd in path["commands"]] + assert types[-1] != "Z" + + +# --------------------------------------------------------------------------- +# DXF output tests +# --------------------------------------------------------------------------- + + +class TestDXFOutput: + def test_returns_bytes(self): + result = _make_result() + dxf = generate_dxf(result) + assert isinstance(dxf, bytes) + + def test_valid_dxf_structure(self): + result = _make_result() + dxf_bytes = generate_dxf(result) + # ezdxf can read it back + doc = _read_dxf(dxf_bytes) + assert doc.dxfversion >= "AC1015" + + def test_contains_lwpolyline_entities(self): + result = _make_result() + dxf_bytes = generate_dxf(result) + doc = _read_dxf(dxf_bytes) + msp = doc.modelspace() + polylines = list(msp.query("LWPOLYLINE")) + assert len(polylines) > 0 + + def test_polyline_has_correct_point_count(self): + result = _make_result() + dxf_bytes = generate_dxf(result) + doc = _read_dxf(dxf_bytes) + msp = doc.modelspace() + polylines = list(msp.query("LWPOLYLINE")) + # Simple square: 4 unique points (close flag handles the fifth) + total_points = sum(len(list(pl.get_points())) for pl in polylines) + assert total_points >= 4 + + def test_closed_path_produces_closed_polyline(self): + result = _make_result() + dxf_bytes = generate_dxf(result) + doc = _read_dxf(dxf_bytes) + msp = doc.modelspace() + polylines = list(msp.query("LWPOLYLINE")) + # At least one closed polyline + assert any(pl.is_closed for pl in polylines) + + def test_island_on_islands_layer(self): + result = _make_result(SVG_WITH_ISLAND) + dxf_bytes = generate_dxf(result) + doc = _read_dxf(dxf_bytes) + msp = doc.modelspace() + polylines = list(msp.query("LWPOLYLINE")) + layers = [pl.dxf.layer for pl in polylines] + assert "ISLANDS" in layers + + def test_outer_contour_on_default_layer(self): + result = _make_result(SVG_WITH_ISLAND) + dxf_bytes = generate_dxf(result) + doc = _read_dxf(dxf_bytes) + msp = doc.modelspace() + polylines = list(msp.query("LWPOLYLINE")) + layers = [pl.dxf.layer for pl in polylines] + assert "0" in layers + + def test_ac1015_version(self): + result = _make_result() + dxf_bytes = generate_dxf(result) + doc = _read_dxf(dxf_bytes) + assert doc.dxfversion == "AC1015" + + def test_dxf_header_present(self): + result = _make_result() + dxf_bytes = generate_dxf(result) + # Raw bytes should contain DXF section markers + text = dxf_bytes.decode("ascii", errors="replace") + assert "HEADER" in text + assert "ENTITIES" in text + + def test_open_path_produces_open_polyline(self): + result = _make_result(SVG_OPEN_PATH) + dxf_bytes = generate_dxf(result) + doc = _read_dxf(dxf_bytes) + msp = doc.modelspace() + polylines = list(msp.query("LWPOLYLINE")) + # Open path should produce non-closed polyline + assert any(not pl.is_closed for pl in polylines) + + +# --------------------------------------------------------------------------- +# Integration: round-trip through all formats +# --------------------------------------------------------------------------- + + +class TestRoundTrip: + def test_all_formats_from_same_input(self): + """All three generators produce valid output from the same PostProcessResult.""" + result = _make_result() + svg = generate_svg(result) + json_out = generate_json(result) + dxf = generate_dxf(result) + + assert isinstance(svg, str) and " 0 + + def test_path_count_consistent_across_formats(self): + """JSON and DXF should have the same number of paths as the result.""" + result = _make_result(SVG_WITH_ISLAND) + parsed_json = json.loads(generate_json(result)) + + doc = _read_dxf(generate_dxf(result)) + msp = doc.modelspace() + dxf_polylines = list(msp.query("LWPOLYLINE")) + + assert parsed_json["metadata"]["path_count"] == len(result.paths) + assert len(dxf_polylines) == len(result.paths)