"""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")