ZeroTouch for Grasshopper

Write your logic.
Betta does the rest.

Decorate a plain C# method with an attribute. Betta generates a stable GUID, inputs, outputs and an icon — a real component you can use on the canvas.

v0.7.0 · MPL-2.0 Rhino 8 · net7.0-windows Put a DLL in, get nodes out
The Betta fish mark
A fish per component — Amber Aqua Cosmic Forest Lime Rose
The whole idea

Every component is the same plumbing to register and dispatch — before the one line you came for.

Subclass GH_Component, register each port, invent a GUID, marshal types through SolveInstance. Betta generates all of it automatically and loads it when Grasshopper starts.

with betta — 1 method body
[GrasshopperCollection("Curve", "Analysis")]
public class CurveTools : IBettaCollection
{
    [GrasshopperMethod("Divide")]
    public List<Point3d> Divide(Curve curve, int count)
    {
        curve.DivideByCount(count, true, out Point3d[] pts);
        return pts.ToList();
    }
}

A ribbon tab, a component, and every port — done. No GH_Component, no RegisterInputParams, no SolveInstance, no GUID to babysit. The method body is the component.

public class DivideComponent : GH_Component
{
    public DivideComponent() : base("Divide","Divide","Divide a curve into points","Curve","Analysis") { }

    protected override void RegisterInputParams(GH_InputParamManager pm)
    {
        pm.AddCurveParameter("Curve","C","curve to divide",GH_ParamAccess.item);
        pm.AddIntegerParameter("Count","N","segment count",GH_ParamAccess.item,10);
    }

    protected override void RegisterOutputParams(GH_OutputParamManager pm)
        => pm.AddPointParameter("Points","P","division points",GH_ParamAccess.list);

    protected override void SolveInstance(IGH_DataAccess DA)
    {
        Curve curve = null; int count = 0;
        if (!DA.GetData(0, ref curve)) return;
        if (!DA.GetData(1, ref count)) return;
        curve.DivideByCount(count, true, out Point3d[] pts);
        DA.SetDataList(0, pts);
    }

    public override Guid ComponentGuid => new Guid("d3b07384-…-0002");
    protected override Bitmap Icon => null;
}

One component, the usual way. Everything wrapped around the single highlighted line is what Betta generates for you — for every method, every time.


How it works

Reflection at startup, a deterministic GUID, one generic component type.

Your services stay plain and framework-agnostic — no Grasshopper types leak in, so the same code unit-tests with no Rhino in sight.

Your collection IBettaCollection

A plain interface or class carrying [GrasshopperMethod]. Inheriting the marker is the opt-in — attribute-only types in unrelated DLLs are ignored.

Registry ComponentDescriptor

Reflection at startup builds one descriptor per [GrasshopperMethod]: ports from the signature, a GUID from MD5(signature), an icon from the type's GUID.

Proxy on the ribbon IGH_ObjectProxy

Each descriptor becomes a canvas proxy registered with Grasshopper's ComponentServer — so it appears as a first-class node in the toolbar.

Solve Method.Invoke

On the canvas every proxy shares one generic GH_Component CLR type. Per solve, the injector maps inputs → your method → outputs. Rename the display freely; the GUID is pinned to the signature, so saved .gh files survive rebuilds and machine moves.


The surface

The whole API is three attributes.

That's the contract to learn. Everything else — ports, GUID, icon, DI, caching — Betta derives.

interface / class [GrasshopperCollection]

Default Category + SubCategory for every method on the type. One tab, one group.

method [GrasshopperMethod]

Publishes the method as a component. Optional NickName, Description, IconResource, Guid, Enabled.

parameter [GrasshopperParameter]

Input name / nickname / description. List<T> → list input automatically. DefaultValue seeds unwired sockets.

Outputs are inferred from the return type.

Return typeExampleBecomes
primitive / Rhino geometrydouble Area(...)one output
List<T>List<Point3d> Grid(...)one list output
ValueTuple(double Sum, double Avg) Stats(...)one per element — named tuple → named outputs
plain custom classReport Analyze(...)one output per public property
[GrasshopperOpaque] class[GrasshopperOpaque] Mesh Build(...)one typed wire — auto Param_BettaGoo<T>
Task<T> / ValueTask<T>Task<Brep> LoadAsync(...)async — cached by input hash, re-solves on completion
IObservable<T>IObservable<double> Ticker(...)live output — pushes every emission through the port

Past hello-world

Depth where real plugins need it.

The one-liner is the front door. Behind it: pipelines, cloud-API ergonomics, live data, and the boring correctness that keeps saved files working.

pipelines

Opaque domain objects

Mark a type [GrasshopperOpaque] and it flows as a single typed wire — Load → Deconstruct pipelines without exploding into properties.

async

Task, cancellation & progress

Task<T> solves off the UI thread; a CancellationToken quits stale work on re-solve; IProgress<int> drives a free status tag.

live

Streaming outputs

Return IObservable<T> and the component goes live — emissions push through, coalesced onto the UI thread so a chatty source can't flood the canvas.

cloud-API

Secrets · triggers · presets

[GrasshopperSecret] reads from Credential Manager, [GrasshopperTrigger] adds a Run gate, [GrasshopperValueList] auto-drops a wired dropdown.

stability

Saved files survive

Component GUIDs are MD5 of the signature and typed wires hash typeof(T).FullName — so .gh files round-trip across rebuilds and machines.

preview

Viewport draw & bake

Opaque types opt into IBettaPreview / IBettaBakeable — detected by name, forwarded by reflection. Add the package only if you need it.

One service, several of those powers at once
[GrasshopperCollection("Web", "Live")]
public class Feeds : IBettaCollection
{
    // async · cancellation · secret — all inferred
    [GrasshopperMethod("Fetch")]
    public async Task<string> Fetch(string url,
        [GrasshopperSecret("api.key")] string key,
        CancellationToken ct) => await Http.GetAsync(url, key, ct);

    // return IObservable and the component goes live
    [GrasshopperMethod("Poll")]
    public IObservable<string> Poll(string url, int everyMs)
        => Observable.Interval(everyMs).Select(_ => Http.Get(url));
}

AI-ready

The agent writes the function body. Nothing else.

The repo ships a Claude Code skill and a CLAUDE.md that hand a coding agent the contract — attributes, return-type mapping, deploy path, GUID gotchas. The boilerplate is generated at runtime, so refactors don't re-spend tokens on it and there's far less code in the diff to review.

MyPack.cs — the entire diff an agent writes
[GrasshopperCollection("MyPack", "Maths")]
public class MyService : IBettaCollection
{
    [GrasshopperMethod("Cube")]
    public double Cube(
        [GrasshopperParameter("Value")] double x) => x * x * x;
}

For users · install in Rhino

Get Betta on the canvas.

Betta is a normal Grasshopper plugin. Any of these drops it under a Betta tab after a Rhino restart — no .NET, no build.

recommended

Rhino Package Manager

Run _PackageManager, search Betta, click Install, restart Rhino.

CLI

Yak

From a Rhino command prompt: yak install betta.

manual

Food4Rhino

Download the release .zip, right-click → Unblock, then unzip into %AppData%\Grasshopper\Libraries\ and restart.


For authors · build a plugin in four steps

New project to a node on the canvas.

  1. New class library

    Target net7.0-windows (or any TFM that references netstandard2.0).

    dotnet new classlib -n MyPack -f net7.0-windows
  2. Reference Betta.Abstractions

    The SDK contract — not the .gha — with ExcludeAssets="runtime".

    <PackageReference Include="Betta.Abstractions" Version="0.7.0"
                      ExcludeAssets="runtime" />
  3. Write a collection

    Class-direct or interface + impl; both opt in by inheriting IBettaCollection.

    [GrasshopperCollection("Curve", "Analysis")]
    public class CurveTools : IBettaCollection
    {
        [GrasshopperMethod("Divide")]
        public List<Point3d> Divide(Curve c, int n)
        {
            c.DivideByCount(n, true, out Point3d[] p);
            return p.ToList();
        }
    }
  4. Drop the DLL in the Betta folder

    Betta watches %AppData%\Grasshopper\Libraries\Betta\ and hot-adds it — no restart.

    dotnet build -c Release
    # copy MyPack.dll → %AppData%\Grasshopper\Libraries\Betta\