"""Halloween bookmark STL -> bookmarks with tassel holes -> kerf-compensated, nested DXF.

Pipeline
  1. section the flat STL at mid-thickness -> 2D silhouettes (one per bookmark)
  2. square each up to its border, long side horizontal, origin bottom-left
  3. drop sub-kerf slivers a 0.15 mm laser cannot cut
  4. place a Ø4.0 mm tassel hole in the roomiest solid spot near an end
  5. kerf compensate by buffering the MATERIAL by +kerf/2 (outer out, holes in)
  6. nest onto the 300x300 DAJA A6 Pro bed, gap 3 mm, margin 5 mm
  7. write one DXF per sheet + a nesting preview
"""
import json
import numpy as np
import trimesh
import shapely
from shapely.geometry import Polygon, Point, box
from shapely import affinity
import ezdxf
from ezdxf.enums import TextEntityAlignment
from PIL import Image, ImageDraw
from scipy.ndimage import distance_transform_edt
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

SRC = "/Users/isaac/Documents/bookmarks_halloween.stl"
OUT = "/Users/isaac/Downloads/scarecrow-work/bookmarks"
KERF = 0.15
MIN_HOLE_W = 0.30
MIN_AREA = 0.02
DECIMATE = 0.004
TASSEL_D = 4.0          # mm ribbon hole
MIN_WALL = 1.2          # mm of material around the tassel hole
SHEET = (300.0, 300.0)  # DAJA A6 Pro bed
MARGIN = 5.0
GAP = 3.0

import os
os.makedirs(OUT, exist_ok=True)
rep = {"source": SRC, "kerf_mm": KERF, "tassel_hole_d_mm": TASSEL_D,
       "sheet_mm": list(SHEET), "gap_mm": GAP, "parts": []}


# ---------- 1. section ----------
m = trimesh.load(SRC)
z_mid = float(m.bounds[0][2] + m.extents[2] / 2)
sec = m.section(plane_origin=[0, 0, z_mid], plane_normal=[0, 0, 1])
p2, _ = sec.to_planar()
raw = sorted(p2.polygons_full, key=lambda p: -p.area)
rep["stl_thickness"] = round(float(m.extents[2]), 3)
rep["section_polys"] = len(raw)


# ---------- 2. square up ----------
def square(p):
    r = np.array(p.exterior.minimum_rotated_rectangle.exterior.coords)[:4]
    e = r[1] - r[0]
    a = np.degrees(np.arctan2(e[1], e[0]))
    while a > 45:
        a -= 90
    while a <= -45:
        a += 90
    q = affinity.rotate(p, -a, origin=(0, 0))
    q = shapely.set_precision(q, 0.001)
    if q.geom_type != "Polygon":
        q = max((g for g in getattr(q, "geoms", [q]) if g.geom_type == "Polygon"),
                key=lambda g: g.area)
    fr = Polygon(q.exterior).minimum_rotated_rectangle
    x0, y0, x1, y1 = fr.bounds
    if x1 - x0 < y1 - y0:                      # long side horizontal
        q = affinity.rotate(q, 90, origin="center")
        fr = Polygon(q.exterior).minimum_rotated_rectangle
        x0, y0, x1, y1 = fr.bounds
    return affinity.translate(q, -x0, -y0), (x1 - x0, y1 - y0)


def ring_width(p):
    if p.is_empty:
        return 0.0
    return 2.0 * shapely.maximum_inscribed_circle(p, tolerance=0.01).length


def clean_ring(coords, tol=DECIMATE):
    """drop collinear points, vectorised (single pass over original neighbours)"""
    c = np.asarray(coords, float)
    if np.allclose(c[0], c[-1]):
        c = c[:-1]
    if len(c) < 3:
        return c
    prev, nxt = np.roll(c, 1, axis=0), np.roll(c, -1, axis=0)
    v2 = nxt - prev
    L = np.hypot(v2[:, 0], v2[:, 1])
    L[L < 1e-12] = 1e-12
    cross = np.abs((c[:, 0] - prev[:, 0]) * v2[:, 1] - (c[:, 1] - prev[:, 1]) * v2[:, 0]) / L
    keep = cross > tol
    if keep.sum() < 3:
        keep = np.ones(len(c), bool)
    return c[keep]


def material_mask(poly, W, H, res=0.2, pad=6):
    """raster material, True = keep.  Padded with void so the panel edge
    counts as a cut edge in the distance transform (otherwise holes drift
    right up to the tip).  index (j,k) -> x=(k-pad)*res, y=(j-pad)*res."""
    nm = max(2, int(np.ceil(W / res)))
    nh = max(2, int(np.ceil(H / res)))
    img = Image.new("1", (nm, nh), 0)
    d = ImageDraw.Draw(img)
    inv = lambda ring: [(x / res, nh - y / res) for x, y in ring]
    d.polygon(inv(poly.exterior.coords), fill=1)
    for r in poly.interiors:
        d.polygon(inv(r.coords), fill=0)
    a = np.array(img, dtype=bool)[::-1, :]          # row 0 = y 0
    return np.pad(a, pad, constant_values=False), res, pad


# ---------- 3..5 per part ----------
def build_part(idx, raw_poly):
    poly, (W, H) = square(raw_poly)
    n_holes_raw = len(poly.interiors)
    kept = []
    for r in poly.interiors:
        p = Polygon(r)
        if ring_width(p) >= MIN_HOLE_W and p.area >= MIN_AREA:
            kept.append(np.array(r.coords))
    dropped = n_holes_raw - len(kept)

    # tassel hole: long side is X, so the ends are x~0 and x~W.
    # want it ~10 mm in from a tip and on the centre line; walk outwards
    # from there until there is enough material for the hole + wall.
    mask, res, pad = material_mask(Polygon(poly.exterior, kept), W, H)
    edt = distance_transform_edt(mask, sampling=res) if mask.any() else np.zeros_like(mask)
    need = TASSEL_D / 2 + MIN_WALL
    cand = None
    for end in ("right", "left"):
        for halfw in (0.22, 0.32, 0.45):
            r_lo = int(round(H * (0.5 - halfw) / res)) + pad
            r_hi = int(round(H * (0.5 + halfw) / res)) + pad
            r_hi = min(r_hi, edt.shape[0] - 1)
            for dist in (10, 8, 12, 6, 14, 5, 16, 20, 25):
                base = (W - dist) if end == "right" else dist
                for dx in (0, 0.5, -0.5, 1, -1, 1.5, -1.5, 2, -2):
                    xc = base + dx
                    if xc < need + 0.3 or xc > W - need - 0.3:
                        continue
                    c = int(round(xc / res)) + pad
                    c = min(max(c, 1), edt.shape[1] - 1)
                    col = edt[max(r_lo, 1):r_hi, c]
                    if col.size == 0:
                        continue
                    jj = int(np.argmax(col))
                    if col[jj] >= need:
                        j = max(r_lo, 1) + jj
                        cand = ((c - pad) * res, (j - pad) * res, float(col[jj]))
                        break
                if cand:
                    break
            if cand:
                break
        if cand:
            break
    if cand is None:
        j, k = np.unravel_index(np.argmax(edt), edt.shape)
        x0 = (k - pad) * res
        if edt[j, k] >= need and need < x0 < W - need:
            cand = (x0, (j - pad) * res, float(edt[j, k]))

    holes = list(kept)
    tassel = None
    if cand:
        x, y, clear = cand
        tassel = {"x": round(x, 2), "y": round(y, 2), "clearance": round(clear, 2),
                  "wall": round(clear - TASSEL_D / 2, 2)}
        hole = Point(x, y).buffer(TASSEL_D / 2, resolution=64)
        inside = shapely.contains(Polygon(poly.exterior, kept), hole.buffer(0.05))
        tassel["fully_inside"] = bool(inside)
        if inside:
            holes.append(np.array(hole.exterior.coords))
        else:
            tassel["rejected"] = True

    # kerf compensation: grow the MATERIAL by kerf/2 -> outer grows, holes shrink
    mat = Polygon(poly.exterior, holes).buffer(KERF / 2, join_style=1, quad_segs=8)
    if mat.geom_type != "Polygon":
        mat = max(mat.geoms, key=lambda g: g.area)
    mat = shapely.set_precision(mat, 0.001)

    rings_out = [clean_ring(mat.exterior.coords)]
    for r in mat.interiors:
        cr = clean_ring(r.coords)
        if len(cr) >= 3:
            rings_out.append(cr)

    # tassel hole ring in the compensated geometry, for reporting only
    rep["parts"].append({
        "index": idx, "size_mm": [round(W, 1), round(H, 1)],
        "cutouts_mesh": n_holes_raw, "cutouts_kept": len(kept),
        "subkerf_dropped": dropped,
        "tassel_hole": tassel,
        "contours": len(rings_out),
        "net_area_mm2": round(mat.area, 1),
    })
    bb = mat.bounds
    return {"idx": idx, "rings": rings_out, "w": bb[2] - bb[0], "h": bb[3] - bb[1],
            "poly": mat, "ox": bb[0], "oy": bb[1]}


raw = [p for p in raw if p.area > 300 and min(np.ptp(np.array(p.exterior.coords), axis=0)) > 8]
rep["parts_used"] = len(raw)
parts = [build_part(i, p) for i, p in enumerate(raw)]

# ---------- 6. nest: bottom-left, 0/90/180/270 ----------
def place(part, ang):
    """rings rotated about part centre then normalised to origin"""
    g = affinity.rotate(part["poly"], ang, origin="center")
    g = affinity.translate(g, -(g.bounds[0]), -(g.bounds[1]))
    return g


def nest(parts, sheet=SHEET, margin=MARGIN, gap=GAP, angles=(0, 180, 90, 270)):
    sw, sh = sheet
    # pre-rotate + pre-buffer each part once per angle (buffered = keep-off zone)
    cache = {}
    for p in parts:
        for ang in angles:
            g = place(p, ang)
            cache[(p["idx"], ang)] = (g, g.buffer(gap / 2, join_style=2))

    sheets, remaining = [], sorted(parts, key=lambda p: (-p["h"], -p["w"]))
    while remaining:
        placed, keepoff = [], []
        progress = True
        while progress:
            progress = False
            for p in list(remaining):
                best = None
                for ang in angles:
                    g, gk = cache[(p["idx"], ang)]
                    bw, bh = gk.bounds[2] - gk.bounds[0], gk.bounds[3] - gk.bounds[1]
                    E = 0.03
                    spots = [(margin - gk.bounds[0] + E, margin - gk.bounds[1] + E)]
                    for o in keepoff:
                        ob = o.bounds
                        spots += [(ob[2] - gk.bounds[0] + E, ob[1] - gk.bounds[1]),
                                  (ob[0] - gk.bounds[0], ob[3] - gk.bounds[1] + E),
                                  (margin - gk.bounds[0] + E, ob[3] - gk.bounds[1] + E)]
                    for (sx, sy) in sorted(spots, key=lambda s: (round(s[1], 3), round(s[0], 3))):
                        if sx < 0 or sy < 0:
                            continue
                        if sx + bw > sw - margin or sy + bh > sh - margin:
                            continue
                        cand = affinity.translate(gk, sx, sy)
                        if any(cand.intersects(o) for o in keepoff):
                            continue
                        best = (g, sx, sy, ang, cand)
                        break
                    if best:
                        break
                if best:
                    g, sx, sy, ang, cand = best
                    keepoff.append(cand)
                    placed.append({"part": p, "ang": ang, "x": round(sx, 3),
                                   "y": round(sy, 3), "geom": g})
                    remaining.remove(p)
                    progress = True
        sheets.append(placed)
        if not placed:            # part bigger than the bed: bail out
            break
    return sheets


sheets = nest(parts)
rep["sheets"] = len(sheets)

# ---------- 7. DXF per sheet ----------
files = []
for si, sheet in enumerate(sheets, 1):
    doc = ezdxf.new("R2010", setup=True)
    doc.units = ezdxf.units.MM
    doc.header["$INSUNITS"] = 4
    doc.header["$MEASUREMENT"] = 1
    msp = doc.modelspace()
    doc.layers.add("CUT", color=1)
    doc.layers.add("SHEET", color=8)
    msp.add_lwpolyline([(0, 0), (SHEET[0], 0), (SHEET[0], SHEET[1]), (0, SHEET[1])],
                       close=True, dxfattribs={"layer": "SHEET"})
    for pl in sheet:
        g = affinity.translate(pl["geom"], pl["x"], pl["y"])
        rings = [np.array(g.exterior.coords)] + [np.array(r.coords) for r in g.interiors]
        for r in rings:
            msp.add_lwpolyline([(float(a), float(b)) for a, b in clean_ring(r)],
                               close=True, dxfattribs={"layer": "CUT"})
    msp.add_text(f"HALLOWEEN BOOKMARKS sheet {si}/{len(sheets)} | kerf {KERF} | "
                 f"{len(sheet)} parts | 1:1 mm", height=2.5,
                 dxfattribs={"layer": "SHEET"}).set_placement(
        (MARGIN, 1.0), align=TextEntityAlignment.LEFT)
    f = f"{OUT}/bookmarks-sheet{si}.dxf"
    # The header $EXTMIN/$EXTMAX are copied from the MODELSPACE extents on save
    # (ezdxf document.update_extents), and a (0,0,0) extent is falsy so it is
    # skipped - the file then keeps the 1e20 "no extents" sentinel and viewers
    # that fit to the header bbox (the portfolio DXF preview does) draw nothing.
    msp.dxf.extmin = (-0.5, -0.5, 0.0)
    msp.dxf.extmax = (SHEET + 0.5, SHEET + 0.5, 0.0)
    msp.dxf.limmin = (0.0, 0.0)
    msp.dxf.limmax = (SHEET, SHEET)
    doc.saveas(f)
    files.append(f)
    d2 = ezdxf.readfile(f)
    used = sum(pl["part"]["poly"].area for pl in sheet)
    # --- verification on the exported file ---
    gs = [affinity.translate(pl["geom"], pl["x"], pl["y"]) for pl in sheet]
    overlap = 0.0
    for i in range(len(gs)):
        for j in range(i + 1, len(gs)):
            overlap += gs[i].intersection(gs[j]).area
    cleared = []
    for i in range(len(gs)):
        for j in range(i + 1, len(gs)):
            cleared.append(gs[i].distance(gs[j]))
    out_of_bed = [pl for pl in sheet
                  if affinity.translate(pl["geom"], pl["x"], pl["y"]).bounds[2] > SHEET[0]
                  or affinity.translate(pl["geom"], pl["x"], pl["y"]).bounds[3] > SHEET[1]]
    rep.setdefault("sheet_detail", []).append({
        "sheet": si, "parts": len(sheet), "polylines": len(d2.modelspace().query("LWPOLYLINE")),
        "audit_errors": len(d2.audit().errors),
        "part_area_cm2": round(used / 100, 2),
        "sheet_use_pct": round(100 * used / (SHEET[0] * SHEET[1]), 1),
        "pair_overlap_mm2": round(overlap, 4),
        "min_gap_mm": round(min(cleared), 3) if cleared else None,
        "off_bed_parts": len(out_of_bed),
        "bbox_ok": all(pl["x"] >= 0 and pl["y"] >= 0 for pl in sheet),
    })

# ---------- preview ----------
fig, axes = plt.subplots(1, len(sheets), figsize=(6.2 * len(sheets), 6.6))
axes = np.atleast_1d(axes)
for ax, sheet, si in zip(axes, sheets, range(1, len(sheets) + 1)):
    ax.add_patch(plt.Rectangle((0, 0), *SHEET, fill=False, ec="#888", lw=1.2, ls="--"))
    for pl in sheet:
        g = affinity.translate(pl["geom"], pl["x"], pl["y"])
        ax.fill(*np.array(g.exterior.coords).T, color="#f0d9a8", ec="#8a6d2f", lw=0.6, zorder=1)
        for r in g.interiors:
            c = np.array(r.coords)
            ax.fill(c[:, 0], c[:, 1], color="white", ec="#8a6d2f", lw=0.3, zorder=2)
    ax.set_xlim(-6, SHEET[0] + 6)
    ax.set_ylim(-6, SHEET[1] + 6)
    ax.set_aspect("equal")
    ax.set_title(f"sheet {si}: {len(sheet)} bookmarks", fontsize=11)
    ax.set_xticks([]); ax.set_yticks([])
fig.suptitle(f"Halloween bookmarks — 300x300 bed, kerf {KERF} mm, {len(parts)} parts", fontsize=13)
fig.savefig(f"{OUT}/nest-preview.png", dpi=130, bbox_inches="tight", facecolor="white")

rep["files"] = files
open(f"{OUT}/report.json", "w").write(json.dumps(rep, indent=2))
print(json.dumps(rep, indent=2))
