"""Compact gear outlines: arcs stay arcs, flanks are sampled to a tolerance.

A gear exported as a dense polyline extrudes into one planar face per segment,
which is what brings CAD to its knees: the 98-tooth ring was 6060 segments.

Three things fix that, and all of them make the geometry *more* accurate, not
less:

  1. Tip and root arcs are genuinely circular, so they are emitted as arcs
     (DXF bulge / SVG "A"). One cylindrical face each instead of a dozen flats.
  2. The outer rim is a circle entity, not a 180-gon.
  3. Involute flanks are sampled adaptively to a chord tolerance, so a gently
     curved flank uses three points instead of twenty-four.

Output is a vertex list of (x, y, bulge), where bulge = tan(sweep/4) describes
the arc from that vertex to the next, and 0 means a straight chord.
"""

import math

TAU = 2 * math.pi


def inv(a):
    return math.tan(a) - a


def polar(r, a):
    return (r * math.cos(a), r * math.sin(a))


def _involute(rb, r_from, r_to, n=64):
    def t_at(r):
        return math.sqrt(max(0.0, (r / rb) ** 2 - 1.0))
    t0, t1 = t_at(max(r_from, rb)), t_at(r_to)
    return [(rb * math.sqrt(1 + t * t), t - math.atan(t))
            for t in (t0 + (t1 - t0) * i / n for i in range(n + 1))]


def _decimate(pts, tol):
    """Douglas-Peucker: keep only the points a chord tolerance demands."""
    if len(pts) < 3:
        return pts
    x0, y0 = pts[0]
    x1, y1 = pts[-1]
    dx, dy = x1 - x0, y1 - y0
    den = math.hypot(dx, dy)
    worst, idx = -1.0, 0
    for i in range(1, len(pts) - 1):
        x, y = pts[i]
        d = (abs(dy * x - dx * y + x1 * y0 - y1 * x0) / den) if den > 1e-12 \
            else math.hypot(x - x0, y - y0)
        if d > worst:
            worst, idx = d, i
    if worst <= tol:
        return [pts[0], pts[-1]]
    return _decimate(pts[:idx + 1], tol)[:-1] + _decimate(pts[idx:], tol)


def _flank(rb, r_from, r_to, tol):
    pts = [polar(r, a) for r, a in _involute(rb, r_from, r_to)]
    return _decimate(pts, tol)


def _rot(pts, ang, mirror=False):
    c, s = math.cos(ang), math.sin(ang)
    out = []
    for x, y in pts:
        if mirror:
            y = -y
        out.append((x * c - y * s, x * s + y * c))
    return out


def bulge(sweep):
    return math.tan(sweep / 4.0)


def external(m, z, pa=20.0, backlash=0.0, addK=1.0, dedK=1.25, tol=0.01):
    a = math.radians(pa)
    r = m * z / 2.0
    rb, ra, rf = r * math.cos(a), r + addK * m, r - dedK * m
    half = math.pi / (2 * z) - (backlash / 2.0) / r
    delta = half + inv(a)

    prof = _involute(rb, max(rf, rb), ra)
    th_tip, th_root = prof[-1][1], prof[0][1]
    # Build one flank in local coords: angle measured as (delta - theta).
    flank = _decimate([polar(rad, -(delta - th)) for rad, th in prof], tol)

    verts = []
    for k in range(z):
        phi = TAU * k / z
        if rf < rb:
            verts.append((*polar(rf, phi - delta), 0.0))
        for x, y in _rot(flank, phi):                      # root -> tip, left
            verts.append((x, y, 0.0))
        tip_sweep = 2 * (delta - th_tip)
        verts[-1] = (verts[-1][0], verts[-1][1], bulge(tip_sweep))
        rev = list(reversed(_rot(flank, phi, mirror=True)))  # tip -> root, right
        for x, y in rev:
            verts.append((x, y, 0.0))
        if rf < rb:
            verts.append((*polar(rf, phi + delta), 0.0))
        e = delta if rf < rb else (delta - th_root)
        root_sweep = (TAU / z) - 2 * e
        verts[-1] = (verts[-1][0], verts[-1][1], bulge(root_sweep))
    return verts, dict(r=r, rb=rb, ra=ra, rf=rf)


def internal(m, z, pa=20.0, backlash=0.0, addK=1.0, dedK=1.25, tol=0.01,
             phase=None):
    a = math.radians(pa)
    r = m * z / 2.0
    rb, ra, rf = r * math.cos(a), r - addK * m, r + dedK * m
    half = math.pi / (2 * z) - (backlash / 2.0) / r
    delta = half - inv(a)
    if phase is None:
        phase = math.pi / z      # half a tooth, to mesh with the planets

    prof = _involute(rb, ra, rf)
    th_tip, th_root = prof[0][1], prof[-1][1]
    flank = _decimate([polar(rad, -(delta + th)) for rad, th in prof], tol)

    verts = []
    for k in range(z):
        phi = TAU * k / z + phase
        for x, y in reversed(_rot(flank, phi)):            # rim -> tip, left
            verts.append((x, y, 0.0))
        verts[-1] = (verts[-1][0], verts[-1][1], bulge(2 * (delta + th_tip)))
        for x, y in _rot(flank, phi, mirror=True):         # tip -> rim, right
            verts.append((x, y, 0.0))
        verts[-1] = (verts[-1][0], verts[-1][1],
                     bulge((TAU / z) - 2 * (delta + th_root)))
    return verts, dict(r=r, rb=rb, ra=ra, rf=rf)


# ------------------------------------------------------------------ writers

def _dxf_header(ents):
    """A complete R12 preamble.

    The old export had no TABLES section and put everything on an undefined
    layer called GEAR. Lenient readers invent the layer; strict ones silently
    drop the entities, which is how the bore circles went missing. Everything
    now goes on layer 0, which exists by definition, and the layer table is
    written out properly.
    """
    xs, ys = [], []
    for e in ents:
        if e[0] == "circle":
            (cx, cy), r = e[1], e[2]
            xs += [cx - r, cx + r]; ys += [cy - r, cy + r]
        else:
            xs += [v[0] for v in e[1]]; ys += [v[1] for v in e[1]]
    x0, x1 = (min(xs), max(xs)) if xs else (0, 0)
    y0, y1 = (min(ys), max(ys)) if ys else (0, 0)
    return (
        "0\nSECTION\n2\nHEADER\n"
        "9\n$ACADVER\n1\nAC1009\n"
        "9\n$INSUNITS\n70\n4\n"
        "9\n$MEASUREMENT\n70\n1\n"
        f"9\n$EXTMIN\n10\n{x0:.6f}\n20\n{y0:.6f}\n30\n0.0\n"
        f"9\n$EXTMAX\n10\n{x1:.6f}\n20\n{y1:.6f}\n30\n0.0\n"
        "0\nENDSEC\n"
        "0\nSECTION\n2\nTABLES\n"
        "0\nTABLE\n2\nLTYPE\n70\n1\n"
        "0\nLTYPE\n2\nCONTINUOUS\n70\n0\n3\nSolid line\n72\n65\n73\n0\n40\n0.0\n"
        "0\nENDTAB\n"
        "0\nTABLE\n2\nLAYER\n70\n1\n"
        "0\nLAYER\n2\n0\n70\n0\n62\n7\n6\nCONTINUOUS\n"
        "0\nENDTAB\n"
        "0\nENDSEC\n"
        "0\nSECTION\n2\nENTITIES\n")


def dxf(entities):
    """entities: list of ('poly', verts) or ('circle', (cx, cy), r)."""
    s = [_dxf_header(entities)]
    for e in entities:
        if e[0] == "circle":
            (cx, cy), r = e[1], e[2]
            s.append(f"0\nCIRCLE\n8\n0\n10\n{cx:.6f}\n20\n{cy:.6f}\n"
                     f"30\n0.0\n40\n{r:.6f}\n")
        else:
            s.append("0\nPOLYLINE\n8\n0\n66\n1\n70\n1\n"
                     "10\n0.0\n20\n0.0\n30\n0.0\n")
            for x, y, b in e[1]:
                s.append(f"0\nVERTEX\n8\n0\n10\n{x:.6f}\n20\n{y:.6f}\n30\n0.0\n")
                if abs(b) > 1e-12:
                    s.append(f"42\n{b:.8f}\n")
            s.append("0\nSEQEND\n8\n0\n")
    s.append("0\nENDSEC\n0\nEOF\n")
    return "".join(s)


def svg_path(verts):
    """Bulges become real SVG arcs, so CAD reimports them as arcs."""
    d = [f"M {verts[0][0]:.5f} {verts[0][1]:.5f}"]
    n = len(verts)
    for i in range(n):
        x0, y0, b = verts[i]
        x1, y1, _ = verts[(i + 1) % n]
        if abs(b) < 1e-12:
            d.append(f"L {x1:.5f} {y1:.5f}")
        else:
            sweep = 4 * math.atan(b)
            chord = math.hypot(x1 - x0, y1 - y0)
            r = abs(chord / (2 * math.sin(sweep / 2))) if abs(sweep) > 1e-9 else 0
            large = 1 if abs(sweep) > math.pi else 0
            sw = 1 if sweep > 0 else 0
            d.append(f"A {r:.5f} {r:.5f} 0 {large} {sw} {x1:.5f} {y1:.5f}")
    return " ".join(d) + " Z"


def svg(entities, extent, title=""):
    pad = 2.0
    size = 2 * (extent + pad)
    body = []
    for e in entities:
        if e[0] == "circle":
            (cx, cy), r = e[1], e[2]
            body.append(f'  <circle cx="{cx:.5f}" cy="{cy:.5f}" r="{r:.5f}" '
                        f'fill="none" stroke="#000" stroke-width="0.2"/>')
        else:
            body.append(f'  <path d="{svg_path(e[1])}" fill="none" '
                        f'stroke="#000" stroke-width="0.2"/>')
    return (f'<svg xmlns="http://www.w3.org/2000/svg" '
            f'width="{size:.3f}mm" height="{size:.3f}mm" '
            f'viewBox="{-extent-pad:.3f} {-extent-pad:.3f} {size:.3f} {size:.3f}">\n'
            f'  <title>{title}</title>\n' + "\n".join(body) + "\n</svg>\n")


if __name__ == "__main__":
    m, tol = 1.25, 0.01
    for name, z, fn in (("sun", 28, external), ("planet", 35, external),
                        ("ring", 98, internal)):
        v, g = fn(m, z, backlash=0.06, tol=tol)
        arcs = sum(1 for _, _, b in v if abs(b) > 1e-12)
        print(f"  {name:7s} {z:3d}T  {len(v):5d} vertices "
              f"({arcs} of them arcs, {len(v)-arcs} straight)  "
              f"{len(v)/z:.1f} per tooth")
