feat: Extended generate_dxf() with units/scale_factor/layer_map params…
- "engine/output/dxf.py" - "engine/api/routes.py" - "engine/tests/test_output.py" GSD-Task: S01/T01
This commit is contained in:
parent
868b444595
commit
6c8c31e13b
3 changed files with 248 additions and 6 deletions
|
|
@ -30,11 +30,18 @@ def _format_response(
|
|||
output_format: str,
|
||||
warnings: list[str],
|
||||
processing_ms: float,
|
||||
*,
|
||||
units: str | None = None,
|
||||
scale_factor: float = 1.0,
|
||||
):
|
||||
"""Build a standardized response from a PostProcessResult and output format.
|
||||
|
||||
SVG and JSON return a JSON envelope with output + metadata.
|
||||
DXF returns raw bytes with application/dxf content type.
|
||||
|
||||
For DXF output, *units* and *scale_factor* are forwarded to
|
||||
:func:`generate_dxf` so the resulting file contains correct unit
|
||||
headers and real-world coordinate values.
|
||||
"""
|
||||
metadata = {
|
||||
"format": output_format,
|
||||
|
|
@ -47,7 +54,12 @@ def _format_response(
|
|||
}
|
||||
|
||||
if output_format == "dxf":
|
||||
dxf_bytes = generate_dxf(result)
|
||||
dxf_kwargs: dict = {}
|
||||
if units is not None:
|
||||
dxf_kwargs["units"] = units
|
||||
if scale_factor != 1.0:
|
||||
dxf_kwargs["scale_factor"] = scale_factor
|
||||
dxf_bytes = generate_dxf(result, **dxf_kwargs)
|
||||
return Response(
|
||||
content=dxf_bytes,
|
||||
media_type="application/dxf",
|
||||
|
|
@ -174,16 +186,24 @@ async def trace(
|
|||
return _format_response(result, output_format, warnings, processing_ms)
|
||||
|
||||
|
||||
VALID_UNITS = {"inches", "mm"}
|
||||
|
||||
|
||||
@router.post("/engine/simplify")
|
||||
async def simplify(
|
||||
file: UploadFile = File(...),
|
||||
epsilon: float = Form(1.0),
|
||||
output_format: str = Form("svg"),
|
||||
units: str | None = Form(None),
|
||||
scale_factor: float = Form(1.0),
|
||||
):
|
||||
"""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.
|
||||
|
||||
For DXF output, *units* (``'inches'`` or ``'mm'``) and *scale_factor*
|
||||
control the unit metadata headers and coordinate scaling in the DXF file.
|
||||
"""
|
||||
if output_format not in VALID_OUTPUT_FORMATS:
|
||||
raise HTTPException(
|
||||
|
|
@ -191,6 +211,12 @@ async def simplify(
|
|||
detail=f"Unsupported output_format '{output_format}'. Must be one of: {', '.join(sorted(VALID_OUTPUT_FORMATS))}",
|
||||
)
|
||||
|
||||
if units is not None and units not in VALID_UNITS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unsupported units '{units}'. Must be one of: {', '.join(sorted(VALID_UNITS))}",
|
||||
)
|
||||
|
||||
raw_bytes = await file.read()
|
||||
if not raw_bytes:
|
||||
raise HTTPException(status_code=422, detail="Uploaded file is empty")
|
||||
|
|
@ -213,4 +239,7 @@ async def simplify(
|
|||
|
||||
processing_ms = round((time.perf_counter() - start) * 1000, 2)
|
||||
|
||||
return _format_response(result, output_format, warnings, processing_ms)
|
||||
return _format_response(
|
||||
result, output_format, warnings, processing_ms,
|
||||
units=units, scale_factor=scale_factor,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,21 +3,38 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Literal
|
||||
|
||||
import ezdxf
|
||||
|
||||
from pipeline.postprocess import PathInfo, PostProcessResult
|
||||
|
||||
# DXF $INSUNITS codes per the HEADER variable specification
|
||||
_INSUNITS_MAP: dict[str, int] = {
|
||||
"inches": 1,
|
||||
"mm": 4,
|
||||
}
|
||||
|
||||
# $MEASUREMENT: 0 = Imperial (inches), 1 = Metric (mm)
|
||||
_MEASUREMENT_MAP: dict[str, int] = {
|
||||
"inches": 0,
|
||||
"mm": 1,
|
||||
}
|
||||
|
||||
|
||||
def _add_path_to_msp(
|
||||
msp: ezdxf.layouts.BaseLayout,
|
||||
path: PathInfo,
|
||||
layer: str = "0",
|
||||
scale_factor: float = 1.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.
|
||||
|
||||
When *scale_factor* is not 1.0, all coordinates are multiplied by it
|
||||
to convert from pixel space to real-world units.
|
||||
"""
|
||||
coords = path.simplified_coords
|
||||
if len(coords) < 2:
|
||||
|
|
@ -26,7 +43,7 @@ def _add_path_to_msp(
|
|||
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]
|
||||
points = [(x * scale_factor, y * scale_factor) 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]:
|
||||
|
|
@ -39,7 +56,13 @@ def _add_path_to_msp(
|
|||
)
|
||||
|
||||
|
||||
def generate_dxf(result: PostProcessResult) -> bytes:
|
||||
def generate_dxf(
|
||||
result: PostProcessResult,
|
||||
*,
|
||||
units: Literal["inches", "mm"] | None = None,
|
||||
scale_factor: float = 1.0,
|
||||
layer_map: dict[int, str] | None = None,
|
||||
) -> bytes:
|
||||
"""Generate an AC1015 (AutoCAD R2000) DXF file from post-processed path data.
|
||||
|
||||
Creates LWPOLYLINE entities from simplified coordinate data. Islands (holes)
|
||||
|
|
@ -47,18 +70,45 @@ def generate_dxf(result: PostProcessResult) -> bytes:
|
|||
|
||||
Args:
|
||||
result: PostProcessResult from the post-processing pipeline.
|
||||
units: Target unit system — ``'inches'`` or ``'mm'``. When set, the
|
||||
``$INSUNITS`` and ``$MEASUREMENT`` DXF header variables are written
|
||||
so downstream tools (Inkscape, LightBurn, AutoCAD) interpret
|
||||
coordinate values in the correct unit.
|
||||
scale_factor: Multiplier applied to every coordinate. Use this to
|
||||
convert from pixel space to real-world units (e.g. 1/96 for
|
||||
96 PPI → inches, 25.4/96 for 96 PPI → mm).
|
||||
layer_map: Optional mapping of path index → DXF layer name. Paths
|
||||
whose index is absent fall back to the default layer logic
|
||||
(``"ISLANDS"`` for islands, ``"0"`` for outer contours).
|
||||
|
||||
Returns:
|
||||
DXF file content as bytes.
|
||||
"""
|
||||
doc = ezdxf.new(dxfversion="R2000") # AC1015
|
||||
|
||||
# --- Unit metadata in DXF header ---
|
||||
if units is not None:
|
||||
doc.header["$INSUNITS"] = _INSUNITS_MAP[units]
|
||||
doc.header["$MEASUREMENT"] = _MEASUREMENT_MAP[units]
|
||||
|
||||
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)
|
||||
# Create any custom layers requested by layer_map
|
||||
if layer_map:
|
||||
for layer_name in set(layer_map.values()):
|
||||
if layer_name not in ("0", "ISLANDS"):
|
||||
doc.layers.add(layer_name)
|
||||
|
||||
for idx, path in enumerate(result.paths):
|
||||
# Determine the base layer: explicit layer_map entry, or default logic
|
||||
if layer_map and idx in layer_map:
|
||||
base_layer = layer_map[idx]
|
||||
else:
|
||||
base_layer = "0"
|
||||
_add_path_to_msp(msp, path, layer=base_layer, scale_factor=scale_factor)
|
||||
|
||||
# Write to string buffer, then encode to bytes
|
||||
stream = io.StringIO()
|
||||
|
|
|
|||
|
|
@ -244,6 +244,169 @@ class TestDXFOutput:
|
|||
assert any(not pl.is_closed for pl in polylines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DXF unit/scale/layer_map tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# 384×576 px artboard = 4×6 inches at 96 PPI
|
||||
_ARTBOARD_SVG_4x6 = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="384" height="576" viewBox="0 0 384 576">'
|
||||
'<path d="M 0,0 L 384,0 L 384,576 L 0,576 Z" fill="black"/>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
|
||||
class TestDXFUnitsAndScale:
|
||||
"""Tests for the units, scale_factor, and layer_map extensions."""
|
||||
|
||||
def test_scale_factor_converts_pixel_coords_to_inches(self):
|
||||
"""A 384×576 px rectangle scaled by 1/96 should span 0–4 × 0–6 inches."""
|
||||
result = _make_result(_ARTBOARD_SVG_4x6)
|
||||
scale = 1.0 / 96.0
|
||||
dxf_bytes = generate_dxf(result, units="inches", scale_factor=scale)
|
||||
doc = _read_dxf(dxf_bytes)
|
||||
msp = doc.modelspace()
|
||||
polylines = list(msp.query("LWPOLYLINE"))
|
||||
assert len(polylines) >= 1
|
||||
|
||||
# Collect all x/y values across polylines
|
||||
all_x: list[float] = []
|
||||
all_y: list[float] = []
|
||||
for pl in polylines:
|
||||
for pt in pl.get_points():
|
||||
all_x.append(pt[0])
|
||||
all_y.append(pt[1])
|
||||
|
||||
assert min(all_x) == pytest.approx(0.0, abs=0.01)
|
||||
assert max(all_x) == pytest.approx(4.0, abs=0.01)
|
||||
assert min(all_y) == pytest.approx(0.0, abs=0.01)
|
||||
assert max(all_y) == pytest.approx(6.0, abs=0.01)
|
||||
|
||||
def test_scale_factor_converts_pixel_coords_to_mm(self):
|
||||
"""A 384×576 px rectangle scaled by 25.4/96 should span 0–101.6 × 0–152.4 mm."""
|
||||
result = _make_result(_ARTBOARD_SVG_4x6)
|
||||
scale = 25.4 / 96.0
|
||||
dxf_bytes = generate_dxf(result, units="mm", scale_factor=scale)
|
||||
doc = _read_dxf(dxf_bytes)
|
||||
msp = doc.modelspace()
|
||||
polylines = list(msp.query("LWPOLYLINE"))
|
||||
|
||||
all_x: list[float] = []
|
||||
all_y: list[float] = []
|
||||
for pl in polylines:
|
||||
for pt in pl.get_points():
|
||||
all_x.append(pt[0])
|
||||
all_y.append(pt[1])
|
||||
|
||||
assert max(all_x) == pytest.approx(101.6, abs=0.1)
|
||||
assert max(all_y) == pytest.approx(152.4, abs=0.1)
|
||||
|
||||
def test_insunits_header_set_for_inches(self):
|
||||
"""$INSUNITS should be 1 (inches) when units='inches'."""
|
||||
result = _make_result()
|
||||
dxf_bytes = generate_dxf(result, units="inches")
|
||||
doc = _read_dxf(dxf_bytes)
|
||||
assert doc.header["$INSUNITS"] == 1
|
||||
|
||||
def test_insunits_header_set_for_mm(self):
|
||||
"""$INSUNITS should be 4 (millimeters) when units='mm'."""
|
||||
result = _make_result()
|
||||
dxf_bytes = generate_dxf(result, units="mm")
|
||||
doc = _read_dxf(dxf_bytes)
|
||||
assert doc.header["$INSUNITS"] == 4
|
||||
|
||||
def test_measurement_header_imperial(self):
|
||||
"""$MEASUREMENT should be 0 (imperial) when units='inches'."""
|
||||
result = _make_result()
|
||||
dxf_bytes = generate_dxf(result, units="inches")
|
||||
doc = _read_dxf(dxf_bytes)
|
||||
assert doc.header["$MEASUREMENT"] == 0
|
||||
|
||||
def test_measurement_header_metric(self):
|
||||
"""$MEASUREMENT should be 1 (metric) when units='mm'."""
|
||||
result = _make_result()
|
||||
dxf_bytes = generate_dxf(result, units="mm")
|
||||
doc = _read_dxf(dxf_bytes)
|
||||
assert doc.header["$MEASUREMENT"] == 1
|
||||
|
||||
def test_no_units_omits_insunits_header(self):
|
||||
"""When no units specified, $INSUNITS stays at the ezdxf R2000 default (6 = meters)."""
|
||||
result = _make_result()
|
||||
dxf_bytes = generate_dxf(result)
|
||||
doc = _read_dxf(dxf_bytes)
|
||||
# ezdxf R2000 template defaults $INSUNITS to 6 (meters) — we don't override it
|
||||
assert doc.header["$INSUNITS"] == 6
|
||||
|
||||
def test_scale_factor_default_no_change(self):
|
||||
"""With scale_factor=1.0 (default), coords remain unchanged from pixel values."""
|
||||
result = _make_result()
|
||||
dxf_bytes = generate_dxf(result)
|
||||
doc = _read_dxf(dxf_bytes)
|
||||
msp = doc.modelspace()
|
||||
polylines = list(msp.query("LWPOLYLINE"))
|
||||
# Original SIMPLE_SVG has coords around 10–90
|
||||
all_x: list[float] = []
|
||||
for pl in polylines:
|
||||
for pt in pl.get_points():
|
||||
all_x.append(pt[0])
|
||||
assert max(all_x) == pytest.approx(90.0, abs=1.0)
|
||||
|
||||
def test_layer_map_assigns_paths_to_named_layers(self):
|
||||
"""layer_map should place specific paths on custom layers."""
|
||||
result = _make_result(SVG_WITH_ISLAND)
|
||||
# Assign path 0 to "CUT" layer
|
||||
dxf_bytes = generate_dxf(result, layer_map={0: "CUT"})
|
||||
doc = _read_dxf(dxf_bytes)
|
||||
msp = doc.modelspace()
|
||||
polylines = list(msp.query("LWPOLYLINE"))
|
||||
layers = [pl.dxf.layer for pl in polylines]
|
||||
assert "CUT" in layers
|
||||
|
||||
def test_layer_map_does_not_override_island_layer(self):
|
||||
"""Islands should still go to ISLANDS layer even when layer_map is used,
|
||||
unless the layer_map explicitly assigns them elsewhere.
|
||||
"""
|
||||
result = _make_result(SVG_WITH_ISLAND)
|
||||
# Only assign path 0 to "CUT", leave other paths to default logic
|
||||
# Find which index is the island
|
||||
island_idx = next(i for i, p in enumerate(result.paths) if p.is_island)
|
||||
non_island_idx = next(i for i, p in enumerate(result.paths) if not p.is_island)
|
||||
dxf_bytes = generate_dxf(result, layer_map={non_island_idx: "CUT"})
|
||||
doc = _read_dxf(dxf_bytes)
|
||||
msp = doc.modelspace()
|
||||
polylines = list(msp.query("LWPOLYLINE"))
|
||||
layers = [pl.dxf.layer for pl in polylines]
|
||||
assert "CUT" in layers
|
||||
assert "ISLANDS" in layers
|
||||
|
||||
def test_combined_units_scale_and_layer_map(self):
|
||||
"""All three features work together: units + scale + layer_map."""
|
||||
result = _make_result(_ARTBOARD_SVG_4x6)
|
||||
scale = 1.0 / 96.0
|
||||
dxf_bytes = generate_dxf(
|
||||
result, units="inches", scale_factor=scale, layer_map={0: "OUTLINE"}
|
||||
)
|
||||
doc = _read_dxf(dxf_bytes)
|
||||
|
||||
# Headers set
|
||||
assert doc.header["$INSUNITS"] == 1
|
||||
assert doc.header["$MEASUREMENT"] == 0
|
||||
|
||||
# Scale applied
|
||||
msp = doc.modelspace()
|
||||
polylines = list(msp.query("LWPOLYLINE"))
|
||||
all_x: list[float] = []
|
||||
for pl in polylines:
|
||||
for pt in pl.get_points():
|
||||
all_x.append(pt[0])
|
||||
assert max(all_x) == pytest.approx(4.0, abs=0.01)
|
||||
|
||||
# Layer assigned
|
||||
layers = [pl.dxf.layer for pl in polylines]
|
||||
assert "OUTLINE" in layers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: round-trip through all formats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue