#!/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:]))
