Skill: elliotbian/grasshopper-generator (version 1.0.0)

--- name: grasshopper-generator description: "Generate Rhino 7 Grasshopper (.ghx) XML files from natural language descriptions or images. Build parametric definitions with native GH components, GhPython scripts, and wiring. Supports concept design: twisted towers, curved facades, Voronoi patterns, diagrids, parametric surfaces, mesh operations, and custom Python geometry. Triggers: grasshopper, generate ghx, parametric definition, GH file, grasshopper definition, 参数化, 生成gh文件, when user wants a .ghx file." --- # Grasshopper Generator Generate Rhino 7 Grasshopper (.ghx) files programmatically. ## Workflow ### 1. Analyze Input **Image** → Identify form (tower/dome/surface/facade), key features (twist/array/voronoi/diagrid), proportions. **Text** → Parse the same from description. ### 2. Design the Graph ``` Sliders → Geometry → Transforms → Booleans → Output ``` Component priority: 1. Native GH components (see `references/component_guids.json` for 152 known GUIDs) 2. GhPython Script for complex custom logic 3. Third-party plugins only when explicitly requested Common patterns: | Form | Key Components | |------|---------------| | Twisted tower | Rectangle → Rotate → Extrude → Series | | Curved facade | Curve → Divide → Orient → Surface | | Voronoi | Populate 2D → Voronoi → Boundary Surfaces | | Diagrid | Hexagonal → Scale → Loft | | Dome | Circle → Rotate → Sweep1 | | Custom logic | GhPython Script | ### 3. Generate .ghx ```python import sys; sys.path.insert(0, 'SKILL_DIR/scripts') from ghx_generator import GHXGenerator gen = GHXGenerator("Definition Name", "Description") r = gen.add_slider("Radius", 20, 1, 100, x=50, y=50) c = gen.add_component("Circle", inputs=["Base Plane", "Radius"], outputs=["Circle"], x=300, y=50) gen.connect(r, "output", c, "Radius") gen.save("output.ghx") ``` ### 4. GhPython Fallback For complex geometry not achievable with native components: ```python py = gen.add_python("Custom", code, inputs=["x", "y"], outputs=["a"], x=300, y=200) ``` GhPython has full `Rhino.Geometry` API. Input variables available by name. Assign `a = result` for output. ### 5. Deliver Save .ghx and send to user. Note adjustable parameters (sliders), definition purpose, and any plugin dependencies. ## File Locations - **Generator:** `scripts/ghx_generator.py` — full API with `add_slider`, `add_component`, `add_python`, `connect`, `save` - **GUID database:** `references/component_guids.json` — 152 authentic component GUIDs extracted from real .gh files - **Reference template:** `references/template_voronoi.ghx` — real Grasshopper file for structural reference ## Notes - Output is .ghx (XML), fully compatible with Rhino 7 - All GUIDs extracted from real Grasshopper installations - For components not in the database, GhPython Script handles everything - Supports wiring via `connect(source_ref, source_param, target_ref, target_param)`

path: README.md
content: # Grasshopper Generator

An OpenClaw skill that generates Rhino 7 Grasshopper (.ghx) files from natural language descriptions or images.

## What It Does

- Generates valid `.ghx` (XML) files compatible with Rhino 7 Grasshopper
- Supports 152 authentic native Grasshopper components with real GUIDs
- GhPython Script components for custom geometry logic
- Automatic wiring between components
- Built-in validation to ensure generated files are structurally correct

## Quick Start

### As an OpenClaw Skill

Install the `.skill` file and use it via your agent:

```
> Generate a parametric twisted tower Grasshopper definition
```

### Standalone Python Library

```python
from ghx_generator import GHXGenerator

gen = GHXGenerator("My Definition", "Description")

# Add a slider
radius = gen.add_slider("Radius", 20, 1, 100, x=50, y=50)

# Add a native component
circle = gen.add_component("Circle", inputs=["Base Plane", "Radius"],
                           outputs=["Circle"], x=300, y=50)

# Wire them
gen.connect(radius, "output", circle, "Radius")

# Save
gen.save("output.ghx")
```

### CLI

```bash
# Generate demo file
python scripts/ghx_generator.py

# Custom output
python scripts/ghx_generator.py -o my_definition.ghx

# List all known components
python scripts/ghx_generator.py --list

# Validate a .ghx file
python scripts/ghx_generator.py --validate myfile.ghx
```

## Components Database

152 authentic component GUIDs extracted from real Grasshopper installations, organized by category:

| Category | Examples |
|----------|---------|
| Params | Number Slider, Panel, Point, Curve |
| Math | Addition, Multiplication, Division |
| Vector | Unit X/Y/Z, Construct Point, Planes |
| Curve | Line, Circle, Rectangle, Divide Curve, Voronoi |
| Surface | Loft, Extrude, Boundary Surfaces, Sweep1 |
| Transform | Move, Rotate, Scale, Mirror, Orient |
| Intersect | Solid Union, Solid Difference |
| Mesh | Mesh Brep, Mesh Join |
| Script | GhPython Script |

For components not in the database, use GhPython Script — it has full `Rhino.Geometry` API access.

## File Structure

```
grasshopper-generator/
├── SKILL.md                    # Skill definition and workflow
├── scripts/
│   └── ghx_generator.py        # Core generator with CLI
└── references/
    ├── component_guids.json    # 152 authentic component GUIDs
    └── template_voronoi.ghx    # Reference template from real GH file
```

## GhPython Example

```python
py = gen.add_python("Twisted Tower", """
import Rhino.Geometry as rg
import math

pts = []
for i in range(floors):
    t = i / max(floors - 1, 1)
    angle = math.radians(twist * t)
    x = radius * math.cos(angle)
    y = radius * math.sin(angle)
    z = height * t
    pts.append(rg.Point3d(x, y, z))

a = rg.NurbsCurve.CreateInterpolated(pts, 3)
""", inputs=["radius", "height", "twist", "floors"],
    outputs=["a"], x=300, y=200)
```

## License

MIT


path: references/component_guids.json
content: {
  "_meta": {
    "version": "3.0",
    "total_files": 33,
    "total_components": 152
  },
  "components": {
    "params": {
      "Number Slider": "57da07bd-ecab-415d-9d86-af36d7073abc",
      "Panel": "59e0b89a-e487-49f8-bab8-b5bab16be14c",
      "Boolean Toggle": "2e78987b-9dfb-42a2-8b76-3923ac8bd91a",
      "Point": "fbac3e32-f100-4292-8692-77240a42fd1a",
      "Curve": "d5967b9f-e8ee-436b-a8ad-29fdcecf32d5",
      "Geometry": "ac2bc2cb-70fb-4dd5-9c78-7e1ea97fe278",
      "Colour Swatch": "9c53bac0-ba66-40bd-8154-ce9829b9db1a",
      "File Path": "06953bda-1d37-4d58-9b38-4b3c74e54c8f",
      "Relay": "b6236720-8d88-4289-93c3-ac4c99f9b97b",
      "Cluster": "f31d8d7a-7536-4ac8-9c96-fde6ecda4d0a",
      "Number": "3e8ca6be-fda8-4aaf-b5c0-3c54c8bb7312",
      "Integer": "2e3ab970-8545-46bb-836c-1c11e5610bce",
      "Sketch": "2844fec5-142d-4381-bd5d-4cbcef6d6fed",
      "MD Slider": "318dacd7-9073-4ede-b043-a0c132eb77e0"
    },
    "math": {
      "Addition": "a0d62394-a118-422d-abb3-6af115c75b25",
      "Subtraction": "9c007a04-d0d9-48e4-9da3-9ba142bc4d46",
      "Multiplication": "ce46b74e-00c9-43c4-805a-193b69ea4a11",
      "Division": "9c85271f-89fa-4e9f-9f4a-d75802120ccc",
      "Negative": "a3371040-e552-4bc8-b0ff-10a840258e88",
      "Radians": "a4cd2751-414d-42ec-8916-476ebf62d7fe"
    },
    "vector": {
      "Construct Point": "3581f42a-9592-4549-bd6b-1c0fc39d067b",
      "Unit X": "79f9fbb3-8f1d-4d9a-88a9-f7961b1012cd",
      "Unit Y": "d3d195ea-2d59-4ffa-90b1-8b7ff3369f69",
      "Unit Z": "9103c240-a6a9-4223-9b42-dbd19bf38e2b",
      "XY Plane": "17b7152b-d30d-4d50-b9ef-c9fe25576fc2",
      "YZ Plane": "fad344bc-09b1-4855-a2e6-437ef5715fe3",
      "Construct Plane": "bc3e379e-7206-4e7b-b63a-ff61f4b38a3e",
      "Construct Domain": "d1a28e95-cf96-4936-bf34-8bf142d731bf"
    },
    "curve": {
      "Line": "8529dbdf-9b6f-42e9-8e1f-c7a2bde56a70",
      "PolyLine": "71b5b089-500a-4ea6-81c5-2f960441a0e8",
      "Circle": "807b86e3-be8d-4970-92b5-f8cdcb45b06b",
      "Rectangle": "d93100b6-d50b-40b2-831a-814659dc38e3",
      "Divide Curve": "2162e72e-72fc-4bf8-9459-d4d82fa8aa14",
      "Perp Frame": "69f3e5ee-4770-44b3-8851-ae10ae555398",
      "Curve Middle": "ccc7b468-e743-4049-891f-299432545898",
      "Curve Closest Point": "2dc44b22-b1dd-460a-a704-6462d6e91096",
      "Hexagonal": "125dc122-8544-4617-945e-bb9a0c101c50",
      "Populate 2D": "e2d958e8-9f08-44f7-bf47-a684882d0b2a",
      "Voronoi": "a4011be0-1c91-45bd-8280-17dd3a9f46f1"
    },
    "surface": {
      "Loft": "a7a41d0a-2188-4f7a-82cc-1a2c4e4ec850",
      "Extrude": "962034e9-cc27-4394-afc4-5c16e3447cf9",
      "Boundary Surfaces": "d51e9b65-aa4e-4fd6-976c-cef35d421d05",
      "Cylinder": "0373008a-80ee-45be-887d-ab5a244afc29",
      "Sphere": "dabc854d-f50e-408a-b001-d043c7de151d",
      "Center Box": "28061aae-04fb-4cb5-ac45-16f3b66bc0a4",
      "Sweep1": "bb6666e7-d0f4-41ec-a257-df2371619f13",
      "Surface Split": "7db14002-c09c-4d7b-9f80-e4e2b00dfa1d",
      "Area": "2e205f24-9279-47b2-b414-d06dcd0b21a7",
      "Evaluate Surface": "353b206e-bde5-4f02-a913-b3b8a977d4b9",
      "Triangulate": "3fba11d5-b30a-4146-8d80-d591e7a0a287"
    },
    "transform": {
      "Move": "e9eb1dcf-92f6-4d4d-84ae-96222d60f56b",
      "Move To Point": "97aabe15-99df-452d-ab10-fc3303107bed",
      "Rotate": "b7798b74-037e-4f0c-8ac7-dc1043d093e0",
      "Rotate 3D": "3dfb9a77-6e05-4016-9f20-94f78607d672",
      "Mirror": "f12daa2f-4fd5-48c1-8ac3-5dea476912ca",
      "Scale": "4d2a06bd-4b0f-4c65-9ee0-4220e4c01703",
      "Scale NU": "290f418a-65ee-406a-a9d0-35699815b512",
      "Orient": "378d0690-9da0-4dd1-ab16-1d15246e7c22",
      "Linear Array": "e87db220-a0a0-4d67-a405-f97fd14b2d7a",
      "Project": "d7ee52ff-89b8-4d1a-8662-3e0dd391d0af",
      "Distance": "93b8e93d-f932-402c-b435-84be04d87666"
    },
    "intersect": {
      "Solid Union": "10434a15-da85-4281-bb64-a2b3a995b9c6",
      "Solid Difference": "fab11c30-2d9c-4d15-ab3c-2289f1ae5c21",
      "Deconstruct Brep": "8d372bdc-9800-45e9-8a26-6e33c5253e21"
    },
    "mesh": {
      "Mesh Brep": "60e7defa-8b21-4ee1-99aa-a9223d6134ff",
      "Mesh Join": "4bc9dbbf-fec8-4348-a3af-e33e7edc8e7b",
      "Simple Mesh": "c3f9cea5-6fd4-4db5-959b-08cd08ed9fe1",
      "Cap Holes": "b648d933-ddea-4e75-834c-8f6f3793e311"
    },
    "sets": {
      "List Item": "59daf374-bc21-4a5e-8282-5504fb7ae9ae",
      "Merge": "3cadddef-1e2b-4c09-9390-0e8f78f7609f",
      "Transform": "610e689b-5adc-47b3-af8f-e3a32b7ea341",
      "Graft Tree": "87e1d9ef-088b-4d30-9dda-8a7448a17329",
      "Flatten Tree": "f80cfe18-9510-4b89-8301-8e58faf423bb",
      "Remap Numbers": "2fcc2743-8339-4cdf-a046-a1f17439191d",
      "Bounding Box": "0bb3d234-9097-45db-9998-621639c87d3b"
    },
    "display": {
      "Custom Preview": "537b0419-bbc2-4ff4-bf08-afe526367b2c",
      "Quick Graph": "2b69bf71-4e69-43aa-b7be-4f6ce7e45bef",
      "Kaleidoscope": "b90eaa92-6e38-4054-a915-afcf486224b3"
    },
    "script": {
      "GhPython Script": "410755b1-224a-4c1e-a407-bf32fb45ea7e",
      "Python Script": "410755b1-224a-4c1e-a407-bf32fb45ea7e",
      "C# Script": "a9a8ebd2-fff5-4c44-a8f5-739736d129ba",
      "VB Script": "079bd9bd-54a0-41d4-98af-db999015f63d",
      "Read File": "6587fcbf-e3cf-480a-b2f5-641794474194"
    },
    "other": {
      "&Weaverbird's Catmull-Clark Subdivision": "4098ec7a-819a-4ced-9eee-86835d7e21c9",
      "Arc": "bb59bffc-f54c-4682-9778-f6c3fe74fce3",
      "Brep ": "4fe828e8-fa95-4cc5-9a8c-c33856ecc783",
      "Brep Edges": "0148a65d-6f42-414a-9db7-9a9b2eb78437",
      "Brep Join": "1addcc85-b04e-46e6-bd4a-6f6c93bf7efd",
      "Button": "a8b97322-2d53-47cd-905e-b932c3ccd74e",
      "Closed": "323f3245-af49-4489-8677-7a2c73664077",
      "Colour HSL": "a45d68b3-c299-4b17-bdae-7975f216cec6",
      "Colour Picker": "339c0ee1-cf11-444f-8e10-65c9150ea755",
      "Construct Mesh": "e2c0f9db-a862-4bd9-810c-ef2610e7a56f",
      "Control Points": "424eb433-2b3a-4859-beaf-804d8af0afd7",
      "Cosine": "d2d2a900-780c-4d58-9a35-1f9d8d35df6f",
      "Cull Index": "501aecbb-c191-4d13-83d6-7ee32445ac50",
      "Curve ": "84627490-0fb2-4498-8138-ad134ee4cb36",
      "Deconstruct": "9abae6b7-fa1d-448c-9209-4a8155345841",
      "Deconstruct Mesh": "ba2d8f57-0738-42b4-b5a5-fe4d853517eb",
      "Deconstruct Plane": "3cd2949b-4ea8-4ffb-a70c-5c380f9f46ea",
      "Deconstuct Rectangle": "e5c33a79-53d5-4f2b-9a97-d3d45c780edc",
      "Dispatch": "d8332545-21b2-4716-96e3-8559a9876e17",
      "Divide Length": "fdc466a9-d3b8-4056-852a-09dba0f74aca",
      "Dot Product": "43b9ea8f-f772-40f2-9880-011a9c3cbbb0",
      "End Points": "11bbd48b-bb0a-4f1b-8167-fa297590390d",
      "Explode": "afb96615-c59a-45c9-9cac-e27acb1c7ca0",
      "Flip Matrix": "41aa4112-9c9b-42f4-847e-503b9d90e4c7",
      "GL Mesh Shader": "ff4eb1f7-7ad6-47cd-bd2d-c50c87e13703",
      "GL Shader": "e61cc873-5643-4154-b97f-3a743be90ae8",
      "Gradient": "6da9f120-3ad0-4b6e-9fe0-f8cde3a649b7",
      "Graph Mapper": "bc984576-7aa6-491f-a91d-e444c33675a7",
      "Interpolate": "2b2a4145-3dff-41d4-a8de-1ea9d29eef33",
      "Join Curves": "8073a420-6bec-49e3-9b18-367f6fd76ac3",
      "Larger Than": "30d58600-1aab-42db-80a3-f1ea6c4269a0",
      "Length": "c75b62fa-0a33-4da7-a5bd-03fd0068fd93",
      "Line SDL": "4c619bc9-39fd-4717-82a6-1e07ea237bbe",
      "List Length": "1817fd29-20ae-4503-b542-f0fb651e67d7",
      "Maximum": "0d1e2027-f153-460d-84c0-f9af431b08cb",
      "Mesh": "1e936df3-0eea-4246-8549-514cb8862b7a",
      "Mesh Colours": "d2cedf38-1149-4adc-8dbf-b06571cb5106",
      "Mesh Sphere": "0a391eac-5048-443c-9c1b-f592299b6dd6",
      "Minimum": "57308b30-772d-4919-ac67-e86c18f3a996",
      "Nurbs Curve": "dde71aef-d6ed-40a6-af98-6b0673983c82",
      "Partition List": "5a93246d-2595-4c28-bc2d-90657634f92a",
      "Path Generator": "8efb8932-41eb-48ef-8b09-798843a8610c",
      "Pipe": "c277f778-6fdf-4890-8f78-347efb23c406",
      "Plane": "4f8984c4-7c7a-4d69-b0a2-183cbb330d20",
      "Plane Normal": "cfb6b17f-ca82-4f5d-b604-d4f69f569de3",
      "Plane Surface": "439a55a5-2f9e-4f66-9de2-32f24fec2ef5",
      "Polygon": "845527a6-5cea-4ae9-a667-96ae1667a4e8",
      "Populate Geometry": "c8cb6a5c-2ffd-4095-ba2a-5c35015e09e4",
      "Power": "78fed580-851b-46fe-af2f-6519a9d378e0",
      "Random": "2ab17f9a-d852-4405-80e1-938c5e57e78d",
      "Range": "9445ca40-cc73-4861-a455-146308676855",
      "Rebuild Curve": "9333c5b3-11f9-423c-bbb5-7e5156430219",
      "Revolution": "cdee962f-4202-456b-a1b4-f3ed9aa0dc29",
      "Robot": "dccf6ccb-7463-4845-96c3-eb494170337c",
      "Robot Preset": "33d7467a-17b3-43db-b26e-2af153aa8491",
      "Robot Tool": "69f6df47-a0e2-45ec-bc24-38460dfe11c6",
      "Scri": "7f5c6c55-f846-4a08-9c9a-cfdc285cc6fe",
      "Series": "e64c5fb1-845c-4ab1-8911-5f338516ba67",
      "Settings (Custom)": "4a0180e5-d8f9-46e7-bd34-ced804601462",
      "Shift Paths": "2d61f4e0-47c5-41d6-a41d-6afa96ee63af",
      "Stream Filter": "eeafc956-268e-461d-8e73-ee05c6f72c01",
      "Tangent Arcs": "f1c0783b-60e9-42a7-8081-925bc755494c",
      "Text Join": "1274d51a-81e6-4ccf-ad1f-0edf4c769cac",
      "Unweld Mesh": "47814a17-ca9e-4305-9400-3a9c8d71c19d",
      "Vector": "16ef3e75-e315-4899-b531-d3166b42dac9",
      "Voronoi 3D": "ba9bb57a-61cf-4207-a1c4-994e371ba4f9",
      "Weaverbird's Mesh Thicken": "761c4e3d-141b-9770-6525-2303925136c3",
      "removeDuplicateLines": "7a02aec5-143c-4319-836d-a550b6b65d1d",
      "x/2": "9df5e896-552d-4c8c-b9ca-4fc147ffa022"
    }
  }
}

path: scripts/ghx_generator.py
content: #!/usr/bin/env python3
"""
GHX Generator — Generate Rhino 7 Grasshopper (.ghx) XML files.

Creates valid .ghx files with native components, GhPython scripts, sliders,
and wiring. All component GUIDs are authentic, extracted from real Grasshopper
installations.

Usage:
    from ghx_generator import GHXGenerator

    gen = GHXGenerator("My Definition", "Optional description")

    # Add sliders for user-adjustable parameters
    radius = gen.add_slider("Radius", 20, 1, 100, x=50, y=50)

    # Add native components (resolved from GUID database)
    circle = gen.add_component("Circle", inputs=["Base Plane", "Radius"],
                               outputs=["Circle"], x=300, y=50)

    # Wire them together
    gen.connect(radius, "output", circle, "Radius")

    # Save
    gen.save("output.ghx")

CLI:
    python ghx_generator.py                    # Generate demo cylinder
    python ghx_generator.py -o my_file.ghx     # Custom output path
    python ghx_generator.py --list             # List known components
    python ghx_generator.py --validate file.ghx  # Validate a .ghx file
"""

from __future__ import annotations

import json
import os
import sys
import uuid
import xml.etree.ElementTree as ET
from xml.dom import minidom
from typing import Optional

# ---------------------------------------------------------------------------
# GHX XML type codes
# ---------------------------------------------------------------------------
TC_BOOL = 1
TC_INT32 = 3
TC_SINGLE = 5
TC_DOUBLE = 6
TC_DATE = 8
TC_GUID = 9
TC_STRING = 10
TC_POINT = 30
TC_POINTF = 31
TC_RECTF = 35
TC_COLOR = 36
TC_VERSION = 80

# ---------------------------------------------------------------------------
# XML helpers
# ---------------------------------------------------------------------------

def _new_guid() -> str:
    """Generate a new random GUID string."""
    return str(uuid.uuid4())


def _item(name: str, value, type_name: str = "gh_string",
          type_code: int = TC_STRING, index: Optional[int] = None) -> ET.Element:
    """Create an XML <item> element."""
    attrs = {"name": name, "type_name": type_name, "type_code": str(type_code)}
    if index is not None:
        attrs["index"] = str(index)
    el = ET.Element("item", attrs)
    if value is not None:
        el.text = str(value)
    return el


def _rect(x, y, w, h) -> ET.Element:
    """Create a Bounds rectangle <item>."""
    el = _item("Bounds", None, "gh_drawing_rectanglef", TC_RECTF)
    for tag, val in [("X", x), ("Y", y), ("W", w), ("H", h)]:
        sub = ET.SubElement(el, tag)
        sub.text = str(val)
    return el


def _pivot(x, y) -> ET.Element:
    """Create a Pivot point <item>."""
    el = _item("Pivot", None, "gh_drawing_pointf", TC_POINTF)
    for tag, val in [("X", x), ("Y", y)]:
        sub = ET.SubElement(el, tag)
        sub.text = str(round(val, 2))
    return el


def _chunk(name: str, index: Optional[int] = None) -> ET.Element:
    """Create a <chunk> element."""
    attrs = {"name": name}
    if index is not None:
        attrs["index"] = str(index)
    return ET.Element("chunk", attrs)


def _wrap_items(items: list[ET.Element]) -> ET.Element:
    """Wrap items in <items count=N>."""
    el = ET.Element("items", {"count": str(len(items))})
    for item in items:
        el.append(item)
    return el


def _wrap_chunks(chunks: list[ET.Element]) -> ET.Element:
    """Wrap chunks in <chunks count=N>."""
    el = ET.Element("chunks", {"count": str(len(chunks))})
    for chunk in chunks:
        el.append(chunk)
    return el


# ---------------------------------------------------------------------------
# Component reference (returned by add_* methods for wiring)
# ---------------------------------------------------------------------------

class ComponentRef:
    """Reference to a placed component, used for wiring via connect()."""

    def __init__(self, instance_guid: str, name: str,
                 input_guids: Optional[dict[str, str]] = None,
                 output_guids: Optional[dict[str, str]] = None):
        self.instance_guid = instance_guid
        self.name = name
        self.input_guids = input_guids or {}    # param_name → InstanceGuid
        self.output_guids = output_guids or {}   # param_name → InstanceGuid

    def __repr__(self):
        return f"ComponentRef({self.name!r}, guid={self.instance_guid[:8]}…)"


# ---------------------------------------------------------------------------
# Main generator class
# ---------------------------------------------------------------------------

class GHXGenerator:
    """Generate Grasshopper .ghx (XML) files.

    Loads component GUIDs from references/component_guids.json automatically.
    """

    _guid_db: dict[str, str] = {}
    _guids_loaded: bool = False

    @classmethod
    def _load_guids(cls):
        if cls._guids_loaded:
            return
        guids_path = os.path.join(
            os.path.dirname(os.path.abspath(__file__)),
            "..", "references", "component_guids.json"
        )
        if os.path.exists(guids_path):
            with open(guids_path, encoding="utf-8") as f:
                data = json.load(f)
            for _cat, comps in data.get("components", {}).items():
                for name, guid in comps.items():
                    cls._guid_db[name] = guid
        cls._guids_loaded = True

    def __init__(self, name: str = "Untitled", description: str = ""):
        self._load_guids()
        self.name = name
        self.description = description
        self.objects: list[tuple[ET.Element, ComponentRef]] = []
        self._doc_id = _new_guid()
        self._auto_x = 100

    def _resolve_guid(self, component_name: str) -> Optional[str]:
        """Look up authentic ComponentGuid from database."""
        if component_name in self._guid_db:
            return self._guid_db[component_name]
        lower = component_name.lower()
        for k, v in self._guid_db.items():
            if k.lower() == lower:
                return v
        return None

    # -----------------------------------------------------------------------
    # Public API: add components
    # -----------------------------------------------------------------------

    def add_slider(self, nickname: str, value: float, min_val: float = 0,
                   max_val: float = 100, digits: int = 3,
                   x: Optional[float] = None, y: Optional[float] = None) -> ComponentRef:
        """Add a Number Slider component."""
        if x is None:
            x = self._auto_x
            self._auto_x += 250
        if y is None:
            y = 50

        instance_guid = _new_guid()
        comp_guid = self._resolve_guid("Number Slider") or _new_guid()

        obj = _chunk("Object")
        obj.append(_wrap_items([
            _item("GUID", comp_guid, "gh_guid", TC_GUID),
            _item("Name", "Number Slider", "gh_string", TC_STRING),
        ]))

        container = _chunk("Container")
        container.append(_wrap_items([
            _item("Description", "Numeric slider for single values", "gh_string", TC_STRING),
            _item("InstanceGuid", instance_guid, "gh_guid", TC_GUID),
            _item("Name", "Number Slider", "gh_string", TC_STRING),
            _item("NickName", nickname, "gh_string", TC_STRING),
            _item("Optional", "false", "gh_bool", TC_BOOL),
            _item("SourceCount", "0", "gh_int32", TC_INT32),
        ]))

        attrs = _chunk("Attributes")
        attrs.append(_wrap_items([_rect(x, y, 200, 20), _pivot(x, y + 10)]))

        slider = _chunk("Slider")
        slider.append(_wrap_items([
            _item("Digits", digits, "gh_int32", TC_INT32),
            _item("GripDisplay", "1", "gh_int32", TC_INT32),
            _item("Interval", "1", "gh_int32", TC_INT32),
            _item("Max", max_val, "gh_double", TC_DOUBLE),
            _item("Min", min_val, "gh_double", TC_DOUBLE),
            _item("SnapCount", "0", "gh_int32", TC_INT32),
            _item("Value", value, "gh_double", TC_DOUBLE),
        ]))

        container.append(_wrap_chunks([attrs, slider]))
        obj.append(_wrap_chunks([container]))

        ref = ComponentRef(instance_guid, nickname,
                           output_guids={"output": instance_guid})
        self.objects.append((obj, ref))
        return ref

    def add_component(self, name: str, nickname: Optional[str] = None,
                      x: Optional[float] = None, y: Optional[float] = None,
                      inputs: Optional[list[str]] = None,
                      outputs: Optional[list[str]] = None) -> ComponentRef:
        """Add a standard Grasshopper component by name.

        Name is resolved to an authentic GUID from the database.
        Falls back to a random GUID if not found (will cause a warning in Rhino).
        """
        nickname = nickname or name
        if x is None:
            x = self._auto_x
            self._auto_x += 200
        if y is None:
            y = 50

        instance_guid = _new_guid()
        comp_guid = self._resolve_guid(name) or _new_guid()

        obj = _chunk("Object")
        obj.append(_wrap_items([
            _item("GUID", comp_guid, "gh_guid", TC_GUID),
            _item("Name", name, "gh_string", TC_STRING),
        ]))

        container = _chunk("Container")
        container.append(_wrap_items([
            _item("Description", "", "gh_string", TC_STRING),
            _item("InstanceGuid", instance_guid, "gh_guid", TC_GUID),
            _item("Name", name, "gh_string", TC_STRING),
            _item("NickName", nickname, "gh_string", TC_STRING),
        ]))

        sub_chunks: list[ET.Element] = []

        # Attributes
        n_in = len(inputs) if inputs else 0
        n_out = len(outputs) if outputs else 0
        comp_h = max(44, 20 * (n_in + n_out) + 4)
        attrs = _chunk("Attributes")
        attrs.append(_wrap_items([
            _rect(x, y, 100, comp_h),
            _pivot(x + 50, y + comp_h / 2),
        ]))
        sub_chunks.append(attrs)

        # Input params
        input_guids: dict[str, str] = {}
        if inputs:
            for i, inp_name in enumerate(inputs):
                inp_guid = _new_guid()
                input_guids[inp_name] = inp_guid
                inp_chunk = _chunk("param_input", index=i)
                inp_chunk.append(_wrap_items([
                    _item("Description", "", "gh_string", TC_STRING),
                    _item("InstanceGuid", inp_guid, "gh_guid", TC_GUID),
                    _item("Name", inp_name, "gh_string", TC_STRING),
                    _item("NickName", inp_name, "gh_string", TC_STRING),
                    _item("Optional", "false", "gh_bool", TC_BOOL),
                    _item("SourceCount", "0", "gh_int32", TC_INT32),
                ]))
                inp_attrs = _chunk("Attributes")
                inp_attrs.append(_wrap_items([
                    _rect(x + 2, y + 2 + i * 20, 18, 20),
                    _pivot(x + 11, y + 12 + i * 20),
                ]))
                inp_chunk.append(_wrap_chunks([inp_attrs]))
                sub_chunks.append(inp_chunk)

        # Output params
        output_guids: dict[str, str] = {}
        if outputs:
            for i, out_name in enumerate(outputs):
                out_guid = _new_guid()
                output_guids[out_name] = out_guid
                out_chunk = _chunk("param_output", index=i)
                out_chunk.append(_wrap_items([
                    _item("Description", "", "gh_string", TC_STRING),
                    _item("InstanceGuid", out_guid, "gh_guid", TC_GUID),
                    _item("Name", out_name, "gh_string", TC_STRING),
                    _item("NickName", out_name, "gh_string", TC_STRING),
                    _item("Optional", "false", "gh_bool", TC_BOOL),
                    _item("SourceCount", "0", "gh_int32", TC_INT32),
                ]))
                out_attrs = _chunk("Attributes")
                out_attrs.append(_wrap_items([
                    _rect(x + 80, y + 2 + i * 20, 18, 20),
                    _pivot(x + 89, y + 12 + i * 20),
                ]))
                out_chunk.append(_wrap_chunks([out_attrs]))
                sub_chunks.append(out_chunk)

        container.append(_wrap_chunks(sub_chunks))
        obj.append(_wrap_chunks([container]))

        ref = ComponentRef(instance_guid, nickname, input_guids, output_guids)
        self.objects.append((obj, ref))
        return ref

    def add_python(self, nickname: str, code: str,
                   inputs: Optional[list[str]] = None,
                   outputs: Optional[list[str]] = None,
                   x: Optional[float] = None,
                   y: Optional[float] = None) -> ComponentRef:
        """Add a GhPython Script component with custom Python code.

        Code has access to input variables by name. Assign `a = result` for output.
        Full Rhino.Geometry API is available.
        """
        inputs = inputs or []
        outputs = outputs or []
        if x is None:
            x = self._auto_x
            self._auto_x += 200
        if y is None:
            y = 50

        instance_guid = _new_guid()
        comp_guid = self._resolve_guid("GhPython Script") or _new_guid()

        obj = _chunk("Object")
        obj.append(_wrap_items([
            _item("GUID", comp_guid, "gh_guid", TC_GUID),
            _item("Name", "GhPython Script", "gh_string", TC_STRING),
        ]))

        container = _chunk("Container")
        container.append(_wrap_items([
            _item("CodeInput", code, "gh_string", TC_STRING),
            _item("Description", "A Python script component", "gh_string", TC_STRING),
            _item("InstanceGuid", instance_guid, "gh_guid", TC_GUID),
            _item("Name", "GhPython Script", "gh_string", TC_STRING),
            _item("NickName", nickname, "gh_string", TC_STRING),
        ]))

        sub_chunks: list[ET.Element] = []

        # Attributes
        attrs = _chunk("Attributes")
        attrs.append(_wrap_items([_rect(x, y, 150, 80), _pivot(x + 75, y + 40)]))
        sub_chunks.append(attrs)

        # ParameterData (input/output IDs)
        param_data = _chunk("ParameterData")
        param_items = [_item("InputCount", len(inputs), "gh_int32", TC_INT32)]
        for _ in inputs:
            param_items.append(_item("InputId", _new_guid(), "gh_guid", TC_GUID))
        param_items.append(_item("OutputCount", len(outputs), "gh_int32", TC_INT32))
        for _ in outputs:
            param_items.append(_item("OutputId", _new_guid(), "gh_guid", TC_GUID))
        param_data.append(_wrap_items(param_items))
        sub_chunks.append(param_data)

        # Input params
        input_guids: dict[str, str] = {}
        for i, inp_name in enumerate(inputs):
            inp_guid = _new_guid()
            input_guids[inp_name] = inp_guid
            inp_chunk = _chunk("InputParam", index=i)
            inp_chunk.append(_wrap_items([
                _item("AllowTreeAccess", "true", "gh_bool", TC_BOOL),
                _item("Description", f"The {inp_name} script variable", "gh_string", TC_STRING),
                _item("InstanceGuid", inp_guid, "gh_guid", TC_GUID),
                _item("Name", inp_name, "gh_string", TC_STRING),
                _item("NickName", inp_name, "gh_string", TC_STRING),
                _item("Optional", "false", "gh_bool", TC_BOOL),
                _item("ScriptParamAccess", "0", "gh_int32", TC_INT32),
                _item("ShowTypeHints", "true", "gh_bool", TC_BOOL),
                _item("SourceCount", "0", "gh_int32", TC_INT32),
                _item("TypeHintID", [REDACTED_SECRET], "gh_guid", TC_GUID),
            ]))
            inp_attrs = _chunk("Attributes")
            inp_attrs.append(_wrap_items([
                _rect(x + 2, y + 2 + i * 20, 18, 20),
                _pivot(x + 11, y + 12 + i * 20),
            ]))
            inp_chunk.append(_wrap_chunks([inp_attrs]))
            sub_chunks.append(inp_chunk)

        # Output params
        output_guids: dict[str, str] = {}
        for i, out_name in enumerate(outputs):
            out_guid = _new_guid()
            output_guids[out_name] = out_guid
            out_chunk = _chunk("OutputParam", index=i)
            out_chunk.append(_wrap_items([
                _item("Description", f"The {out_name} output variable", "gh_string", TC_STRING),
                _item("InstanceGuid", out_guid, "gh_guid", TC_GUID),
                _item("Name", out_name, "gh_string", TC_STRING),
                _item("NickName", out_name, "gh_string", TC_STRING),
                _item("Optional", "false", "gh_bool", TC_BOOL),
                _item("SourceCount", "0", "gh_int32", TC_INT32),
            ]))
            out_attrs = _chunk("Attributes")
            out_attrs.append(_wrap_items([
                _rect(x + 130, y + 2 + i * 20, 18, 20),
                _pivot(x + 139, y + 12 + i * 20),
            ]))
            out_chunk.append(_wrap_chunks([out_attrs]))
            sub_chunks.append(out_chunk)

        container.append(_wrap_chunks(sub_chunks))
        obj.append(_wrap_chunks([container]))

        ref = ComponentRef(instance_guid, nickname, input_guids, output_guids)
        self.objects.append((obj, ref))
        return ref

    # -----------------------------------------------------------------------
    # Public API: wiring
    # -----------------------------------------------------------------------

    def connect(self, source: ComponentRef, source_param: str,
                target: ComponentRef, target_param: str) -> None:
        """Wire source output to target input.

        For sliders, source_param is always "output".
        For components, use the output param name (e.g. "Circle", "Extrusion").
        """
        target_input_guid = target.input_guids.get(target_param)
        if not target_input_guid:
            available = list(target.input_guids.keys())
            raise ValueError(
                f"Input '{target_param}' not found on {target.name}. "
                f"Available inputs: {available}"
            )

        # Find the output GUID to use as source reference
        source_guid = source.output_guids.get(source_param)
        if not source_guid:
            raise ValueError(
                f"Output '{source_param}' not found on {source.name}. "
                f"Available outputs: {list(source.output_guids.keys())}"
            )

        # Find target object and update its param_input Source/SourceCount
        for obj_el, ref in self.objects:
            if ref.instance_guid == target.instance_guid:
                self._add_source(obj_el, target_input_guid, source_guid)
                return

        raise ValueError(f"Target component not found: {target.name}")

    def _add_source(self, obj_el: ET.Element, param_guid: str, source_guid: str):
        """Add a source reference to a parameter within an object XML tree."""
        parent_map = {child: parent for parent in obj_el.iter() for child in parent}

        for elem in obj_el.iter():
            if (elem.tag == "item" and elem.get("name") == "InstanceGuid"
                    and elem.text == param_guid):
                items_el = parent_map.get(elem)
                if items_el is None or items_el.tag != "items":
                    return

                source_count = 0
                source_items: list[ET.Element] = []
                for item in items_el.findall("item"):
                    if item.get("name") == "SourceCount":
                        source_count = int(item.text) + 1
                        item.text = str(source_count)
                    elif item.get("name") == "Source":
                        source_items.append(item)

                new_source = _item("Source", source_guid, "gh_guid", TC_GUID,
                                   index=len(source_items))
                items_el.append(new_source)
                items_el.set("count", str(len(items_el.findall("item"))))
                return

    # -----------------------------------------------------------------------
    # Public API: output
    # -----------------------------------------------------------------------

    def generate(self) -> ET.Element:
        """Build the complete .ghx XML tree."""
        root = ET.Element("Archive", {"name": "Root"})
        root.append(ET.Comment("Grasshopper archive"))
        root.append(ET.Comment("Generated by grasshopper-generator skill"))

        # Archive version
        ver = _item("ArchiveVersion", None, "gh_version", TC_VERSION)
        for tag, val in [("Major", "0"), ("Minor", "2"), ("Revision", "2")]:
            ET.SubElement(ver, tag).text = val
        root.append(_wrap_items([ver]))

        # Definition chunk
        definition = _chunk("Definition")
        pv = _item("plugin_version", None, "gh_version", TC_VERSION)
        for tag, val in [("Major", "1"), ("Minor", "0"), ("Revision", "7")]:
            ET.SubElement(pv, tag).text = val
        definition.append(_wrap_items([pv]))

        # Document header
        header = _chunk("DocumentHeader")
        header.append(_wrap_items([
            _item("DocumentID", self._doc_id, "gh_guid", TC_GUID),
            _item("Preview", "Shaded", "gh_string", TC_STRING),
            _item("PreviewMeshType", "1", "gh_int32", TC_INT32),
        ]))

        # Definition properties
        props = _chunk("DefinitionProperties")
        props.append(_wrap_items([
            _item("Description", self.description, "gh_string", TC_STRING),
            _item("Name", self.name, "gh_string", TC_STRING),
        ]))

        # Layout + Libraries
        rcp = _chunk("RcpLayout")
        rcp.append(_wrap_items([_item("GroupCount", "0", "gh_int32", TC_INT32)]))

        libs = _chunk("GHALibraries")
        libs.append(_wrap_items([_item("Count", "0", "gh_int32", TC_INT32)]))

        # Definition objects
        obj_chunks = []
        for i, (obj_el, _) in enumerate(self.objects):
            obj_el.set("index", str(i))
            obj_chunks.append(obj_el)

        def_objects = _chunk("DefinitionObjects")
        def_objects.append(_wrap_items([
            _item("ObjectCount", str(len(self.objects)), "gh_int32", TC_INT32),
        ]))
        if obj_chunks:
            def_objects.append(_wrap_chunks(obj_chunks))

        definition.append(_wrap_chunks([header, props, rcp, libs, def_objects]))
        root.append(_wrap_chunks([definition]))
        return root

    def to_xml_string(self) -> str:
        """Generate pretty-printed XML string."""
        tree = self.generate()
        rough = ET.tostring(tree, encoding="unicode", xml_declaration=False)
        dom = minidom.parseString(rough)
        lines = dom.toprettyxml(indent="  ", encoding=None).split("\n")
        return "\n".join(line for line in lines if line.strip())

    def save(self, path: str) -> str:
        """Save .ghx file to disk. Returns the path."""
        with open(path, "w", encoding="utf-8") as f:
            f.write(self.to_xml_string())
        return path


# ---------------------------------------------------------------------------
# Validation utility
# ---------------------------------------------------------------------------

def validate_ghx(path: str) -> list[str]:
    """Validate a .ghx file structure. Returns list of error messages."""
    errors: list[str] = []
    try:
        tree = ET.parse(path)
    except ET.ParseError as e:
        return [f"XML parse error: {e}"]

    root = tree.getroot()
    if root.tag != "Archive":
        errors.append("Root element is not 'Archive'")

    # Check DefinitionObjects
    def_objs = root.find(".//chunk[@name='DefinitionObjects']")
    if def_objs is None:
        errors.append("Missing DefinitionObjects chunk")
        return errors

    # Collect all InstanceGuids
    all_instance_guids: set[str] = set()
    for item in root.iter("item"):
        if item.get("name") == "InstanceGuid" and item.text:
            all_instance_guids.add(item.text)

    # Check each Object
    obj_count = 0
    for chunk in root.iter("chunk"):
        if chunk.get("name") != "Object":
            continue
        obj_count += 1
        idx = chunk.get("index", "?")
        name_el = chunk.find("items/item[@name='Name']")
        obj_name = name_el.text if name_el is not None else "?"

        container = chunk.find("chunks/chunk[@name='Container']")
        if container is None:
            errors.append(f"Object[{idx}] ({obj_name}): missing Container chunk")
            continue

        # Check Container has items with InstanceGuid
        c_items = container.find("items")
        iguid = None
        if c_items is not None:
            for item in c_items.findall("item"):
                if item.get("name") == "InstanceGuid" and item.text:
                    iguid = item.text
                    break

        if iguid is None:
            errors.append(f"Object[{idx}] ({obj_name}): Container missing InstanceGuid")

    # Check Source references
    sources_missing = 0
    for item in root.iter("item"):
        if item.get("name") == "Source" and item.text:
            if item.text not in all_instance_guids:
                sources_missing += 1
                errors.append(f"Source ref {item.text} not found in any InstanceGuid")

    if not errors:
        print(f"✅ Valid: {obj_count} objects, {len(all_instance_guids)} GUIDs, all sources resolve")
    else:
        print(f"❌ {len(errors)} issue(s) found:")
        for e in errors:
            print(f"   • {e}")

    return errors


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

def _demo():
    """Generate a demo parametric cylinder."""
    gen = GHXGenerator("Parametric Cylinder", "Circle + Extrude demo")

    r = gen.add_slider("Radius", 20, 1, 100, x=50, y=50)
    h = gen.add_slider("Height", 50, 1, 200, x=50, y=100)
    circle = gen.add_component("Circle", inputs=["Base Plane", "Radius"],
                               outputs=["Circle"], x=300, y=50)
    unit_z = gen.add_component("Unit Z", outputs=["Unit Vector"], x=300, y=150)
    extrude = gen.add_component("Extrude", inputs=["Base", "Direction"],
                                outputs=["Extrusion"], x=500, y=50)

    gen.connect(r, "output", circle, "Radius")
    gen.connect(circle, "Circle", extrude, "Base")
    gen.connect(unit_z, "Unit Vector", extrude, "Direction")
    return gen


def _list_components():
    """Print all known components."""
    GHXGenerator._load_guids()
    db = GHXGenerator._guid_db
    cats: dict[str, list[str]] = {}
    for name in sorted(db):
        cats.setdefault("all", []).append(name)

    print(f"Known components: {len(db)}")
    for name in sorted(db):
        print(f"  {name}: {db[name]}")


def main():
    import argparse
    parser = argparse.ArgumentParser(description="Grasshopper .ghx file generator")
    parser.add_argument("-o", "--output", default="/tmp/demo_cylinder.ghx",
                        help="Output .ghx path")
    parser.add_argument("--list", action="store_true",
                        help="List all known component names and GUIDs")
    parser.add_argument("--validate", metavar="FILE",
                        help="Validate a .ghx file")
    args = parser.parse_args()

    if args.list:
        _list_components()
        return

    if args.validate:
        errors = validate_ghx(args.validate)
        sys.exit(1 if errors else 0)

    gen = _demo()
    path = gen.save(args.output)
    print(f"Saved to {path}")
    print(f"Objects: {len(gen.objects)}")

    # Auto-validate
    validate_ghx(path)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)