# S01 — Export Flow (View 3) + DXF Generation — Research

**Date:** 2026-03-26

## Summary

This slice builds View 3 (Export) — the final step in the upload → design → export flow. The core challenge is converting the in-memory Konva canvas state (pixel-based objects) into downloadable DXF/SVG/PNG files at real-world scale. The engine already has a working DXF generator (`engine/output/dxf.py`) that produces AC1015 LWPOLYLINE entities from `PostProcessResult`, but it operates on engine-traced SVG paths — not on arbitrary canvas objects (shapes, text, images). The export flow must bridge this gap.

The main risks are: (1) DXF scale accuracy — converting from 96 PPI screen pixels to real-world inches/mm, (2) text-to-paths enforcement before export (text objects can't be represented in DXF), and (3) composing multiple canvas object types (shapes, images, text-as-paths) into a single coherent DXF file with proper layering. The engine's existing DXF generator is a good foundation but needs extension to accept layer assignments and unit configuration.

The frontend work is substantial: a new Export view with format selection, layer assignment UI, pre-export validation warnings, and download triggers. Canvas state must be lifted from DesignCanvas to App.tsx so it can be shared with the Export view.

## Recommendation

**Build client-side SVG composition + server-side DXF generation.** The frontend composes all canvas objects into a single SVG document (with text already converted to paths), sends it to the engine's existing `/engine/simplify` endpoint with `output_format=dxf`, and receives the DXF file. For SVG export, the composed SVG is downloaded directly. For PNG, use Konva's built-in `stage.toDataURL()`.

This avoids duplicating geometry handling in the frontend and reuses the engine's battle-tested DXF generator + postprocessing pipeline. The engine's `generate_dxf()` needs a small extension: accept unit configuration (`$INSUNITS`) and a scale factor so coordinates map to real-world dimensions.

## Implementation Landscape

### Key Files

**Frontend (app/src/):**
- `App.tsx` — Currently owns `view` state and `svgResult`. Must be extended to receive canvas state from View 2 and pass it to View 3. The `useCanvasState` hook currently lives inside `DesignCanvas.tsx` — it needs to be lifted to `App.tsx` or its state passed via a callback.
- `views/DesignCanvas.tsx` — View 2 container. Needs an "Export" button that triggers navigation to View 3 with canvas state. Currently has no `onExport` / `onNavigateToExport` prop.
- `views/ExportView.tsx` — **New file.** The Export view. Shows format selector (DXF/SVG/PNG), pre-export validation panel (warnings for unconverted text, open paths), layer assignment (for DXF), unit selection (inches/mm), and download button.
- `utils/exportService.ts` — **New file.** Core export logic: (1) compose canvas objects into SVG string, (2) call engine API for DXF conversion, (3) trigger downloads. Pure functions, no React.
- `utils/artboardShapes.ts` — Has `toPx()` and `fromPx()` converters (96 PPI). The export service uses `fromPx()` to convert pixel coords back to real-world units.
- `api/engine.ts` — Needs a new function to call `/engine/simplify` or `/engine/trace` with `output_format=dxf` and receive binary DXF bytes (not JSON).
- `types/canvas.ts` — `CanvasState`, `CanvasObject`, `ArtboardConfig` types. No changes needed.
- `types/engine.ts` — May need a DXF response type (binary blob, not JSON).
- `hooks/useCanvasState.ts` — No changes to the hook itself, but its instantiation point moves.

**Engine (engine/):**
- `output/dxf.py` — `generate_dxf()` needs extension: accept `units` param ('inches'|'mm'), set `$INSUNITS` header (1 for inches, 4 for mm), accept optional `scale_factor` to convert pixel coords to real-world coords, accept optional `layer_map` to assign paths to named DXF layers instead of just "0"/"ISLANDS".
- `api/routes.py` — `_format_response()` and `/engine/simplify` already support `output_format=dxf`. The DXF binary response path works (tested in M001). May need to pass unit/scale params through to `generate_dxf()`.
- `pipeline/postprocess.py` — `PostProcessResult` and `PathInfo` are the intermediate representation consumed by `generate_dxf()`. No changes needed — the frontend composes SVG, the engine parses it.

### Build Order

1. **Engine DXF unit/scale support** (lowest risk, unblocks everything) — Extend `generate_dxf()` to accept `units` and `scale_factor` params. Set `$INSUNITS` and `$MEASUREMENT` DXF headers. Apply scale_factor to all coordinates. Add `units` and `scale_factor` as optional Form params to `/engine/simplify`. Add tests. This is the critical correctness piece for R020.

2. **Canvas state lifting** — Move `useCanvasState` instantiation from `DesignCanvas.tsx` to `App.tsx`. Pass state + actions as props to DesignCanvas. Add `onExport` callback prop to DesignCanvas that triggers view transition to 'export'. This is mechanical refactoring but touches many prop types.

3. **Export service (SVG composition)** — Build `utils/exportService.ts` with `composeCanvasSVG(objects, artboard)` that renders all visible canvas objects into an SVG string. Text objects → error (must be converted first). Image objects → inline `<image>` or re-embedded SVG path data. Shape objects → SVG primitives. Coordinate space: use `fromPx()` to convert to real-world units in the SVG viewBox.

4. **Pre-export validation** — Build validation logic: check for unconverted text objects, check for open paths (from trace metadata), check artboard is set. Return structured warnings/errors.

5. **Export view UI** — Build `ExportView.tsx`: format selector, validation panel, layer assignment (DXF only), download button. Wire to export service.

6. **DXF API client + download** — Extend `api/engine.ts` with a function that POSTs composed SVG to `/engine/simplify?output_format=dxf` and returns a Blob for download. Handle the binary response (not JSON).

7. **PNG export** — Use Konva's `stage.toDataURL({ pixelRatio: 2 })` for PNG export. This is trivial since Konva has built-in support.

### Verification Approach

1. **Engine DXF scale test**: Create a PostProcessResult with known pixel coordinates (e.g., a 384×576 pixel rectangle = 4×6 inches at 96 PPI). Generate DXF with `scale_factor=1/96` and `units='inches'`. Read back with ezdxf, verify polyline coordinates are 0-4 × 0-6 and `$INSUNITS` is 1.

2. **SVG composition test**: Unit test `composeCanvasSVG()` with a known set of canvas objects (rect + image + circle). Verify the output SVG contains correct elements with proper coordinates.

3. **Pre-export validation test**: Unit test validation catches unconverted text objects, reports them as blocking errors.

4. **End-to-end manual test**: Design a sign with text + imported vector in View 2, navigate to View 3, export as DXF, open in Inkscape — verify correct geometry and scale. This matches the milestone's "After this" demo criteria.

5. **PNG export test**: Export PNG, verify image dimensions match artboard at expected pixel ratio.

6. **TypeScript compilation**: `tsc -b` passes with no errors after all changes.

## Don't Hand-Roll

| Problem | Existing Solution | Why Use It |
|---------|------------------|------------|
| DXF file generation | `ezdxf` (already in engine deps) | Battle-tested DXF library with AC1015+ support, layer management, unit headers. Already used in `engine/output/dxf.py`. |
| PNG export from canvas | Konva.js `stage.toDataURL()` | Built-in Konva method. Returns data URL or triggers blob download. No need for html2canvas or other libraries. |
| SVG path parsing for DXF | Engine `postprocess_svg()` pipeline | Already parses SVG paths into `PathInfo` coordinates consumed by `generate_dxf()`. Don't duplicate this in the frontend. |
| Pixel ↔ real-world unit conversion | `artboardShapes.ts` `toPx()` / `fromPx()` | Already implements 96 PPI conversion with inches/mm support. |
| File download trigger | Browser `URL.createObjectURL()` + hidden `<a>` click | Standard browser download pattern. No library needed. |

## Constraints

- **Engine DXF generator only accepts `PostProcessResult`** — the frontend can't send arbitrary canvas objects to the engine for DXF conversion. It must first compose them into SVG, then send the SVG through `/engine/simplify` which runs `postprocess_svg()` → `generate_dxf()`.
- **Text objects cannot be represented in DXF** — text must be converted to paths (via the existing "Convert to Paths" feature in ShapeProperties) before export. The export view must enforce this as a blocking validation error.
- **Image objects with raster sources (PNG/JPG) cannot go into DXF** — only vector image objects (SVG blob URLs from imported traces or converted text) contain path data extractable to DXF. Raster images would need to be re-traced. The validation should warn about this.
- **The `/engine/simplify` endpoint returns DXF as raw bytes with `Content-Disposition: attachment`** — the frontend API client must handle `response.blob()` instead of `response.json()` for DXF format.
- **Konva.js `toDataURL()` requires the stage to be rendered** — PNG export must happen from View 2 (where the stage exists) or the stage must be re-rendered offscreen in View 3. Simpler: trigger PNG download from View 2 before navigating, or keep the stage mounted.
- **P012 applies**: Adding any new canvas object type would require updates in 6 files. This slice doesn't add new types but the export service must handle all 6 existing types exhaustively.
- **96 PPI is the fixed conversion factor** — `artboardShapes.ts` defines `PPI = 96`. All pixel-to-real-world conversions use this constant. DXF scale_factor = `1/96` for inches, `25.4/96` for mm.

## Common Pitfalls

- **SVG composition coordinate space mismatch** — Canvas objects use pixel coordinates relative to the artboard origin. The composed SVG must use the same coordinate space. The `viewBox` should match the artboard dimensions in pixels, and `width`/`height` attributes should be in real-world units for scale-correct SVG export.
- **DXF Y-axis inversion** — DXF uses a standard cartesian coordinate system (Y-up). SVG/Canvas use Y-down. The engine's `generate_dxf()` currently does NOT flip Y coordinates. For laser cutting, this may or may not matter (LightBurn handles it). But verify: if the engine's PostProcessResult coords come from SVG (Y-down), the DXF should either flip them or document the convention. Check existing tests.
- **Binary response handling in fetch API** — The existing `simplifyVector()` in `api/engine.ts` uses `res.json()` which fails for DXF binary responses. Need a separate code path: if `output_format=dxf`, use `res.blob()`.
- **Blob URL cleanup** — Composed SVG blob URLs and download blob URLs must be revoked with `URL.revokeObjectURL()` after use to prevent memory leaks.
- **Konva stage reference for PNG export** — The `stageRef` is currently owned by DesignCanvas. If PNG export happens from View 3, the stage isn't mounted. Options: (a) export PNG before leaving View 2, (b) render an offscreen Konva stage in View 3, (c) keep View 2 mounted but hidden. Option (a) is simplest.

## Open Risks

- **DXF scale verification requires external tool validation** — The milestone "After this" requires opening DXF in Inkscape/LightBurn with correct geometry. Automated tests can verify coordinates via ezdxf, but true validation needs a human opening the file. The automated test should at least verify that a 4-inch artboard produces DXF coordinates spanning 0-4 in the inch unit system.
- **Complex SVG composition for image objects** — Image objects sourced from engine traces contain complex SVG path data (compound paths with fill-rule="evenodd"). Re-embedding these into a composed SVG document requires careful namespace handling and may produce large SVG strings that slow down the engine's postprocessor.
- **Konva stage.toDataURL() pixel ratio across devices** — Different devices have different `devicePixelRatio`. The PNG export should use a fixed `pixelRatio` (e.g., 2) for consistency, not the device default.

## Skills Discovered

| Technology | Skill | Status |
|------------|-------|--------|
| ezdxf | — | none found |
| Konva.js | — | none found |
| DXF export | — | none found |
