kerf-engine/engine/output/dxf.py
jlightner 4fad89288e 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
2026-03-26 06:17:06 +00:00

116 lines
3.8 KiB
Python

"""DXF output generator — AC1015+ DXF from PostProcessResult using ezdxf."""
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:
return
target_layer = "ISLANDS" if path.is_island else layer
# LWPOLYLINE uses (x, y[, start_width, end_width, bulge]) tuples
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]:
points = points[:-1]
msp.add_lwpolyline(
points,
dxfattribs={"layer": target_layer},
close=path.is_closed,
)
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)
are placed on an "ISLANDS" layer; outer contours on the default "0" layer.
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
# 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()
doc.write(stream)
return stream.getvalue().encode("utf-8")