diff --git a/.gsd/completed-units-M002.json b/.gsd/completed-units-M002.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/.gsd/completed-units-M002.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.gsd/event-log.jsonl b/.gsd/event-log.jsonl index 12b205b..b6f46fc 100644 --- a/.gsd/event-log.jsonl +++ b/.gsd/event-log.jsonl @@ -30,3 +30,5 @@ {"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S03","taskId":"T03"},"ts":"2026-03-26T05:58:01.944Z","actor":"agent","hash":"61f3bf5bb0b4c33f","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-slice","params":{"milestoneId":"M002","sliceId":"S03"},"ts":"2026-03-26T06:00:46.895Z","actor":"agent","hash":"0a887fcfebe01587","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-milestone","params":{"milestoneId":"M002"},"ts":"2026-03-26T06:06:46.769Z","actor":"agent","hash":"56704af548d63e18","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} +{"cmd":"plan-slice","params":{"milestoneId":"M003","sliceId":"S01"},"ts":"2026-03-26T06:13:19.433Z","actor":"agent","hash":"0c1c14d2a8ee7643","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} +{"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T01"},"ts":"2026-03-26T06:16:59.236Z","actor":"agent","hash":"f6bd52e1fbbe7e7f","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} diff --git a/.gsd/milestones/M003/slices/S01/S01-PLAN.md b/.gsd/milestones/M003/slices/S01/S01-PLAN.md index 47a2697..cdeb1e9 100644 --- a/.gsd/milestones/M003/slices/S01/S01-PLAN.md +++ b/.gsd/milestones/M003/slices/S01/S01-PLAN.md @@ -1,6 +1,68 @@ # S01: Export Flow (View 3) + DXF Generation -**Goal:** Build View 3 Export UI with DXF/SVG/PNG generation, layer assignment, text-to-paths enforcement, pre-export validation, and download +**Goal:** Complete Export view (View 3) with DXF/SVG/PNG download. Users design a sign in View 2, navigate to View 3, select export format, see pre-export validation warnings, and download files with correct real-world scale. **Demo:** After this: Design a sign with text + imported vector, export as DXF, open in Inkscape/LightBurn with correct geometry and scale ## Tasks +- [x] **T01: Extended generate_dxf() with units/scale_factor/layer_map params and wired units+scale_factor through /engine/simplify API endpoint** — The engine's `generate_dxf()` currently produces DXF files with raw pixel coordinates and no unit metadata. This task extends it to accept `units` ('inches'|'mm') and `scale_factor` parameters, sets the `$INSUNITS` and `$MEASUREMENT` DXF headers, applies `scale_factor` to all polyline coordinates, and adds optional `layer_map` support. The `/engine/simplify` endpoint is extended with optional `units` and `scale_factor` Form params that pass through to the DXF generator via `_format_response()`. Tests verify a known-size artboard (384×576 px = 4×6 inches at 96 PPI) produces DXF coordinates spanning 0–4 × 0–6 with correct `$INSUNITS=1`. + - Estimate: 1h + - Files: engine/output/dxf.py, engine/api/routes.py, engine/tests/test_output.py + - Verify: cd engine && .venv/bin/python -m pytest tests/test_output.py -v -W ignore::DeprecationWarning +- [ ] **T02: Lift canvas state to App.tsx and add Export navigation** — Currently `useCanvasState()` is instantiated inside `DesignCanvas.tsx`, making canvas state inaccessible to View 3. This task lifts the hook instantiation to `App.tsx`, passes state + actions as props to `DesignCanvas`, and adds an `onExport` callback prop that triggers navigation to the 'export' view. The Export view placeholder in App.tsx is updated to receive canvas state props. DesignCanvas gets an 'Export' button in its toolbar area. All existing DesignCanvas functionality must remain unchanged — this is a mechanical prop-threading refactor. + +Key constraints: +- `useCanvasState` hook itself is NOT modified — only where it's called changes +- DesignCanvas receives `state`, `addObject`, `removeObject`, `updateObject`, `selectObjects`, `deselectAll`, `reorderObject`, `toggleVisibility`, `toggleLock`, `setArtboard`, `undo`, `redo`, `canUndo`, `canRedo` as props instead of calling the hook internally +- The `traceMetadata` param to `useCanvasState()` comes from App.tsx's existing `traceMetadata` state +- App.tsx passes a `stageRef` to DesignCanvas and receives it back so PNG export can work later +- A `Ref` is created in App.tsx and passed to DesignCanvas for stage access from View 3 + - Estimate: 1.5h + - Files: app/src/App.tsx, app/src/views/DesignCanvas.tsx + - Verify: cd app && npx tsc -b --noEmit && npx vitest run +- [ ] **T03: Build export service with SVG composition, validation, and DXF API client** — This task creates the core export logic as pure utility functions: (1) `composeCanvasSVG()` renders visible canvas objects into an SVG string with correct coordinate space and real-world unit dimensions, (2) `validateForExport()` checks for unconverted text objects (blocking error), raster-only images (warning), and missing artboard (blocking error), (3) `exportDxf()` in the API client POSTs composed SVG to `/engine/simplify?output_format=dxf` with `units` and `scale_factor` params and returns a Blob, (4) `triggerDownload()` creates a hidden anchor + blob URL to trigger browser download with proper cleanup. + +SVG composition details: +- The SVG viewBox matches artboard dimensions in pixels +- `width`/`height` attributes use real-world units (e.g., `width="4in"` or `width="101.6mm"`) +- RectObject → ``, CircleObject → ``, EllipseObject → ``, LineObject → `` +- ImageObject with SVG blob src → inline the SVG content (extract path data from blob URL) +- ImageObject with raster src → skip (validation warns about this) +- TextObject → error (validation blocks this — must be converted to paths first) +- Objects with `visible: false` are skipped +- All coordinates are in the artboard's pixel space (the engine handles conversion via scale_factor) + +DXF API client: +- New function `exportAsDxf(svgContent: string, units: 'inches' | 'mm', scaleFactor: number, signal?: AbortSignal): Promise` +- Uses FormData with file as Blob, output_format=dxf, units, scale_factor +- Returns `response.blob()` not `response.json()` + +Unit tests cover: SVG composition with known objects produces correct SVG elements, validation catches text objects, validation warns on raster images, coordinate space is correct. + - Estimate: 2h + - Files: app/src/utils/exportService.ts, app/src/utils/__tests__/exportService.test.ts, app/src/api/engine.ts, app/src/api/__tests__/engine.test.ts + - Verify: cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts && npx tsc -b --noEmit +- [ ] **T04: Build Export view UI with format selection, validation panel, and download wiring** — This task builds the ExportView component — the final piece that wires everything together. The view receives canvas state from App.tsx (lifted in T02) and uses the export service (built in T03) to compose SVG, validate, call the engine API, and trigger downloads. + +ExportView layout: +- Header with "Export" title and a "← Back to Design" button that navigates back to View 2 +- Format selector: three cards/buttons for DXF, SVG, PNG with descriptions +- Unit selector (DXF/SVG only): inches or mm radio buttons, defaulting to the artboard's unit +- Validation panel: shows blocking errors (red, disables export) and warnings (yellow, allows export). Runs `validateForExport()` on mount and when objects change +- Canvas preview: a small thumbnail of the current design (use Konva `stage.toDataURL()` from the stageRef passed through App.tsx, captured before navigating to export view) +- Download button: disabled when blocking errors exist; triggers the appropriate export flow + +Export flows by format: +- **DXF**: Call `composeCanvasSVG()` → call `exportAsDxf()` with units and scale_factor (1/96 for inches, 25.4/96 for mm) → `triggerDownload()` with the returned blob and filename `export.dxf` +- **SVG**: Call `composeCanvasSVG()` → create blob from SVG string → `triggerDownload()` with filename `export.svg` +- **PNG**: Use the preview data URL (captured from Konva stage before navigating) or re-render. Since stageRef may not be mounted in View 3, capture PNG data URL before transitioning to export view and pass it as a prop. Convert data URL to blob → `triggerDownload()` with filename `export.png` + +PNG capture strategy: When user clicks 'Export' in View 2, capture `stageRef.current.toDataURL({ pixelRatio: 2 })` and store in App.tsx state, then navigate to export view. This avoids needing the Konva stage mounted in View 3. + +App.tsx updates: +- Add `pngDataUrl` state, set it in the onExport handler before view transition +- Pass `pngDataUrl` to ExportView +- Wire ExportView's onBack to navigate back to canvas view + +CSS: New `ExportView.module.css` with the view layout. Follow existing patterns from DesignCanvas.module.css and App.css. + - Estimate: 2h + - Files: app/src/views/ExportView.tsx, app/src/views/ExportView.module.css, app/src/App.tsx + - Verify: cd app && npx tsc -b --noEmit && npx vitest run diff --git a/.gsd/milestones/M003/slices/S01/S01-RESEARCH.md b/.gsd/milestones/M003/slices/S01/S01-RESEARCH.md new file mode 100644 index 0000000..dcadd05 --- /dev/null +++ b/.gsd/milestones/M003/slices/S01/S01-RESEARCH.md @@ -0,0 +1,109 @@ +# 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 `` 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 `` 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 | diff --git a/.gsd/milestones/M003/slices/S01/tasks/T01-PLAN.md b/.gsd/milestones/M003/slices/S01/tasks/T01-PLAN.md new file mode 100644 index 0000000..e40d67d --- /dev/null +++ b/.gsd/milestones/M003/slices/S01/tasks/T01-PLAN.md @@ -0,0 +1,26 @@ +--- +estimated_steps: 1 +estimated_files: 3 +skills_used: [] +--- + +# T01: Extend engine DXF generator with unit/scale support and wire through API + +The engine's `generate_dxf()` currently produces DXF files with raw pixel coordinates and no unit metadata. This task extends it to accept `units` ('inches'|'mm') and `scale_factor` parameters, sets the `$INSUNITS` and `$MEASUREMENT` DXF headers, applies `scale_factor` to all polyline coordinates, and adds optional `layer_map` support. The `/engine/simplify` endpoint is extended with optional `units` and `scale_factor` Form params that pass through to the DXF generator via `_format_response()`. Tests verify a known-size artboard (384×576 px = 4×6 inches at 96 PPI) produces DXF coordinates spanning 0–4 × 0–6 with correct `$INSUNITS=1`. + +## Inputs + +- ``engine/output/dxf.py` — existing DXF generator that accepts PostProcessResult` +- ``engine/api/routes.py` — existing `/engine/simplify` endpoint and `_format_response()` helper` +- ``engine/tests/test_output.py` — existing DXF/SVG/JSON output tests` +- ``engine/pipeline/postprocess.py` — PostProcessResult and PathInfo types consumed by DXF generator` + +## Expected Output + +- ``engine/output/dxf.py` — extended with units, scale_factor, layer_map params; sets $INSUNITS/$MEASUREMENT headers; applies scale to coordinates` +- ``engine/api/routes.py` — /engine/simplify accepts optional units and scale_factor Form params; _format_response passes them to generate_dxf` +- ``engine/tests/test_output.py` — new tests: scale_factor converts pixel coords to real-world coords, $INSUNITS header set correctly for inches/mm, layer_map assigns paths to named layers` + +## Verification + +cd engine && .venv/bin/python -m pytest tests/test_output.py -v -W ignore::DeprecationWarning diff --git a/.gsd/milestones/M003/slices/S01/tasks/T01-SUMMARY.md b/.gsd/milestones/M003/slices/S01/tasks/T01-SUMMARY.md new file mode 100644 index 0000000..10550c1 --- /dev/null +++ b/.gsd/milestones/M003/slices/S01/tasks/T01-SUMMARY.md @@ -0,0 +1,81 @@ +--- +id: T01 +parent: S01 +milestone: M003 +provides: [] +requires: [] +affects: [] +key_files: ["engine/output/dxf.py", "engine/api/routes.py", "engine/tests/test_output.py"] +key_decisions: ["DXF $INSUNITS codes: inches=1, mm=4; $MEASUREMENT: inches=0, mm=1", "layer_map is index-based (dict[int, str]) with island auto-layer fallback", "scale_factor applied uniformly to all (x, y) coords; caller computes the value"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "Ran pytest tests/test_output.py: 36/36 passed (25 existing + 11 new). Ran full engine suite: 207/207 passed. Scale conversion tests verify 384×576 px artboard produces 4×6 inch and 101.6×152.4 mm coordinates. $INSUNITS header tests confirm inches=1, mm=4. Layer_map tests confirm custom layer assignment and island layer preservation." +completed_at: 2026-03-26T06:16:59.178Z +blocker_discovered: false +--- + +# T01: Extended generate_dxf() with units/scale_factor/layer_map params and wired units+scale_factor through /engine/simplify API endpoint + +> Extended generate_dxf() with units/scale_factor/layer_map params and wired units+scale_factor through /engine/simplify API endpoint + +## What Happened +--- +id: T01 +parent: S01 +milestone: M003 +key_files: + - engine/output/dxf.py + - engine/api/routes.py + - engine/tests/test_output.py +key_decisions: + - DXF $INSUNITS codes: inches=1, mm=4; $MEASUREMENT: inches=0, mm=1 + - layer_map is index-based (dict[int, str]) with island auto-layer fallback + - scale_factor applied uniformly to all (x, y) coords; caller computes the value +duration: "" +verification_result: passed +completed_at: 2026-03-26T06:16:59.197Z +blocker_discovered: false +--- + +# T01: Extended generate_dxf() with units/scale_factor/layer_map params and wired units+scale_factor through /engine/simplify API endpoint + +**Extended generate_dxf() with units/scale_factor/layer_map params and wired units+scale_factor through /engine/simplify API endpoint** + +## What Happened + +Extended generate_dxf() with three keyword-only args (units, scale_factor, layer_map), set $INSUNITS/$MEASUREMENT DXF headers for inches/mm, applied scale_factor to all polyline coordinates, added layer_map for custom DXF layer assignment. Wired units and scale_factor through the /engine/simplify API endpoint via _format_response(). Added 11 new tests covering scale conversion (384x576 px → 4x6 inches, 101.6x152.4 mm), DXF header correctness, layer mapping, and combined feature usage. + +## Verification + +Ran pytest tests/test_output.py: 36/36 passed (25 existing + 11 new). Ran full engine suite: 207/207 passed. Scale conversion tests verify 384×576 px artboard produces 4×6 inch and 101.6×152.4 mm coordinates. $INSUNITS header tests confirm inches=1, mm=4. Layer_map tests confirm custom layer assignment and island layer preservation. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd engine && .venv/bin/python -m pytest tests/test_output.py -v -W ignore::DeprecationWarning` | 0 | ✅ pass | 390ms | +| 2 | `cd engine && .venv/bin/python -m pytest tests/ -v -W ignore::DeprecationWarning` | 0 | ✅ pass | 1060ms | + + +## Deviations + +Adjusted test_no_units_omits_insunits_header: ezdxf R2000 defaults $INSUNITS to 6 (meters) not 0 (unitless) as plan assumed. + +## Known Issues + +None. + +## Files Created/Modified + +- `engine/output/dxf.py` +- `engine/api/routes.py` +- `engine/tests/test_output.py` + + +## Deviations +Adjusted test_no_units_omits_insunits_header: ezdxf R2000 defaults $INSUNITS to 6 (meters) not 0 (unitless) as plan assumed. + +## Known Issues +None. diff --git a/.gsd/milestones/M003/slices/S01/tasks/T02-PLAN.md b/.gsd/milestones/M003/slices/S01/tasks/T02-PLAN.md new file mode 100644 index 0000000..22d1fdf --- /dev/null +++ b/.gsd/milestones/M003/slices/S01/tasks/T02-PLAN.md @@ -0,0 +1,32 @@ +--- +estimated_steps: 7 +estimated_files: 2 +skills_used: [] +--- + +# T02: Lift canvas state to App.tsx and add Export navigation + +Currently `useCanvasState()` is instantiated inside `DesignCanvas.tsx`, making canvas state inaccessible to View 3. This task lifts the hook instantiation to `App.tsx`, passes state + actions as props to `DesignCanvas`, and adds an `onExport` callback prop that triggers navigation to the 'export' view. The Export view placeholder in App.tsx is updated to receive canvas state props. DesignCanvas gets an 'Export' button in its toolbar area. All existing DesignCanvas functionality must remain unchanged — this is a mechanical prop-threading refactor. + +Key constraints: +- `useCanvasState` hook itself is NOT modified — only where it's called changes +- DesignCanvas receives `state`, `addObject`, `removeObject`, `updateObject`, `selectObjects`, `deselectAll`, `reorderObject`, `toggleVisibility`, `toggleLock`, `setArtboard`, `undo`, `redo`, `canUndo`, `canRedo` as props instead of calling the hook internally +- The `traceMetadata` param to `useCanvasState()` comes from App.tsx's existing `traceMetadata` state +- App.tsx passes a `stageRef` to DesignCanvas and receives it back so PNG export can work later +- A `Ref` is created in App.tsx and passed to DesignCanvas for stage access from View 3 + +## Inputs + +- ``app/src/App.tsx` — current view router with svgResult/traceMetadata state` +- ``app/src/views/DesignCanvas.tsx` — current View 2 container that owns useCanvasState` +- ``app/src/hooks/useCanvasState.ts` — hook providing UseCanvasStateReturn interface` +- ``app/src/types/canvas.ts` — CanvasState, CanvasObject, ArtboardConfig types` + +## Expected Output + +- ``app/src/App.tsx` — instantiates useCanvasState, passes state/actions as props to DesignCanvas and (placeholder) ExportView, creates stageRef` +- ``app/src/views/DesignCanvas.tsx` — receives canvas state/actions via props instead of calling useCanvasState internally; adds Export button; accepts and uses stageRef from parent` + +## Verification + +cd app && npx tsc -b --noEmit && npx vitest run diff --git a/.gsd/milestones/M003/slices/S01/tasks/T03-PLAN.md b/.gsd/milestones/M003/slices/S01/tasks/T03-PLAN.md new file mode 100644 index 0000000..b63026b --- /dev/null +++ b/.gsd/milestones/M003/slices/S01/tasks/T03-PLAN.md @@ -0,0 +1,44 @@ +--- +estimated_steps: 15 +estimated_files: 4 +skills_used: [] +--- + +# T03: Build export service with SVG composition, validation, and DXF API client + +This task creates the core export logic as pure utility functions: (1) `composeCanvasSVG()` renders visible canvas objects into an SVG string with correct coordinate space and real-world unit dimensions, (2) `validateForExport()` checks for unconverted text objects (blocking error), raster-only images (warning), and missing artboard (blocking error), (3) `exportDxf()` in the API client POSTs composed SVG to `/engine/simplify?output_format=dxf` with `units` and `scale_factor` params and returns a Blob, (4) `triggerDownload()` creates a hidden anchor + blob URL to trigger browser download with proper cleanup. + +SVG composition details: +- The SVG viewBox matches artboard dimensions in pixels +- `width`/`height` attributes use real-world units (e.g., `width="4in"` or `width="101.6mm"`) +- RectObject → ``, CircleObject → ``, EllipseObject → ``, LineObject → `` +- ImageObject with SVG blob src → inline the SVG content (extract path data from blob URL) +- ImageObject with raster src → skip (validation warns about this) +- TextObject → error (validation blocks this — must be converted to paths first) +- Objects with `visible: false` are skipped +- All coordinates are in the artboard's pixel space (the engine handles conversion via scale_factor) + +DXF API client: +- New function `exportAsDxf(svgContent: string, units: 'inches' | 'mm', scaleFactor: number, signal?: AbortSignal): Promise` +- Uses FormData with file as Blob, output_format=dxf, units, scale_factor +- Returns `response.blob()` not `response.json()` + +Unit tests cover: SVG composition with known objects produces correct SVG elements, validation catches text objects, validation warns on raster images, coordinate space is correct. + +## Inputs + +- ``app/src/types/canvas.ts` — CanvasObject union type (rect, circle, ellipse, line, image, text), ArtboardConfig` +- ``app/src/utils/artboardShapes.ts` — toPx(), fromPx(), PPI constant (96)` +- ``app/src/api/engine.ts` — existing API client with traceImage() and simplifyVector() patterns` +- ``app/src/App.tsx` — updated in T02 with lifted canvas state (provides the state shape export service consumes)` + +## Expected Output + +- ``app/src/utils/exportService.ts` — composeCanvasSVG(), validateForExport(), triggerDownload() functions` +- ``app/src/utils/__tests__/exportService.test.ts` — tests for SVG composition, validation logic, download trigger` +- ``app/src/api/engine.ts` — new exportAsDxf() function for binary DXF response handling` +- ``app/src/api/__tests__/engine.test.ts` — test for exportAsDxf() with mocked fetch returning blob` + +## Verification + +cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts && npx tsc -b --noEmit diff --git a/.gsd/milestones/M003/slices/S01/tasks/T04-PLAN.md b/.gsd/milestones/M003/slices/S01/tasks/T04-PLAN.md new file mode 100644 index 0000000..4040c8b --- /dev/null +++ b/.gsd/milestones/M003/slices/S01/tasks/T04-PLAN.md @@ -0,0 +1,50 @@ +--- +estimated_steps: 18 +estimated_files: 3 +skills_used: [] +--- + +# T04: Build Export view UI with format selection, validation panel, and download wiring + +This task builds the ExportView component — the final piece that wires everything together. The view receives canvas state from App.tsx (lifted in T02) and uses the export service (built in T03) to compose SVG, validate, call the engine API, and trigger downloads. + +ExportView layout: +- Header with "Export" title and a "← Back to Design" button that navigates back to View 2 +- Format selector: three cards/buttons for DXF, SVG, PNG with descriptions +- Unit selector (DXF/SVG only): inches or mm radio buttons, defaulting to the artboard's unit +- Validation panel: shows blocking errors (red, disables export) and warnings (yellow, allows export). Runs `validateForExport()` on mount and when objects change +- Canvas preview: a small thumbnail of the current design (use Konva `stage.toDataURL()` from the stageRef passed through App.tsx, captured before navigating to export view) +- Download button: disabled when blocking errors exist; triggers the appropriate export flow + +Export flows by format: +- **DXF**: Call `composeCanvasSVG()` → call `exportAsDxf()` with units and scale_factor (1/96 for inches, 25.4/96 for mm) → `triggerDownload()` with the returned blob and filename `export.dxf` +- **SVG**: Call `composeCanvasSVG()` → create blob from SVG string → `triggerDownload()` with filename `export.svg` +- **PNG**: Use the preview data URL (captured from Konva stage before navigating) or re-render. Since stageRef may not be mounted in View 3, capture PNG data URL before transitioning to export view and pass it as a prop. Convert data URL to blob → `triggerDownload()` with filename `export.png` + +PNG capture strategy: When user clicks 'Export' in View 2, capture `stageRef.current.toDataURL({ pixelRatio: 2 })` and store in App.tsx state, then navigate to export view. This avoids needing the Konva stage mounted in View 3. + +App.tsx updates: +- Add `pngDataUrl` state, set it in the onExport handler before view transition +- Pass `pngDataUrl` to ExportView +- Wire ExportView's onBack to navigate back to canvas view + +CSS: New `ExportView.module.css` with the view layout. Follow existing patterns from DesignCanvas.module.css and App.css. + +## Inputs + +- ``app/src/App.tsx` — updated in T02 with lifted canvas state and stageRef` +- ``app/src/utils/exportService.ts` — composeCanvasSVG(), validateForExport(), triggerDownload() from T03` +- ``app/src/api/engine.ts` — exportAsDxf() from T03` +- ``app/src/types/canvas.ts` — CanvasState, ArtboardConfig, CanvasObject types` +- ``app/src/utils/artboardShapes.ts` — fromPx() for unit display` +- ``app/src/views/DesignCanvas.module.css` — existing view CSS patterns to follow` + +## Expected Output + +- ``app/src/views/ExportView.tsx` — complete Export view with format selector, validation panel, unit selector, download button` +- ``app/src/views/ExportView.module.css` — view-specific styles` +- ``app/src/App.tsx` — wires ExportView with canvas state, pngDataUrl, and navigation callbacks` + +## Verification + +cd app && npx tsc -b --noEmit && npx vitest run diff --git a/.gsd/reports/M002-2026-03-26T06-07-21.html b/.gsd/reports/M002-2026-03-26T06-07-21.html new file mode 100644 index 0000000..03838ea --- /dev/null +++ b/.gsd/reports/M002-2026-03-26T06-07-21.html @@ -0,0 +1,1748 @@ + + + + + +GSD Report — kerf-engine — M002 + + + +
+ +
+ +
+ +
+

Summary

+ +

kerf-engine is 67% complete across 3 milestones. $41.85 spent. Currently executing M003/S01.

+
2/3Milestones
6/9Slices
planningPhase
$41.85Cost
51.67MTokens
2h 0mDuration
986Tool calls
37Units
3Remaining
17.6/hrRate
$6.98Cost/slice
52.4kTokens/tool
100.0%Cache hit
M002Scope
+
+
+ 67% +
+
+ Executing M003/S01 — Export Flow (View 3) + DXF Generation +
+ +
ETA: ~10m 15s remaining (3 slices at 17.6/hr)
+ +
+ +
+

Blockers

+

No blockers or high-risk items found.

+
+ +
+

Progress

+ +
+ + + M001 + Kerf Engine — Raster-to-Vector Pipeline & API + 3/3 + + + +
+
+ + + S01 + Core Pipeline — Preprocessing + Vectorization + high — dependency installation, OpenCV+Potrace+VTracer integration + + critical + + +
+ +
+ untested +
+ + + +
+
+
+ + + S02 + Post-Processing + Output Formats (SVG, DXF, JSON) + high — DXF generation quality is hard to validate programmatically + S01 + critical + + +
+
provides: postprocess_svg() function for RDP simplification + island detection + node countingprovides: Output generators: generate_dxf(), generate_json(), generate_svg()provides: /engine/simplify endpoint for SVG-to-SVG/DXF/JSON simplificationprovides: output_format routing on /engine/trace (svg, dxf, json)provides: _format_response() pattern for consistent multi-format responsesrequires: Core pipeline: preprocessing + potrace_trace() + vtracer_trace() producing raw SVG output
+
+ passed +
+
Decisions
  • DXF output as raw bytes with application/dxf content-type and metadata in X-Kerf-Metadata header
  • postprocess_svg() replaces regex metadata extraction — full XML path parsing for structured PathInfo objects
  • _format_response() shared helper for consistent response shaping across endpoints
  • Islands placed on separate ISLANDS layer in DXF for downstream CAM tool compatibility
  • Bezier curves linearized during post-processing for RDP simplification and DXF polyline generation
+
Patterns
  • PostProcessResult as the universal intermediate representation consumed by all output generators
  • Output generators are pure functions: PostProcessResult → bytes/string, no side effects
  • _format_response() pattern for consistent multi-format API responses with metadata
+ +
+
+
+ + + S03 + Preset System + Engine Docker Packaging + low — presets are config files; Docker packaging is well-understood + S02 + critical + + +
+
provides: Preset system with 5 tuned configs and merge-based param resolutionprovides: GET /engine/presets endpointprovides: Docker image kerf-engine:dev with healthcheckprovides: GET /engine/health endpointrequires: Post-processing pipeline and output format generators (SVG, DXF, JSON) consumed by preset-driven trace flow
+
+ passed +
+
Decisions
  • Preset default is 'sign' — covers the most common use case
  • Presets use flat JSON with three sections (preprocessing, vectorization, postprocessing)
  • resolve_params merges preset → user_params with user taking precedence
  • Custom preset has empty param sections so pipeline defaults apply unless user provides overrides
  • Multi-stage Docker build separates build deps from runtime (smaller image, no compiler tools)
  • Engine image contains only engine source — enforces Engine/App separation (D001)
  • Health endpoint at /engine/health for namespace consistency with other /engine/* routes
+
Patterns
  • Preset-driven pipeline configuration: presets define defaults, user params override
  • Multi-stage Docker build pattern for Python+C extension packages (pypotrace)
  • Dual health endpoints: /health (root) for simple checks, /engine/health (namespaced) for Docker/orchestration
+ +
+
+
+
+ + + M002 + M002: React Frontend — Import & Convert UI + Design Canvas + 3/3 + + + +
+
+ + + S01 + Import & Convert UI (View 1) + medium — debounced preview updates, Engine API integration from browser + + critical + + +
+ +
+ untested +
+ + + +
+
+
+ + + S02 + Design Canvas Core (View 2) + medium — Konva.js setup, selection handles, undo/redo state management + S01 + critical + + +
+
provides: postprocess_svg() function for RDP simplification + island detection + node countingprovides: Output generators: generate_dxf(), generate_json(), generate_svg()provides: /engine/simplify endpoint for SVG-to-SVG/DXF/JSON simplificationprovides: output_format routing on /engine/trace (svg, dxf, json)provides: _format_response() pattern for consistent multi-format responsesrequires: Core pipeline: preprocessing + potrace_trace() + vtracer_trace() producing raw SVG output
+
+ passed +
+
Decisions
  • DXF output as raw bytes with application/dxf content-type and metadata in X-Kerf-Metadata header
  • postprocess_svg() replaces regex metadata extraction — full XML path parsing for structured PathInfo objects
  • _format_response() shared helper for consistent response shaping across endpoints
  • Islands placed on separate ISLANDS layer in DXF for downstream CAM tool compatibility
  • Bezier curves linearized during post-processing for RDP simplification and DXF polyline generation
+
Patterns
  • PostProcessResult as the universal intermediate representation consumed by all output generators
  • Output generators are pure functions: PostProcessResult → bytes/string, no side effects
  • _format_response() pattern for consistent multi-format API responses with metadata
+ +
+
+
+ + + S03 + Text System + Font Loading + medium — opentype.js integration, font loading from volume, path extraction accuracy + S02 + critical + + +
+
provides: Preset system with 5 tuned configs and merge-based param resolutionprovides: GET /engine/presets endpointprovides: Docker image kerf-engine:dev with healthcheckprovides: GET /engine/health endpointrequires: Post-processing pipeline and output format generators (SVG, DXF, JSON) consumed by preset-driven trace flow
+
+ passed +
+
Decisions
  • Preset default is 'sign' — covers the most common use case
  • Presets use flat JSON with three sections (preprocessing, vectorization, postprocessing)
  • resolve_params merges preset → user_params with user taking precedence
  • Custom preset has empty param sections so pipeline defaults apply unless user provides overrides
  • Multi-stage Docker build separates build deps from runtime (smaller image, no compiler tools)
  • Engine image contains only engine source — enforces Engine/App separation (D001)
  • Health endpoint at /engine/health for namespace consistency with other /engine/* routes
+
Patterns
  • Preset-driven pipeline configuration: presets define defaults, user params override
  • Multi-stage Docker build pattern for Python+C extension packages (pypotrace)
  • Dual health endpoints: /health (root) for simple checks, /engine/health (namespaced) for Docker/orchestration
+ +
+
+
+
+ + + M003 + M003 + 0/3 + critical path + + +
+
+ + + S01 + Export Flow (View 3) + DXF Generation + high — DXF scale accuracy and geometry quality + + critical + + +
+ +
+ untested +
+ + + +
+
+
+ + + S02 + Docker Packaging + README + low — Docker packaging is well-understood pattern + S01 + critical + + +
+
provides: postprocess_svg() function for RDP simplification + island detection + node countingprovides: Output generators: generate_dxf(), generate_json(), generate_svg()provides: /engine/simplify endpoint for SVG-to-SVG/DXF/JSON simplificationprovides: output_format routing on /engine/trace (svg, dxf, json)provides: _format_response() pattern for consistent multi-format responsesrequires: Core pipeline: preprocessing + potrace_trace() + vtracer_trace() producing raw SVG output
+
+ passed +
+
Decisions
  • DXF output as raw bytes with application/dxf content-type and metadata in X-Kerf-Metadata header
  • postprocess_svg() replaces regex metadata extraction — full XML path parsing for structured PathInfo objects
  • _format_response() shared helper for consistent response shaping across endpoints
  • Islands placed on separate ISLANDS layer in DXF for downstream CAM tool compatibility
  • Bezier curves linearized during post-processing for RDP simplification and DXF polyline generation
+
Patterns
  • PostProcessResult as the universal intermediate representation consumed by all output generators
  • Output generators are pure functions: PostProcessResult → bytes/string, no side effects
  • _format_response() pattern for consistent multi-format API responses with metadata
+ +
+
+
+ + + S03 + Embed Mode + medium — Shadow DOM + Konva.js + font loading interactions + S02 + critical + + +
+
provides: Preset system with 5 tuned configs and merge-based param resolutionprovides: GET /engine/presets endpointprovides: Docker image kerf-engine:dev with healthcheckprovides: GET /engine/health endpointrequires: Post-processing pipeline and output format generators (SVG, DXF, JSON) consumed by preset-driven trace flow
+
+ passed +
+
Decisions
  • Preset default is 'sign' — covers the most common use case
  • Presets use flat JSON with three sections (preprocessing, vectorization, postprocessing)
  • resolve_params merges preset → user_params with user taking precedence
  • Custom preset has empty param sections so pipeline defaults apply unless user provides overrides
  • Multi-stage Docker build separates build deps from runtime (smaller image, no compiler tools)
  • Engine image contains only engine source — enforces Engine/App separation (D001)
  • Health endpoint at /engine/health for namespace consistency with other /engine/* routes
+
Patterns
  • Preset-driven pipeline configuration: presets define defaults, user params override
  • Multi-stage Docker build pattern for Python+C extension packages (pypotrace)
  • Dual health endpoints: /health (root) for simple checks, /engine/health (namespaced) for Docker/orchestration
+ +
+
+
+
+ +
+

Timeline

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#TypeIDModelStartedDurationCostTokensToolsTierRoutedTruncCHF
1execute-taskM001/S01/T01opus-4-6Mar 26, 2026, 04:00 AM6m 19s$1.141.31M39
2execute-taskM001/S01/T02opus-4-6Mar 26, 2026, 04:07 AM3m 41s$0.9761.03M30
3execute-taskM001/S01/T03opus-4-6Mar 26, 2026, 04:11 AM3m 59s$1.121.29M34
4execute-taskM001/S01/T04opus-4-6Mar 26, 2026, 04:15 AM3m 29s$0.000000
5execute-taskM001/S01/T05opus-4-6Mar 26, 2026, 04:18 AM4m 8s$1.371.35M40
6complete-sliceM001/S01opus-4-6Mar 26, 2026, 04:22 AM3m 19s$0.734655.6k21
7complete-sliceM001/S01opus-4-6Mar 26, 2026, 04:25 AM2m 4s$0.796881.5k30
8execute-taskM001/S02/T01opus-4-6Mar 26, 2026, 04:28 AM4m 27s$1.421.43M29
9execute-taskM001/S02/T03opus-4-6Mar 26, 2026, 04:37 AM2m 33s$1.12833.4k20
10complete-sliceM001/S02opus-4-6Mar 26, 2026, 04:39 AM2m 8s$0.572626.3k17
11execute-taskM001/S03/T01opus-4-6Mar 26, 2026, 04:42 AM3m 51s$1.441.64M31
12execute-taskM001/S03/T02opus-4-6Mar 26, 2026, 04:45 AM3m 45s$1.522.14M35
13complete-sliceM001/S03opus-4-6Mar 26, 2026, 04:49 AM2m 25s$0.682870.9k20
14validate-milestoneM001opus-4-6Mar 26, 2026, 04:52 AM2m 30s$1.291.96M16
15complete-milestoneM001opus-4-6Mar 26, 2026, 04:54 AM2m 33s$0.9461.33M22
16research-sliceM002/S01opus-4-6Mar 26, 2026, 04:57 AM2m 26s$0.8121.02M28
17plan-sliceM002/S01opus-4-6Mar 26, 2026, 04:59 AM2m 35s$0.760842.6k17
18execute-taskM002/S01/T01opus-4-6Mar 26, 2026, 05:02 AM3m 12s$1.341.98M37
19execute-taskM002/S01/T02opus-4-6Mar 26, 2026, 05:05 AM2m 5s$0.787931.1k23
20execute-taskM002/S01/T03opus-4-6Mar 26, 2026, 05:07 AM8m 6s$2.903.97M56
21execute-taskM002/S01/T04opus-4-6Mar 26, 2026, 05:15 AM2m 4s$0.9731.33M25
22complete-sliceM002/S01opus-4-6Mar 26, 2026, 05:17 AM2m 32s$0.8501.12M15
23research-sliceM002/S02opus-4-6Mar 26, 2026, 05:20 AM3m 31s$1.291.71M38
24plan-sliceM002/S02opus-4-6Mar 26, 2026, 05:23 AM2m 52s$0.861959.7k20
25execute-taskM002/S02/T01opus-4-6Mar 26, 2026, 05:26 AM5m 17s$2.032.61M40
26execute-taskM002/S02/T02opus-4-6Mar 26, 2026, 05:32 AM4m 9s$1.762.24M38
27execute-taskM002/S02/T03opus-4-6Mar 26, 2026, 05:36 AM3m 51s$1.491.61M25
28execute-taskM002/S02/T04opus-4-6Mar 26, 2026, 05:40 AM1m 25s$0.8191.11M17
29complete-sliceM002/S02opus-4-6Mar 26, 2026, 05:41 AM2m 19s$0.535483.4k11
30research-sliceM002/S03opus-4-6Mar 26, 2026, 05:44 AM2m 44s$1.161.46M37
31plan-sliceM002/S03opus-4-6Mar 26, 2026, 05:46 AM2m 10s$0.714677.4k18
32execute-taskM002/S03/T01opus-4-6Mar 26, 2026, 05:48 AM4m 6s$1.502.05M29
33execute-taskM002/S03/T02opus-4-6Mar 26, 2026, 05:53 AM2m 40s$1.361.86M30
34execute-taskM002/S03/T03opus-4-6Mar 26, 2026, 05:55 AM2m 20s$1.281.67M23
35complete-sliceM002/S03opus-4-6Mar 26, 2026, 05:58 AM2m 39s$0.9941.28M16
36validate-milestoneM002opus-4-6Mar 26, 2026, 06:00 AM2m 34s$0.9131.08M28
37complete-milestoneM002opus-4-6Mar 26, 2026, 06:03 AM3m 54s$1.572.34M31
+
+
+ +
+

Dependencies

+ +
+

M001: Kerf Engine — Raster-to-Vector Pipeline & API

+
+ done + active + pending + parked +
+
+ + + + + + + + + + + + + S01 + Core Pipeline — P… + S01: Core Pipeline — Preprocessing + Vectorization + + + S02 + Post-Processing +… + S02: Post-Processing + Output Formats (SVG, DXF, JSON) + + + S03 + Preset System + E… + S03: Preset System + Engine Docker Packaging + + +
+
+
+

M002: M002: React Frontend — Import & Convert UI + Design Canvas

+
+ done + active + pending + parked +
+
+ + + + + + + + + + + + + S01 + Import & Convert … + S01: Import & Convert UI (View 1) + + + S02 + Design Canvas Cor… + S02: Design Canvas Core (View 2) + + + S03 + Text System + Fon… + S03: Text System + Font Loading + + +
+
+
+

M003: M003

+
+ done + active + pending + parked +
+
+ + + + + + + + + + + + + S01 + Export Flow (View… + S01: Export Flow (View 3) + DXF Generation + + + S02 + Docker Packaging … + S02: Docker Packaging + README + + + S03 + Embed Mode + S03: Embed Mode + + +
+
+
+ +
+

Metrics

+ +
$41.85Total cost
51.67MTotal tokens
1.7kInput
321.5kOutput
49.94MCache read
1.41MCache write
2h 0mDuration
37Units
986Tool calls
0Truncations
+ + +
+

Token breakdown

+
+
Input: 1.7k (0.0%)Output: 321.5k (0.6%)Cache read: 49.94M (96.6%)Cache write: 1.41M (2.7%)
+
+ +
+

Cost over time

+ + $41.85$31.39$20.93$10.46$0.0000 + + + #1 + #37 + +
+ +
+

Cost by phase

+
+
research
+
+
$3.26
+
+
3 units
+
+
planning
+
+
$2.33
+
+
3 units
+
+
execution
+
+
$31.09
+
+
24 units
+
+
completion
+
+
$5.16
+
+
7 units
+

Tokens by phase

+
+
research
+
+
4.19M
+
+
$3.26
+
+
planning
+
+
2.48M
+
+
$2.33
+
+
execution
+
+
39.09M
+
+
$31.09
+
+
completion
+
+
5.91M
+
+
$5.16
+
+ +
+

Cost by slice

+
+
M001
+
+
$2.23
+
+
2 units
+
+
M001/S01
+
+
$6.14
+
+
7 units
+
+
M001/S02
+
+
$3.11
+
+
3 units
+
+
M001/S03
+
+
$3.65
+
+
3 units
+
+
M002
+
+
$2.48
+
+
2 units
+
+
M002/S01
+
+
$8.43
+
+
7 units
+
+
M002/S02
+
+
$8.79
+
+
7 units
+
+
M002/S03
+
+
$7.02
+
+
6 units
+

Cost by model

+
+
opus-4-6
+
+
$41.85
+
+
37 units
+

Duration by slice

+
+
M001
+
+
5m 3s
+
+
$2.23
+
+
M001/S01
+
+
27m 2s
+
+
$6.14
+
+
M001/S02
+
+
9m 9s
+
+
$3.11
+
+
M001/S03
+
+
10m 2s
+
+
$3.65
+
+
M002
+
+
6m 28s
+
+
$2.48
+
+
M002/S01
+
+
23m 3s
+
+
$8.43
+
+
M002/S02
+
+
23m 27s
+
+
$8.79
+
+
M002/S03
+
+
16m 40s
+
+
$7.02
+
+ +
+

Slice timeline

+ + M001/S01 + M001/S01: 27m 7s +M001/S02 + M001/S02: 13m 56s +M001/S03 + M001/S03: 10m 10s +M001 + M001: 5m 4s +M002/S01 + M002/S01: 23m 5s +M002/S02 + M002/S02: 23m 41s +M002/S03 + M002/S03: 16m 49s +M002 + M002: 6m 28s + Mar 26, 2026, 04:00 AMMar 26, 2026, 04:32 AMMar 26, 2026, 05:04 AMMar 26, 2026, 05:35 AMMar 26, 2026, 06:07 AM + +
+ +
+ +
+

Health

+ +
Token profilestandard
Truncation rate0.0% per unit (0 total)
Continue-here rate0.0% per unit (0 total)
Tool calls986
Messages608 assistant / 3 user
+ +

Tier breakdown

+ + + + + + + +
TierUnitsCostTokens
unknown37$41.8551.67M
+ + + +
+ +
+

Changelog 6

+ +
+
+ M002/S03 + Text System + Font Loading + Mar 26, 2026, 06:00 AM +
+

Added text objects to the design canvas with fontService (opentype.js-powered font loading, caching, text-to-path conversion), text tool in toolbar, text-specific property controls, and Convert to Paths action — 24 fontService tests + all 95 app tests pass

+ +
Decisions +
  • Preset default is 'sign' — covers the most common use case
  • Presets use flat JSON with three sections (preprocessing, vectorization, postprocessing)
  • resolve_params merges preset → user_params with user taking precedence
  • Custom preset has empty param sections so pipeline defaults apply unless user provides overrides
  • Multi-stage Docker build separates build deps from runtime (smaller image, no compiler tools)
  • Engine image contains only engine source — enforces Engine/App separation (D001)
  • Health endpoint at /engine/health for namespace consistency with other /engine/* routes
+
+ +
+ 14 files modified +
    +
  • app/src/utils/fontService.ts — New — font loading, caching, text-to-path conversion service using opentype.js
  • app/src/utils/__tests__/fontService.test.ts — New — 24 unit tests for fontService (registry, loading, caching, path conversion)
  • app/public/fonts/Roboto-Regular.ttf — New — bundled OFL-licensed Roboto variable font
  • app/public/fonts/OpenSans-Regular.ttf — New — bundled OFL-licensed Open Sans variable font
  • app/public/fonts/Lato-Regular.ttf — New — bundled OFL-licensed Lato font
  • app/src/App.css — Modified — added @font-face declarations for Roboto, Open Sans, Lato
  • app/package.json — Modified — added opentype.js v1.3.4 dependency
  • app/src/types/canvas.ts — Modified — added TextObject interface and extended CanvasObject union with 'text' type
  • app/src/components/canvas/KonvaStage.tsx — Modified — text rendering, text tool creation, text sizing/transform, 'text' in CanvasTool union
  • app/src/components/canvas/CanvasToolbar.tsx — Modified — added text tool to TOOLS array
  • app/src/components/canvas/ObjectPanel.tsx — Modified — added 'T' icon for text objects in TYPE_ICONS
  • app/src/components/canvas/ShapeProperties.tsx — Modified — text property controls (content, font family, size, letter spacing, line height), Convert to Paths button, getWidth/getHeight cases
  • app/src/components/canvas/AlignmentBar.tsx — Modified — added text case to toBoundingRect for exhaustive switch
  • app/src/views/DesignCanvas.tsx — Modified — added onConvertToPath handler wiring ShapeProperties to canvas state
  • +
+
+
+
+
+ M002/S02 + Design Canvas Core (View 2) + Mar 26, 2026, 05:44 AM +
+

Built the Konva.js-powered 2D design canvas with artboard setup, shape tools, selection/transform, layer management, alignment, properties editing, keyboard shortcuts, and undo/redo — 48 new tests (71 total), zero TypeScript errors.

+ +
Decisions +
  • DXF output as raw bytes with application/dxf content-type and metadata in X-Kerf-Metadata header
  • postprocess_svg() replaces regex metadata extraction — full XML path parsing for structured PathInfo objects
  • _format_response() shared helper for consistent response shaping across endpoints
  • Islands placed on separate ISLANDS layer in DXF for downstream CAM tool compatibility
  • Bezier curves linearized during post-processing for RDP simplification and DXF polyline generation
+
+ +
+ 19 files modified +
    +
  • app/src/types/canvas.ts — New — discriminated union type system for canvas objects (rect, circle, ellipse, line, image), ArtboardConfig, CanvasState, and CanvasAction types
  • app/src/hooks/useCanvasState.ts — New — central state management hook with useReducer+useRef for CRUD, selection, undo/redo, reorder, visibility/lock
  • app/src/hooks/__tests__/useCanvasState.test.ts — New — 20 tests covering all useCanvasState operations including undo/redo edge cases
  • app/src/utils/artboardShapes.ts — New — artboard shape presets, shield/pennant SVG path generators, toPx/fromPx unit conversion, artboardClipPath
  • app/src/utils/__tests__/artboardShapes.test.ts — New — 15 tests for artboard presets, path generation, unit conversion, clip paths
  • app/src/utils/alignment.ts — New — 9 pure alignment/distribute/center functions for canvas objects
  • app/src/utils/__tests__/alignment.test.ts — New — 13 tests for alignment, distribution, and center-on-artboard
  • app/src/components/canvas/ArtboardSetup.tsx — New — modal for artboard shape/size/unit selection on entering canvas view
  • app/src/components/canvas/KonvaStage.tsx — New — Konva Stage+Layer rendering artboard, all object types, Transformer, rubber-band selection
  • app/src/components/canvas/ObjectPanel.tsx — New — layer management panel with z-order list, reorder, visibility/lock toggles, rename
  • app/src/components/canvas/AlignmentBar.tsx — New — alignment and distribution toolbar consuming alignment utils
  • app/src/components/canvas/CanvasToolbar.tsx — New — tool switcher, undo/redo, grid toggle, zoom controls
  • app/src/components/canvas/ShapeProperties.tsx — New — property editor for selected shape (stroke, fill, dimensions, line style, opacity)
  • app/src/views/DesignCanvas.tsx — New — View 2 container wiring KonvaStage + all panels to useCanvasState, SVG import, keyboard shortcuts
  • app/src/views/DesignCanvas.module.css — New — CSS module for DesignCanvas layout
  • app/src/App.tsx — Modified — wired DesignCanvas with svgData/traceMetadata props, removed underscore prefixes
  • app/src/App.css — Modified — added comprehensive canvas UI styles (toolbar, panels, artboard setup, shape properties, alignment bar)
  • app/src/test-setup.ts — Modified — added vitest-canvas-mock import for Konva testing
  • app/package.json — Modified — added konva, react-konva, vitest-canvas-mock dependencies
  • +
+
+
+
+
+ M002/S01 + Import & Convert UI (View 1) + Mar 26, 2026, 05:20 AM +
+

Built the complete Import & Convert view (View 1) — file upload, preset selection, debounced live vectorization preview with parameter sliders, output stats bar, and Use This button to advance to canvas.

+ + +
+ 18 files modified +
    +
  • engine/main.py — Added CORSMiddleware allowing all origins for dev
  • app/vite.config.ts — Vite config with React plugin, dev proxy to engine, Vitest config
  • app/src/types/engine.ts — TypeScript interfaces for PresetConfig, TraceResponse, TraceMetadata, PresetsResponse
  • app/src/api/engine.ts — Typed API client: getPresets(), traceImage(), simplifyVector() with AbortSignal
  • app/src/api/__tests__/engine.test.ts — 9 unit tests for API client covering URL/method/FormData/AbortSignal/errors
  • app/src/hooks/useDebouncedTrace.ts — Custom hook: debounced trace with AbortController, SVG mode detection, params stabilization
  • app/src/hooks/__tests__/useDebouncedTrace.test.ts — 7 tests for debounce, abort, SVG routing, error handling, cleanup
  • app/src/views/ImportConvert.tsx — View 1 container: two-column layout wiring FileUpload, PresetSelector, ParameterSliders, SvgPreview, OutputInfoBar, Use This button
  • app/src/views/ImportConvert.module.css — CSS module for ImportConvert layout
  • app/src/components/FileUpload.tsx — Drag-and-drop file upload with thumbnail preview and SVG detection
  • app/src/components/PresetSelector.tsx — Fetches presets from engine, renders cards with selection state, auto-selects sign
  • app/src/components/ParameterSliders.tsx — Mode-aware range sliders for vectorization parameters
  • app/src/components/SvgPreview.tsx — Responsive SVG rendering with loading/error states
  • app/src/components/OutputInfoBar.tsx — Color-coded stats bar with warnings display
  • app/src/components/__tests__/OutputInfoBar.test.tsx — 7 tests for color coding logic and edge cases
  • app/src/App.tsx — ViewState routing, SVG/metadata state for View 2 handoff
  • app/src/App.css — Global styles for shared components
  • app/src/test-setup.ts — Vitest setup with jest-dom matchers
  • +
+
+
+
+
+ M001/S03 + Preset System + Engine Docker Packaging + Mar 26, 2026, 04:52 AM +
+

Shipped 5 pipeline presets (sign, patch, stencil, detailed, custom) with merge-based param resolution, GET /engine/presets endpoint, and a multi-stage Docker image with healthcheck that runs the engine standalone.

+ +
Decisions +
  • Preset default is 'sign' — covers the most common use case
  • Presets use flat JSON with three sections (preprocessing, vectorization, postprocessing)
  • resolve_params merges preset → user_params with user taking precedence
  • Custom preset has empty param sections so pipeline defaults apply unless user provides overrides
  • Multi-stage Docker build separates build deps from runtime (smaller image, no compiler tools)
  • Engine image contains only engine source — enforces Engine/App separation (D001)
  • Health endpoint at /engine/health for namespace consistency with other /engine/* routes
+
+ +
+ 10 files modified +
    +
  • engine/presets/sign.json — Sign preset — aggressive simplification for signage vectorization
  • engine/presets/patch.json — Patch preset — smooth curves with auto-close for embroidery
  • engine/presets/stencil.json — Stencil preset — heavy simplification with fixed threshold
  • engine/presets/detailed.json — Detailed preset — max fidelity for illustrations
  • engine/presets/custom.json — Custom preset — empty defaults, user controls everything
  • engine/presets/loader.py — Preset loader with caching, listing, and param merge resolution
  • engine/api/routes.py — Added GET /engine/presets, GET /engine/health, wired preset into /engine/trace
  • engine/tests/test_presets.py — 28 tests covering loader, resolution, endpoint, integration, cross-preset differentiation
  • docker/Dockerfile.engine — Multi-stage Dockerfile: builder compiles pypotrace, runtime uses slim image with engine source only
  • .dockerignore — Excludes .git, .venv, __pycache__, .gsd, node_modules from Docker build context
  • +
+
+
+
+
+ M001/S02 + Post-Processing + Output Formats (SVG, DXF, JSON) + Mar 26, 2026, 04:41 AM +
+

Full post-processing pipeline (RDP simplification, island detection, open path repair) with three output format generators (SVG, DXF, JSON) and /engine/simplify endpoint — 169 tests passing.

+ +
Decisions +
  • DXF output as raw bytes with application/dxf content-type and metadata in X-Kerf-Metadata header
  • postprocess_svg() replaces regex metadata extraction — full XML path parsing for structured PathInfo objects
  • _format_response() shared helper for consistent response shaping across endpoints
  • Islands placed on separate ISLANDS layer in DXF for downstream CAM tool compatibility
  • Bezier curves linearized during post-processing for RDP simplification and DXF polyline generation
+
+ +
+ 9 files modified +
    +
  • engine/pipeline/postprocess.py — New: Post-processing module with RDP simplification, island detection, open path repair, SVG path parsing (414 lines)
  • engine/output/__init__.py — New: Package init exporting generate_dxf, generate_json, generate_svg
  • engine/output/dxf.py — New: AC1015 DXF generator using ezdxf — LWPOLYLINE entities with island layer separation (66 lines)
  • engine/output/json_output.py — New: JSON output generator with path commands and properties (76 lines)
  • engine/output/svg.py — New: SVG output generator returning simplified SVG from PostProcessResult (22 lines)
  • engine/api/routes.py — Rewritten: integrated postprocess_svg(), output_format routing, /engine/simplify endpoint, _format_response() helper (175 lines)
  • engine/tests/test_postprocess.py — New: Tests for RDP, island detection, open paths, SVG parsing, node counting (375 lines)
  • engine/tests/test_output.py — New: Tests for DXF structure, JSON structure, SVG output, round-trip consistency (274 lines)
  • engine/tests/test_api.py — Rewritten: 35 integration tests for /engine/trace and /engine/simplify across all format combinations (515 lines)
  • +
+
+
+
+
+ M001/S01 + Core Pipeline — Preprocessing + Vectorization + Mar 26, 2026, 04:30 AM +
+ + + +
+
+ +
+

Knowledge 25

+ +

Rules 1

+ + + +
IDScopeRule
#ScopeRule
+

Patterns 13

+ + + +
IDPattern
#Pattern
P001Test images generated programmatically via numpy
P002Tests must use .venv/bin/python -m pytest
P003PostProcessResult is the universal intermediate representation
P004_format_response() for consistent multi-format API responses
P005Preset-driven pipeline: resolve_params() merges preset → user
P006JSON.stringify for React hook dependency stabilization
P007Vite dev proxy for engine API calls
P008CSS modules for views, global App.css for shared component styles
P009opentype.js needs dynamic import() and local type declarations
P010Font Y-axis flip: canvas_y = ascender - font_y * scale
P011Letter spacing requires manual per-character glyph positioning
P012Adding new CanvasObject types requires exhaustive switch updates in 6 files
+

Lessons 11

+ + + +
IDLesson
#What Happened
L001pypotrace fails to build from pip
L002VTracer Python bindings work directly — no subprocess needed
L003pypotrace Bitmap requires uint32 data
L004ezdxf emits pyparsing deprecation warnings in tests
L005DXF output format needs binary response, not JSON envelope
L006postprocess_svg() fully parses SVG paths into coordinates
L007Docker build context must be project root, not engine/
L008Engine container has dual health endpoints
L009useCallback-based triggerTrace caused infinite re-render loops
L010jest-canvas-mock crashes in Vitest with "ReferenceError: jest is not defined"
+
+ +
+

Captures

+

No captures recorded.

+
+ +
+

Artifacts

+ +

Missing changelogs 3

+ + + + + + +
MilestoneSliceTitle
M003S01Export Flow (View 3) + DXF Generation
M003S02Docker Packaging + README
M003S03Embed Mode
+

Recently completed 6

+ + + + + + + + + +
MilestoneSliceTitleCompleted
M002S03Text System + Font LoadingMar 26, 2026, 06:00 AM
M002S02Design Canvas Core (View 2)Mar 26, 2026, 05:44 AM
M002S01Import & Convert UI (View 1)Mar 26, 2026, 05:20 AM
M001S03Preset System + Engine Docker PackagingMar 26, 2026, 04:52 AM
M001S02Post-Processing + Output Formats (SVG, DXF, JSON)Mar 26, 2026, 04:41 AM
+
+ +
+

Planning

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDMilestoneStateContextDraftUpdated
M001Kerf Engine — Raster-to-Vector Pipeline & APIundiscussed
M002M002: React Frontend — Import & Convert UI + Design Canvasundiscussed
M003M003undiscussed
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/.gsd/reports/index.html b/.gsd/reports/index.html index 97e70f8..66442d0 100644 --- a/.gsd/reports/index.html +++ b/.gsd/reports/index.html @@ -130,7 +130,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
Updated - Mar 26, 2026, 04:57 AM + Mar 26, 2026, 06:07 AM
@@ -144,6 +144,10 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
M001
+
+
M002
+ +
@@ -152,24 +156,39 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}

Project Overview

-
$15.14Total Cost
-
17.34MTotal Tokens
-
51m 18sDuration
-
3/9Slices
-
1/3Milestones
-
1Reports
+
$41.85Total Cost
+
51.67MTotal Tokens
+
2h 0mDuration
+
6/9Slices
+
2/3Milestones
+
2Reports
-
- 33% complete +
+ 67% complete
- +

Cost Progression

+
+ + + + M001: Kerf Engine — Raster-to-Vector Pipeline & API — $15.14 + + M002: M002: React Frontend — Import & Convert UI + Design Canvas — $41.85 + + $15.14 + $41.85 + +
+ M001M002 +
+
-

Progression 1

+

Progression 2

@@ -202,7 +242,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px} /home/aux/development/xpltdco/kerf-engine - Updated Mar 26, 2026, 04:57 AM + Updated Mar 26, 2026, 06:07 AM diff --git a/.gsd/reports/reports.json b/.gsd/reports/reports.json index cacc68b..10021e4 100644 --- a/.gsd/reports/reports.json +++ b/.gsd/reports/reports.json @@ -19,6 +19,22 @@ "doneMilestones": 1, "totalMilestones": 3, "phase": "planning" + }, + { + "filename": "M002-2026-03-26T06-07-21.html", + "generatedAt": "2026-03-26T06:07:21.708Z", + "milestoneId": "M002", + "milestoneTitle": "M002: React Frontend — Import & Convert UI + Design Canvas", + "label": "M002: M002: React Frontend — Import & Convert UI + Design Canvas", + "kind": "milestone", + "totalCost": 41.85195349999999, + "totalTokens": 51674727, + "totalDuration": 7258574, + "doneSlices": 6, + "totalSlices": 9, + "doneMilestones": 2, + "totalMilestones": 3, + "phase": "planning" } ] } diff --git a/.gsd/state-manifest.json b/.gsd/state-manifest.json index b77a5a2..0f61ff8 100644 --- a/.gsd/state-manifest.json +++ b/.gsd/state-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "exported_at": "2026-03-26T06:06:46.767Z", + "exported_at": "2026-03-26T06:16:59.234Z", "milestones": [ { "id": "M001", @@ -344,11 +344,11 @@ "completed_at": null, "full_summary_md": "", "full_uat_md": "", - "goal": "Build View 3 Export UI with DXF/SVG/PNG generation, layer assignment, text-to-paths enforcement, pre-export validation, and download", - "success_criteria": "- Format selector: DXF, SVG, PNG\n- DXF layer assignment UI\n- Text-to-paths enforcement warning and one-click convert\n- Pre-export validation: closed paths, open endpoints, node count, unconverted text\n- Correct real-world scale in DXF (inches/mm)\n- Default filename pattern applied\n- Download triggers browser file save", - "proof_level": "integration + manual — DXF validated in external tools by human", - "integration_closure": "Full end-to-end workflow complete: Import → Canvas → Export", - "observability_impact": "Export generation time; validation warning counts", + "goal": "Complete Export view (View 3) with DXF/SVG/PNG download. Users design a sign in View 2, navigate to View 3, select export format, see pre-export validation warnings, and download files with correct real-world scale.", + "success_criteria": "## Must-Haves\n\n- Engine `generate_dxf()` accepts `units` and `scale_factor` params; sets `$INSUNITS` and `$MEASUREMENT` DXF headers; applies scale_factor to all polyline coordinates\n- `/engine/simplify` endpoint accepts optional `units` and `scale_factor` Form params, passes them through to `generate_dxf()`\n- Canvas state (objects, artboard, selectedIds) is lifted from `DesignCanvas` to `App.tsx` so Export view can access it\n- DesignCanvas has an \"Export\" button that navigates to View 3 with canvas state\n- `exportService.ts` composes visible canvas objects into a single SVG document with correct coordinate space\n- Pre-export validation detects unconverted text objects (blocking error) and raster-only images (warning)\n- DXF API client handles binary `response.blob()` for DXF format responses\n- Export view shows format selector (DXF/SVG/PNG), validation panel, unit selector (inches/mm), and download button\n- PNG export uses Konva `stage.toDataURL()` with fixed `pixelRatio: 2`\n- SVG export downloads the composed SVG directly with real-world unit dimensions\n- A 4×6 inch artboard produces DXF coordinates spanning 0–4 × 0–6 with `$INSUNITS=1`\n- `tsc -b` passes with zero errors after all changes\n\n## Proof Level\n\n- This slice proves: integration (engine DXF scale correctness + frontend export flow composition)\n- Real runtime required: yes (engine must process SVG → DXF)\n- Human/UAT required: yes (final DXF opened in Inkscape/LightBurn for visual geometry validation)\n\n## Verification\n\n- `cd engine && .venv/bin/python -m pytest tests/test_output.py -v` — DXF scale/unit tests pass\n- `cd app && npx vitest run src/utils/__tests__/exportService.test.ts` — SVG composition and validation tests pass\n- `cd app && npx vitest run src/api/__tests__/engine.test.ts` — DXF API client tests pass\n- `cd app && npx tsc -b --noEmit` — TypeScript compiles with zero errors\n\n## Observability / Diagnostics\n\n- Runtime signals: engine logs DXF generation with applied scale_factor and units in response metadata; frontend console.warn for pre-export validation issues\n- Inspection surfaces: `X-Kerf-Metadata` header on DXF responses includes units, scale_factor, path_count; Export view shows validation panel with warnings/errors\n- Failure visibility: DXF generation errors surface as HTTP 500 with detail message; validation errors shown in UI before export is allowed\n\n## Integration Closure\n\n- Upstream surfaces consumed: `engine/output/dxf.py` (existing DXF generator), `engine/api/routes.py` (existing `/engine/simplify` endpoint), `app/src/hooks/useCanvasState.ts` (canvas state management), `app/src/utils/artboardShapes.ts` (PPI conversion), `app/src/types/canvas.ts` (CanvasObject union)\n- New wiring introduced in this slice: App.tsx lifts canvas state and passes to ExportView; ExportView composes SVG from canvas state and calls engine API for DXF conversion; binary blob download pipeline\n- What remains before the milestone is truly usable end-to-end: Docker packaging (S02), embed mode (S03)", + "proof_level": "integration", + "integration_closure": "Upstream: engine DXF generator, /engine/simplify endpoint, useCanvasState hook, artboardShapes PPI conversion, CanvasObject types. New wiring: App.tsx state lifting → ExportView → exportService → engine API. Remaining: Docker (S02), embed (S03).", + "observability_impact": "DXF responses include units/scale_factor in X-Kerf-Metadata header. Pre-export validation surfaces blocking errors and warnings in UI. Engine errors propagate as HTTP 500 with detail.", "sequence": 0, "replan_triggered_at": null }, @@ -1423,6 +1423,176 @@ "observability_impact": "", "full_plan_md": "", "sequence": 0 + }, + { + "milestone_id": "M003", + "slice_id": "S01", + "id": "T01", + "title": "Extended generate_dxf() with units/scale_factor/layer_map params and wired units+scale_factor through /engine/simplify API endpoint", + "status": "complete", + "one_liner": "Extended generate_dxf() with units/scale_factor/layer_map params and wired units+scale_factor through /engine/simplify API endpoint", + "narrative": "Extended generate_dxf() with three keyword-only args (units, scale_factor, layer_map), set $INSUNITS/$MEASUREMENT DXF headers for inches/mm, applied scale_factor to all polyline coordinates, added layer_map for custom DXF layer assignment. Wired units and scale_factor through the /engine/simplify API endpoint via _format_response(). Added 11 new tests covering scale conversion (384x576 px → 4x6 inches, 101.6x152.4 mm), DXF header correctness, layer mapping, and combined feature usage.", + "verification_result": "Ran pytest tests/test_output.py: 36/36 passed (25 existing + 11 new). Ran full engine suite: 207/207 passed. Scale conversion tests verify 384×576 px artboard produces 4×6 inch and 101.6×152.4 mm coordinates. $INSUNITS header tests confirm inches=1, mm=4. Layer_map tests confirm custom layer assignment and island layer preservation.", + "duration": "", + "completed_at": "2026-03-26T06:16:59.178Z", + "blocker_discovered": false, + "deviations": "Adjusted test_no_units_omits_insunits_header: ezdxf R2000 defaults $INSUNITS to 6 (meters) not 0 (unitless) as plan assumed.", + "known_issues": "None.", + "key_files": [ + "engine/output/dxf.py", + "engine/api/routes.py", + "engine/tests/test_output.py" + ], + "key_decisions": [ + "DXF $INSUNITS codes: inches=1, mm=4; $MEASUREMENT: inches=0, mm=1", + "layer_map is index-based (dict[int, str]) with island auto-layer fallback", + "scale_factor applied uniformly to all (x, y) coords; caller computes the value" + ], + "full_summary_md": "---\nid: T01\nparent: S01\nmilestone: M003\nkey_files:\n - engine/output/dxf.py\n - engine/api/routes.py\n - engine/tests/test_output.py\nkey_decisions:\n - DXF $INSUNITS codes: inches=1, mm=4; $MEASUREMENT: inches=0, mm=1\n - layer_map is index-based (dict[int, str]) with island auto-layer fallback\n - scale_factor applied uniformly to all (x, y) coords; caller computes the value\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-03-26T06:16:59.197Z\nblocker_discovered: false\n---\n\n# T01: Extended generate_dxf() with units/scale_factor/layer_map params and wired units+scale_factor through /engine/simplify API endpoint\n\n**Extended generate_dxf() with units/scale_factor/layer_map params and wired units+scale_factor through /engine/simplify API endpoint**\n\n## What Happened\n\nExtended generate_dxf() with three keyword-only args (units, scale_factor, layer_map), set $INSUNITS/$MEASUREMENT DXF headers for inches/mm, applied scale_factor to all polyline coordinates, added layer_map for custom DXF layer assignment. Wired units and scale_factor through the /engine/simplify API endpoint via _format_response(). Added 11 new tests covering scale conversion (384x576 px → 4x6 inches, 101.6x152.4 mm), DXF header correctness, layer mapping, and combined feature usage.\n\n## Verification\n\nRan pytest tests/test_output.py: 36/36 passed (25 existing + 11 new). Ran full engine suite: 207/207 passed. Scale conversion tests verify 384×576 px artboard produces 4×6 inch and 101.6×152.4 mm coordinates. $INSUNITS header tests confirm inches=1, mm=4. Layer_map tests confirm custom layer assignment and island layer preservation.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `cd engine && .venv/bin/python -m pytest tests/test_output.py -v -W ignore::DeprecationWarning` | 0 | ✅ pass | 390ms |\n| 2 | `cd engine && .venv/bin/python -m pytest tests/ -v -W ignore::DeprecationWarning` | 0 | ✅ pass | 1060ms |\n\n\n## Deviations\n\nAdjusted test_no_units_omits_insunits_header: ezdxf R2000 defaults $INSUNITS to 6 (meters) not 0 (unitless) as plan assumed.\n\n## Known Issues\n\nNone.\n\n## Files Created/Modified\n\n- `engine/output/dxf.py`\n- `engine/api/routes.py`\n- `engine/tests/test_output.py`\n", + "description": "The engine's `generate_dxf()` currently produces DXF files with raw pixel coordinates and no unit metadata. This task extends it to accept `units` ('inches'|'mm') and `scale_factor` parameters, sets the `$INSUNITS` and `$MEASUREMENT` DXF headers, applies `scale_factor` to all polyline coordinates, and adds optional `layer_map` support. The `/engine/simplify` endpoint is extended with optional `units` and `scale_factor` Form params that pass through to the DXF generator via `_format_response()`. Tests verify a known-size artboard (384×576 px = 4×6 inches at 96 PPI) produces DXF coordinates spanning 0–4 × 0–6 with correct `$INSUNITS=1`.", + "estimate": "1h", + "files": [ + "engine/output/dxf.py", + "engine/api/routes.py", + "engine/tests/test_output.py" + ], + "verify": "cd engine && .venv/bin/python -m pytest tests/test_output.py -v -W ignore::DeprecationWarning", + "inputs": [ + "`engine/output/dxf.py` — existing DXF generator that accepts PostProcessResult", + "`engine/api/routes.py` — existing `/engine/simplify` endpoint and `_format_response()` helper", + "`engine/tests/test_output.py` — existing DXF/SVG/JSON output tests", + "`engine/pipeline/postprocess.py` — PostProcessResult and PathInfo types consumed by DXF generator" + ], + "expected_output": [ + "`engine/output/dxf.py` — extended with units, scale_factor, layer_map params; sets $INSUNITS/$MEASUREMENT headers; applies scale to coordinates", + "`engine/api/routes.py` — /engine/simplify accepts optional units and scale_factor Form params; _format_response passes them to generate_dxf", + "`engine/tests/test_output.py` — new tests: scale_factor converts pixel coords to real-world coords, $INSUNITS header set correctly for inches/mm, layer_map assigns paths to named layers" + ], + "observability_impact": "", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M003", + "slice_id": "S01", + "id": "T02", + "title": "Lift canvas state to App.tsx and add Export navigation", + "status": "pending", + "one_liner": "", + "narrative": "", + "verification_result": "", + "duration": "", + "completed_at": null, + "blocker_discovered": false, + "deviations": "", + "known_issues": "", + "key_files": [], + "key_decisions": [], + "full_summary_md": "", + "description": "Currently `useCanvasState()` is instantiated inside `DesignCanvas.tsx`, making canvas state inaccessible to View 3. This task lifts the hook instantiation to `App.tsx`, passes state + actions as props to `DesignCanvas`, and adds an `onExport` callback prop that triggers navigation to the 'export' view. The Export view placeholder in App.tsx is updated to receive canvas state props. DesignCanvas gets an 'Export' button in its toolbar area. All existing DesignCanvas functionality must remain unchanged — this is a mechanical prop-threading refactor.\n\nKey constraints:\n- `useCanvasState` hook itself is NOT modified — only where it's called changes\n- DesignCanvas receives `state`, `addObject`, `removeObject`, `updateObject`, `selectObjects`, `deselectAll`, `reorderObject`, `toggleVisibility`, `toggleLock`, `setArtboard`, `undo`, `redo`, `canUndo`, `canRedo` as props instead of calling the hook internally\n- The `traceMetadata` param to `useCanvasState()` comes from App.tsx's existing `traceMetadata` state\n- App.tsx passes a `stageRef` to DesignCanvas and receives it back so PNG export can work later\n- A `Ref` is created in App.tsx and passed to DesignCanvas for stage access from View 3", + "estimate": "1.5h", + "files": [ + "app/src/App.tsx", + "app/src/views/DesignCanvas.tsx" + ], + "verify": "cd app && npx tsc -b --noEmit && npx vitest run", + "inputs": [ + "`app/src/App.tsx` — current view router with svgResult/traceMetadata state", + "`app/src/views/DesignCanvas.tsx` — current View 2 container that owns useCanvasState", + "`app/src/hooks/useCanvasState.ts` — hook providing UseCanvasStateReturn interface", + "`app/src/types/canvas.ts` — CanvasState, CanvasObject, ArtboardConfig types" + ], + "expected_output": [ + "`app/src/App.tsx` — instantiates useCanvasState, passes state/actions as props to DesignCanvas and (placeholder) ExportView, creates stageRef", + "`app/src/views/DesignCanvas.tsx` — receives canvas state/actions via props instead of calling useCanvasState internally; adds Export button; accepts and uses stageRef from parent" + ], + "observability_impact": "", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M003", + "slice_id": "S01", + "id": "T03", + "title": "Build export service with SVG composition, validation, and DXF API client", + "status": "pending", + "one_liner": "", + "narrative": "", + "verification_result": "", + "duration": "", + "completed_at": null, + "blocker_discovered": false, + "deviations": "", + "known_issues": "", + "key_files": [], + "key_decisions": [], + "full_summary_md": "", + "description": "This task creates the core export logic as pure utility functions: (1) `composeCanvasSVG()` renders visible canvas objects into an SVG string with correct coordinate space and real-world unit dimensions, (2) `validateForExport()` checks for unconverted text objects (blocking error), raster-only images (warning), and missing artboard (blocking error), (3) `exportDxf()` in the API client POSTs composed SVG to `/engine/simplify?output_format=dxf` with `units` and `scale_factor` params and returns a Blob, (4) `triggerDownload()` creates a hidden anchor + blob URL to trigger browser download with proper cleanup.\n\nSVG composition details:\n- The SVG viewBox matches artboard dimensions in pixels\n- `width`/`height` attributes use real-world units (e.g., `width=\"4in\"` or `width=\"101.6mm\"`)\n- RectObject → ``, CircleObject → ``, EllipseObject → ``, LineObject → ``\n- ImageObject with SVG blob src → inline the SVG content (extract path data from blob URL)\n- ImageObject with raster src → skip (validation warns about this)\n- TextObject → error (validation blocks this — must be converted to paths first)\n- Objects with `visible: false` are skipped\n- All coordinates are in the artboard's pixel space (the engine handles conversion via scale_factor)\n\nDXF API client:\n- New function `exportAsDxf(svgContent: string, units: 'inches' | 'mm', scaleFactor: number, signal?: AbortSignal): Promise`\n- Uses FormData with file as Blob, output_format=dxf, units, scale_factor\n- Returns `response.blob()` not `response.json()`\n\nUnit tests cover: SVG composition with known objects produces correct SVG elements, validation catches text objects, validation warns on raster images, coordinate space is correct.", + "estimate": "2h", + "files": [ + "app/src/utils/exportService.ts", + "app/src/utils/__tests__/exportService.test.ts", + "app/src/api/engine.ts", + "app/src/api/__tests__/engine.test.ts" + ], + "verify": "cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts && npx tsc -b --noEmit", + "inputs": [ + "`app/src/types/canvas.ts` — CanvasObject union type (rect, circle, ellipse, line, image, text), ArtboardConfig", + "`app/src/utils/artboardShapes.ts` — toPx(), fromPx(), PPI constant (96)", + "`app/src/api/engine.ts` — existing API client with traceImage() and simplifyVector() patterns", + "`app/src/App.tsx` — updated in T02 with lifted canvas state (provides the state shape export service consumes)" + ], + "expected_output": [ + "`app/src/utils/exportService.ts` — composeCanvasSVG(), validateForExport(), triggerDownload() functions", + "`app/src/utils/__tests__/exportService.test.ts` — tests for SVG composition, validation logic, download trigger", + "`app/src/api/engine.ts` — new exportAsDxf() function for binary DXF response handling", + "`app/src/api/__tests__/engine.test.ts` — test for exportAsDxf() with mocked fetch returning blob" + ], + "observability_impact": "", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M003", + "slice_id": "S01", + "id": "T04", + "title": "Build Export view UI with format selection, validation panel, and download wiring", + "status": "pending", + "one_liner": "", + "narrative": "", + "verification_result": "", + "duration": "", + "completed_at": null, + "blocker_discovered": false, + "deviations": "", + "known_issues": "", + "key_files": [], + "key_decisions": [], + "full_summary_md": "", + "description": "This task builds the ExportView component — the final piece that wires everything together. The view receives canvas state from App.tsx (lifted in T02) and uses the export service (built in T03) to compose SVG, validate, call the engine API, and trigger downloads.\n\nExportView layout:\n- Header with \"Export\" title and a \"← Back to Design\" button that navigates back to View 2\n- Format selector: three cards/buttons for DXF, SVG, PNG with descriptions\n- Unit selector (DXF/SVG only): inches or mm radio buttons, defaulting to the artboard's unit\n- Validation panel: shows blocking errors (red, disables export) and warnings (yellow, allows export). Runs `validateForExport()` on mount and when objects change\n- Canvas preview: a small thumbnail of the current design (use Konva `stage.toDataURL()` from the stageRef passed through App.tsx, captured before navigating to export view)\n- Download button: disabled when blocking errors exist; triggers the appropriate export flow\n\nExport flows by format:\n- **DXF**: Call `composeCanvasSVG()` → call `exportAsDxf()` with units and scale_factor (1/96 for inches, 25.4/96 for mm) → `triggerDownload()` with the returned blob and filename `export.dxf`\n- **SVG**: Call `composeCanvasSVG()` → create blob from SVG string → `triggerDownload()` with filename `export.svg`\n- **PNG**: Use the preview data URL (captured from Konva stage before navigating) or re-render. Since stageRef may not be mounted in View 3, capture PNG data URL before transitioning to export view and pass it as a prop. Convert data URL to blob → `triggerDownload()` with filename `export.png`\n\nPNG capture strategy: When user clicks 'Export' in View 2, capture `stageRef.current.toDataURL({ pixelRatio: 2 })` and store in App.tsx state, then navigate to export view. This avoids needing the Konva stage mounted in View 3.\n\nApp.tsx updates:\n- Add `pngDataUrl` state, set it in the onExport handler before view transition\n- Pass `pngDataUrl` to ExportView\n- Wire ExportView's onBack to navigate back to canvas view\n\nCSS: New `ExportView.module.css` with the view layout. Follow existing patterns from DesignCanvas.module.css and App.css.", + "estimate": "2h", + "files": [ + "app/src/views/ExportView.tsx", + "app/src/views/ExportView.module.css", + "app/src/App.tsx" + ], + "verify": "cd app && npx tsc -b --noEmit && npx vitest run", + "inputs": [ + "`app/src/App.tsx` — updated in T02 with lifted canvas state and stageRef", + "`app/src/utils/exportService.ts` — composeCanvasSVG(), validateForExport(), triggerDownload() from T03", + "`app/src/api/engine.ts` — exportAsDxf() from T03", + "`app/src/types/canvas.ts` — CanvasState, ArtboardConfig, CanvasObject types", + "`app/src/utils/artboardShapes.ts` — fromPx() for unit display", + "`app/src/views/DesignCanvas.module.css` — existing view CSS patterns to follow" + ], + "expected_output": [ + "`app/src/views/ExportView.tsx` — complete Export view with format selector, validation panel, unit selector, download button", + "`app/src/views/ExportView.module.css` — view-specific styles", + "`app/src/App.tsx` — wires ExportView with canvas state, pngDataUrl, and navigation callbacks" + ], + "observability_impact": "", + "full_plan_md": "", + "sequence": 0 } ], "decisions": [ @@ -1942,6 +2112,28 @@ "verdict": "✅ pass", "duration_ms": 2200, "created_at": "2026-03-26T05:58:01.901Z" + }, + { + "id": 38, + "task_id": "T01", + "slice_id": "S01", + "milestone_id": "M003", + "command": "cd engine && .venv/bin/python -m pytest tests/test_output.py -v -W ignore::DeprecationWarning", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 390, + "created_at": "2026-03-26T06:16:59.178Z" + }, + { + "id": 39, + "task_id": "T01", + "slice_id": "S01", + "milestone_id": "M003", + "command": "cd engine && .venv/bin/python -m pytest tests/ -v -W ignore::DeprecationWarning", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 1060, + "created_at": "2026-03-26T06:16:59.178Z" } ] } \ No newline at end of file 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 # ---------------------------------------------------------------------------