diff --git a/engine/api/routes.py b/engine/api/routes.py index 3ca93dc..b98eefc 100644 --- a/engine/api/routes.py +++ b/engine/api/routes.py @@ -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, + ) diff --git a/engine/output/dxf.py b/engine/output/dxf.py index ba36c04..775c0e4 100644 --- a/engine/output/dxf.py +++ b/engine/output/dxf.py @@ -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() diff --git a/engine/tests/test_output.py b/engine/tests/test_output.py index ed3d26f..29fd888 100644 --- a/engine/tests/test_output.py +++ b/engine/tests/test_output.py @@ -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 = ( + '' + '' + "" +) + + +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 # ---------------------------------------------------------------------------