# -*- coding: utf-8 -*-
"""
RAP — Rhino Watcher  v3.0 (web edition)
=======================================
This script runs inside Rhino (Rhino 8, Python 3). It watches the JSON
state file written by RAP Studio (schema `rhino_controller_v4.0`) and,
every time the file changes, clears all JIG_* geometry and redraws from
scratch. Ported from the desktop Rhino Controller watcher v2.3 and
extended to rebuild the web-native nouns the desktop version ignored.

The watcher never writes to the JSON file (single exception: clearing
the one-shot tactile3d `_export_once` flag). Communication is one-way:
RAP Studio (via the RAP Bridge) writes, the watcher reads.

Run inside Rhino's ScriptEditor, or:
    STATE_FILE = r"C:/path/to/state.json"   # optional pre-set
    exec(open(r"C:/path/to/rap_watcher.py").read())

2D layers (classic bay jig — unchanged from desktop v2.3):
    JIG_SITE         Site boundary (rectangle OR irregular lot polygon)
    JIG_ZONES        Zone boundary polygons
    JIG_GRID         Global structural grid lines
    JIG_BACKGROUND   White masks for z-order overlaps
    JIG_HATCHES      Room fill patterns for tactile output
    JIG_BAYS         Structural gridlines
    JIG_COLUMNS      Column squares at grid intersections
    JIG_PLAN         Wall outlines with aperture cutouts
    JIG_BLOCKS       Aperture symbols and room labels
    JIG_CORRIDOR     Corridor edges, centerlines, hatch fills
    JIG_VOIDS        Void outlines (rectangles or circles)
    JIG_ROOMS        Cell room boundaries, labels, areas, hatches
    JIG_LABELS       Bay labels in English and Braille
    JIG_LEGEND       Braille plus English legend key box

3D layers:
    JIG_TACTILE3D    Extruded bay walls, floor slab, clipping plane
    JIG_WEB::<layer> Web-native solids (floor plates, extruded boxes,
                     free walls with openings, free columns). One
                     sublayer per RAP Studio CAD layer, so the studio's
                     layer table survives the trip into Rhino.

PHASE LAYER SCHEME (documented choice):
    When the state defines MORE THAN ONE design phase (`web_phases`),
    every web-native element is placed on a phase leaf UNDER its
    geometry layer:
        JIG_WEB::<layer>::JIG_PHASE_<Phase Name>
    Why this scheme: (1) the classic JIG_* layers stay untouched, so
    bay-jig geometry and every desktop workflow keep working; (2) one
    click on JIG_WEB::<layer> still hides that whole CAD layer (Rhino
    parent visibility wins); (3) each phase is toggleable via layer
    visibility — filter the Layers panel by "JIG_PHASE_<name>" and flip
    the matching leaves in one gesture. Phases whose `visible` field is
    "hidden" are still BUILT, then their leaves are switched off — the
    geometry is never lost, it is one click away.
    Bay-jig geometry ALWAYS stays on the classic JIG_* layers; the
    bay→phase map (`web_bay_phase`) is echoed in the status summary but
    never moves bays.

LINEWEIGHT / LINETYPE:
    `state.layers[<name>].linetype` maps onto Rhino's stock linetypes
    (solid→Continuous, dashed→Dashed, dotted→Dots, center→Center,
    hidden→Hidden) via rs.LayerLinetype, and `lineweight_mm` is applied
    as the layer print width via rs.LayerPrintWidth. Both are
    best-effort: silently skipped if the doc template lacks the
    linetype or the rhinoscriptsyntax build lacks LayerPrintWidth.

UNITS:
    The state file is in FEET. Geometry is drawn 1:1 in model units,
    exactly like desktop v2.3 — use a feet-based Rhino template for
    true-size models.

CHANGES vs desktop v2.3 (each deliberate):
    * NEW: rebuilds `web_regions` (floor plates as extruded slabs at
      their level's z, thickness default 0.5 ft; boxes as extruded
      massing volumes), `web_walls` (extruded wall strips split around
      `web_openings` — doors cut to the floor with a header above,
      windows get sill + header solids, portals are full-height gaps),
      `web_columns` (square extruded columns), and the irregular
      `site.corners` lot polygon (previously only a rectangle).
    * TCP query listener is ENABLED at startup. v2.3 shipped with it
      commented out because IronPython's rhinoscriptsyntax is not
      thread-safe; Rhino 8 runs CPython and the listener never calls
      rs.* anyway (it serves stats cached on the main thread).
    * `{"type":"status"}` now answers with ok / objects / per-layer
      counts / rebuilt_at / schema (the RAP Bridge relays ping+status).
    * Desktop channel-server hooks trimmed (object_inventory.json,
      pending_edits.json, pending_script.py) — the web loop talks
      through the RAP Bridge instead.
    * `_draw_voids` skips bays with a null void_center (web bays often
      have no void; v2.3 assumed one was always present).
    * IN_RHINO=False no longer skips the build: every geometry call
      goes through a thin wrapper that records (layer, kind) into a
      build log, so the full state→build plan runs headless. That
      powers `--selftest <state.json>`: load a fixture, run one
      rebuild, print a JSON summary, exit.
"""
import io, json, math, os, subprocess, sys, time, threading

try:
    import rhinoscriptsyntax as rs
    import Rhino
    import scriptcontext as sc
    IN_RHINO = True
except ImportError:
    IN_RHINO = False
    print("[WARN] Not in Rhino. Geometry calls are recorded to a build log only.")

# ── Configuration ──────────────────────────────────────────

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) if '__file__' in dir() else os.getcwd()
# Allow STATE_FILE to be pre-set before exec(), e.g.:
#   STATE_FILE = r"C:\path\to\rap-project\state.json"
#   exec(open(r"C:\path\to\rap_watcher.py").read())
if 'STATE_FILE' not in dir() or not globals().get('STATE_FILE'):
    _candidate = os.path.join(SCRIPT_DIR, "state.json")
    _cwd_candidate = os.path.join(os.getcwd(), "state.json")
    if os.path.exists(_candidate):
        STATE_FILE = _candidate
    elif os.path.exists(_cwd_candidate):
        STATE_FILE = _cwd_candidate
    else:
        # Default: next to script (will show helpful error later)
        STATE_FILE = _candidate
POLL_SEC = 0.5

LAYERS = [
    "JIG_SITE", "JIG_ZONES", "JIG_GRID", "JIG_BACKGROUND", "JIG_HATCHES",
    "JIG_BAYS", "JIG_COLUMNS", "JIG_PLAN", "JIG_BLOCKS", "JIG_CORRIDOR",
    "JIG_VOIDS", "JIG_ROOMS", "JIG_LABELS", "JIG_LEGEND", "JIG_TACTILE3D",
]

LAYER_COLORS = {
    "JIG_SITE":       (80, 80, 80),
    "JIG_ZONES":      (60, 120, 60),
    "JIG_GRID":       (180, 180, 180),
    "JIG_BACKGROUND": (255,255,255),
    "JIG_HATCHES":    (200,200,200),
    "JIG_BAYS":       (0, 0, 0),
    "JIG_COLUMNS":    (0, 0, 0),
    "JIG_PLAN":       (40, 40, 40),
    "JIG_BLOCKS":     (0, 0, 0),
    "JIG_CORRIDOR":   (100, 100, 100),
    "JIG_VOIDS":      (0, 0, 0),
    "JIG_LABELS":     (0, 0, 0),
    "JIG_LEGEND":     (0, 0, 0),
    "JIG_TACTILE3D":  (180, 60, 60),
}

# Parent layer for all web-native geometry + its default color
WEB_PARENT_LAYER = "JIG_WEB"
WEB_LAYER_COLOR = (40, 90, 160)

# Window sill height (ft) when splitting free walls around window
# openings. Clamped down when sill + opening height would exceed the
# wall height.
WINDOW_SILL_FT = 3.0

# state.layers linetype -> Rhino stock linetype name
LINETYPE_MAP = {
    "solid": "Continuous", "dashed": "Dashed", "dotted": "Dots",
    "center": "Center", "hidden": "Hidden",
}

# ── Audio feedback ────────────────────────────────────────
# Short audio signals so a blind user knows the rebuild happened
# without needing to read the Rhino command line.
#
# AUDIO_MODE controls what plays after each rebuild:
#   "chime"  — short system beep only (fast, non-blocking)
#   "speak"  — spoken summary via native TTS (e.g. "2 bays, 1 door")
#   "both"   — chime then speak
#   "none"   — silent (screen reader users who read Rhino output)
AUDIO_MODE = os.environ.get("RHINO_CONTROLLER_AUDIO",
             os.environ.get("LAYOUT_JIG_AUDIO", "both"))
SPEAK_RATE = int(os.environ.get("RHINO_CONTROLLER_SPEAK_RATE",
                 os.environ.get("LAYOUT_JIG_SPEAK_RATE", "3")))


def _chime():
    """Play a short system beep. Non-blocking, fire-and-forget."""
    try:
        if sys.platform == "darwin":
            subprocess.Popen(
                ["afplay", "/System/Library/Sounds/Glass.aiff"],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        else:
            subprocess.Popen(
                ["powershell", "-NoProfile", "-Command",
                 "[System.Console]::Beep(880, 120)"],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    except Exception:
        pass


def _speak(text):
    """Speak text via the platform's native TTS.

    macOS uses `say`; Windows uses PowerShell SpeechSynthesizer.
    Non-blocking (fire-and-forget). Uses SPEAK_RATE for speed.
    """
    try:
        if sys.platform == "darwin":
            # `say` rate is words-per-minute. Map SPEAK_RATE (-10..10) -> ~120..280.
            wpm = max(90, min(320, 175 + int(SPEAK_RATE) * 15))
            subprocess.Popen(
                ["say", "-r", str(wpm), text],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            return
        escaped = text.replace("'", "''")
        ps_cmd = (
            "Add-Type -AssemblyName System.Speech;"
            "$s=New-Object System.Speech.Synthesis.SpeechSynthesizer;"
            "$s.Rate={0};"
            "$s.Speak('{1}')").format(SPEAK_RATE, escaped)
        subprocess.Popen(
            ["powershell", "-NoProfile", "-Command", ps_cmd],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    except Exception:
        pass


def _audio_feedback(state):
    """Play audio after a successful rebuild (Rhino sessions only)."""
    if not IN_RHINO:
        return
    if AUDIO_MODE == "none":
        return

    if AUDIO_MODE in ("chime", "both"):
        _chime()

    if AUDIO_MODE in ("speak", "both"):
        nbays = len(state.get("bays", {}))
        total_aps = sum(len(b.get("apertures", []))
                        for b in state.get("bays", {}).values())
        nweb = (len(state.get("web_regions", []) or []) +
                len(state.get("web_walls", []) or []) +
                len(state.get("web_columns", []) or []))
        parts = []
        parts.append("{0} bay{1}".format(nbays, "" if nbays == 1 else "s"))
        if total_aps:
            parts.append("{0} aperture{1}".format(
                total_aps, "" if total_aps == 1 else "s"))
        if nweb:
            parts.append("{0} web element{1}".format(
                nweb, "" if nweb == 1 else "s"))
        _speak("Rebuilt. " + ", ".join(parts) + ".")

# Map user-facing hatch names to Rhino's built-in hatch patterns
HATCH_MAP = {"diagonal": "Hatch1", "crosshatch": "Grid", "dots": "Dots",
             "horizontal": "Dash", "solid": "Solid"}

# ══════════════════════════════════════════════════════════
# STYLE + STATE HELPERS
# ══════════════════════════════════════════════════════════

def _s(state, key, default):
    """Read a style parameter from state['style'], with fallback."""
    return state.get("style", {}).get(key, default)

def _watcher_default_state():
    """Minimal defaults so a partial hand-edited state.json doesn't crash
    the redraw loop. Keys here mirror those the watcher actually reads.
    RAP Studio's exporter is the authoritative writer; this is a safety
    net for files that bypass it."""
    return {
        "schema": "rhino_controller_v4.0",
        "meta": {},
        "bays": {},
        "rooms": {},
        "zones": {},
        "grid": None,
        "site": {"origin": [0.0, 0.0], "width": 180.0, "height": 260.0},
        "style": {},
        "blocks": {
            "door": {}, "window": {}, "portal": {}, "room": {},
        },
        "legend": {"enabled": False},
        "tactile3d": {"enabled": False},
        "section": {"axis": None, "offset": None},
        "hatch_library_path": "./hatches/",
        "print": {},
        "bambu": {},
        # Multi-level support (v4.0). Old single-level state.json files
        # are filled with one "ground" level so multi-level draw code
        # treats them as a 1-level model without crashing.
        "levels": [{"name": "ground", "z": 0.0, "label": "Ground floor"}],
        "slabs": [],
        # First-class CAD layers (RAP Studio's layer table).
        "layers": {},
        # Web-native nouns (RAP Studio free elements + phases).
        "web_walls": [],
        "web_columns": [],
        "web_openings": [],
        "web_regions": [],
        "web_phases": [],
        "web_focus": "composite",
        "web_bay_phase": {},
    }


def _watcher_merge_defaults(target, defaults):
    """Recursively fill missing keys in target from defaults."""
    for k, v in defaults.items():
        if k not in target:
            if isinstance(v, dict):
                target[k] = {}
                _watcher_merge_defaults(target[k], v)
            elif isinstance(v, list):
                target[k] = list(v)
            else:
                target[k] = v
        elif isinstance(v, dict):
            if not isinstance(target[k], dict):
                # Wrong-type value (e.g., string where dict expected) — replace
                target[k] = {}
            _watcher_merge_defaults(target[k], v)
    return target


def _load_state():
    """Load state.json with graceful recovery.

    Returns None if the file is missing or unrecoverable (caller skips
    redraw). Returns a complete dict with defaults filled in for any
    missing or wrong-type top-level keys, so a hand-edited partial JSON
    file loads without crashing the redraw pipeline.
    """
    if not os.path.exists(STATE_FILE):
        return None
    try:
        with open(STATE_FILE, "rb") as f:
            state = json.loads(f.read().decode("utf-8"))
    except (ValueError, IOError) as e:
        print("[RC] state.json parse error: {0}".format(e))
        return None
    if not isinstance(state, dict):
        print("[RC] state.json is not a JSON object; skipping rebuild.")
        return None
    _watcher_merge_defaults(state, _watcher_default_state())
    return state


def _state_mtime():
    try: return os.stat(STATE_FILE).st_mtime
    except: return 0

# ══════════════════════════════════════════════════════════
# GEOMETRY HELPERS
# ══════════════════════════════════════════════════════════

def _get_spacing_arrays(bay):
    nx, ny = bay["bays"]
    sx_a = bay.get("spacing_x"); sy_a = bay.get("spacing_y")
    if sx_a and len(sx_a) == nx:
        cx = [0.0]
        for s in sx_a: cx.append(cx[-1] + s)
    else:
        s = bay["spacing"][0]; cx = [i * s for i in range(nx + 1)]
    if sy_a and len(sy_a) == ny:
        cy = [0.0]
        for s in sy_a: cy.append(cy[-1] + s)
    else:
        s = bay["spacing"][1]; cy = [j * s for j in range(ny + 1)]
    return cx, cy

def _local_to_world(lx, ly, origin, rot_deg):
    r = math.radians(rot_deg)
    wx = origin[0] + lx * math.cos(r) - ly * math.sin(r)
    wy = origin[1] + lx * math.sin(r) + ly * math.cos(r)
    return (wx, wy, 0)

def _arc_points(cx, cy, radius, start_deg, end_deg, n=24):
    pts = []
    for i in range(n + 1):
        a = math.radians(start_deg + (end_deg - start_deg) * i / n)
        pts.append((cx + radius * math.cos(a), cy + radius * math.sin(a), 0))
    return pts

# ══════════════════════════════════════════════════════════
# LAYER MANAGEMENT + BUILD LOG
# ══════════════════════════════════════════════════════════
# Every drawing primitive goes through a thin wrapper. In Rhino the
# wrapper calls rhinoscriptsyntax; outside Rhino it records a
# (layer, kind) tuple into BUILD_LOG so the full state→build plan can
# run (and be asserted) headless.

BUILD_LOG = []            # [(layer_full_path, kind), ...] — reset each rebuild
_WEB_STATS = {}           # extension counters — reset each rebuild
_CURRENT_LAYER = ["JIG_SITE"]
_DYNAMIC_LAYERS = []      # web layer full paths ensured this session (ordered)


def _record(kind):
    BUILD_LOG.append((_CURRENT_LAYER[0], kind))


def _bump(key, n=1):
    _WEB_STATS[key] = _WEB_STATS.get(key, 0) + n


def _ensure_layers():
    if not IN_RHINO: return
    for name in LAYERS:
        if not rs.IsLayer(name):
            rs.AddLayer(name, LAYER_COLORS.get(name, (0,0,0)))


def _ensure_layer_path(path, color=None):
    """Ensure a (possibly nested `parent::child`) layer path exists."""
    if path not in _DYNAMIC_LAYERS and path not in LAYERS:
        _DYNAMIC_LAYERS.append(path)
    if not IN_RHINO:
        return path
    parts = path.split("::")
    full = ""
    for part in parts:
        parent = full if full else None
        full = part if not full else full + "::" + part
        if not rs.IsLayer(full):
            rs.AddLayer(part, color or WEB_LAYER_COLOR, parent=parent)
    return path


def _all_jig_layers():
    """Every JIG layer that exists right now (classic + dynamic web)."""
    names = list(LAYERS)
    if IN_RHINO:
        try:
            for lname in (rs.LayerNames() or []):
                if lname.startswith("JIG_") and lname not in names:
                    names.append(lname)
        except Exception:
            pass
    for lname in _DYNAMIC_LAYERS:
        if lname not in names:
            names.append(lname)
    return names


def _clear_layer(name):
    if not IN_RHINO: return
    if not rs.IsLayer(name): return
    objs = rs.ObjectsByLayer(name)
    if objs: rs.DeleteObjects(objs)


def _clear_all():
    # Clear every JIG layer, including web sublayers left by an earlier
    # rebuild whose elements have since been deleted in the studio.
    for name in _all_jig_layers():
        _clear_layer(name)
    del BUILD_LOG[:]
    _WEB_STATS.clear()


def _set_layer(name):
    _CURRENT_LAYER[0] = name
    if IN_RHINO:
        if "::" in name or not rs.IsLayer(name):
            _ensure_layer_path(name)
        rs.CurrentLayer(name)

# ══════════════════════════════════════════════════════════
# DRAWING PRIMITIVES
# ══════════════════════════════════════════════════════════

def _add_line(p1, p2):
    _record("line")
    if IN_RHINO: return rs.AddLine(p1, p2)

def _add_rect(x0, y0, x1, y1):
    _record("rect")
    if IN_RHINO:
        pts = [(x0,y0,0),(x1,y0,0),(x1,y1,0),(x0,y1,0),(x0,y0,0)]
        return rs.AddPolyline(pts)

def _add_polyline(pts):
    if len(pts) < 2: return None
    _record("polyline")
    if IN_RHINO: return rs.AddPolyline(pts)

def _add_circle(cx, cy, r):
    _record("circle")
    if IN_RHINO: return rs.AddCircle((cx, cy, 0), r)

def _add_text(txt, pt, height, font="Arial"):
    _record("text")
    if IN_RHINO: return rs.AddText(txt, pt, height, font)

def _add_text_dot(txt, pt):
    _record("textdot")
    if IN_RHINO: return rs.AddTextDot(txt, pt)

def _add_hatch(boundary_id, pattern="Solid", scale=1.0, rotation=0.0):
    _record("hatch")
    if not IN_RHINO: return None
    try: return rs.AddHatch(boundary_id, pattern, scale, rotation)
    except: return None

def _add_surface_fill(boundary_id):
    _record("surface")
    if not IN_RHINO: return None
    try:
        srf = rs.AddPlanarSrf(boundary_id)
        if srf: return srf[0]
    except: pass
    return None

def _add_extrusion(pts2d, z0, height, kind="extrusion"):
    """Closed planar profile at z0, extruded straight up by `height`.

    `pts2d` is an OPEN list of (x, y) corner tuples; the profile is
    closed automatically. Returns the capped solid's id (Rhino) or None.
    """
    if height <= 1e-9 or len(pts2d) < 3:
        return None
    _record(kind)
    if not IN_RHINO:
        return None
    pts = [(p[0], p[1], z0) for p in pts2d]
    pts.append(pts[0])
    profile = rs.AddPolyline(pts)
    if not profile:
        print("[RC] AddPolyline failed for {0}".format(kind))
        return None
    brep = rs.ExtrudeCurveStraight(profile, (0, 0, 0), (0, 0, height))
    rs.DeleteObject(profile)
    if not brep:
        print("[RC] ExtrudeCurveStraight failed for {0}".format(kind))
        return None
    rs.CapPlanarHoles(brep)
    return brep

def _add_dashed_line(p1, p2, dash_len, gap_len):
    """Draw a dashed line between two 3D points."""
    dx = p2[0]-p1[0]; dy = p2[1]-p1[1]
    total = math.hypot(dx, dy)
    if total < 0.001: return
    ux = dx/total; uy = dy/total
    pos = 0.0
    while pos < total:
        end = min(pos + dash_len, total)
        _add_line((p1[0]+ux*pos, p1[1]+uy*pos, 0),
                  (p1[0]+ux*end, p1[1]+uy*end, 0))
        pos = end + gap_len

# ══════════════════════════════════════════════════════════
# DRAW: SITE BOUNDARY
# ══════════════════════════════════════════════════════════

def _draw_site(state):
    """Site boundary. RAP Studio exports `site.corners` — the irregular
    lot polygon when one exists, else the four rectangle corners. Draw
    the real closed polyline whenever ≥3 corners are given; fall back
    to the classic origin/width/height rectangle otherwise."""
    _set_layer("JIG_SITE")
    site = state["site"]
    corners = site.get("corners") or site.get("boundary")
    if corners and len(corners) >= 3:
        pts = [(c[0], c[1], 0) for c in corners]
        pts.append(pts[0])
        _add_polyline(pts)
        _WEB_STATS["site_boundary_points"] = len(corners)
        _WEB_STATS["site_boundary_closed"] = True
    else:
        ox, oy = site["origin"]
        _add_rect(ox, oy, ox + site["width"], oy + site["height"])
        _WEB_STATS["site_boundary_points"] = 4
        _WEB_STATS["site_boundary_closed"] = True

# ══════════════════════════════════════════════════════════
# DRAW: GLOBAL STRUCTURAL GRID
# ══════════════════════════════════════════════════════════

def _draw_global_grid(state):
    """Draw global structural grid lines."""
    _set_layer("JIG_GRID")
    grid = state.get("grid")
    if not grid:
        return
    spacing = grid.get("spacing", 0)
    if spacing <= 0:
        return
    site = state.get("site", {})
    ox, oy = site.get("origin", [0, 0])
    w = site.get("width", 180)
    h = site.get("height", 260)

    x = ox
    while x <= ox + w:
        _add_line((x, oy, 0), (x, oy + h, 0))
        x += spacing
    y = oy
    while y <= oy + h:
        _add_line((ox, y, 0), (ox + w, y, 0))
        y += spacing

# ══════════════════════════════════════════════════════════
# DRAW: ZONE BOUNDARIES
# ══════════════════════════════════════════════════════════

def _draw_zones(state):
    """Draw zone boundary polygons."""
    _set_layer("JIG_ZONES")
    zones = state.get("zones", {})
    for zname in sorted(zones.keys()):
        zdata = zones[zname]
        corners = zdata.get("corners", [])
        if len(corners) < 3:
            continue
        pts = [(c[0], c[1], 0) for c in corners]
        pts.append((corners[0][0], corners[0][1], 0))  # close
        obj = _add_polyline(pts)
        if obj and IN_RHINO:
            rs.SetUserText(obj, "JIG_OWNER", "PLJ")
            rs.SetUserText(obj, "JIG_ID", "zone_{0}".format(zname))
        # Label at centroid
        label = zdata.get("label", zname)
        if label:
            cx = sum(c[0] for c in corners) / len(corners)
            cy = sum(c[1] for c in corners) / len(corners)
            _add_text_dot(label, (cx, cy, 0))

# ══════════════════════════════════════════════════════════
# DRAW: Z-ORDER BACKGROUND MASKS
# ══════════════════════════════════════════════════════════

def _draw_background_masks(state):
    _set_layer("JIG_BACKGROUND")
    bays = state["bays"]
    pad = _s(state, "background_pad", 2.0)
    sorted_bays = sorted(bays.items(), key=lambda x: x[1].get("z_order", 0))
    for name, bay in sorted_bays:
        if bay.get("z_order", 0) <= 0: continue
        gt = bay.get("grid_type", "rectangular")
        ox, oy = bay["origin"]; rot = bay["rotation_deg"]
        if gt == "rectangular":
            cx, cy = _get_spacing_arrays(bay)
            w, h = cx[-1], cy[-1]
            corners = [(-pad,-pad),(w+pad,-pad),(w+pad,h+pad),(-pad,h+pad),(-pad,-pad)]
            world_pts = [_local_to_world(lx,ly,(ox,oy),rot) for lx,ly in corners]
            boundary = _add_polyline(world_pts)
            if boundary and IN_RHINO:
                srf = _add_surface_fill(boundary)
                if srf: rs.ObjectColor(srf, (255,255,255))
        else:
            outer = bay.get("rings",4) * bay.get("ring_spacing",20) + pad
            circ = _add_circle(ox, oy, outer)
            if circ and IN_RHINO:
                srf = _add_surface_fill(circ)
                if srf: rs.ObjectColor(srf, (255,255,255))

# ══════════════════════════════════════════════════════════
# DRAW: ROOM HATCH FILLS
# ══════════════════════════════════════════════════════════

def _draw_room_hatches(state):
    _set_layer("JIG_HATCHES")
    rooms = state.get("rooms", {}); bays = state.get("bays", {})
    for rid, rm in rooms.items():
        hi = rm.get("hatch_image","none")
        if hi == "none": continue
        rtype = rm.get("type","bay"); src = rm.get("source_bay")
        hs = rm.get("hatch_scale",1.0); hr = rm.get("hatch_rotation",0.0)
        boundary_id = None
        if rtype == "bay" and src and src in bays:
            bay = bays[src]; gt = bay.get("grid_type","rectangular")
            ox, oy = bay["origin"]; rot = bay["rotation_deg"]
            if gt == "rectangular":
                cx, cy = _get_spacing_arrays(bay)
                corners = [(0,0),(cx[-1],0),(cx[-1],cy[-1]),(0,cy[-1]),(0,0)]
                boundary_id = _add_polyline([_local_to_world(lx,ly,(ox,oy),rot) for lx,ly in corners])
            else:
                outer = bay.get("rings",4) * bay.get("ring_spacing",20)
                boundary_id = _add_circle(ox, oy, outer)
        elif rtype == "void" and src and src in bays:
            bay = bays[src]; vc = bay.get("void_center"); vs = bay.get("void_size")
            if not vc or not vs: continue
            if bay.get("void_shape","rectangle") == "circle":
                boundary_id = _add_circle(vc[0], vc[1], vs[0]/2.0)
            else:
                x0 = vc[0]-vs[0]/2.0; y0 = vc[1]-vs[1]/2.0
                boundary_id = _add_rect(x0, y0, x0+vs[0], y0+vs[1])
        elif rtype == "landscape":
            site = state["site"]; ox, oy = site["origin"]
            boundary_id = _add_rect(ox, oy, ox+site["width"], oy+site["height"])
        if boundary_id is None: continue
        base = os.path.splitext(hi)[0].lower()
        pattern = HATCH_MAP.get(base, "Hatch1")
        _add_hatch(boundary_id, pattern, hs, hr)

# ══════════════════════════════════════════════════════════
# DRAW: GRID LINES
# ══════════════════════════════════════════════════════════

def _draw_bays(state):
    _set_layer("JIG_BAYS")
    sorted_bays = sorted(state["bays"].items(), key=lambda x: x[1].get("z_order",0))
    for name, bay in sorted_bays:
        gt = bay.get("grid_type","rectangular")
        ox, oy = bay["origin"]; rot = bay["rotation_deg"]
        if gt == "radial":
            nr = bay.get("rings",4); rs_val = bay.get("ring_spacing",20)
            na = bay.get("arms",8); arc = bay.get("arc_deg",360)
            arc_start = bay.get("arc_start_deg",0)
            for ring in range(1, nr+1):
                r = ring * rs_val
                if arc >= 360: _add_circle(ox, oy, r)
                else: _add_polyline(_arc_points(ox, oy, r, arc_start, arc_start+arc, 48))
            outer = nr * rs_val
            for arm in range(na):
                angle = arc_start + arc * arm / na
                a = math.radians(angle)
                _add_line((ox,oy,0), (ox+outer*math.cos(a), oy+outer*math.sin(a), 0))
            if arc < 360:
                a = math.radians(arc_start + arc)
                _add_line((ox,oy,0), (ox+outer*math.cos(a), oy+outer*math.sin(a), 0))
        else:
            cx, cy = _get_spacing_arrays(bay)
            for y_val in cy:
                _add_line(_local_to_world(cx[0],y_val,(ox,oy),rot),
                          _local_to_world(cx[-1],y_val,(ox,oy),rot))
            for x_val in cx:
                _add_line(_local_to_world(x_val,cy[0],(ox,oy),rot),
                          _local_to_world(x_val,cy[-1],(ox,oy),rot))

# ══════════════════════════════════════════════════════════
# DRAW: COLUMNS (bay grid intersections)
# ══════════════════════════════════════════════════════════

def _draw_columns(state):
    _set_layer("JIG_COLUMNS")
    cs = _s(state, "column_size", 1.5)
    half = cs / 2.0
    for name, bay in state["bays"].items():
        gt = bay.get("grid_type","rectangular")
        ox, oy = bay["origin"]; rot = bay["rotation_deg"]
        if gt == "rectangular":
            cx, cy = _get_spacing_arrays(bay)
            for x_val in cx:
                for y_val in cy:
                    wx, wy, _ = _local_to_world(x_val, y_val, (ox,oy), rot)
                    _add_rect(wx-half, wy-half, wx+half, wy+half)
        else:
            _add_rect(ox-half, oy-half, ox+half, oy+half)
            nr = bay.get("rings",4); rs_val = bay.get("ring_spacing",20)
            na = bay.get("arms",8); arc = bay.get("arc_deg",360)
            arc_start = bay.get("arc_start_deg",0)
            for ring in range(1, nr+1):
                r = ring * rs_val
                for arm in range(na):
                    angle = arc_start + arc * arm / na
                    a = math.radians(angle)
                    cx_pt = ox + r*math.cos(a); cy_pt = oy + r*math.sin(a)
                    _add_rect(cx_pt-half, cy_pt-half, cx_pt+half, cy_pt+half)

# ══════════════════════════════════════════════════════════
# DRAW: WALLS + APERTURE CUTOUTS
# ══════════════════════════════════════════════════════════

def _calc_wall_segments(wall_len, apertures):
    """Return solid wall segments as (start, end) pairs, skipping apertures."""
    if not apertures: return [(0.0, wall_len)]
    segments = []; pos = 0.0
    for ap in apertures:
        cn = ap.get("corner",0); wd = ap.get("width",3)
        if cn > pos: segments.append((pos, cn))
        pos = cn + wd
    if pos < wall_len: segments.append((pos, wall_len))
    return segments

def _draw_wall_line(state, seg_start, seg_end, fixed_val, axis, half_t, ox, oy, rot):
    """Draw a pair of offset wall lines for one solid segment."""
    if axis == "x":
        _add_line(_local_to_world(seg_start, fixed_val-half_t, (ox,oy), rot),
                  _local_to_world(seg_end,   fixed_val-half_t, (ox,oy), rot))
        _add_line(_local_to_world(seg_start, fixed_val+half_t, (ox,oy), rot),
                  _local_to_world(seg_end,   fixed_val+half_t, (ox,oy), rot))
    else:
        _add_line(_local_to_world(fixed_val-half_t, seg_start, (ox,oy), rot),
                  _local_to_world(fixed_val-half_t, seg_end,   (ox,oy), rot))
        _add_line(_local_to_world(fixed_val+half_t, seg_start, (ox,oy), rot),
                  _local_to_world(fixed_val+half_t, seg_end,   (ox,oy), rot))

def _draw_plan_layout(state):
    _set_layer("JIG_PLAN")
    for name, bay in state["bays"].items():
        if bay.get("grid_type","rectangular") != "rectangular": continue
        w = bay.get("walls",{})
        if not w.get("enabled"): continue
        t = w.get("thickness",0.5); half_t = t/2.0
        ox, oy = bay["origin"]; rot = bay["rotation_deg"]
        cx, cy = _get_spacing_arrays(bay)
        aps = bay.get("apertures",[])
        # Horizontal walls (x-axis gridlines)
        for j, y_val in enumerate(cy):
            wall_aps = sorted([a for a in aps if a.get("axis")=="x" and a.get("gridline")==j],
                              key=lambda a: a.get("corner",0))
            for s, e in _calc_wall_segments(cx[-1], wall_aps):
                _draw_wall_line(state, s, e, y_val, "x", half_t, ox, oy, rot)
            # End caps at each aperture edge
            for ap in wall_aps:
                cn = ap.get("corner",0); wd = ap.get("width",3)
                for x_pos in [cn, cn+wd]:
                    _add_line(_local_to_world(x_pos, y_val-half_t, (ox,oy), rot),
                              _local_to_world(x_pos, y_val+half_t, (ox,oy), rot))
        # Vertical walls (y-axis gridlines)
        for i, x_val in enumerate(cx):
            wall_aps = sorted([a for a in aps if a.get("axis")=="y" and a.get("gridline")==i],
                              key=lambda a: a.get("corner",0))
            for s, e in _calc_wall_segments(cy[-1], wall_aps):
                _draw_wall_line(state, s, e, x_val, "y", half_t, ox, oy, rot)
            for ap in wall_aps:
                cn = ap.get("corner",0); wd = ap.get("width",3)
                for y_pos in [cn, cn+wd]:
                    _add_line(_local_to_world(x_val-half_t, y_pos, (ox,oy), rot),
                              _local_to_world(x_val+half_t, y_pos, (ox,oy), rot))

    # ── Radial bay walls ──
    for name, bay in state["bays"].items():
        if bay.get("grid_type","rectangular") != "radial": continue
        w = bay.get("walls",{})
        if not w.get("enabled"): continue
        t = w.get("thickness",0.5); half_t = t/2.0
        ox, oy = bay["origin"]
        nr = bay.get("rings",4); ring_sp = bay.get("ring_spacing",20)
        na = bay.get("arms",8); arc = bay.get("arc_deg",360)
        arc_start = bay.get("arc_start_deg",0)
        arc_n = int(_s(state, "arc_segments", 16))
        outer = nr * ring_sp
        # Ring walls
        for ring_idx in range(1, nr+1):
            r = ring_idx * ring_sp
            if arc >= 360:
                _add_circle(ox, oy, r - half_t)
                _add_circle(ox, oy, r + half_t)
            else:
                _add_polyline(_arc_points(ox, oy, r - half_t, arc_start, arc_start+arc, arc_n*2))
                _add_polyline(_arc_points(ox, oy, r + half_t, arc_start, arc_start+arc, arc_n*2))
                # End caps at arc endpoints
                for ang in [arc_start, arc_start+arc]:
                    a_rad = math.radians(ang)
                    _add_line((ox+(r-half_t)*math.cos(a_rad), oy+(r-half_t)*math.sin(a_rad), 0),
                              (ox+(r+half_t)*math.cos(a_rad), oy+(r+half_t)*math.sin(a_rad), 0))
        # Arm walls
        for arm in range(na):
            angle = arc_start + arc * arm / na
            a_rad = math.radians(angle)
            perp_rad = math.radians(angle + 90)
            dx = half_t * math.cos(perp_rad)
            dy = half_t * math.sin(perp_rad)
            r_inner = ring_sp  # start from first ring
            for sign in [-1, 1]:
                sx = ox + r_inner * math.cos(a_rad) + sign * dx
                sy = oy + r_inner * math.sin(a_rad) + sign * dy
                ex = ox + outer * math.cos(a_rad) + sign * dx
                ey = oy + outer * math.sin(a_rad) + sign * dy
                _add_line((sx, sy, 0), (ex, ey, 0))
        # Closing arm for partial arcs
        if arc < 360:
            angle = arc_start + arc
            a_rad = math.radians(angle)
            perp_rad = math.radians(angle + 90)
            dx = half_t * math.cos(perp_rad)
            dy = half_t * math.sin(perp_rad)
            r_inner = ring_sp
            for sign in [-1, 1]:
                sx = ox + r_inner * math.cos(a_rad) + sign * dx
                sy = oy + r_inner * math.sin(a_rad) + sign * dy
                ex = ox + outer * math.cos(a_rad) + sign * dx
                ey = oy + outer * math.sin(a_rad) + sign * dy
                _add_line((sx, sy, 0), (ex, ey, 0))

# ══════════════════════════════════════════════════════════
# APERTURE SYMBOL DRAWING
# ══════════════════════════════════════════════════════════

def _draw_door_symbol(ap, bay, ox, oy, rot, cx, cy, wd, arc_n):
    axis = ap.get("axis","x"); gl = ap.get("gridline",0)
    cn = ap.get("corner",0); hinge_pos = ap.get("hinge","start")
    swing_dir = ap.get("swing","positive")
    swing_sign = 1 if swing_dir == "positive" else -1
    if axis == "x":
        y_val = cy[gl] if gl < len(cy) else cy[-1]
        hx = cn if hinge_pos == "start" else cn + wd
        hy = y_val
        if hinge_pos == "start": start_ang, end_ang = 0, 90*swing_sign
        else: start_ang, end_ang = 180, 180+90*swing_sign
    else:
        x_val = cx[gl] if gl < len(cx) else cx[-1]
        hx = x_val; hy = cn if hinge_pos == "start" else cn + wd
        if hinge_pos == "start": start_ang, end_ang = 90, 90+90*swing_sign
        else: start_ang, end_ang = 270, 270+90*swing_sign
    a0 = min(start_ang, end_ang); a1 = max(start_ang, end_ang)
    arc_pts = []
    for k in range(arc_n + 1):
        ang = math.radians(a0 + (a1-a0)*k/arc_n)
        arc_pts.append(_local_to_world(hx+wd*math.cos(ang), hy+wd*math.sin(ang), (ox,oy), rot))
    _add_polyline(arc_pts)
    leaf_ang = math.radians(end_ang)
    _add_line(_local_to_world(hx, hy, (ox,oy), rot),
              _local_to_world(hx+wd*math.cos(leaf_ang), hy+wd*math.sin(leaf_ang), (ox,oy), rot))

def _draw_window_symbol(ap, bay, ox, oy, rot, cx, cy, wd):
    axis = ap.get("axis","x"); gl = ap.get("gridline",0); cn = ap.get("corner",0)
    if axis == "x":
        y_val = cy[gl] if gl < len(cy) else cy[-1]
        _add_line(_local_to_world(cn, y_val, (ox,oy), rot),
                  _local_to_world(cn+wd, y_val, (ox,oy), rot))
    else:
        x_val = cx[gl] if gl < len(cx) else cx[-1]
        _add_line(_local_to_world(x_val, cn, (ox,oy), rot),
                  _local_to_world(x_val, cn+wd, (ox,oy), rot))

def _draw_portal_symbol(ap, bay, ox, oy, rot, cx, cy, wd):
    axis = ap.get("axis","x"); gl = ap.get("gridline",0)
    cn = ap.get("corner",0); t = bay.get("walls",{}).get("thickness",0.5)
    mark = t * 1.5
    if axis == "x":
        y_val = cy[gl] if gl < len(cy) else cy[-1]
        _add_line(_local_to_world(cn,    y_val-mark,(ox,oy),rot),
                  _local_to_world(cn,    y_val+mark,(ox,oy),rot))
        _add_line(_local_to_world(cn+wd, y_val-mark,(ox,oy),rot),
                  _local_to_world(cn+wd, y_val+mark,(ox,oy),rot))
    else:
        x_val = cx[gl] if gl < len(cx) else cx[-1]
        _add_line(_local_to_world(x_val-mark, cn,   (ox,oy),rot),
                  _local_to_world(x_val+mark, cn,   (ox,oy),rot))
        _add_line(_local_to_world(x_val-mark, cn+wd,(ox,oy),rot),
                  _local_to_world(x_val+mark, cn+wd,(ox,oy),rot))

# ══════════════════════════════════════════════════════════
# DRAW: BLOCK INSERTIONS (aperture symbols + room labels)
# ══════════════════════════════════════════════════════════

def _draw_block_insertions(state):
    _set_layer("JIG_BLOCKS")
    blocks = state.get("blocks",{})
    arc_n = int(_s(state, "arc_segments", 16))
    for name, bay in state["bays"].items():
        if bay.get("grid_type","rectangular") != "rectangular": continue
        ox, oy = bay["origin"]; rot = bay["rotation_deg"]
        cx, cy = _get_spacing_arrays(bay)
        for ap in bay.get("apertures",[]):
            atype = ap.get("type","door"); wd = ap.get("width",3)
            bk = blocks.get(atype,{})
            if atype == "door":     _draw_door_symbol(ap,bay,ox,oy,rot,cx,cy,wd,arc_n)
            elif atype == "window": _draw_window_symbol(ap,bay,ox,oy,rot,cx,cy,wd)
            elif atype == "portal": _draw_portal_symbol(ap,bay,ox,oy,rot,cx,cy,wd)
            if bk.get("show_label",True):
                axis = ap.get("axis","x"); gl = ap.get("gridline",0)
                cn = ap.get("corner",0)
                prefix = bk.get("label_prefix",atype[0].upper())
                aid = ap.get("id",""); num = "".join(c for c in aid if c.isdigit()) or aid
                lh = bk.get("label_height",1.5); t = bay.get("walls",{}).get("thickness",0.5)
                label_offset = t + lh * 0.5
                if axis == "x":
                    y_val = cy[gl] if gl < len(cy) else cy[-1]
                    _add_text("{0}{1}".format(prefix, num),
                              _local_to_world(cn+wd/2, y_val+label_offset, (ox,oy), rot), lh)
                else:
                    x_val = cx[gl] if gl < len(cx) else cx[-1]
                    _add_text("{0}{1}".format(prefix, num),
                              _local_to_world(x_val+label_offset, cn+wd/2, (ox,oy), rot), lh)
    # Room labels
    rooms = state.get("rooms",{}); room_bk = blocks.get("room",{})
    for rid, rm in rooms.items():
        if not room_bk.get("show_label",True): continue
        label = rm.get("label","")
        if not label: continue
        lh = room_bk.get("label_height",3.0); bays = state.get("bays",{})
        rtype = rm.get("type","bay"); src = rm.get("source_bay")
        if rtype == "bay" and src and src in bays:
            bay = bays[src]; ox, oy = bay["origin"]; rot = bay["rotation_deg"]
            if bay.get("grid_type","rectangular") == "rectangular":
                cxs, cys = _get_spacing_arrays(bay)
                pt = _local_to_world(cxs[-1]/2, cys[-1]/2, (ox,oy), rot)
            else: pt = (ox, oy, 0)
            _add_text(label, pt, lh)
        elif rtype == "void" and src and src in bays:
            vc = bays[src].get("void_center")
            if vc: _add_text(label, (vc[0], vc[1], 0), lh*0.8)
        elif rtype == "landscape":
            site = state["site"]
            _add_text(label, (site["origin"][0]+5, site["origin"][1]+5, 0), lh*0.6)

# ══════════════════════════════════════════════════════════
# DRAW: CORRIDORS
# ══════════════════════════════════════════════════════════

def _draw_corridors(state):
    _set_layer("JIG_CORRIDOR")
    dash_len = _s(state, "corridor_dash_len", 3.0)
    gap_len  = _s(state, "corridor_gap_len", 2.0)
    for name, bay in state["bays"].items():
        cor = bay.get("corridor",{})
        if not cor.get("enabled") or bay.get("grid_type","rectangular") != "rectangular":
            continue
        ox, oy = bay["origin"]; rot = bay["rotation_deg"]
        cx, cy = _get_spacing_arrays(bay)
        axis = cor.get("axis","x"); pos = cor.get("position",1)
        half_w = cor.get("width",8.0) / 2.0
        if axis == "x":
            if pos < 0 or pos >= len(cy): continue
            y_center = cy[pos]; x_s = cx[0]; x_e = cx[-1]
            y_top = y_center+half_w; y_bot = y_center-half_w
            _add_line(_local_to_world(x_s,y_top,(ox,oy),rot), _local_to_world(x_e,y_top,(ox,oy),rot))
            _add_line(_local_to_world(x_s,y_bot,(ox,oy),rot), _local_to_world(x_e,y_bot,(ox,oy),rot))
            _add_dashed_line(_local_to_world(x_s,y_center,(ox,oy),rot),
                             _local_to_world(x_e,y_center,(ox,oy),rot), dash_len, gap_len)
            ht = cor.get("hatch","none")
            if ht not in ("none",""):
                boundary = _add_polyline([
                    _local_to_world(x_s,y_bot,(ox,oy),rot), _local_to_world(x_e,y_bot,(ox,oy),rot),
                    _local_to_world(x_e,y_top,(ox,oy),rot), _local_to_world(x_s,y_top,(ox,oy),rot),
                    _local_to_world(x_s,y_bot,(ox,oy),rot)])
                if boundary: _add_hatch(boundary, ht, cor.get("hatch_scale",4.0))
        else:
            if pos < 0 or pos >= len(cx): continue
            x_center = cx[pos]; y_s = cy[0]; y_e = cy[-1]
            x_l = x_center-half_w; x_r = x_center+half_w
            _add_line(_local_to_world(x_l,y_s,(ox,oy),rot), _local_to_world(x_l,y_e,(ox,oy),rot))
            _add_line(_local_to_world(x_r,y_s,(ox,oy),rot), _local_to_world(x_r,y_e,(ox,oy),rot))
            _add_dashed_line(_local_to_world(x_center,y_s,(ox,oy),rot),
                             _local_to_world(x_center,y_e,(ox,oy),rot), dash_len, gap_len)
            ht = cor.get("hatch","none")
            if ht not in ("none",""):
                boundary = _add_polyline([
                    _local_to_world(x_l,y_s,(ox,oy),rot), _local_to_world(x_r,y_s,(ox,oy),rot),
                    _local_to_world(x_r,y_e,(ox,oy),rot), _local_to_world(x_l,y_e,(ox,oy),rot),
                    _local_to_world(x_l,y_s,(ox,oy),rot)])
                if boundary: _add_hatch(boundary, ht, cor.get("hatch_scale",4.0))

# ══════════════════════════════════════════════════════════
# DRAW: VOIDS
# ══════════════════════════════════════════════════════════

def _draw_voids(state):
    _set_layer("JIG_VOIDS")
    for name, bay in state["bays"].items():
        vc = bay.get("void_center"); vs = bay.get("void_size")
        # Web bays often carry null voids — skip instead of crashing.
        if not vc or not vs: continue
        if bay.get("void_shape","rectangle") == "circle":
            _add_circle(vc[0], vc[1], vs[0]/2.0)
        else:
            x0 = vc[0]-vs[0]/2.0; y0 = vc[1]-vs[1]/2.0
            _add_rect(x0, y0, x0+vs[0], y0+vs[1])

# ══════════════════════════════════════════════════════════
# DRAW: LABELS
# ══════════════════════════════════════════════════════════

def _draw_labels(state):
    _set_layer("JIG_LABELS")
    txt_h = _s(state, "label_text_height", 0.3)
    brl_h = _s(state, "braille_text_height", 0.5)
    label_off = _s(state, "label_offset", 3.0)
    for name, bay in state["bays"].items():
        gt = bay.get("grid_type","rectangular"); ox, oy = bay["origin"]
        if gt == "rectangular":
            cx_arr, cy_arr = _get_spacing_arrays(bay); rot = bay["rotation_deg"]
            label_pt = _local_to_world(cx_arr[-1]/2, cy_arr[-1]+label_off, (ox,oy), rot)
            braille_pt = _local_to_world(cx_arr[-1]/2, cy_arr[-1]+label_off+brl_h*1.5, (ox,oy), rot)
        else:
            outer = bay.get("rings",4)*bay.get("ring_spacing",20)
            label_pt = (ox, oy+outer+label_off, 0)
            braille_pt = (ox, oy+outer+label_off+brl_h*1.5, 0)
        label = bay.get("label", "Bay {0}".format(name)); braille = bay.get("braille","")
        if label:  _add_text(label, label_pt, txt_h)
        if braille: _add_text(braille, braille_pt, brl_h)

# ══════════════════════════════════════════════════════════
# DRAW: CELL ROOMS
# ══════════════════════════════════════════════════════════

def _draw_cell_rooms(state):
    """Draw room boundaries, labels, hatches, and areas for cell subdivisions."""
    _set_layer("JIG_ROOMS")
    txt_h = _s(state, "label_text_height", 0.3)
    for bay_name, bay in state["bays"].items():
        cells = bay.get("cells")
        if not cells or bay.get("grid_type","rectangular") != "rectangular":
            continue
        ox, oy = bay["origin"]; rot = bay["rotation_deg"]
        cx, cy = _get_spacing_arrays(bay)
        nx, ny = bay["bays"]
        # Build lookup: (c,r) -> room name
        cell_names = {}
        for key, cl in cells.items():
            parts = key.split(",")
            if len(parts) == 2:
                c, r = int(parts[0]), int(parts[1])
                cell_names[(c, r)] = cl.get("name", "")
        # Group by room name
        rooms = {}
        for (c, r), name in cell_names.items():
            if not name: continue
            if name not in rooms:
                rooms[name] = {"cells": [], "label": "", "braille": "",
                               "hatch": "none", "hatch_scale": 1.0,
                               "hatch_rotation": 0.0, "area": 0.0}
            rooms[name]["cells"].append((c, r))
            cl = cells["{0},{1}".format(c, r)]
            cell_area = (cx[c+1] - cx[c]) * (cy[r+1] - cy[r])
            rooms[name]["area"] += cell_area
            if cl.get("label") and not rooms[name]["label"]:
                rooms[name]["label"] = cl["label"]
            if cl.get("braille") and not rooms[name]["braille"]:
                rooms[name]["braille"] = cl["braille"]
            if cl.get("hatch","none") != "none" and rooms[name]["hatch"] == "none":
                rooms[name]["hatch"] = cl["hatch"]
                rooms[name]["hatch_scale"] = cl.get("hatch_scale", 1.0)
                rooms[name]["hatch_rotation"] = cl.get("hatch_rotation", 0.0)
        if not rooms: continue
        for rname, rd in rooms.items():
            room_set = set(rd["cells"])
            # Draw boundary edges where adjacent cell differs
            for c, r in rd["cells"]:
                x0, x1 = cx[c], cx[c+1]
                y0, y1 = cy[r], cy[r+1]
                # Left
                if c == 0 or (c-1, r) not in room_set:
                    _add_line(_local_to_world(x0,y0,(ox,oy),rot),
                              _local_to_world(x0,y1,(ox,oy),rot))
                # Right
                if c == nx-1 or (c+1, r) not in room_set:
                    _add_line(_local_to_world(x1,y0,(ox,oy),rot),
                              _local_to_world(x1,y1,(ox,oy),rot))
                # Bottom
                if r == 0 or (c, r-1) not in room_set:
                    _add_line(_local_to_world(x0,y0,(ox,oy),rot),
                              _local_to_world(x1,y0,(ox,oy),rot))
                # Top
                if r == ny-1 or (c, r+1) not in room_set:
                    _add_line(_local_to_world(x0,y1,(ox,oy),rot),
                              _local_to_world(x1,y1,(ox,oy),rot))
                # Hatch each cell (they tile seamlessly)
                cl = cells.get("{0},{1}".format(c, r), {})
                ht = cl.get("hatch", rd["hatch"])
                hs = cl.get("hatch_scale", rd["hatch_scale"])
                hr = cl.get("hatch_rotation", rd["hatch_rotation"])
                if ht not in ("none", ""):
                    base = os.path.splitext(ht)[0].lower()
                    pattern = HATCH_MAP.get(base, "Hatch1")
                    boundary = _add_polyline([
                        _local_to_world(x0,y0,(ox,oy),rot),
                        _local_to_world(x1,y0,(ox,oy),rot),
                        _local_to_world(x1,y1,(ox,oy),rot),
                        _local_to_world(x0,y1,(ox,oy),rot),
                        _local_to_world(x0,y0,(ox,oy),rot)])
                    if boundary:
                        _add_hatch(boundary, pattern, hs, hr)
            # Label at centroid of all cells in the room
            label = rd["label"] or rname
            n = len(rd["cells"])
            sum_x = sum((cx[c] + cx[c+1]) / 2.0 for c, r in rd["cells"])
            sum_y = sum((cy[r] + cy[r+1]) / 2.0 for c, r in rd["cells"])
            ctr = _local_to_world(sum_x/n, sum_y/n, (ox,oy), rot)
            _add_text(label, ctr, txt_h * 6)
            # Area text below label
            area = rd["area"]
            area_txt = "{0:,.0f} sf".format(area)
            ctr_below = _local_to_world(sum_x/n, sum_y/n - txt_h*8, (ox,oy), rot)
            _add_text(area_txt, ctr_below, txt_h * 4)
            # Braille below area
            brl = rd.get("braille","")
            if brl:
                brl_pt = _local_to_world(sum_x/n, sum_y/n - txt_h*14, (ox,oy), rot)
                _add_text(brl, brl_pt, _s(state, "braille_text_height", 0.5))

# ══════════════════════════════════════════════════════════
# DRAW: LEGEND (Braille + English key)
# ══════════════════════════════════════════════════════════

def _draw_legend(state):
    leg = state.get("legend", {})
    if not leg.get("enabled", False): return
    _set_layer("JIG_LEGEND")

    site = state["site"]; sox, soy = site["origin"]
    sw, sh = site["width"], site["height"]
    lw = leg.get("width", 40.0)
    pad = leg.get("padding", 3.0)
    row_h = leg.get("row_height", 7.0)
    swatch = leg.get("swatch_size", 5.0)
    txt_h = leg.get("text_height", 2.0)
    brl_h = leg.get("braille_height", 2.5)
    show_braille = leg.get("show_braille", True)
    show_hatches = leg.get("show_hatches", True)
    show_apertures = leg.get("show_apertures", True)

    rooms = state.get("rooms", {}); blocks = state.get("blocks", {})
    hatched_rooms = {rid: rm for rid, rm in rooms.items()
                     if rm.get("hatch_image","none") != "none"} if show_hatches else {}
    ap_types = []
    if show_apertures:
        for bt in ("door","window","portal"):
            if bt in blocks: ap_types.append(bt)
    n_rows = len(hatched_rooms) + len(ap_types)
    if n_rows == 0: return

    total_h = pad * 2 + (1 + n_rows) * row_h

    pos = leg.get("position", "bottom-right")
    if pos == "bottom-right":   lox = sox + sw + pad * 2;  loy = soy
    elif pos == "bottom-left":  lox = sox - lw - pad * 2;  loy = soy
    elif pos == "top-right":    lox = sox + sw + pad * 2;  loy = soy + sh - total_h
    elif pos == "top-left":     lox = sox - lw - pad * 2;  loy = soy + sh - total_h
    elif pos == "custom":       lox, loy = leg.get("custom_origin", [0, 0])
    else:                       lox = sox + sw + pad * 2;  loy = soy

    _add_rect(lox, loy, lox + lw, loy + total_h)

    cursor_y = loy + total_h - pad
    title = leg.get("title", "Legend")
    _add_text(title, (lox + pad, cursor_y - txt_h, 0), txt_h * 1.3)
    if show_braille and leg.get("title_braille"):
        _add_text(leg["title_braille"],
                  (lox + pad + len(title) * txt_h * 0.7, cursor_y - brl_h, 0), brl_h)
    cursor_y -= row_h

    for rid in sorted(hatched_rooms):
        rm = hatched_rooms[rid]
        sx0 = lox + pad; sy0 = cursor_y - swatch
        swatch_id = _add_rect(sx0, sy0, sx0 + swatch, cursor_y)
        if swatch_id:
            hi = rm.get("hatch_image","none")
            base = os.path.splitext(hi)[0].lower()
            pattern = HATCH_MAP.get(base, "Hatch1")
            _add_hatch(swatch_id, pattern,
                       rm.get("hatch_scale",1.0), rm.get("hatch_rotation",0.0))
        label = rm.get("label", rid)
        _add_text(label, (sx0 + swatch + pad, cursor_y - txt_h * 1.2, 0), txt_h)
        if show_braille:
            braille = rm.get("braille", "")
            if braille:
                _add_text(braille,
                          (sx0 + swatch + pad, cursor_y - txt_h * 1.2 - brl_h * 1.2, 0), brl_h)
        cursor_y -= row_h

    for bt in ap_types:
        bk = blocks[bt]
        prefix = bk.get("label_prefix", bt[0].upper())
        sx0 = lox + pad; sy0 = cursor_y - swatch
        mid_y = sy0 + swatch / 2.0
        if bt == "door":
            arc_pts = _arc_points(sx0, sy0, swatch, 0, 90, 8)
            _add_polyline(arc_pts)
            _add_line((sx0, sy0, 0), (sx0 + swatch, sy0, 0))
        elif bt == "window":
            _add_line((sx0, mid_y, 0), (sx0 + swatch, mid_y, 0))
            _add_line((sx0, mid_y - swatch*0.15, 0), (sx0, mid_y + swatch*0.15, 0))
            _add_line((sx0+swatch, mid_y-swatch*0.15, 0), (sx0+swatch, mid_y+swatch*0.15, 0))
        elif bt == "portal":
            _add_line((sx0, sy0, 0), (sx0, sy0 + swatch*0.4, 0))
            _add_line((sx0+swatch, sy0, 0), (sx0+swatch, sy0+swatch*0.4, 0))
        _add_text("{0} = {1}".format(prefix, bt.title()),
                  (sx0 + swatch + pad, cursor_y - txt_h * 1.2, 0), txt_h)
        cursor_y -= row_h

# ══════════════════════════════════════════════════════════
# DRAW: TACTILE 3D (extruded bay walls for 3D printing)
# ══════════════════════════════════════════════════════════

def _draw_tactile3d(state):
    """Build 3D geometry for tactile plan models.

    Extrudes each solid bay-wall segment as a capped box. Aperture
    locations are left open — no extrusion where a door, window,
    or portal sits. A clipping plane at cut_height trims the tops.
    STL export only runs when auto_export is on or when the
    controller sets the _export_once flag via 'tactile3d export'.
    """
    t3 = state.get("tactile3d", {})
    if not t3.get("enabled", False): return
    _set_layer("JIG_TACTILE3D")

    wall_height = t3.get("wall_height", 9.0)
    cut_height = t3.get("cut_height", 4.0)
    floor_thick = t3.get("floor_thickness", 0.5)
    floor_on = t3.get("floor_enabled", t3.get("floor", True))
    scale_f = t3.get("scale_factor", 1.0)
    auto_export = t3.get("auto_export", False)
    export_once = t3.get("_export_once", False)
    export_path = t3.get("export_path", "")

    # Extrude only up to the cut height
    extrude_h = min(wall_height, cut_height)
    created_ids = []

    # ── Extrude wall segments ──
    for name, bay in state["bays"].items():
        if bay.get("grid_type", "rectangular") != "rectangular": continue
        w = bay.get("walls", {})
        if not w.get("enabled"): continue
        t = w.get("thickness", 0.5); half_t = t / 2.0
        ox, oy = bay["origin"]; rot = bay["rotation_deg"]
        cx, cy = _get_spacing_arrays(bay)
        aps = bay.get("apertures", [])

        for j, y_val in enumerate(cy):
            wall_aps = sorted(
                [a for a in aps if a.get("axis")=="x" and a.get("gridline")==j],
                key=lambda a: a.get("corner", 0))
            for seg_s, seg_e in _calc_wall_segments(cx[-1], wall_aps):
                obj = _extrude_wall_box(seg_s, seg_e, y_val, "x",
                                        half_t, ox, oy, rot, extrude_h)
                if obj: created_ids.append(obj)

        for i, x_val in enumerate(cx):
            wall_aps = sorted(
                [a for a in aps if a.get("axis")=="y" and a.get("gridline")==i],
                key=lambda a: a.get("corner", 0))
            for seg_s, seg_e in _calc_wall_segments(cy[-1], wall_aps):
                obj = _extrude_wall_box(seg_s, seg_e, x_val, "y",
                                        half_t, ox, oy, rot, extrude_h)
                if obj: created_ids.append(obj)

    # ── Radial bay 3D walls ──
    for name, bay in state["bays"].items():
        if bay.get("grid_type","rectangular") != "radial": continue
        w = bay.get("walls",{})
        if not w.get("enabled"): continue
        t = w.get("thickness",0.5); half_t = t / 2.0
        ox, oy = bay["origin"]
        nr = bay.get("rings",4); ring_sp = bay.get("ring_spacing",20)
        na = bay.get("arms",8); arc = bay.get("arc_deg",360)
        arc_start = bay.get("arc_start_deg",0)
        outer = nr * ring_sp
        n_seg = 48  # segments per full circle for smooth curves
        # Ring walls as extruded arc profiles
        for ring_idx in range(1, nr+1):
            r = ring_idx * ring_sp
            inner_pts = _arc_points(ox, oy, r - half_t, arc_start, arc_start + arc, n_seg)
            outer_pts = _arc_points(ox, oy, r + half_t, arc_start, arc_start + arc, n_seg)
            outer_pts.reverse()
            profile_pts = [(p[0], p[1]) for p in inner_pts + outer_pts]
            obj = _add_extrusion(profile_pts, 0.0, extrude_h, "bay_wall_extrusion")
            if obj: created_ids.append(obj)
        # Arm walls as extruded boxes
        arm_count = na + (1 if arc < 360 else 0)
        for arm in range(arm_count):
            angle = arc_start + arc * arm / na if arm < na else arc_start + arc
            a_rad = math.radians(angle)
            perp_rad = math.radians(angle + 90)
            dx = half_t * math.cos(perp_rad)
            dy = half_t * math.sin(perp_rad)
            r_inner = ring_sp
            corners = [
                (ox + r_inner*math.cos(a_rad) - dx, oy + r_inner*math.sin(a_rad) - dy),
                (ox + outer*math.cos(a_rad) - dx, oy + outer*math.sin(a_rad) - dy),
                (ox + outer*math.cos(a_rad) + dx, oy + outer*math.sin(a_rad) + dy),
                (ox + r_inner*math.cos(a_rad) + dx, oy + r_inner*math.sin(a_rad) + dy),
            ]
            obj = _add_extrusion(corners, 0.0, extrude_h, "bay_wall_extrusion")
            if obj: created_ids.append(obj)

    # ── Floor slab ──
    if floor_on:
        slab = _create_floor_slab(state, floor_thick)
        if slab: created_ids.append(slab)

    # ── Clipping plane ──
    _add_clipping_plane(state, cut_height)

    # ── Scale ──
    if IN_RHINO and scale_f != 1.0 and created_ids:
        origin_pt = state["site"]["origin"]
        rs.ScaleObjects(created_ids, (origin_pt[0], origin_pt[1], 0),
                        (scale_f, scale_f, scale_f))

    print("[RC TACTILE3D] Created {0} objects.".format(len(created_ids)))
    # ── Export only when explicitly requested ──
    should_export = (auto_export or export_once) and export_path and created_ids
    if should_export:
        _export_stl(created_ids, export_path)
    # Clear the one-shot flag in memory AND in the JSON file.
    # This is the ONE exception to the "watcher never writes" rule —
    # without clearing it, the flag stays True and re-triggers on
    # every watcher restart.
    if export_once and IN_RHINO:
        t3["_export_once"] = False
        try:
            with open(STATE_FILE, "rb") as _f:
                _disk = json.loads(_f.read().decode("utf-8"))
            if "_export_once" in _disk.get("tactile3d", {}):
                _disk["tactile3d"]["_export_once"] = False
                _tmp = STATE_FILE + ".tmp"
                with open(_tmp, "wb") as _fw:
                    _fw.write(json.dumps(_disk, indent=2).encode("utf-8"))
                os.replace(_tmp, STATE_FILE)
                # Update mtime so we don't re-trigger a redraw
                _watcher_state["last_mtime"] = os.stat(STATE_FILE).st_mtime
        except Exception as _e:
            print("[RC] Could not clear _export_once: {0}".format(_e))


def _extrude_wall_box(seg_start, seg_end, fixed_val, axis, half_t,
                      ox, oy, rot, height):
    """Extrude one bay-wall segment as a capped 3D box."""
    if seg_end - seg_start < 0.001: return None  # skip degenerate segments
    if axis == "x":
        corners = [
            _local_to_world(seg_start, fixed_val - half_t, (ox, oy), rot),
            _local_to_world(seg_end,   fixed_val - half_t, (ox, oy), rot),
            _local_to_world(seg_end,   fixed_val + half_t, (ox, oy), rot),
            _local_to_world(seg_start, fixed_val + half_t, (ox, oy), rot),
        ]
    else:
        corners = [
            _local_to_world(fixed_val - half_t, seg_start, (ox, oy), rot),
            _local_to_world(fixed_val + half_t, seg_start, (ox, oy), rot),
            _local_to_world(fixed_val + half_t, seg_end,   (ox, oy), rot),
            _local_to_world(fixed_val - half_t, seg_end,   (ox, oy), rot),
        ]
    return _add_extrusion([(c[0], c[1]) for c in corners], 0.0, height,
                          "bay_wall_extrusion")


def _create_floor_slab(state, thickness):
    """Create a floor slab from z=-thickness to z=0 spanning the site."""
    _record("floor_slab")
    if not IN_RHINO: return None
    site = state["site"]
    sox, soy = site["origin"]; sw, sh = site["width"], site["height"]
    corners = [
        (sox, soy, -thickness), (sox+sw, soy, -thickness),
        (sox+sw, soy+sh, -thickness), (sox, soy+sh, -thickness),
        (sox, soy, -thickness),
    ]
    profile = rs.AddPolyline(corners)
    if not profile: return None
    srf = rs.AddPlanarSrf(profile)
    if not srf:
        rs.DeleteObject(profile)
        return None
    srf_id = srf[0]
    path = rs.AddLine((0, 0, 0), (0, 0, thickness))
    brep = rs.ExtrudeSurface(srf_id, path, True)
    rs.DeleteObject(profile)
    rs.DeleteObject(srf_id)
    rs.DeleteObject(path)
    if brep:
        rs.CapPlanarHoles(brep)
        return brep
    return None


def _add_clipping_plane(state, cut_height):
    """Place a horizontal clipping plane at the given height."""
    _record("clipping_plane")
    if not IN_RHINO: return None
    site = state["site"]
    sox, soy = site["origin"]; sw, sh = site["width"], site["height"]
    center = Rhino.Geometry.Point3d(sox + sw/2.0, soy + sh/2.0, cut_height)
    plane = Rhino.Geometry.Plane(center, Rhino.Geometry.Vector3d.ZAxis)
    extent = max(sw, sh) * 2.0
    try:
        return sc.doc.Objects.AddClippingPlane(
            plane, extent, extent, sc.doc.Views.ActiveView.ActiveViewportID)
    except Exception as e:
        print("[RC TACTILE3D] Clipping plane: {0}".format(e))
        return None


def _export_stl(obj_ids, filepath):
    """Convert 3D objects to meshes and write binary STL directly.

    Writes the binary STL format from mesh vertex/face data using
    struct.pack. No dependency on Rhino.FileIO.FileStl and no
    interactive Export dialog. Works even when EnableRedraw is off.
    """
    if not IN_RHINO:
        return
    try:
        import System
        import struct

        # --- collect meshes from all geometry types ---
        meshes = []
        mp = Rhino.Geometry.MeshingParameters.Default
        for oid in obj_ids:
            robj = sc.doc.Objects.FindId(System.Guid(str(oid)))
            if robj is None:
                continue
            geom = robj.Geometry
            if isinstance(geom, Rhino.Geometry.Mesh):
                meshes.append(geom)
            elif isinstance(geom, Rhino.Geometry.Brep):
                ms = Rhino.Geometry.Mesh.CreateFromBrep(geom, mp)
                if ms:
                    for m in ms:
                        meshes.append(m)
            elif isinstance(geom, Rhino.Geometry.Extrusion):
                brep = geom.ToBrep()
                if brep:
                    ms = Rhino.Geometry.Mesh.CreateFromBrep(brep, mp)
                    if ms:
                        for m in ms:
                            meshes.append(m)

        if not meshes:
            print("[RC TACTILE3D] No meshable geometry found.")
            return

        # --- join into one mesh, triangulate ---
        joined = Rhino.Geometry.Mesh()
        for m in meshes:
            joined.Append(m)
        joined.Faces.ConvertQuadsToTriangles()
        joined.FaceNormals.ComputeFaceNormals()

        # --- ensure output directory ---
        out_dir = os.path.dirname(filepath)
        if out_dir and not os.path.exists(out_dir):
            os.makedirs(out_dir)

        # --- write binary STL ---
        tri_count = joined.Faces.Count
        verts = joined.Vertices
        fnormals = joined.FaceNormals

        f = open(filepath, "wb")
        try:
            # 80-byte header
            hdr = "Binary STL - RAP Watcher"
            hdr = hdr + "\0" * (80 - len(hdr))
            f.write(hdr.encode("ascii"))
            # triangle count (uint32 LE)
            f.write(struct.pack("<I", tri_count))
            # each triangle: normal(3f) + v0(3f) + v1(3f) + v2(3f) + attr(H)
            for i in range(tri_count):
                face = joined.Faces[i]
                fn = fnormals[i]
                f.write(struct.pack("<fff",
                    float(fn.X), float(fn.Y), float(fn.Z)))
                for vi in [face.A, face.B, face.C]:
                    v = verts[vi]
                    f.write(struct.pack("<fff",
                        float(v.X), float(v.Y), float(v.Z)))
                f.write(struct.pack("<H", 0))
        finally:
            f.close()

        sz = os.path.getsize(filepath)
        print("[RC TACTILE3D] Exported {0} triangles ({1} KB) to {2}".format(
            tri_count, sz // 1024, filepath))
    except Exception as e:
        print("[RC TACTILE3D] Export error: {0}".format(e))

# ══════════════════════════════════════════════════════════
# WEB-NATIVE NOUNS (RAP Studio free elements) — v3.0 extension
# ══════════════════════════════════════════════════════════

def _sanitize_layer_token(name):
    """Make a state layer/phase name safe as one Rhino layer path token."""
    s = str(name).replace("::", "-").strip()
    return s if s else "Default"


def _phases_by_id(state):
    out = {}
    for p in (state.get("web_phases") or []):
        if isinstance(p, dict) and p.get("id") is not None:
            out[p["id"]] = p
    return out


def _multi_phase(state):
    return len(state.get("web_phases") or []) > 1


def _web_layer_path(state, elem):
    """Full Rhino layer path for one web element.

    Base: JIG_WEB::<layer>. With >1 design phase, a phase leaf is added:
    JIG_WEB::<layer>::JIG_PHASE_<Phase Name> (see header for rationale).
    """
    layer = _sanitize_layer_token(elem.get("layer") or "Default")
    path = WEB_PARENT_LAYER + "::" + layer
    if _multi_phase(state):
        pid = elem.get("phase") or "main"
        ph = _phases_by_id(state).get(pid)
        pname = _sanitize_layer_token(ph.get("name") if ph and ph.get("name") else pid)
        path = path + "::JIG_PHASE_" + pname
    return path


def _apply_layer_style(state, layer_name):
    """Best-effort: push the state layer's linetype + lineweight onto the
    JIG_WEB::<layer> Rhino layer. Skipped silently when the doc lacks the
    linetype or rhinoscriptsyntax lacks LayerPrintWidth."""
    if not IN_RHINO:
        return
    ldef = (state.get("layers") or {}).get(layer_name)
    if not isinstance(ldef, dict):
        return
    path = WEB_PARENT_LAYER + "::" + _sanitize_layer_token(layer_name)
    lt = LINETYPE_MAP.get(ldef.get("linetype", "solid"))
    if lt:
        try:
            rs.LayerLinetype(path, lt)
        except Exception:
            pass
    lw = ldef.get("lineweight_mm")
    if lw:
        try:
            rs.LayerPrintWidth(path, float(lw))
        except Exception:
            pass


def _level_z(state, idx):
    """z (ft) of a level index, defaulting safely."""
    levels = state.get("levels") or []
    try:
        i = int(idx or 0)
    except (TypeError, ValueError):
        i = 0
    if 0 <= i < len(levels):
        return float(levels[i].get("z", 0.0))
    return 0.0


def _web_wall_height(state, elem=None):
    """Wall height: element override → tactile3d.wall_height → 8 ft."""
    if elem and elem.get("height"):
        return float(elem["height"])
    h = state.get("tactile3d", {}).get("wall_height")
    return float(h) if h else 8.0


def _set_web_layer(state, elem):
    layer_name = elem.get("layer") or "Default"
    path = _web_layer_path(state, elem)
    _ensure_layer_path(path)
    _apply_layer_style(state, layer_name)
    _set_layer(path)
    return path


def _name_object(obj, name):
    if obj and IN_RHINO and name:
        try:
            rs.ObjectName(obj, str(name))
        except Exception:
            pass


def _draw_web_regions(state):
    """Floor plates → extruded slabs (thickness, default 0.5 ft) at their
    level's z; extruded boxes → massing solids (height). Layer:
    JIG_WEB::<layer> (+ phase leaf when multi-phase)."""
    regions = state.get("web_regions") or []
    # Forward-compat: accept split top-level keys too.
    extras = [("plate", r) for r in (state.get("web_plates") or [])]
    extras += [("box", r) for r in (state.get("web_boxes") or [])]
    items = [(r.get("kind"), r) for r in regions if isinstance(r, dict)] + extras
    for kind, reg in items:
        origin = reg.get("origin") or [reg.get("x", 0.0), reg.get("y", 0.0)]
        size = reg.get("size") or [reg.get("w", 0.0), reg.get("h", 0.0)]
        x, y = float(origin[0]), float(origin[1])
        w, d = float(size[0]), float(size[1])
        if w <= 1e-9 or d <= 1e-9:
            continue
        z0 = _level_z(state, reg.get("level", 0))
        _set_web_layer(state, reg)
        # Freeform footprint: the web studio's `poly` (list of [x,y], >=3 points)
        # IS the outline; origin/size are just its bounding box.
        poly = reg.get("poly")
        if isinstance(poly, list) and len(poly) >= 3:
            try:
                corners = [(float(p[0]), float(p[1])) for p in poly]
            except (TypeError, ValueError, IndexError):
                corners = [(x, y), (x + w, y), (x + w, y + d), (x, y + d)]
        else:
            corners = [(x, y), (x + w, y), (x + w, y + d), (x, y + d)]
        if kind == "plate":
            th = float(reg.get("thickness") or 0.5)
            obj = _add_extrusion(corners, z0, th, "plate_solid")
            _bump("plates")
        else:  # "box"
            ht = float(reg.get("height") or 8.0)
            obj = _add_extrusion(corners, z0, ht, "box_solid")
            _bump("boxes")
        _name_object(obj, reg.get("name") or reg.get("id"))


def _openings_by_wall(state):
    out = {}
    for op in (state.get("web_openings") or []):
        if isinstance(op, dict) and op.get("wallId"):
            out.setdefault(op["wallId"], []).append(op)
    return out


def _draw_web_walls(state):
    """Free walls → extruded strips along a→b, split around openings.

    No booleans: each wall becomes up-to-N+1 full-height solids between
    its N openings, plus per-opening infill — doors cut to the floor and
    get a header above the leaf; windows get a sill (WINDOW_SILL_FT,
    clamped) and a header; portals stay full-height gaps."""
    walls = state.get("web_walls") or []
    openings = _openings_by_wall(state)
    for wall in walls:
        if not isinstance(wall, dict):
            continue
        a = wall.get("a") or [wall.get("x1", 0.0), wall.get("y1", 0.0)]
        b = wall.get("b") or [wall.get("x2", 0.0), wall.get("y2", 0.0)]
        ax, ay = float(a[0]), float(a[1])
        bx, by = float(b[0]), float(b[1])
        run = math.hypot(bx - ax, by - ay)
        if run < 0.001:
            continue
        ux, uy = (bx - ax) / run, (by - ay) / run
        # Perpendicular for the thickness offset
        px, py = -uy, ux
        t = float(wall.get("thickness") or 0.5)
        half_t = t / 2.0
        z0 = _level_z(state, wall.get("level", 0))
        wall_h = _web_wall_height(state, wall)

        def strip(s, e):
            """4 plan corners of the wall strip between run-params s..e."""
            sx, sy = ax + ux * s, ay + uy * s
            ex, ey = ax + ux * e, ay + uy * e
            return [(sx - px * half_t, sy - py * half_t),
                    (ex - px * half_t, ey - py * half_t),
                    (ex + px * half_t, ey + py * half_t),
                    (sx + px * half_t, sy + py * half_t)]

        _set_web_layer(state, wall)
        _bump("walls")

        # Sorted, clamped opening gaps along the run
        gaps = []
        for op in sorted(openings.get(wall.get("id"), []),
                         key=lambda o: o.get("pos", 0.5)):
            c = max(0.0, min(1.0, float(op.get("pos", 0.5)))) * run
            hw = float(op.get("width", 3.0)) / 2.0
            g0, g1 = max(0.0, c - hw), min(run, c + hw)
            if g1 - g0 > 0.001:
                gaps.append((g0, g1, op))

        # Full-height solid segments between the gaps
        cursor = 0.0
        for g0, g1, _op in gaps:
            if g0 - cursor > 0.001:
                obj = _add_extrusion(strip(cursor, g0), z0, wall_h, "wall_solid")
                _name_object(obj, wall.get("id"))
                _bump("wall_solids")
            cursor = max(cursor, g1)
        if run - cursor > 0.001:
            obj = _add_extrusion(strip(cursor, run), z0, wall_h, "wall_solid")
            _name_object(obj, wall.get("id"))
            _bump("wall_solids")

        # Per-opening infill (sills + headers)
        for g0, g1, op in gaps:
            otype = op.get("type", "door")
            oh = float(op.get("height", 7.0))
            _bump("openings")
            if otype == "portal":
                continue  # full-height gap
            if otype == "window":
                sill = min(WINDOW_SILL_FT, max(0.0, wall_h - oh))
                if sill > 0.001:
                    obj = _add_extrusion(strip(g0, g1), z0, sill, "wall_sill")
                    _name_object(obj, op.get("id"))
                    _bump("wall_sills")
                head_z = z0 + sill + oh
            else:  # door — cut to the floor
                head_z = z0 + oh
            head_h = (z0 + wall_h) - head_z
            if head_h > 0.001:
                _set_web_layer(state, wall)  # keep layer current
                _record_header = _add_extrusion(strip(g0, g1),
                                                head_z, head_h, "wall_header")
                _name_object(_record_header, op.get("id"))
                _bump("wall_headers")


def _draw_web_columns(state):
    """Free columns → square extruded solids at their level's z."""
    for col in (state.get("web_columns") or []):
        if not isinstance(col, dict):
            continue
        at = col.get("at") or [col.get("x", 0.0), col.get("y", 0.0)]
        cx, cy = float(at[0]), float(at[1])
        size = float(col.get("size") or 1.0)
        half = size / 2.0
        z0 = _level_z(state, col.get("level", 0))
        col_h = _web_wall_height(state)
        _set_web_layer(state, col)
        corners = [(cx - half, cy - half), (cx + half, cy - half),
                   (cx + half, cy + half), (cx - half, cy + half)]
        obj = _add_extrusion(corners, z0, col_h, "column_solid")
        _name_object(obj, col.get("id"))
        _bump("columns")


def _apply_phase_visibility(state):
    """Switch OFF the phase leaves of phases marked visible == "hidden".
    Geometry is built either way — hiding is one layer click, reversible."""
    hidden = [p for p in (state.get("web_phases") or [])
              if isinstance(p, dict) and p.get("visible") == "hidden"]
    names = []
    for ph in hidden:
        pname = _sanitize_layer_token(ph.get("name") or ph.get("id") or "")
        if not pname:
            continue
        names.append(pname)
        leaf = "::JIG_PHASE_" + pname
        for lname in _all_jig_layers():
            if lname.endswith(leaf) and IN_RHINO:
                try:
                    rs.LayerVisible(lname, False)
                except Exception:
                    pass
    _WEB_STATS["hidden_phases"] = names

# ══════════════════════════════════════════════════════════
# MASTER REDRAW
# ══════════════════════════════════════════════════════════

def redraw(state):
    if IN_RHINO:
        rs.EnableRedraw(False)
    warnings = 0
    try:
        _ensure_layers()
        _clear_all()
        steps = [
            ("site", _draw_site),
            ("grid", _draw_global_grid),
            ("zones", _draw_zones),
            ("backgrounds", _draw_background_masks),
            ("hatches", _draw_room_hatches),
            ("bays", _draw_bays),
            ("columns", _draw_columns),
            ("walls", _draw_plan_layout),
            ("blocks", _draw_block_insertions),
            ("corridors", _draw_corridors),
            ("voids", _draw_voids),
            ("labels", _draw_labels),
            ("rooms", _draw_cell_rooms),
            ("legend", _draw_legend),
            ("tactile3d", _draw_tactile3d),
            # v3.0 — web-native nouns from RAP Studio
            ("web_regions", _draw_web_regions),
            ("web_walls", _draw_web_walls),
            ("web_columns", _draw_web_columns),
            ("web_phases", _apply_phase_visibility),
        ]
        for step_name, step_fn in steps:
            try:
                step_fn(state)
            except Exception as e:
                warnings += 1
                print("[RC] WARNING: {0} failed: {1}".format(step_name, e))
        if IN_RHINO:
            rs.ZoomExtents()
    finally:
        if IN_RHINO:
            rs.EnableRedraw(True)
    _WEB_STATS["step_warnings"] = warnings
    # Cache stats on the main thread so the TCP listener never calls rs.*
    _collect_stats(state)
    t3 = state.get("tactile3d", {})
    t3_s = "  tactile3D ON (cut {0}ft)".format(t3.get("cut_height", 4.0)) if t3.get("enabled") else ""
    leg_s = "  legend ON" if state.get("legend",{}).get("enabled") else ""
    total_aps = sum(len(b.get("apertures",[])) for b in state["bays"].values())
    nweb = (_WEB_STATS.get("plates", 0) + _WEB_STATS.get("boxes", 0) +
            _WEB_STATS.get("walls", 0) + _WEB_STATS.get("columns", 0))
    print("[RC] Redrawn: {0} bays, {1} apertures, {2} rooms, {3} web elements{4}{5}".format(
        len(state["bays"]), total_aps, len(state.get("rooms", {})), nweb, leg_s, t3_s))
    _audio_feedback(state)


def _collect_stats(state):
    """Populate _cached_stats from the live doc (Rhino) or BUILD_LOG."""
    try:
        layer_stats = {}
        if IN_RHINO:
            for lname in _all_jig_layers():
                if not rs.IsLayer(lname):
                    continue
                objs = rs.ObjectsByLayer(lname)
                layer_stats[lname] = len(objs) if objs else 0
            all_objs = []
            for lname in layer_stats:
                objs = rs.ObjectsByLayer(lname)
                if objs:
                    all_objs.extend(objs)
            bb = {}
            if all_objs:
                bx = rs.BoundingBox(all_objs)
                if bx and len(bx) >= 8:
                    bb = {"min_x": bx[0][0], "min_y": bx[0][1], "min_z": bx[0][2],
                          "max_x": bx[6][0], "max_y": bx[6][1], "max_z": bx[6][2]}
            _cached_stats["bounding_box"] = bb
        else:
            for lname, _kind in BUILD_LOG:
                layer_stats[lname] = layer_stats.get(lname, 0) + 1
            _cached_stats["bounding_box"] = {}
        kind_stats = {}
        for _lname, kind in BUILD_LOG:
            kind_stats[kind] = kind_stats.get(kind, 0) + 1
        _cached_stats["layer_count"] = len(layer_stats)
        _cached_stats["object_count"] = sum(layer_stats.values())
        _cached_stats["layer_stats"] = layer_stats
        _cached_stats["kind_stats"] = kind_stats
        _cached_stats["last_rebuild"] = time.strftime("%Y-%m-%d %H:%M:%S")
        _cached_stats["rebuilt_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
    except Exception as e:
        print("[RC] WARNING: stats collection failed: {0}".format(e))


# ══════════════════════════════════════════════════════════
# TCP QUERY LISTENER
# ══════════════════════════════════════════════════════════
# TCP server on port 1998 that answers read-only queries relayed by the
# RAP Bridge (rap_bridge.py) — the bridge whitelists "ping" and "status".
#
# Supported queries (newline-delimited JSON):
#   {"type": "ping"}                   -> {"status": "ok"}
#   {"type": "status"}                 -> ok/objects/layers/rebuilt_at/schema
#   {"type": "layer_stats"}            -> per-layer object counts
#   {"type": "bounding_box"}           -> world bounding box
#   {"type": "object_count", "params": {"layer": "JIG_COLUMNS"}}
#
# All queries are READ-ONLY and served from _cached_stats, which is
# populated on the main thread during redraw() — the listener thread
# never touches rhinoscriptsyntax.
#
# SECURITY — read before adding a query type. This port is 127.0.0.1 with NO
# token: ANY process on the student's machine can connect to it. The RAP
# Bridge's token protects the HTTP hop only, never this socket. So every query
# type here must be (a) read-only, (b) served from _cached_stats, and (c) a
# NAMED, parameterised operation — never an interpreter. The old "run_script"
# type (exec() behind a substring blocklist) violated all three and has been
# removed; it is refused explicitly below so an old client gets a clear answer.

import socket as _socket

QUERY_PORT = 1998
QUERY_HOST = "127.0.0.1"


def _handle_query(request):
    """Process a single query dict and return a response dict.

    Runs in the listener thread. NEVER calls rhinoscriptsyntax --
    all Rhino data is served from _cached_stats, which is populated
    on the main thread during redraw().
    """
    qtype = request.get("type", "")
    params = request.get("params", {})

    if qtype == "ping":
        return {"status": "ok"}

    if qtype == "status":
        # v3.0 shape (what RAP Studio's Drive panel reads) + the legacy
        # v2.3 "result" block for older desktop clients.
        return {
            "status": "ok",
            "ok": True,
            "objects": _cached_stats.get("object_count", 0),
            "layers": _cached_stats.get("layer_stats", {}),
            "rebuilt_at": _cached_stats.get("rebuilt_at", ""),
            "schema": "rhino_controller_v4.0",
            "result": {
                "layer_count": _cached_stats.get("layer_count", 0),
                "object_count": _cached_stats.get("object_count", 0),
                "last_rebuild": _cached_stats.get("last_rebuild", ""),
            },
        }

    if qtype == "layer_stats":
        return {"status": "ok", "result": _cached_stats.get("layer_stats", {})}

    if qtype == "bounding_box":
        return {"status": "ok", "result": _cached_stats.get("bounding_box", {})}

    if qtype == "object_count":
        layer = params.get("layer", "")
        if layer:
            count = _cached_stats.get("layer_stats", {}).get(layer, 0)
        else:
            count = _cached_stats.get("object_count", 0)
        return {"status": "ok", "result": {"count": count, "layer": layer or "all"}}

    if qtype == "run_script":
        # REMOVED (security). This used to exec() caller-supplied Python, guarded
        # only by a SUBSTRING blocklist of modifying rs.* names. That is not a
        # sandbox: getattr("Add"+"Box"), __import__, eval, aliasing and plain
        # string building all walk straight past it, and anything reachable from
        # exec() runs with the student's full Rhino AND filesystem privileges.
        # The port below is 127.0.0.1 with NO token, so every local process on
        # the machine could have used it. Nothing in RAP ever called this — the
        # bridge's /query whitelist is ping|status — so it is deleted outright
        # rather than hardened. Do not reintroduce it: see
        # platform/plans/rap-rhino-coverage.md ("Remove run_script, do not
        # extend it"). If scripted read-back is ever wanted, add a NAMED,
        # parameterised query type here instead of an interpreter.
        return {"status": "error",
                "message": "run_script has been removed. Use a named read-only query "
                           "(ping, status, layer_stats, bounding_box, object_count)."}

    return {"status": "error", "message": "Unknown query type: '{0}'".format(qtype)}


def _listener_loop():
    """TCP listener thread. Accepts one connection at a time."""
    try:
        srv = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
        srv.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
        srv.bind((QUERY_HOST, QUERY_PORT))
        srv.listen(1)
        srv.settimeout(2.0)
        print("[RC] TCP listener on {0}:{1}".format(QUERY_HOST, QUERY_PORT))
    except Exception as e:
        print("[RC] TCP listener failed to start: {0}".format(e))
        print("[RC] (RAP Bridge will report the watcher unreachable -- rebuilds still work)")
        return

    while True:
        try:
            conn, addr = srv.accept()
        except _socket.timeout:
            continue
        except Exception:
            break

        try:
            conn.settimeout(5.0)
            buf = b""
            while True:
                chunk = conn.recv(4096)
                if not chunk:
                    break
                buf += chunk
                if b"\n" in buf:
                    break

            if buf.strip():
                request = json.loads(buf.strip())
                response = _handle_query(request)
                conn.sendall(json.dumps(response).encode("utf-8") + b"\n")
        except Exception as e:
            try:
                err = json.dumps({"status": "error", "message": str(e)})
                conn.sendall(err.encode("utf-8") + b"\n")
            except Exception:
                pass
        finally:
            try:
                conn.close()
            except Exception:
                pass


def start_listener():
    """Start the TCP query listener in a background thread."""
    t = threading.Thread(target=_listener_loop)
    t.daemon = True
    t.start()
    return t


# ══════════════════════════════════════════════════════════
# FILE WATCHER (Idle-event based, safe for Rhino)
# ══════════════════════════════════════════════════════════
# The watcher hooks Rhino.RhinoApp.Idle so that file checks
# and geometry rebuilds happen on the MAIN thread. Never call
# rhinoscriptsyntax from a background thread — it will crash.
#
# The TCP listener is the only background thread; it only reads
# cached stats and never modifies geometry.

_watcher_state = {
    "last_mtime": 0,
    "last_check": 0,
    "running": False,
}

# Cached stats from the last redraw (main thread).
# The TCP listener reads these instead of calling rhinoscriptsyntax.
_cached_stats = {
    "layer_count": 0,
    "object_count": 0,
    "layer_stats": {},
    "kind_stats": {},
    "bounding_box": {},
    "last_rebuild": "",
    "rebuilt_at": "",
}


def _on_idle(sender, args):
    """Called on Rhino's main thread during idle moments.

    Checks file mtime at most every POLL_SEC seconds. If the
    file changed, reloads and redraws on the main thread.
    """
    now = time.time()
    if now - _watcher_state["last_check"] < POLL_SEC:
        return
    _watcher_state["last_check"] = now
    try:
        mt = _state_mtime()
        if mt > _watcher_state["last_mtime"]:
            _watcher_state["last_mtime"] = mt
            state = _load_state()
            if state:
                redraw(state)
    except Exception as e:
        print("[RC] Watcher error: {0}".format(e))


def start_watcher():
    """Hook the Rhino Idle event to watch for file changes.

    Safe to call multiple times (from repeated exec() calls).
    Unhooks any previous handler first to prevent accumulation.
    """
    if not IN_RHINO:
        print("[RC] Not in Rhino. Using fallback thread watcher.")
        _start_thread_watcher()
        return
    # Remove previous handler if exec() was called again
    if _watcher_state["running"]:
        try:
            Rhino.RhinoApp.Idle -= _on_idle
        except Exception:
            pass
        print("[RC] Stopped previous watcher.")
    Rhino.RhinoApp.Idle += _on_idle
    _watcher_state["running"] = True
    print("[RC] Watching: {0}".format(STATE_FILE))
    print("[RC] Watcher attached to Rhino Idle event.")


def stop_watcher():
    """Unhook the Rhino Idle event."""
    if IN_RHINO and _watcher_state["running"]:
        try:
            Rhino.RhinoApp.Idle -= _on_idle
        except Exception:
            pass
        _watcher_state["running"] = False
        print("[RC] Watcher stopped.")


def _start_thread_watcher():
    """Fallback for non-Rhino (dry-run) testing only."""
    def _loop():
        while True:
            try:
                mt = _state_mtime()
                if mt > _watcher_state["last_mtime"]:
                    _watcher_state["last_mtime"] = mt
                    state = _load_state()
                    if state:
                        redraw(state)
                        print("[DRY RUN] Rebuilt {0} objects into the build log.".format(
                            len(BUILD_LOG)))
                time.sleep(POLL_SEC)
            except KeyboardInterrupt:
                break
            except Exception as e:
                print("[RC] Error: {0}".format(e))
                time.sleep(2)
    t = threading.Thread(target=_loop)
    t.daemon = True
    t.start()


# ══════════════════════════════════════════════════════════
# SELF-TEST MODE (headless)
# ══════════════════════════════════════════════════════════
#   python3 rap_watcher.py --selftest path/to/state.json
# Loads the fixture, runs ONE full rebuild with IN_RHINO=False (every
# geometry call lands in BUILD_LOG), prints a JSON summary as the LAST
# stdout line, and exits 0 on success / 1 on any step warning / 2 on a
# missing or unreadable fixture.

def _selftest_summary(state):
    web_keys = ("plates", "boxes", "walls", "wall_solids", "wall_headers",
                "wall_sills", "openings", "columns")
    warnings = _WEB_STATS.get("step_warnings", 0)
    return {
        "ok": warnings == 0,
        "schema": state.get("schema", ""),
        "in_rhino": IN_RHINO,
        "objects": _cached_stats.get("object_count", 0),
        "layers": _cached_stats.get("layer_stats", {}),
        "kinds": _cached_stats.get("kind_stats", {}),
        "web": dict((k, _WEB_STATS.get(k, 0)) for k in web_keys),
        "bays": len(state.get("bays", {})),
        "phases": [p.get("name", p.get("id", "")) for p in (state.get("web_phases") or [])
                   if isinstance(p, dict)],
        "hidden_phases": _WEB_STATS.get("hidden_phases", []),
        "bay_phase": state.get("web_bay_phase", {}),
        "site_boundary_points": _WEB_STATS.get("site_boundary_points", 0),
        "site_boundary_closed": _WEB_STATS.get("site_boundary_closed", False),
        "rebuilt_at": _cached_stats.get("rebuilt_at", ""),
        "warnings": warnings,
    }


def _run_selftest(fixture_path):
    global STATE_FILE
    STATE_FILE = os.path.abspath(fixture_path)
    state = _load_state()
    if state is None:
        print(json.dumps({"ok": False,
                          "error": "could not load state file: {0}".format(STATE_FILE)}))
        return 2
    redraw(state)
    summary = _selftest_summary(state)
    print(json.dumps(summary))
    return 0 if summary["ok"] else 1


# ── Startup ───────────────────────────────────────────────

_argv = getattr(sys, "argv", []) or []
if (not IN_RHINO) and len(_argv) >= 3 and _argv[1] == "--selftest":
    sys.exit(_run_selftest(_argv[2]))

state = _load_state()
if state:
    try:
        redraw(state)
    except Exception as e:
        print("[RC] Initial redraw failed: {0}".format(e))
        if IN_RHINO:
            try:
                rs.EnableRedraw(True)
            except Exception:
                pass
    start_watcher()
    if IN_RHINO:
        # Rhino 8 runs CPython, and the listener only reads _cached_stats
        # (never rhinoscriptsyntax), so it is safe on a background thread.
        start_listener()
    print("[RC] Ready. Push from RAP Studio (or edit state.json) and the "
          "viewport updates automatically.")
else:
    print("[RC] No state file at {0}".format(STATE_FILE))
    print("  Set STATE_FILE before exec(), or run the RAP Bridge and push "
          "from RAP Studio to create one.")
