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,
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 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
|
# Integration: round-trip through all formats
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue