feat: Extended generate_dxf() with units/scale_factor/layer_map params…
- "engine/output/dxf.py" - "engine/api/routes.py" - "engine/tests/test_output.py" GSD-Task: S01/T01
This commit is contained in:
parent
17bb1ab0ef
commit
4fad89288e
16 changed files with 2671 additions and 26 deletions
1
.gsd/completed-units-M002.json
Normal file
1
.gsd/completed-units-M002.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[]
|
||||||
|
|
@ -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-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-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":"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"}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,68 @@
|
||||||
# S01: Export Flow (View 3) + DXF Generation
|
# 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
|
**Demo:** After this: Design a sign with text + imported vector, export as DXF, open in Inkscape/LightBurn with correct geometry and scale
|
||||||
|
|
||||||
## Tasks
|
## 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<Konva.Stage>` 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 → `<rect>`, CircleObject → `<circle>`, EllipseObject → `<ellipse>`, LineObject → `<polyline>`
|
||||||
|
- 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<Blob>`
|
||||||
|
- 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
|
||||||
|
|
|
||||||
109
.gsd/milestones/M003/slices/S01/S01-RESEARCH.md
Normal file
109
.gsd/milestones/M003/slices/S01/S01-RESEARCH.md
Normal file
|
|
@ -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 `<image>` or re-embedded SVG path data. Shape objects → SVG primitives. Coordinate space: use `fromPx()` to convert to real-world units in the SVG viewBox.
|
||||||
|
|
||||||
|
4. **Pre-export validation** — Build validation logic: check for unconverted text objects, check for open paths (from trace metadata), check artboard is set. Return structured warnings/errors.
|
||||||
|
|
||||||
|
5. **Export view UI** — Build `ExportView.tsx`: format selector, validation panel, layer assignment (DXF only), download button. Wire to export service.
|
||||||
|
|
||||||
|
6. **DXF API client + download** — Extend `api/engine.ts` with a function that POSTs composed SVG to `/engine/simplify?output_format=dxf` and returns a Blob for download. Handle the binary response (not JSON).
|
||||||
|
|
||||||
|
7. **PNG export** — Use Konva's `stage.toDataURL({ pixelRatio: 2 })` for PNG export. This is trivial since Konva has built-in support.
|
||||||
|
|
||||||
|
### Verification Approach
|
||||||
|
|
||||||
|
1. **Engine DXF scale test**: Create a PostProcessResult with known pixel coordinates (e.g., a 384×576 pixel rectangle = 4×6 inches at 96 PPI). Generate DXF with `scale_factor=1/96` and `units='inches'`. Read back with ezdxf, verify polyline coordinates are 0-4 × 0-6 and `$INSUNITS` is 1.
|
||||||
|
|
||||||
|
2. **SVG composition test**: Unit test `composeCanvasSVG()` with a known set of canvas objects (rect + image + circle). Verify the output SVG contains correct elements with proper coordinates.
|
||||||
|
|
||||||
|
3. **Pre-export validation test**: Unit test validation catches unconverted text objects, reports them as blocking errors.
|
||||||
|
|
||||||
|
4. **End-to-end manual test**: Design a sign with text + imported vector in View 2, navigate to View 3, export as DXF, open in Inkscape — verify correct geometry and scale. This matches the milestone's "After this" demo criteria.
|
||||||
|
|
||||||
|
5. **PNG export test**: Export PNG, verify image dimensions match artboard at expected pixel ratio.
|
||||||
|
|
||||||
|
6. **TypeScript compilation**: `tsc -b` passes with no errors after all changes.
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Existing Solution | Why Use It |
|
||||||
|
|---------|------------------|------------|
|
||||||
|
| DXF file generation | `ezdxf` (already in engine deps) | Battle-tested DXF library with AC1015+ support, layer management, unit headers. Already used in `engine/output/dxf.py`. |
|
||||||
|
| PNG export from canvas | Konva.js `stage.toDataURL()` | Built-in Konva method. Returns data URL or triggers blob download. No need for html2canvas or other libraries. |
|
||||||
|
| SVG path parsing for DXF | Engine `postprocess_svg()` pipeline | Already parses SVG paths into `PathInfo` coordinates consumed by `generate_dxf()`. Don't duplicate this in the frontend. |
|
||||||
|
| Pixel ↔ real-world unit conversion | `artboardShapes.ts` `toPx()` / `fromPx()` | Already implements 96 PPI conversion with inches/mm support. |
|
||||||
|
| File download trigger | Browser `URL.createObjectURL()` + hidden `<a>` click | Standard browser download pattern. No library needed. |
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **Engine DXF generator only accepts `PostProcessResult`** — the frontend can't send arbitrary canvas objects to the engine for DXF conversion. It must first compose them into SVG, then send the SVG through `/engine/simplify` which runs `postprocess_svg()` → `generate_dxf()`.
|
||||||
|
- **Text objects cannot be represented in DXF** — text must be converted to paths (via the existing "Convert to Paths" feature in ShapeProperties) before export. The export view must enforce this as a blocking validation error.
|
||||||
|
- **Image objects with raster sources (PNG/JPG) cannot go into DXF** — only vector image objects (SVG blob URLs from imported traces or converted text) contain path data extractable to DXF. Raster images would need to be re-traced. The validation should warn about this.
|
||||||
|
- **The `/engine/simplify` endpoint returns DXF as raw bytes with `Content-Disposition: attachment`** — the frontend API client must handle `response.blob()` instead of `response.json()` for DXF format.
|
||||||
|
- **Konva.js `toDataURL()` requires the stage to be rendered** — PNG export must happen from View 2 (where the stage exists) or the stage must be re-rendered offscreen in View 3. Simpler: trigger PNG download from View 2 before navigating, or keep the stage mounted.
|
||||||
|
- **P012 applies**: Adding any new canvas object type would require updates in 6 files. This slice doesn't add new types but the export service must handle all 6 existing types exhaustively.
|
||||||
|
- **96 PPI is the fixed conversion factor** — `artboardShapes.ts` defines `PPI = 96`. All pixel-to-real-world conversions use this constant. DXF scale_factor = `1/96` for inches, `25.4/96` for mm.
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- **SVG composition coordinate space mismatch** — Canvas objects use pixel coordinates relative to the artboard origin. The composed SVG must use the same coordinate space. The `viewBox` should match the artboard dimensions in pixels, and `width`/`height` attributes should be in real-world units for scale-correct SVG export.
|
||||||
|
- **DXF Y-axis inversion** — DXF uses a standard cartesian coordinate system (Y-up). SVG/Canvas use Y-down. The engine's `generate_dxf()` currently does NOT flip Y coordinates. For laser cutting, this may or may not matter (LightBurn handles it). But verify: if the engine's PostProcessResult coords come from SVG (Y-down), the DXF should either flip them or document the convention. Check existing tests.
|
||||||
|
- **Binary response handling in fetch API** — The existing `simplifyVector()` in `api/engine.ts` uses `res.json()` which fails for DXF binary responses. Need a separate code path: if `output_format=dxf`, use `res.blob()`.
|
||||||
|
- **Blob URL cleanup** — Composed SVG blob URLs and download blob URLs must be revoked with `URL.revokeObjectURL()` after use to prevent memory leaks.
|
||||||
|
- **Konva stage reference for PNG export** — The `stageRef` is currently owned by DesignCanvas. If PNG export happens from View 3, the stage isn't mounted. Options: (a) export PNG before leaving View 2, (b) render an offscreen Konva stage in View 3, (c) keep View 2 mounted but hidden. Option (a) is simplest.
|
||||||
|
|
||||||
|
## Open Risks
|
||||||
|
|
||||||
|
- **DXF scale verification requires external tool validation** — The milestone "After this" requires opening DXF in Inkscape/LightBurn with correct geometry. Automated tests can verify coordinates via ezdxf, but true validation needs a human opening the file. The automated test should at least verify that a 4-inch artboard produces DXF coordinates spanning 0-4 in the inch unit system.
|
||||||
|
- **Complex SVG composition for image objects** — Image objects sourced from engine traces contain complex SVG path data (compound paths with fill-rule="evenodd"). Re-embedding these into a composed SVG document requires careful namespace handling and may produce large SVG strings that slow down the engine's postprocessor.
|
||||||
|
- **Konva stage.toDataURL() pixel ratio across devices** — Different devices have different `devicePixelRatio`. The PNG export should use a fixed `pixelRatio` (e.g., 2) for consistency, not the device default.
|
||||||
|
|
||||||
|
## Skills Discovered
|
||||||
|
|
||||||
|
| Technology | Skill | Status |
|
||||||
|
|------------|-------|--------|
|
||||||
|
| ezdxf | — | none found |
|
||||||
|
| Konva.js | — | none found |
|
||||||
|
| DXF export | — | none found |
|
||||||
26
.gsd/milestones/M003/slices/S01/tasks/T01-PLAN.md
Normal file
26
.gsd/milestones/M003/slices/S01/tasks/T01-PLAN.md
Normal file
|
|
@ -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
|
||||||
81
.gsd/milestones/M003/slices/S01/tasks/T01-SUMMARY.md
Normal file
81
.gsd/milestones/M003/slices/S01/tasks/T01-SUMMARY.md
Normal file
|
|
@ -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.
|
||||||
32
.gsd/milestones/M003/slices/S01/tasks/T02-PLAN.md
Normal file
32
.gsd/milestones/M003/slices/S01/tasks/T02-PLAN.md
Normal file
|
|
@ -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<Konva.Stage>` 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
|
||||||
44
.gsd/milestones/M003/slices/S01/tasks/T03-PLAN.md
Normal file
44
.gsd/milestones/M003/slices/S01/tasks/T03-PLAN.md
Normal file
|
|
@ -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 → `<rect>`, CircleObject → `<circle>`, EllipseObject → `<ellipse>`, LineObject → `<polyline>`
|
||||||
|
- 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<Blob>`
|
||||||
|
- 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
|
||||||
50
.gsd/milestones/M003/slices/S01/tasks/T04-PLAN.md
Normal file
50
.gsd/milestones/M003/slices/S01/tasks/T04-PLAN.md
Normal file
|
|
@ -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
|
||||||
1748
.gsd/reports/M002-2026-03-26T06-07-21.html
Normal file
1748
.gsd/reports/M002-2026-03-26T06-07-21.html
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -130,7 +130,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
||||||
</div>
|
</div>
|
||||||
<div class="hdr-right">
|
<div class="hdr-right">
|
||||||
<span class="gen-lbl">Updated</span>
|
<span class="gen-lbl">Updated</span>
|
||||||
<span class="gen">Mar 26, 2026, 04:57 AM</span>
|
<span class="gen">Mar 26, 2026, 06:07 AM</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -144,6 +144,10 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
||||||
<div class="toc-group-label">M001</div>
|
<div class="toc-group-label">M001</div>
|
||||||
<ul><li><a href="M001-2026-03-26T04-57-16.html">Mar 26, 2026, 04:57 AM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
|
<ul><li><a href="M001-2026-03-26T04-57-16.html">Mar 26, 2026, 04:57 AM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="toc-group">
|
||||||
|
<div class="toc-group-label">M002</div>
|
||||||
|
<ul><li><a href="M002-2026-03-26T06-07-21.html">Mar 26, 2026, 06:07 AM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
|
|
@ -152,24 +156,39 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
||||||
<h2>Project Overview</h2>
|
<h2>Project Overview</h2>
|
||||||
|
|
||||||
<div class="idx-summary">
|
<div class="idx-summary">
|
||||||
<div class="idx-stat"><span class="idx-val">$15.14</span><span class="idx-lbl">Total Cost</span></div>
|
<div class="idx-stat"><span class="idx-val">$41.85</span><span class="idx-lbl">Total Cost</span></div>
|
||||||
<div class="idx-stat"><span class="idx-val">17.34M</span><span class="idx-lbl">Total Tokens</span></div>
|
<div class="idx-stat"><span class="idx-val">51.67M</span><span class="idx-lbl">Total Tokens</span></div>
|
||||||
<div class="idx-stat"><span class="idx-val">51m 18s</span><span class="idx-lbl">Duration</span></div>
|
<div class="idx-stat"><span class="idx-val">2h 0m</span><span class="idx-lbl">Duration</span></div>
|
||||||
<div class="idx-stat"><span class="idx-val">3/9</span><span class="idx-lbl">Slices</span></div>
|
<div class="idx-stat"><span class="idx-val">6/9</span><span class="idx-lbl">Slices</span></div>
|
||||||
<div class="idx-stat"><span class="idx-val">1/3</span><span class="idx-lbl">Milestones</span></div>
|
<div class="idx-stat"><span class="idx-val">2/3</span><span class="idx-lbl">Milestones</span></div>
|
||||||
<div class="idx-stat"><span class="idx-val">1</span><span class="idx-lbl">Reports</span></div>
|
<div class="idx-stat"><span class="idx-val">2</span><span class="idx-lbl">Reports</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="idx-progress">
|
<div class="idx-progress">
|
||||||
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:33%"></div></div>
|
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:67%"></div></div>
|
||||||
<span class="idx-pct">33% complete</span>
|
<span class="idx-pct">67% complete</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sparkline-wrap"><h3>Cost Progression</h3>
|
||||||
|
<div class="sparkline">
|
||||||
|
<svg viewBox="0 0 600 60" width="600" height="60" class="spark-svg">
|
||||||
|
<polyline points="12.0,35.0 588.0,12.0" class="spark-line" fill="none"/>
|
||||||
|
<circle cx="12.0" cy="35.0" r="3" class="spark-dot">
|
||||||
|
<title>M001: Kerf Engine — Raster-to-Vector Pipeline & API — $15.14</title>
|
||||||
|
</circle><circle cx="588.0" cy="12.0" r="3" class="spark-dot">
|
||||||
|
<title>M002: M002: React Frontend — Import & Convert UI + Design Canvas — $41.85</title>
|
||||||
|
</circle>
|
||||||
|
<text x="12" y="58" class="spark-lbl">$15.14</text>
|
||||||
|
<text x="588" y="58" text-anchor="end" class="spark-lbl">$41.85</text>
|
||||||
|
</svg>
|
||||||
|
<div class="spark-axis">
|
||||||
|
<span class="spark-tick" style="left:2.0%" title="2026-03-26T04:57:16.166Z">M001</span><span class="spark-tick" style="left:98.0%" title="2026-03-26T06:07:21.708Z">M002</span>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="idx-cards">
|
<section class="idx-cards">
|
||||||
<h2>Progression <span class="sec-count">1</span></h2>
|
<h2>Progression <span class="sec-count">2</span></h2>
|
||||||
<div class="cards-grid">
|
<div class="cards-grid">
|
||||||
<a class="report-card card-latest" href="M001-2026-03-26T04-57-16.html">
|
<a class="report-card" href="M001-2026-03-26T04-57-16.html">
|
||||||
<div class="card-top">
|
<div class="card-top">
|
||||||
<span class="card-label">M001: Kerf Engine — Raster-to-Vector Pipeline & API</span>
|
<span class="card-label">M001: Kerf Engine — Raster-to-Vector Pipeline & API</span>
|
||||||
<span class="card-kind card-kind-milestone">milestone</span>
|
<span class="card-kind card-kind-milestone">milestone</span>
|
||||||
|
|
@ -188,6 +207,27 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
||||||
<span>3/9 slices</span>
|
<span>3/9 slices</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</a>
|
||||||
|
<a class="report-card card-latest" href="M002-2026-03-26T06-07-21.html">
|
||||||
|
<div class="card-top">
|
||||||
|
<span class="card-label">M002: M002: React Frontend — Import & Convert UI + Design Canvas</span>
|
||||||
|
<span class="card-kind card-kind-milestone">milestone</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-date">Mar 26, 2026, 06:07 AM</div>
|
||||||
|
<div class="card-progress">
|
||||||
|
<div class="card-bar-track">
|
||||||
|
<div class="card-bar-fill" style="width:67%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="card-pct">67%</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-stats">
|
||||||
|
<span>$41.85</span>
|
||||||
|
<span>51.67M</span>
|
||||||
|
<span>2h 0m</span>
|
||||||
|
<span>6/9 slices</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-delta"><span>+$26.72</span><span>+3 slices</span><span>+1 milestone</span></div>
|
||||||
<div class="card-latest-badge">Latest</div>
|
<div class="card-latest-badge">Latest</div>
|
||||||
</a></div>
|
</a></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -202,7 +242,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
||||||
<span class="ftr-sep">—</span>
|
<span class="ftr-sep">—</span>
|
||||||
<span>/home/aux/development/xpltdco/kerf-engine</span>
|
<span>/home/aux/development/xpltdco/kerf-engine</span>
|
||||||
<span class="ftr-sep">—</span>
|
<span class="ftr-sep">—</span>
|
||||||
<span>Updated Mar 26, 2026, 04:57 AM</span>
|
<span>Updated Mar 26, 2026, 06:07 AM</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,22 @@
|
||||||
"doneMilestones": 1,
|
"doneMilestones": 1,
|
||||||
"totalMilestones": 3,
|
"totalMilestones": 3,
|
||||||
"phase": "planning"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"exported_at": "2026-03-26T06:06:46.767Z",
|
"exported_at": "2026-03-26T06:16:59.234Z",
|
||||||
"milestones": [
|
"milestones": [
|
||||||
{
|
{
|
||||||
"id": "M001",
|
"id": "M001",
|
||||||
|
|
@ -344,11 +344,11 @@
|
||||||
"completed_at": null,
|
"completed_at": null,
|
||||||
"full_summary_md": "",
|
"full_summary_md": "",
|
||||||
"full_uat_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",
|
"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": "- 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",
|
"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 + manual — DXF validated in external tools by human",
|
"proof_level": "integration",
|
||||||
"integration_closure": "Full end-to-end workflow complete: Import → Canvas → Export",
|
"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": "Export generation time; validation warning counts",
|
"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,
|
"sequence": 0,
|
||||||
"replan_triggered_at": null
|
"replan_triggered_at": null
|
||||||
},
|
},
|
||||||
|
|
@ -1423,6 +1423,176 @@
|
||||||
"observability_impact": "",
|
"observability_impact": "",
|
||||||
"full_plan_md": "",
|
"full_plan_md": "",
|
||||||
"sequence": 0
|
"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<Konva.Stage>` 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 → `<rect>`, CircleObject → `<circle>`, EllipseObject → `<ellipse>`, LineObject → `<polyline>`\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<Blob>`\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": [
|
"decisions": [
|
||||||
|
|
@ -1942,6 +2112,28 @@
|
||||||
"verdict": "✅ pass",
|
"verdict": "✅ pass",
|
||||||
"duration_ms": 2200,
|
"duration_ms": 2200,
|
||||||
"created_at": "2026-03-26T05:58:01.901Z"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -30,11 +30,18 @@ def _format_response(
|
||||||
output_format: str,
|
output_format: str,
|
||||||
warnings: list[str],
|
warnings: list[str],
|
||||||
processing_ms: float,
|
processing_ms: float,
|
||||||
|
*,
|
||||||
|
units: str | None = None,
|
||||||
|
scale_factor: float = 1.0,
|
||||||
):
|
):
|
||||||
"""Build a standardized response from a PostProcessResult and output format.
|
"""Build a standardized response from a PostProcessResult and output format.
|
||||||
|
|
||||||
SVG and JSON return a JSON envelope with output + metadata.
|
SVG and JSON return a JSON envelope with output + metadata.
|
||||||
DXF returns raw bytes with application/dxf content type.
|
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 = {
|
metadata = {
|
||||||
"format": output_format,
|
"format": output_format,
|
||||||
|
|
@ -47,7 +54,12 @@ def _format_response(
|
||||||
}
|
}
|
||||||
|
|
||||||
if output_format == "dxf":
|
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(
|
return Response(
|
||||||
content=dxf_bytes,
|
content=dxf_bytes,
|
||||||
media_type="application/dxf",
|
media_type="application/dxf",
|
||||||
|
|
@ -174,16 +186,24 @@ async def trace(
|
||||||
return _format_response(result, output_format, warnings, processing_ms)
|
return _format_response(result, output_format, warnings, processing_ms)
|
||||||
|
|
||||||
|
|
||||||
|
VALID_UNITS = {"inches", "mm"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/engine/simplify")
|
@router.post("/engine/simplify")
|
||||||
async def simplify(
|
async def simplify(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
epsilon: float = Form(1.0),
|
epsilon: float = Form(1.0),
|
||||||
output_format: str = Form("svg"),
|
output_format: str = Form("svg"),
|
||||||
|
units: str | None = Form(None),
|
||||||
|
scale_factor: float = Form(1.0),
|
||||||
):
|
):
|
||||||
"""Simplify an existing SVG using RDP path simplification.
|
"""Simplify an existing SVG using RDP path simplification.
|
||||||
|
|
||||||
Accepts an SVG file upload, applies Ramer-Douglas-Peucker simplification
|
Accepts an SVG file upload, applies Ramer-Douglas-Peucker simplification
|
||||||
with the given epsilon, and returns the result in the requested format.
|
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:
|
if output_format not in VALID_OUTPUT_FORMATS:
|
||||||
raise HTTPException(
|
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))}",
|
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()
|
raw_bytes = await file.read()
|
||||||
if not raw_bytes:
|
if not raw_bytes:
|
||||||
raise HTTPException(status_code=422, detail="Uploaded file is empty")
|
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)
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,38 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
import ezdxf
|
import ezdxf
|
||||||
|
|
||||||
from pipeline.postprocess import PathInfo, PostProcessResult
|
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(
|
def _add_path_to_msp(
|
||||||
msp: ezdxf.layouts.BaseLayout,
|
msp: ezdxf.layouts.BaseLayout,
|
||||||
path: PathInfo,
|
path: PathInfo,
|
||||||
layer: str = "0",
|
layer: str = "0",
|
||||||
|
scale_factor: float = 1.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a single PathInfo as an LWPOLYLINE entity to the modelspace.
|
"""Add a single PathInfo as an LWPOLYLINE entity to the modelspace.
|
||||||
|
|
||||||
Closed paths get the LWPOLYLINE close flag set.
|
Closed paths get the LWPOLYLINE close flag set.
|
||||||
Islands are placed on a separate "ISLANDS" layer for downstream CAM tools.
|
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
|
coords = path.simplified_coords
|
||||||
if len(coords) < 2:
|
if len(coords) < 2:
|
||||||
|
|
@ -26,7 +43,7 @@ def _add_path_to_msp(
|
||||||
target_layer = "ISLANDS" if path.is_island else layer
|
target_layer = "ISLANDS" if path.is_island else layer
|
||||||
|
|
||||||
# LWPOLYLINE uses (x, y[, start_width, end_width, bulge]) tuples
|
# 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
|
# Remove duplicate close point if the polyline close flag handles it
|
||||||
if path.is_closed and len(points) > 1 and points[0] == points[-1]:
|
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.
|
"""Generate an AC1015 (AutoCAD R2000) DXF file from post-processed path data.
|
||||||
|
|
||||||
Creates LWPOLYLINE entities from simplified coordinate data. Islands (holes)
|
Creates LWPOLYLINE entities from simplified coordinate data. Islands (holes)
|
||||||
|
|
@ -47,18 +70,45 @@ def generate_dxf(result: PostProcessResult) -> bytes:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
result: PostProcessResult from the post-processing pipeline.
|
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:
|
Returns:
|
||||||
DXF file content as bytes.
|
DXF file content as bytes.
|
||||||
"""
|
"""
|
||||||
doc = ezdxf.new(dxfversion="R2000") # AC1015
|
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()
|
msp = doc.modelspace()
|
||||||
|
|
||||||
# Create ISLANDS layer for hole/island paths
|
# Create ISLANDS layer for hole/island paths
|
||||||
doc.layers.add("ISLANDS", color=1) # color 1 = red in AutoCAD
|
doc.layers.add("ISLANDS", color=1) # color 1 = red in AutoCAD
|
||||||
|
|
||||||
for path in result.paths:
|
# Create any custom layers requested by layer_map
|
||||||
_add_path_to_msp(msp, path)
|
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
|
# Write to string buffer, then encode to bytes
|
||||||
stream = io.StringIO()
|
stream = io.StringIO()
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,169 @@ class TestDXFOutput:
|
||||||
assert any(not pl.is_closed for pl in polylines)
|
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 = (
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="384" height="576" viewBox="0 0 384 576">'
|
||||||
|
'<path d="M 0,0 L 384,0 L 384,576 L 0,576 Z" fill="black"/>'
|
||||||
|
"</svg>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
# Integration: round-trip through all formats
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue