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:
jlightner 2026-03-26 06:17:06 +00:00
parent 868b444595
commit 6c8c31e13b
3 changed files with 248 additions and 6 deletions

View file

@ -30,11 +30,18 @@ def _format_response(
output_format: str, output_format: str,
warnings: list[str], warnings: list[str],
processing_ms: float, processing_ms: float,
*,
units: str | None = None,
scale_factor: float = 1.0,
): ):
"""Build a standardized response from a PostProcessResult and output format. """Build a standardized response from a PostProcessResult and output format.
SVG and JSON return a JSON envelope with output + metadata. SVG and JSON return a JSON envelope with output + metadata.
DXF returns raw bytes with application/dxf content type. 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 = { metadata = {
"format": output_format, "format": output_format,
@ -47,7 +54,12 @@ def _format_response(
} }
if output_format == "dxf": 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( return Response(
content=dxf_bytes, content=dxf_bytes,
media_type="application/dxf", media_type="application/dxf",
@ -174,16 +186,24 @@ async def trace(
return _format_response(result, output_format, warnings, processing_ms) return _format_response(result, output_format, warnings, processing_ms)
VALID_UNITS = {"inches", "mm"}
@router.post("/engine/simplify") @router.post("/engine/simplify")
async def simplify( async def simplify(
file: UploadFile = File(...), file: UploadFile = File(...),
epsilon: float = Form(1.0), epsilon: float = Form(1.0),
output_format: str = Form("svg"), output_format: str = Form("svg"),
units: str | None = Form(None),
scale_factor: float = Form(1.0),
): ):
"""Simplify an existing SVG using RDP path simplification. """Simplify an existing SVG using RDP path simplification.
Accepts an SVG file upload, applies Ramer-Douglas-Peucker simplification Accepts an SVG file upload, applies Ramer-Douglas-Peucker simplification
with the given epsilon, and returns the result in the requested format. 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: if output_format not in VALID_OUTPUT_FORMATS:
raise HTTPException( 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))}", 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() raw_bytes = await file.read()
if not raw_bytes: if not raw_bytes:
raise HTTPException(status_code=422, detail="Uploaded file is empty") 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) 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,
)

View file

@ -3,21 +3,38 @@
from __future__ import annotations from __future__ import annotations
import io import io
from typing import Literal
import ezdxf import ezdxf
from pipeline.postprocess import PathInfo, PostProcessResult 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( def _add_path_to_msp(
msp: ezdxf.layouts.BaseLayout, msp: ezdxf.layouts.BaseLayout,
path: PathInfo, path: PathInfo,
layer: str = "0", layer: str = "0",
scale_factor: float = 1.0,
) -> None: ) -> None:
"""Add a single PathInfo as an LWPOLYLINE entity to the modelspace. """Add a single PathInfo as an LWPOLYLINE entity to the modelspace.
Closed paths get the LWPOLYLINE close flag set. Closed paths get the LWPOLYLINE close flag set.
Islands are placed on a separate "ISLANDS" layer for downstream CAM tools. 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 coords = path.simplified_coords
if len(coords) < 2: if len(coords) < 2:
@ -26,7 +43,7 @@ def _add_path_to_msp(
target_layer = "ISLANDS" if path.is_island else layer target_layer = "ISLANDS" if path.is_island else layer
# LWPOLYLINE uses (x, y[, start_width, end_width, bulge]) tuples # 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 # Remove duplicate close point if the polyline close flag handles it
if path.is_closed and len(points) > 1 and points[0] == points[-1]: 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. """Generate an AC1015 (AutoCAD R2000) DXF file from post-processed path data.
Creates LWPOLYLINE entities from simplified coordinate data. Islands (holes) Creates LWPOLYLINE entities from simplified coordinate data. Islands (holes)
@ -47,18 +70,45 @@ def generate_dxf(result: PostProcessResult) -> bytes:
Args: Args:
result: PostProcessResult from the post-processing pipeline. 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: Returns:
DXF file content as bytes. DXF file content as bytes.
""" """
doc = ezdxf.new(dxfversion="R2000") # AC1015 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() msp = doc.modelspace()
# Create ISLANDS layer for hole/island paths # Create ISLANDS layer for hole/island paths
doc.layers.add("ISLANDS", color=1) # color 1 = red in AutoCAD doc.layers.add("ISLANDS", color=1) # color 1 = red in AutoCAD
for path in result.paths: # Create any custom layers requested by layer_map
_add_path_to_msp(msp, path) 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 # Write to string buffer, then encode to bytes
stream = io.StringIO() stream = io.StringIO()

View file

@ -244,6 +244,169 @@ class TestDXFOutput:
assert any(not pl.is_closed for pl in polylines) 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 04 × 06 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 0101.6 × 0152.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 1090
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 # Integration: round-trip through all formats
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------