Overview
Rhino 9 adds an SDK for in-viewport user interface objects - widgets that draw inside a viewport and respond to the mouse. Instead of pushing the user out to a command line or panel, you can put the control next to the thing it controls.
If you have run Rhino 9 you may have already used them. ArrayCrvAdvanced uses on-curve span grips and rotation handles; Patch puts a clickable continuity badge on each constraint curve; ScaleEach and RotateEach use in-viewport sliders; GlobalEdgeContinuity drops a numbered dot at each evaluated edge; Markup is built as a floating HUD; and Grasshopper’s Curve Widget and Angular Widget components are the same machinery.
The API is available in both RhinoCommon (the widget classes are in Rhino.UI; the doc.ViewUserInterface table is in Rhino.DocObjects.Tables) and the C/C++ SDK (rhinoSdkUserInterfaceObject.h).
Two Kinds of UI Object
World-space objects live at a 3d point in the model. They pan and zoom with the scene, and they are depth-sorted against each other. The grip classes can additionally be dragged in 3d, constrained to a curve or circle, and osnapped. Use these when the control is a location: a base point, a direction, a rotation.
Screen-space controls are 2d widgets aligned to the viewport, at a fixed pixel size regardless of zoom. They can optionally “stick” to a 3d point so they follow an object around the screen while staying screen-aligned.
Everything derives from a common base - UserInterfaceObjectBase in RhinoCommon, CRhinoUserInterfaceObject in C++ - which is where the mouse events and visibility live.
Adding and Removing Objects
UI objects belong to a document, and are added with a group id. The group id is how you find and remove them later, so a convenient convention is to use your command’s id.
doc.ViewUserInterface.Add(new MyGrip(Point3d.Origin), this.Id);
doc.AddUserInterfaceObject(new CMyGrip(ON_3dPoint::Origin), CommandUUID());
Removing is done by the same group id, and returns the number of objects removed. This gives you a natural toggle - a command that adds its widgets the first time it is run and clears them the next time:
public override string EnglishName => "SampleGrip";
protected override Result RunCommand(RhinoDoc doc, RunMode mode)
{
// If the grip is already there, take it away. Otherwise, add one.
bool gripExisted = doc.ViewUserInterface.RemoveByGroupId(this.Id) > 0;
if (!gripExisted)
doc.ViewUserInterface.Add(new MyGrip(new Point3d(0, 0, 0)), this.Id);
doc.Views.Redraw();
return Result.Success;
}
Note that the command returns immediately. The grip stays live in the viewport after the command has ended - it is owned by the document, not by the command that created it. This is the fundamental difference from a getter or a display conduit, and it is the reason these widgets feel like part of the model rather than part of a modal operation.
Redraws are not automatic when you add or remove objects. Call doc.Views.Redraw() (CRhinoDoc::Redraw() in C++) after changing what should be on screen.
A World-Space Grip
Derive from the grip class and override OnDrag to be told where the user has moved it.
using Rhino;
using Rhino.Commands;
using Rhino.Geometry;
using Rhino.UI;
class MyGrip : GripUserInterfaceObject
{
public MyGrip(Point3d location) : base(location)
{
GripFillColor = System.Drawing.Color.Orange;
}
protected override void OnDrag(Point3d newLocation, MouseState mouse)
{
base.OnDrag(newLocation, mouse);
RhinoApp.WriteLine($"grip is at {GripLocation}");
}
}
The same thing in C++:
class CMyGrip : public CRhinoGripUserInterfaceObject
{
public:
CMyGrip(const ON_3dPoint& location)
: CRhinoGripUserInterfaceObject(location)
{
SetGripFillColor(ON_Color(255, 165, 0));
}
void OnDrag(const ON_3dPoint& point, const CRhinoMouseEventArgs& mouse) override
{
CRhinoGripUserInterfaceObject::OnDrag(point, mouse);
const ON_3dPoint pt = GripLocation();
RhinoApp().Print(L"grip is at %g,%g,%g\n", pt.x, pt.y, pt.z);
}
};
Calling the base implementation matters: the default OnDrag is what actually moves the grip. If you skip it, you take responsibility for setting the location yourself - the GripLocation property in RhinoCommon, SetGripLocation in C++. That is occasionally what you want, if you are snapping the value to something of your own.
Grips can be styled (GripShape, GripRadius, GripColor, GripFillColor, GripStrokeWidth) and constrained. Constrain accepts a curve, circle, line or arc, and restricts dragging to that geometry.
Screen-Space Controls
Controls are positioned with a location in logical pixels plus an alignment, which together determine where they land in any given view. Alignment is relative to the viewport, so a Left/Bottom control with a location of (10, 100) sits 10 pixels in from the left edge and 100 up from the bottom, in every view, at every zoom level.
var btn = new UserInterfaceButton
{
Text = "Bake",
Location = new System.Drawing.PointF(10, 100),
HorizontalAlignment = ControlHorizontalAlignment.Left,
VerticalAlignment = ControlVerticalAlignment.Bottom
};
btn.Click += (s, e) => RhinoApp.WriteLine("clicked");
doc.ViewUserInterface.Add(btn, Id);
var slider = new UserInterfaceSlider
{
Location = new System.Drawing.PointF(10, 140),
HorizontalAlignment = ControlHorizontalAlignment.Left,
VerticalAlignment = ControlVerticalAlignment.Bottom,
Range = new Interval(0, 10),
Value = 5,
DigitPrecision = 0 // 0 == integers only
};
slider.ValueChanged += (s, e) => RhinoApp.WriteLine($"{slider.Value}");
doc.ViewUserInterface.Add(slider, Id);
Instead of text, a control can display an image based on an SVG string - SetSvg in RhinoCommon, SetImageSVG in C++. This is the recommended way to get a crisp icon at every DPI.
Leave Size unset (zero) and the control computes its own size from its contents. Set it explicitly only when you need a fixed footprint.
To make a control follow an object around the screen, give it a tracking point:
control.TrackingPoint = someObject.Geometry.GetBoundingBox(true).Center;
The control stays screen-aligned and screen-sized, but its position now updates as the user orbits and zooms.
Modifying the Document from a Mouse Handler
Your mouse handler runs inside a mouse callback, not inside a command. Undo records and getters are not set up the way you would expect, so adding, deleting or transforming objects directly from OnDrag or a Click handler produces broken or missing undo.
In C++ there is an explicit path for this. Call RunCommand(mouse) from your handler, and do the actual document work in the OnRunCommand override, which executes in proper command scope:
class CMyGrip : public CRhinoGripUserInterfaceObject
{
public:
CMyGrip(const ON_3dPoint& location)
: CRhinoGripUserInterfaceObject(location)
{
}
void OnMouseUp(const CRhinoMouseEventArgs& mouse) override
{
CRhinoGripUserInterfaceObject::OnMouseUp(mouse);
// Defer the document edit into command scope
RunCommand(mouse);
}
CRhinoCommand::result OnRunCommand(const CRhinoCommandContext& context, const CRhinoMouseEventArgs& mouse) override
{
// Safe to modify the document here - this is inside a command,
// so undo is recorded correctly.
CRhinoDoc* doc = CRhinoDoc::FromRuntimeSerialNumber(context.m_rhino_doc_sn);
if (nullptr == doc)
return CRhinoCommand::failure;
doc->AddPointObject(GripLocation());
doc->Redraw();
return CRhinoCommand::success;
}
};
Caveats
Objects outlive the command that created them. Nothing cleans them up when your command ends. If you add widgets, you are responsible for removing them - on document close, on plugin unload, or on the next run of your command.
Sizes and locations are in logical pixels. Rhino handles the DPI scaling for you. Do not pre-multiply by a scale factor.
Redraw explicitly. Changing a property on a widget, or adding and removing widgets, does not by itself trigger a redraw.
Mouse handlers run on the UI thread and block the viewport. Keep them cheap. Anything expensive belongs behind RunCommand, or on a background thread with the result marshalled back.
