Back to skills
SkillHub ClubDesign ProductFull StackDesigner

create-dxf

Create RFQ-ready 2D DXF (and optional SVG preview) files from a strict, validated JSON spec derived from a natural-language design prompt. Use for sheet/plate parts (waterjet/laser/router) like mounting plates, gussets, brackets, hole patterns, and slots.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3,071
Hot score
99
Updated
March 19, 2026
Overall rating
C4.0
Composite score
4.0
Best-practice grade
B73.6

Install command

npx @skill-hub/cli install openclaw-skills-create-dxf

Repository

openclaw/skills

Skill path: skills/ajmwagar/create-dxf

Create RFQ-ready 2D DXF (and optional SVG preview) files from a strict, validated JSON spec derived from a natural-language design prompt. Use for sheet/plate parts (waterjet/laser/router) like mounting plates, gussets, brackets, hole patterns, and slots.

Open repository

Best for

Primary workflow: Design Product.

Technical facets: Full Stack, Designer.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: openclaw.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install create-dxf into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/openclaw/skills before adding create-dxf to shared team environments
  • Use create-dxf for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: create-dxf
description: Create RFQ-ready 2D DXF (and optional SVG preview) files from a strict, validated JSON spec derived from a natural-language design prompt. Use for sheet/plate parts (waterjet/laser/router) like mounting plates, gussets, brackets, hole patterns, and slots.
---

# create-dxf

Deterministically generate a **manufacturing-friendly DXF** from a small JSON spec (center-origin, explicit units). Also emits an SVG preview.

## Quick start

1) Convert prompt → JSON (see `references/spec_schema.md`).
2) Validate:

```bash
python3 scripts/create_dxf.py validate spec.json
```

3) Render:

```bash
python3 scripts/create_dxf.py render spec.json --outdir out
```

Outputs:
- `out/<name>.dxf`
- `out/<name>.svg`

## Notes

- DXF uses simple entities for compatibility: closed `LWPOLYLINE` outer profile + `CIRCLE` holes.
- Default layers are manufacturing-oriented:
  - `CUT_OUTER` (outer perimeter)
  - `CUT_INNER` (holes/slots)
  - `NOTES` (optional)

## Resources

- `scripts/create_dxf.py`
- `references/spec_schema.md`
- `references/test_prompts.md`


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### scripts/create_dxf.py

```python
#!/usr/bin/env python3
"""Generate RFQ-ready 2D manufacturing files (DXF + SVG) from a validated JSON spec.

Design goals:
- Deterministic geometry (no CAD UI, no external deps)
- Quote-friendly outputs: units, clean layers, closed profiles
- Minimal entity set for maximum compatibility (DXF R12-ish)

Supported part types:
- "plate": rectangular plate with optional corner radius, holes, and slots (cut geometry)
- "polyline": arbitrary closed polyline cut profile (e.g., silhouettes like a state outline)
- "drawing": rounded-rect outline with etch geometry (circles / rounded-rects / polylines / svg paths) for illustrations

Usage:
  python3 scripts/rfq_cad.py validate spec.json
  python3 scripts/rfq_cad.py render spec.json --outdir out

The spec schema is documented in references/spec_schema.md.
"""

from __future__ import annotations

import argparse
import json
import math
import os
import sys
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple


# ----------------------------
# Spec models
# ----------------------------


def _req(d: Dict[str, Any], k: str, typ):
    if k not in d:
        raise ValueError(f"missing required field: {k}")
    v = d[k]
    if not isinstance(v, typ):
        raise ValueError(f"field {k} must be {typ.__name__}")
    return v


def _opt(d: Dict[str, Any], k: str, typ, default=None):
    v = d.get(k, default)
    if v is None:
        return None
    if not isinstance(v, typ):
        raise ValueError(f"field {k} must be {typ.__name__}")
    return v


@dataclass
class Hole:
    x: float
    y: float
    diameter: float


@dataclass
class Slot:
    x: float
    y: float
    length: float
    width: float
    angle_deg: float = 0.0


@dataclass
class PlateSpec:
    units: str
    width: float
    height: float
    thickness: Optional[float]
    corner_radius: float
    holes: List[Hole]
    slots: List[Slot]
    layer_profile: str = "PROFILE"
    layer_holes: str = "HOLES"
    layer_notes: str = "NOTES"


@dataclass
class PolylineSpec:
    units: str
    points: List[Tuple[float, float]]
    closed: bool = True
    layer: str = "CUT_OUTER"


@dataclass
class EtchCircle:
    x: float
    y: float
    diameter: float


@dataclass
class EtchRoundedRect:
    x: float
    y: float
    width: float
    height: float
    radius: float


@dataclass
class EtchPolyline:
    points: List[Tuple[float, float]]
    closed: bool = True


@dataclass
class EtchSvgPath:
    # SVG path 'd' string; interpreted in absolute coordinates unless you apply transforms.
    d: str
    x: float = 0.0
    y: float = 0.0
    scale: float = 1.0


@dataclass
class DrawingSpec:
    units: str
    width: float
    height: float
    corner_radius: float
    etch_circles: List[EtchCircle]
    etch_rounded_rects: List[EtchRoundedRect]
    etch_polylines: List[EtchPolyline]
    etch_svg_paths: List[EtchSvgPath]
    layer_outline: str = "OUTLINE"
    layer_etch: str = "ETCH"


def load_spec(path: str) -> Dict[str, Any]:
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def parse_plate(spec: Dict[str, Any]) -> PlateSpec:
    kind = _req(spec, "kind", str)
    if kind != "plate":
        raise ValueError(f"unsupported kind for plate parser: {kind}")

    units = _req(spec, "units", str)
    if units not in ("mm", "in"):
        raise ValueError("units must be 'mm' or 'in'")

    width = float(_req(spec, "width", (int, float)))
    height = float(_req(spec, "height", (int, float)))
    thickness_v = spec.get("thickness")
    thickness = float(thickness_v) if isinstance(thickness_v, (int, float)) else None

    corner_radius = float(_opt(spec, "corner_radius", (int, float), 0.0))
    if width <= 0 or height <= 0:
        raise ValueError("width/height must be > 0")
    if corner_radius < 0:
        raise ValueError("corner_radius must be >= 0")
    if corner_radius * 2 > min(width, height):
        raise ValueError("corner_radius too large for plate size")

    holes: List[Hole] = []
    for h in _opt(spec, "holes", list, []) or []:
        if not isinstance(h, dict):
            raise ValueError("holes[] entries must be objects")
        holes.append(
            Hole(
                x=float(_req(h, "x", (int, float))),
                y=float(_req(h, "y", (int, float))),
                diameter=float(_req(h, "diameter", (int, float))),
            )
        )

    slots: List[Slot] = []
    for s in _opt(spec, "slots", list, []) or []:
        if not isinstance(s, dict):
            raise ValueError("slots[] entries must be objects")
        slots.append(
            Slot(
                x=float(_req(s, "x", (int, float))),
                y=float(_req(s, "y", (int, float))),
                length=float(_req(s, "length", (int, float))),
                width=float(_req(s, "width", (int, float))),
                angle_deg=float(_opt(s, "angle_deg", (int, float), 0.0)),
            )
        )

    layers = spec.get("layers", {}) if isinstance(spec.get("layers"), dict) else {}

    # Manufacturing-first defaults:
    # - outer perimeter is CUT_OUTER
    # - inner features are CUT_INNER
    return PlateSpec(
        units=units,
        width=width,
        height=height,
        thickness=thickness,
        corner_radius=corner_radius,
        holes=holes,
        slots=slots,
        layer_profile=str(layers.get("profile", "CUT_OUTER")),
        layer_holes=str(layers.get("holes", "CUT_INNER")),
        layer_notes=str(layers.get("notes", "NOTES")),
    )


def validate_plate(p: PlateSpec) -> None:
    # Simple sanity checks: keep holes/slots inside the profile bounding box.
    # This does NOT do full self-intersection checks.
    half_w = p.width / 2
    half_h = p.height / 2

    def inside(x: float, y: float, margin: float) -> bool:
        return (-half_w + margin) <= x <= (half_w - margin) and (-half_h + margin) <= y <= (half_h - margin)

    for h in p.holes:
        if h.diameter <= 0:
            raise ValueError("hole diameter must be > 0")
        r = h.diameter / 2
        if not inside(h.x, h.y, r):
            raise ValueError(f"hole at ({h.x},{h.y}) would exit profile")

    for s in p.slots:
        if s.length <= 0 or s.width <= 0:
            raise ValueError("slot length/width must be > 0")
        # conservative bound: treat slot as a circle with radius length/2
        r = max(s.length, s.width) / 2
        if not inside(s.x, s.y, r):
            raise ValueError(f"slot at ({s.x},{s.y}) may exit profile (conservative check)")


def parse_polyline(spec: Dict[str, Any]) -> PolylineSpec:
    kind = _req(spec, "kind", str)
    if kind != "polyline":
        raise ValueError(f"unsupported kind for polyline parser: {kind}")

    units = _req(spec, "units", str)
    if units not in ("mm", "in"):
        raise ValueError("units must be 'mm' or 'in'")

    pts_in = _req(spec, "points", list)
    pts: List[Tuple[float, float]] = []
    for pt in pts_in:
        if not isinstance(pt, dict):
            raise ValueError("points[] entries must be objects")
        pts.append((float(_req(pt, "x", (int, float))), float(_req(pt, "y", (int, float)))))
    closed = bool(_opt(spec, "closed", bool, True))
    layer = str(_opt(spec, "layer", str, "CUT_OUTER") or "CUT_OUTER")

    return PolylineSpec(units=units, points=pts, closed=closed, layer=layer)


def validate_polyline(p: PolylineSpec) -> None:
    if len(p.points) < 3:
        raise ValueError("polyline must have at least 3 points")


def parse_drawing(spec: Dict[str, Any]) -> DrawingSpec:
    kind = _req(spec, "kind", str)
    if kind != "drawing":
        raise ValueError(f"unsupported kind for drawing parser: {kind}")

    units = _req(spec, "units", str)
    if units not in ("mm", "in"):
        raise ValueError("units must be 'mm' or 'in'")

    width = float(_req(spec, "width", (int, float)))
    height = float(_req(spec, "height", (int, float)))
    corner_radius = float(_opt(spec, "corner_radius", (int, float), 0.0))

    if width <= 0 or height <= 0:
        raise ValueError("width/height must be > 0")
    if corner_radius < 0:
        raise ValueError("corner_radius must be >= 0")
    if corner_radius * 2 > min(width, height):
        raise ValueError("corner_radius too large for outline")

    etch_circles: List[EtchCircle] = []
    for c in _opt(spec, "etch_circles", list, []) or []:
        if not isinstance(c, dict):
            raise ValueError("etch_circles[] entries must be objects")
        etch_circles.append(
            EtchCircle(
                x=float(_req(c, "x", (int, float))),
                y=float(_req(c, "y", (int, float))),
                diameter=float(_req(c, "diameter", (int, float))),
            )
        )

    etch_rounded_rects: List[EtchRoundedRect] = []
    for rr in _opt(spec, "etch_rounded_rects", list, []) or []:
        if not isinstance(rr, dict):
            raise ValueError("etch_rounded_rects[] entries must be objects")
        etch_rounded_rects.append(
            EtchRoundedRect(
                x=float(_req(rr, "x", (int, float))),
                y=float(_req(rr, "y", (int, float))),
                width=float(_req(rr, "width", (int, float))),
                height=float(_req(rr, "height", (int, float))),
                radius=float(_opt(rr, "radius", (int, float), 0.0) or 0.0),
            )
        )

    etch_polylines: List[EtchPolyline] = []
    for pl in _opt(spec, "etch_polylines", list, []) or []:
        if not isinstance(pl, dict):
            raise ValueError("etch_polylines[] entries must be objects")
        pts_in = _req(pl, "points", list)
        pts: List[Tuple[float, float]] = []
        for pt in pts_in:
            if not isinstance(pt, dict):
                raise ValueError("etch_polylines[].points[] entries must be objects")
            pts.append((float(_req(pt, "x", (int, float))), float(_req(pt, "y", (int, float)))))
        closed = bool(_opt(pl, "closed", bool, True))
        if len(pts) < 2:
            raise ValueError("etch polyline must have at least 2 points")
        etch_polylines.append(EtchPolyline(points=pts, closed=closed))

    etch_svg_paths: List[EtchSvgPath] = []
    for sp in _opt(spec, "etch_svg_paths", list, []) or []:
        if not isinstance(sp, dict):
            raise ValueError("etch_svg_paths[] entries must be objects")
        d_str = str(_req(sp, "d", str))
        etch_svg_paths.append(
            EtchSvgPath(
                d=d_str,
                x=float(_opt(sp, "x", (int, float), 0.0) or 0.0),
                y=float(_opt(sp, "y", (int, float), 0.0) or 0.0),
                scale=float(_opt(sp, "scale", (int, float), 1.0) or 1.0),
            )
        )

    layers = spec.get("layers", {}) if isinstance(spec.get("layers"), dict) else {}

    return DrawingSpec(
        units=units,
        width=width,
        height=height,
        corner_radius=corner_radius,
        etch_circles=etch_circles,
        etch_rounded_rects=etch_rounded_rects,
        etch_polylines=etch_polylines,
        etch_svg_paths=etch_svg_paths,
        layer_outline=str(layers.get("outline", "OUTLINE")),
        layer_etch=str(layers.get("etch", "ETCH")),
    )


def validate_drawing(d: DrawingSpec) -> None:
    half_w = d.width / 2
    half_h = d.height / 2

    def inside(x: float, y: float, margin: float) -> bool:
        return (-half_w + margin) <= x <= (half_w - margin) and (-half_h + margin) <= y <= (half_h - margin)

    for c in d.etch_circles:
        if c.diameter <= 0:
            raise ValueError("etch circle diameter must be > 0")
        r = c.diameter / 2
        if not inside(c.x, c.y, r):
            raise ValueError(f"etch circle at ({c.x},{c.y}) would exit outline")

    # Basic overlap check for etch circles (helps catch camera lens overlaps).
    for i in range(len(d.etch_circles)):
        for j in range(i + 1, len(d.etch_circles)):
            a = d.etch_circles[i]
            b2 = d.etch_circles[j]
            ra = a.diameter / 2
            rb = b2.diameter / 2
            dx = a.x - b2.x
            dy = a.y - b2.y
            dist2 = dx * dx + dy * dy
            min_dist = ra + rb
            if dist2 < (min_dist * min_dist):
                raise ValueError(f"etch circles overlap: ({a.x},{a.y},d={a.diameter}) vs ({b2.x},{b2.y},d={b2.diameter})")

    for rr in d.etch_rounded_rects:
        if rr.width <= 0 or rr.height <= 0:
            raise ValueError("etch rounded_rect width/height must be > 0")
        if rr.radius < 0:
            raise ValueError("etch rounded_rect radius must be >= 0")
        if rr.radius * 2 > min(rr.width, rr.height):
            raise ValueError("etch rounded_rect radius too large")
        # bounding-box check (no rotation supported for rounded rects)
        if abs(rr.x) + (rr.width / 2) > half_w or abs(rr.y) + (rr.height / 2) > half_h:
            raise ValueError(f"etch rounded_rect at ({rr.x},{rr.y}) may exit outline")


# ----------------------------
# Geometry helpers
# ----------------------------


def rot(x: float, y: float, deg: float) -> Tuple[float, float]:
    a = math.radians(deg)
    ca, sa = math.cos(a), math.sin(a)
    return (x * ca - y * sa, x * sa + y * ca)


def bulge_for_angle(theta_deg: float) -> float:
    # DXF bulge = tan(theta/4), theta is the included angle of the arc segment
    return math.tan(math.radians(theta_deg) / 4.0)


def parse_svg_path_d(d: str) -> List[List[Tuple[float, float]]]:
    """Parse a minimal SVG path 'd' into polylines.

    Supported commands:
    - M/m x y
    - L/l x y
    - C/c x1 y1 x2 y2 x y
    - Z/z

    Returns a list of subpaths, each as a list of (x,y) points.

    This is enough to ingest many logo silhouettes that are cubic-bezier based.
    """

    import re

    tokens = re.findall(r"[MLCZmlcz]|-?\d*\.?\d+(?:e[-+]?\d+)?", d)
    i = 0
    cur = (0.0, 0.0)
    start = (0.0, 0.0)
    paths: List[List[Tuple[float, float]]] = []
    cur_path: List[Tuple[float, float]] = []

    def getf() -> float:
        nonlocal i
        if i >= len(tokens):
            raise ValueError("unexpected end of svg path")
        v = float(tokens[i])
        i += 1
        return v

    def cubic(p0, p1, p2, p3, steps=28):
        pts: List[Tuple[float, float]] = []
        for s in range(1, steps + 1):
            t = s / steps
            mt = 1 - t
            x = (
                mt * mt * mt * p0[0]
                + 3 * mt * mt * t * p1[0]
                + 3 * mt * t * t * p2[0]
                + t * t * t * p3[0]
            )
            y = (
                mt * mt * mt * p0[1]
                + 3 * mt * mt * t * p1[1]
                + 3 * mt * t * t * p2[1]
                + t * t * t * p3[1]
            )
            pts.append((x, y))
        return pts

    def is_cmd(tok: str) -> bool:
        return tok in ("M", "L", "C", "Z", "m", "l", "c", "z")

    last_cmd: Optional[str] = None

    while i < len(tokens):
        tok = tokens[i]
        if is_cmd(tok):
            cmd = tok
            i += 1
            last_cmd = cmd
        else:
            # Implicit command repetition (SVG allows omitting the command letter)
            if last_cmd is None:
                raise ValueError(f"svg path starts with number: {tok}")
            cmd = last_cmd

        cmd_u = cmd.upper()

        if cmd_u == "M":
            # First pair is moveto; subsequent pairs are treated as implicit lineto.
            first = True
            while i < len(tokens) and not is_cmd(tokens[i]):
                x, y = getf(), getf()
                if cmd.islower():
                    x += cur[0]
                    y += cur[1]
                cur = (x, y)
                if first:
                    start = cur
                    if cur_path:
                        paths.append(cur_path)
                    cur_path = [cur]
                    first = False
                else:
                    cur_path.append(cur)
            # After moveto, implicit command becomes lineto per SVG spec.
            last_cmd = "l" if cmd.islower() else "L"

        elif cmd_u == "L":
            while i < len(tokens) and not is_cmd(tokens[i]):
                x, y = getf(), getf()
                if cmd.islower():
                    x += cur[0]
                    y += cur[1]
                cur = (x, y)
                cur_path.append(cur)

        elif cmd_u == "C":
            while i < len(tokens) and not is_cmd(tokens[i]):
                x1, y1 = getf(), getf()
                x2, y2 = getf(), getf()
                x, y = getf(), getf()
                if cmd.islower():
                    x1 += cur[0]; y1 += cur[1]
                    x2 += cur[0]; y2 += cur[1]
                    x += cur[0]; y += cur[1]
                p0 = cur
                p1 = (x1, y1)
                p2 = (x2, y2)
                p3 = (x, y)
                cur_path.extend(cubic(p0, p1, p2, p3))
                cur = p3

        elif cmd_u == "Z":
            if cur_path and cur_path[0] != cur_path[-1]:
                cur_path.append(cur_path[0])
            cur = start

        else:
            raise ValueError(f"unsupported SVG path command: {cmd}")

    if cur_path:
        paths.append(cur_path)

    return paths


# ----------------------------
# DXF writer (minimal)
# ----------------------------


def dxf_header(units: str) -> List[str]:
    """DXF header.

    Notes:
    - DXF does not *reliably* enforce units across all consumers.
    - Setting $INSUNITS helps many CAM/CAD tools interpret scale.

    $INSUNITS values (AutoCAD):
      1=inches, 4=millimeters
    """

    insunits = 4 if units == "mm" else 1

    # Use R2000 header so $INSUNITS is recognized widely.
    return [
        "0", "SECTION", "2", "HEADER",
        "9", "$ACADVER", "1", "AC1015",  # R2000
        "9", "$INSUNITS", "70", str(insunits),
        "0", "ENDSEC",
        "0", "SECTION", "2", "TABLES",
        "0", "ENDSEC",
        "0", "SECTION", "2", "ENTITIES",
    ]


def dxf_footer() -> List[str]:
    return [
        "0", "ENDSEC",
        "0", "EOF",
    ]


def dxf_circle(layer: str, x: float, y: float, r: float) -> List[str]:
    return [
        "0", "CIRCLE",
        "8", layer,
        "10", f"{x:.6f}",
        "20", f"{y:.6f}",
        "30", "0.0",
        "40", f"{r:.6f}",
    ]


def dxf_lwpolyline(layer: str, verts: List[Tuple[float, float, float]], closed: bool = True) -> List[str]:
    # verts: [(x,y,bulge), ...]
    out = [
        "0", "LWPOLYLINE",
        "8", layer,
        "90", str(len(verts)),
        "70", "1" if closed else "0",
    ]
    for (x, y, b) in verts:
        out += [
            "10", f"{x:.6f}",
            "20", f"{y:.6f}",
            "42", f"{b:.6f}",
        ]
    return out


# ----------------------------
# SVG writer (minimal)
# ----------------------------


def svg_path_plate_outline(p: PlateSpec) -> str:
    """SVG path for a rounded rectangle outline centered at origin."""
    # Coordinate system: spec uses center-origin. SVG uses top-left origin.
    # We'll map center-origin to SVG by translating to (half_w, half_h) and flipping Y.
    w, h, r = p.width, p.height, p.corner_radius
    hw, hh = w / 2, h / 2

    def m(x: float, y: float) -> Tuple[float, float]:
        return (x + hw, hh - y)

    if r <= 0:
        x0, y0 = m(-hw, -hh)
        x1, y1 = m(hw, -hh)
        x2, y2 = m(hw, hh)
        x3, y3 = m(-hw, hh)
        return f"M {x0:.3f},{y0:.3f} L {x1:.3f},{y1:.3f} L {x2:.3f},{y2:.3f} L {x3:.3f},{y3:.3f} Z"

    # Rounded rectangle using SVG arc commands
    # Start at bottom-left corner tangent
    x0, y0 = m(-hw + r, -hh)
    x1, y1 = m(hw - r, -hh)
    x2, y2 = m(hw, -hh + r)
    x3, y3 = m(hw, hh - r)
    x4, y4 = m(hw - r, hh)
    x5, y5 = m(-hw + r, hh)
    x6, y6 = m(-hw, hh - r)
    x7, y7 = m(-hw, -hh + r)

    # NOTE: because we flip Y in mapping, sweep flags are inverted.
    # Using sweep=0 here produces outward corners in the rendered SVG.
    return (
        f"M {x0:.3f},{y0:.3f} "
        f"L {x1:.3f},{y1:.3f} "
        f"A {r:.3f},{r:.3f} 0 0 0 {x2:.3f},{y2:.3f} "
        f"L {x3:.3f},{y3:.3f} "
        f"A {r:.3f},{r:.3f} 0 0 0 {x4:.3f},{y4:.3f} "
        f"L {x5:.3f},{y5:.3f} "
        f"A {r:.3f},{r:.3f} 0 0 0 {x6:.3f},{y6:.3f} "
        f"L {x7:.3f},{y7:.3f} "
        f"A {r:.3f},{r:.3f} 0 0 0 {x0:.3f},{y0:.3f} Z"
    )


def render_svg(p: PlateSpec, out_path: str) -> None:
    w, h = p.width, p.height
    hw, hh = w / 2, h / 2

    def m(x: float, y: float) -> Tuple[float, float]:
        return (x + hw, hh - y)

    parts: List[str] = []

    outline = svg_path_plate_outline(p)
    parts.append(f"<path d=\"{outline}\" fill=\"none\" stroke=\"black\" stroke-width=\"0.4\"/>")

    for hole in p.holes:
        cx, cy = m(hole.x, hole.y)
        r = hole.diameter / 2
        parts.append(f"<circle cx=\"{cx:.3f}\" cy=\"{cy:.3f}\" r=\"{r:.3f}\" fill=\"none\" stroke=\"black\" stroke-width=\"0.3\"/>")

    for s in p.slots:
        L, W = s.length, s.width
        r = W / 2
        pts = [
            (-(L / 2 - r), -W / 2),
            ((L / 2 - r), -W / 2),
            ((L / 2), -W / 2 + r),
            ((L / 2), W / 2 - r),
            ((L / 2 - r), W / 2),
            (-(L / 2 - r), W / 2),
            (-(L / 2), W / 2 - r),
            (-(L / 2), -W / 2 + r),
        ]

        def tr(x: float, y: float) -> Tuple[float, float]:
            xr, yr = rot(x, y, s.angle_deg)
            return (xr + s.x, yr + s.y)

        mpts = [m(*tr(x, y)) for (x, y) in pts]
        (x0, y0) = mpts[0]
        d = [f"M {x0:.3f},{y0:.3f}"]
        d.append(f"L {mpts[1][0]:.3f},{mpts[1][1]:.3f}")
        d.append(f"A {r:.3f},{r:.3f} 0 0 1 {mpts[2][0]:.3f},{mpts[2][1]:.3f}")
        d.append(f"L {mpts[3][0]:.3f},{mpts[3][1]:.3f}")
        d.append(f"A {r:.3f},{r:.3f} 0 0 1 {mpts[4][0]:.3f},{mpts[4][1]:.3f}")
        d.append(f"L {mpts[5][0]:.3f},{mpts[5][1]:.3f}")
        d.append(f"A {r:.3f},{r:.3f} 0 0 1 {mpts[6][0]:.3f},{mpts[6][1]:.3f}")
        d.append(f"L {mpts[7][0]:.3f},{mpts[7][1]:.3f}")
        d.append(f"A {r:.3f},{r:.3f} 0 0 1 {mpts[0][0]:.3f},{mpts[0][1]:.3f} Z")
        parts.append(f"<path d=\"{' '.join(d)}\" fill=\"none\" stroke=\"black\" stroke-width=\"0.3\"/>")

    meta = f"units={p.units}; thickness={p.thickness if p.thickness is not None else 'n/a'}"
    svg = (
        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
        f"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w:.3f}\" height=\"{h:.3f}\" viewBox=\"0 0 {w:.3f} {h:.3f}\">\n"
        f"<!-- {meta} -->\n"
        + "\n".join(parts)
        + "\n</svg>\n"
    )

    with open(out_path, "w", encoding="utf-8") as f:
        f.write(svg)


def render_svg_drawing(d: DrawingSpec, out_path: str) -> None:
    # Compute a true bounding box over all geometry, then pad it. This avoids tight crops
    # and keeps the drawing centered regardless of where the etch geometry sits.
    pad = 10.0 if d.units == "mm" else 0.4

    def bounds_init():
        return (1e18, 1e18, -1e18, -1e18)  # minx,miny,maxx,maxy

    def bounds_add(b, x, y):
        minx, miny, maxx, maxy = b
        return (min(minx, x), min(miny, y), max(maxx, x), max(maxy, y))

    b = bounds_init()

    # Outline bounds (in model coords)
    hw, hh = d.width / 2, d.height / 2
    b = bounds_add(b, -hw, -hh)
    b = bounds_add(b, hw, hh)

    # Circles
    for c in d.etch_circles:
        r = c.diameter / 2
        b = bounds_add(b, c.x - r, c.y - r)
        b = bounds_add(b, c.x + r, c.y + r)

    # Rounded rects
    for rr in d.etch_rounded_rects:
        b = bounds_add(b, rr.x - rr.width / 2, rr.y - rr.height / 2)
        b = bounds_add(b, rr.x + rr.width / 2, rr.y + rr.height / 2)

    # Polylines
    for pl in d.etch_polylines:
        for (x, y) in pl.points:
            b = bounds_add(b, x, y)

    # SVG paths (sampled)
    for sp in d.etch_svg_paths:
        for pts0 in parse_svg_path_d(sp.d):
            for (x, y) in pts0:
                x2 = (x * sp.scale) + sp.x
                y2 = (y * sp.scale) + sp.y
                b = bounds_add(b, x2, y2)

    minx, miny, maxx, maxy = b
    # Handle degenerate
    if not (minx < maxx and miny < maxy):
        minx, miny, maxx, maxy = (-hw, -hh, hw, hh)

    width = (maxx - minx) + 2 * pad
    height = (maxy - miny) + 2 * pad

    def m(x: float, y: float) -> Tuple[float, float]:
        # Map model coords into SVG coords with padding and Y flip.
        return ((x - minx) + pad, (maxy - y) + pad)

    parts: List[str] = []

    # Outline
    # Build outline manually using the bbox mapping (so it stays consistent with pad/centering).
    # We'll reuse svg_path_plate_outline by temporarily shifting origin to the outline bbox center.
    outline_plate = PlateSpec(units=d.units, width=d.width, height=d.height, thickness=None, corner_radius=d.corner_radius, holes=[], slots=[])
    # svg_path_plate_outline assumes center-origin and then maps to [0..w]x[0..h]. Here we want
    # coordinates in our bbox-space; easiest is to generate points explicitly.
    # We'll just draw a rounded rect via <rect> in SVG for correctness.
    x0, y0 = m(-d.width / 2, d.height / 2)  # top-left
    parts.append(
        f"<rect x=\"{x0:.3f}\" y=\"{y0:.3f}\" width=\"{d.width:.3f}\" height=\"{d.height:.3f}\" rx=\"{d.corner_radius:.3f}\" ry=\"{d.corner_radius:.3f}\" fill=\"none\" stroke=\"black\" stroke-width=\"1.2\"/>")

    # Etch shapes
    for c in d.etch_circles:
        cx, cy = m(c.x, c.y)
        r = c.diameter / 2
        parts.append(f"<circle cx=\"{cx:.3f}\" cy=\"{cy:.3f}\" r=\"{r:.3f}\" fill=\"none\" stroke=\"#111\" stroke-width=\"0.8\"/>")

    for rr in d.etch_rounded_rects:
        # draw as SVG rounded rect
        x0, y0 = m(rr.x - rr.width / 2, rr.y + rr.height / 2)  # top-left after transform
        parts.append(
            f"<rect x=\"{x0:.3f}\" y=\"{y0:.3f}\" width=\"{rr.width:.3f}\" height=\"{rr.height:.3f}\" rx=\"{rr.radius:.3f}\" ry=\"{rr.radius:.3f}\" fill=\"none\" stroke=\"#111\" stroke-width=\"0.8\"/>")

    # Etch polylines
    for pl in d.etch_polylines:
        pts = [m(x, y) for (x, y) in pl.points]
        if not pts:
            continue
        dcmd = [f"M {pts[0][0]:.3f},{pts[0][1]:.3f}"]
        for (x, y) in pts[1:]:
            dcmd.append(f"L {x:.3f},{y:.3f}")
        if pl.closed:
            dcmd.append("Z")
        parts.append(f"<path d=\"{' '.join(dcmd)}\" fill=\"none\" stroke=\"#111\" stroke-width=\"0.8\" stroke-linejoin=\"round\"/>" )

    # Etch SVG paths (render directly in SVG too)
    for sp in d.etch_svg_paths:
        # Apply scale+translation with an SVG transform. Note: y-flip already handled by view mapping,
        # so we convert by wrapping in a group that maps from model coords to SVG coords.
        # Simplest: sample via our parser (keeps DXF+SVG consistent).
        for pts0 in parse_svg_path_d(sp.d):
            pts = [m((x * sp.scale) + sp.x, (y * sp.scale) + sp.y) for (x, y) in pts0]
            if not pts:
                continue
            dcmd = [f"M {pts[0][0]:.3f},{pts[0][1]:.3f}"]
            for (x, y) in pts[1:]:
                dcmd.append(f"L {x:.3f},{y:.3f}")
            dcmd.append("Z")
            parts.append(f"<path d=\"{' '.join(dcmd)}\" fill=\"none\" stroke=\"#111\" stroke-width=\"0.8\" stroke-linejoin=\"round\"/>")

    meta = f"units={d.units}; pad={pad}; bbox=({minx:.3f},{miny:.3f})-({maxx:.3f},{maxy:.3f})"
    svg = (
        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
        f"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width:.3f}\" height=\"{height:.3f}\" viewBox=\"0 0 {width:.3f} {height:.3f}\">\n"
        f"<!-- {meta} -->\n"
        + "\n".join(parts)
        + "\n</svg>\n"
    )

    with open(out_path, "w", encoding="utf-8") as f:
        f.write(svg)



def render_dxf_drawing(d: DrawingSpec, out_path: str) -> None:
    ents: List[str] = []

    # Outline as rounded-rect polyline
    w, h, r = d.width, d.height, d.corner_radius
    hw, hh = w / 2, h / 2

    if r <= 0:
        verts = [
            (-hw, -hh, 0.0),
            (hw, -hh, 0.0),
            (hw, hh, 0.0),
            (-hw, hh, 0.0),
        ]
        ents += dxf_lwpolyline(d.layer_outline, verts, closed=True)
    else:
        b = bulge_for_angle(90.0)
        verts = [
            (-hw + r, -hh, 0.0),
            (hw - r, -hh, b),
            (hw, -hh + r, 0.0),
            (hw, hh - r, b),
            (hw - r, hh, 0.0),
            (-hw + r, hh, b),
            (-hw, hh - r, 0.0),
            (-hw, -hh + r, b),
        ]
        ents += dxf_lwpolyline(d.layer_outline, verts, closed=True)

    # Etch circles (cameras, etc)
    for c in d.etch_circles:
        ents += dxf_circle(d.layer_etch, c.x, c.y, c.diameter / 2)

    # Etch polylines (logos, outlines)
    for pl in d.etch_polylines:
        verts = [(x, y, 0.0) for (x, y) in pl.points]
        ents += dxf_lwpolyline(d.layer_etch, verts, closed=pl.closed)

    # Etch SVG paths (sampled cubic-bezier to polyline)
    for sp in d.etch_svg_paths:
        subpaths = parse_svg_path_d(sp.d)
        for pts in subpaths:
            verts = [((x * sp.scale) + sp.x, (y * sp.scale) + sp.y, 0.0) for (x, y) in pts]
            ents += dxf_lwpolyline(d.layer_etch, verts, closed=True)

    # Etch rounded rects (buttons, camera island)
    for rr in d.etch_rounded_rects:
        w2, h2, r2 = rr.width, rr.height, rr.radius
        hw2, hh2 = w2 / 2, h2 / 2
        x0, y0 = rr.x, rr.y
        if r2 <= 0:
            verts = [
                (x0 - hw2, y0 - hh2, 0.0),
                (x0 + hw2, y0 - hh2, 0.0),
                (x0 + hw2, y0 + hh2, 0.0),
                (x0 - hw2, y0 + hh2, 0.0),
            ]
            ents += dxf_lwpolyline(d.layer_etch, verts, closed=True)
        else:
            b = bulge_for_angle(90.0)
            r2 = min(r2, hw2, hh2)
            verts = [
                (x0 - hw2 + r2, y0 - hh2, 0.0),
                (x0 + hw2 - r2, y0 - hh2, b),
                (x0 + hw2, y0 - hh2 + r2, 0.0),
                (x0 + hw2, y0 + hh2 - r2, b),
                (x0 + hw2 - r2, y0 + hh2, 0.0),
                (x0 - hw2 + r2, y0 + hh2, b),
                (x0 - hw2, y0 + hh2 - r2, 0.0),
                (x0 - hw2, y0 - hh2 + r2, b),
            ]
            ents += dxf_lwpolyline(d.layer_etch, verts, closed=True)

    content = "\n".join(dxf_header(d.units) + ents + dxf_footer()) + "\n"
    with open(out_path, "w", encoding="utf-8") as f:
        f.write(content)


def render_dxf_polyline(p: PolylineSpec, out_path: str) -> None:
    ents: List[str] = []
    verts = [(x, y, 0.0) for (x, y) in p.points]
    ents += dxf_lwpolyline(p.layer, verts, closed=p.closed)
    content = "\n".join(dxf_header(p.units) + ents + dxf_footer()) + "\n"
    with open(out_path, "w", encoding="utf-8") as f:
        f.write(content)


def render_svg_polyline(p: PolylineSpec, out_path: str) -> None:
    pad = 10.0 if p.units == "mm" else 0.4
    xs = [x for x, _ in p.points]
    ys = [y for _, y in p.points]
    minx, maxx = min(xs), max(xs)
    miny, maxy = min(ys), max(ys)
    width = (maxx - minx) + 2 * pad
    height = (maxy - miny) + 2 * pad

    def m(x: float, y: float) -> Tuple[float, float]:
        return ((x - minx) + pad, (maxy - y) + pad)

    pts = [m(x, y) for (x, y) in p.points]
    if not pts:
        raise ValueError("empty polyline")
    dcmd = [f"M {pts[0][0]:.3f},{pts[0][1]:.3f}"]
    for x, y in pts[1:]:
        dcmd.append(f"L {x:.3f},{y:.3f}")
    if p.closed:
        dcmd.append("Z")

    meta = f"units={p.units}; pad={pad}"
    svg = (
        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
        f"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width:.3f}\" height=\"{height:.3f}\" viewBox=\"0 0 {width:.3f} {height:.3f}\">\n"
        f"<!-- {meta} -->\n"
        f"<path d=\"{' '.join(dcmd)}\" fill=\"none\" stroke=\"black\" stroke-width=\"0.6\" stroke-linejoin=\"round\"/>\n"
        "</svg>\n"
    )
    with open(out_path, "w", encoding="utf-8") as f:
        f.write(svg)


def render_dxf(p: PlateSpec, out_path: str) -> None:
    ents: List[str] = []

    # Profile outline as LWPOLYLINE centered at origin.
    w, h, r = p.width, p.height, p.corner_radius
    hw, hh = w / 2, h / 2

    if r <= 0:
        verts = [
            (-hw, -hh, 0.0),
            (hw, -hh, 0.0),
            (hw, hh, 0.0),
            (-hw, hh, 0.0),
        ]
        ents += dxf_lwpolyline(p.layer_profile, verts, closed=True)
    else:
        # Rounded-rect outline as a single closed polyline with bulges on the arc segments.
        # Vertex bulge applies to the segment *starting* at that vertex.
        b = bulge_for_angle(90.0)  # quarter circle (CCW)
        verts = [
            (-hw + r, -hh, 0.0),           # bottom edge (straight)
            (hw - r, -hh, b),              # bottom-right corner arc to (hw, -hh + r)
            (hw, -hh + r, 0.0),            # right edge (straight)
            (hw, hh - r, b),               # top-right corner arc to (hw - r, hh)
            (hw - r, hh, 0.0),             # top edge (straight)
            (-hw + r, hh, b),              # top-left corner arc to (-hw, hh - r)
            (-hw, hh - r, 0.0),            # left edge (straight)
            (-hw, -hh + r, b),             # bottom-left corner arc to (-hw + r, -hh)
        ]
        ents += dxf_lwpolyline(p.layer_profile, verts, closed=True)

    # Holes as circles
    for hole in p.holes:
        ents += dxf_circle(p.layer_holes, hole.x, hole.y, hole.diameter / 2)

    # Slots: approximate using a polyline with 4 straight segments and 2 semicircle bulges.
    # Construct in local coords along X axis, then rotate.
    for s in p.slots:
        L, W = s.length, s.width
        rslot = W / 2
        # Tangent points (local)
        p1 = (-(L / 2 - rslot), -rslot)
        p2 = ((L / 2 - rslot), -rslot)
        p3 = ((L / 2), 0.0)
        p4 = ((L / 2 - rslot), rslot)
        p5 = (-(L / 2 - rslot), rslot)
        p6 = (-(L / 2), 0.0)
        # Bulge for semicircle: theta=180 => tan(45)=1
        bsemi = bulge_for_angle(180.0)

        def tr(x: float, y: float) -> Tuple[float, float]:
            xr, yr = rot(x, y, s.angle_deg)
            return (xr + s.x, yr + s.y)

        verts = [
            (*tr(*p1), 0.0),
            (*tr(*p2), bsemi),  # arc to p4 via p3
            (*tr(*p4), 0.0),
            (*tr(*p5), bsemi),  # arc to p1 via p6
        ]
        ents += dxf_lwpolyline(p.layer_holes, verts, closed=True)

    content = "\n".join(dxf_header(p.units) + ents + dxf_footer()) + "\n"
    with open(out_path, "w", encoding="utf-8") as f:
        f.write(content)


# ----------------------------
# CLI
# ----------------------------


def cmd_validate(args: argparse.Namespace) -> int:
    spec = load_spec(args.spec)
    kind = spec.get("kind")

    if kind == "plate":
        p = parse_plate(spec)
        validate_plate(p)
    elif kind == "polyline":
        p = parse_polyline(spec)
        validate_polyline(p)
    elif kind == "drawing":
        d = parse_drawing(spec)
        validate_drawing(d)
    else:
        raise ValueError(f"unsupported kind: {kind}")

    print("OK")
    return 0


def cmd_render(args: argparse.Namespace) -> int:
    spec = load_spec(args.spec)
    kind = spec.get("kind")

    outdir = args.outdir
    os.makedirs(outdir, exist_ok=True)

    base = os.path.splitext(os.path.basename(args.spec))[0]
    dxf_path = os.path.join(outdir, f"{base}.dxf")
    svg_path = os.path.join(outdir, f"{base}.svg")

    if kind == "plate":
        p = parse_plate(spec)
        validate_plate(p)
        render_dxf(p, dxf_path)
        render_svg(p, svg_path)
    elif kind == "polyline":
        p = parse_polyline(spec)
        validate_polyline(p)
        render_dxf_polyline(p, dxf_path)
        render_svg_polyline(p, svg_path)
    elif kind == "drawing":
        d = parse_drawing(spec)
        validate_drawing(d)
        render_dxf_drawing(d, dxf_path)
        render_svg_drawing(d, svg_path)
    else:
        raise ValueError(f"unsupported kind: {kind}")

    print(dxf_path)
    print(svg_path)
    return 0


def main(argv: List[str]) -> int:
    ap = argparse.ArgumentParser(prog="rfq_cad")
    sub = ap.add_subparsers(dest="cmd", required=True)

    ap_v = sub.add_parser("validate", help="validate a JSON spec")
    ap_v.add_argument("spec")
    ap_v.set_defaults(func=cmd_validate)

    ap_r = sub.add_parser("render", help="render DXF+SVG from a JSON spec")
    ap_r.add_argument("spec")
    ap_r.add_argument("--outdir", default="out")
    ap_r.set_defaults(func=cmd_render)

    args = ap.parse_args(argv)
    return int(args.func(args))


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))

```

### references/spec_schema.md

```markdown
# agentic-rfq-cad spec schema (v0)

This skill turns a **strict JSON spec** into **RFQ-ready** files:
- `*.dxf` (2D profile + holes/slots)
- `*.svg` (preview + alternate vector)

Design intent: generate deterministic, shop-friendly files. Keep the spec small and validate it.

## Units

All coordinates are in the specified `units`.
- `"mm"` recommended
- `"in"` allowed

Origin is the **center** of the part:
- +X right
- +Y up

## Plate spec (`kind: "plate"`)

```json
{
  "kind": "plate",
  "units": "mm",
  "width": 120,
  "height": 80,
  "thickness": 6.35,
  "corner_radius": 6,
  "holes": [
    {"x": -45, "y": -25, "diameter": 5.2},
    {"x":  45, "y": -25, "diameter": 5.2},
    {"x":  45, "y":  25, "diameter": 5.2},
    {"x": -45, "y":  25, "diameter": 5.2}
  ],
  "slots": [
    {"x": 0, "y": 0, "length": 30, "width": 6.5, "angle_deg": 0}
  ],
  "layers": {
    "profile": "CUT_OUTER",
    "holes": "CUT_INNER",
    "notes": "NOTES"
  }
}
```

### Fields

- `width`, `height` (required): outer size
- `thickness` (optional but strongly recommended): used for RFQ packet metadata
- `corner_radius` (optional, default 0)
- `holes[]` (optional): each is `{x, y, diameter}`
- `slots[]` (optional): each is `{x, y, length, width, angle_deg}` where:
  - `length`: center-to-center length including rounded ends
  - `width`: slot width
  - `angle_deg`: rotation (degrees) around origin

### Output conventions

- Layers (defaults):
  - `CUT_OUTER`: outer profile (closed)
  - `CUT_INNER`: holes + slots (closed)
  - `NOTES`: optional human metadata
- DXF profile: `LWPOLYLINE` (closed)
- DXF holes: `CIRCLE`
- DXF slots: `LWPOLYLINE` with semicircular ends
- SVG: minimal stroke-only geometry for preview

## Non-goals (v0)

- No bend lines / flat patterns yet
- No kerf compensation
- No full geometric self-intersection checks
- No STEP/STL generation (planned)

```

### references/test_prompts.md

```markdown
# Test prompts (for agents)

These prompts are intended to be converted into a strict JSON spec (see spec_schema.md), then rendered via:

```bash
python3 scripts/rfq_cad.py validate <spec.json>
python3 scripts/rfq_cad.py render <spec.json> --outdir out
```

## Prompt 1 — Universal mounting plate

"Design a 120mm x 80mm x 6.35mm aluminum mounting plate with 6mm corner radii. Add 4 mounting holes for M5 clearance (5.2mm) located 10mm from each edge. Add one horizontal cable slot centered on the plate: 30mm long x 6.5mm wide. Output DXF+SVG in mm."

## Prompt 2 — Camera/robot bracket plate

"Make a 100mm x 60mm x 3mm plate (5052 aluminum) with square corners. Put 4 holes for M4 clearance (4.5mm) on a 75mm x 35mm rectangle pattern centered on the plate. Add two vertical slots near the left and right edges: each slot 20mm x 6mm, centered at x=±40mm, y=0, rotated 90 degrees."

## Prompt 3 — Gusset-like plate

"Create a 150mm x 150mm x 6mm plate with 10mm corner radii. Add 6 holes: three M6 clearance (6.6mm) along y=+50mm at x=-50,0,+50 and three along y=-50 at x=-50,0,+50."

## Prompt 4 — Inch-based example

"Design a 4.0in x 2.0in x 0.25in plate with 0.125in corner radius. Add 4 holes for #10 clearance (0.201in) at 0.25in from each edge. Output DXF in inches."

```



---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### _meta.json

```json
{
  "owner": "ajmwagar",
  "slug": "create-dxf",
  "displayName": "Create Dxf",
  "latest": {
    "version": "0.1.1",
    "publishedAt": 1769952487919,
    "commit": "https://github.com/clawdbot/skills/commit/fc594602c9f08cfec1e4708b66298700f0a4feec"
  },
  "history": []
}

```

create-dxf | SkillHub