shapes¶
goo ships three shape blobs - Sector, Arc, Polygon - plus a set of compositor helpers under Goo.Shapes. all three render on the GPU through ui_shape.shader as an analytic signed-distance field: crisp at any size, no baked texture, and free to animate. Sector and Arc carry their geometry in scalar uniforms; Polygon rides its vertices on a small (N x 1) points-texture. every shape exposes the same surface - BackgroundColor, HoverBackgroundColor, TransitionMs, and the eight On* handlers.
Ngon and Star are not separate types. they are thin factories that compute vertices and return a Polygon, so they inherit the whole shape surface for free. (Squircle was removed; reach for Ngon/Star or a hand-authored Polygon.)
the color quirk¶
the engine renders panel backgrounds in this order: BackgroundColor is a solid rectangle fill behind the panel, BackgroundImage composites on top with its per-pixel alpha, and BackgroundTint multiplies into the image's RGB channels.
if BackgroundColor were written straight through, a shape would render as a solid rectangle in that color with the silhouette composited inside - not the shape you wanted. so goo redirects BackgroundColor on a shape blob to mean "the color of the shape": it is fed to ui_shape.shader as the ShapeColor uniform, the same for Sector, Arc, and Polygon. there is no rectangle fill and no mask to tint.
new Sector
{
StartAngle = 0f, EndAngle = 60f,
InnerRadius = 0.4f, OuterRadius = 1f,
BackgroundColor = Color.Red, // paints the wedge red, not the surrounding rect
};
interactivity - clicks match the outline¶
a shape's drawn outline is also its hit-test. Sector, Arc, and Polygon hit-test against their analytic outline automatically: a click/press/right/middle handler fires only when the cursor lands inside the silhouette, not the empty corner of the bounding box. you write the handler exactly as on any blob - no opt-in, no dispatcher:
new Sector
{
StartAngle = 0, EndAngle = 90, InnerRadius = 0.3f, OuterRadius = 1f,
BackgroundColor = Color.Cyan,
OnClick = _ => Fire(), // fires only on the wedge, not the bounding-box corner
};
because Ngon/Star return a Polygon, decorate the returned blob with with { ... } and the same guard comes for free:
Shapes.Star(5) with { BackgroundColor = Color.Yellow, OnClick = _ => Collect() }; // fires only on the star, not the gaps
hover parity (outline-accurate)¶
every shape hovers exactly like its Container-styled counterpart, clipped to the drawn outline. set HoverBackgroundColor (the hover mirror of BackgroundColor) and optionally TransitionMs (fade duration, 0 snaps). the fill recolors only inside the silhouette.
new Sector {
StartAngle = 0, EndAngle = 90, InnerRadius = 0.4f, OuterRadius = 1f,
BackgroundColor = slate, HoverBackgroundColor = sky, TransitionMs = 120,
OnMouseEnter = e => ..., OnMouseLeave = e => ...,
}
Shapes.Star(5) with { BackgroundColor = slate, HoverBackgroundColor = sky, TransitionMs = 120 };
OnMouseEnter/OnMouseLeave always fire on the true outline crossing rather than the bounding rect, whether or not a hover color is set. supplying a hover color additionally engages the recolor fade. the recolor reads the cursor against this panel's own rect, so (like the click guard) a shape still recolors when another panel occludes it. Disc, Pill, and RoundedRect are native rounded boxes and already take real HoverBackgroundColor.
radial menus¶
for a wedge menu, Shapes.RadialMenu pairs Ring visuals with a RadialDispatcher on one circular host. the host's native circular border-radius answers "is the cursor on the ring," the dispatcher answers "which wedge," and hovering recolors the hovered slot:
Shapes.RadialMenu(
segments: 6, innerRadius: 0.4f,
colors: slotColors, hoverColor: Color.White,
onSlot: i => Choose(i));
onSlot fires with the slot index on click. the center hole and outside-the-ring are inert. RadialMenu and Ring are composite subtrees, not shape blobs - they do not carry the BackgroundColor/HoverBackgroundColor surface themselves.
elliptical and rounded shapes are native¶
discs, pills, and rounded rectangles need no SDF - the engine hit-tests BorderRadius natively, so the drawn outline and the clickable outline agree for free:
| helper | what it builds |
|---|---|
Shapes.Disc(key?) |
circular Container (50% BorderRadius, 100% size). |
Shapes.Pill(color, key?) |
filled pill/stadium (50% BorderRadius). |
Shapes.RoundedRect(color, radius, key?) |
filled rounded rectangle with the given radius. |
these are plain interactive Containers: set an OnClick and it works, no guard needed.
the donut-hole wrinkle¶
native BorderRadius rounds the outer corners but cannot punch an inner hole. a true ring or donut that must reject clicks in its center has two options: tolerate center hits (fine for a radial menu that wants a center-cancel zone), or use the guarded path - a Sector with InnerRadius set, or RadialMenu - which excludes the hole precisely. Arc has no inner/outer radius pair (only a single Radius plus StrokeWidth), so it cannot punch a hole itself; reach for Sector when you need a donut shape.
composites¶
Goo.Shapes factories return composite subtrees. every helper that internally applies a rotation defaults PointerEvents = PointerEvents.None on every node it produces, so mouse events bubble past the rotated descendants and reach the parent in its unrotated frame.
| helper | what it builds |
|---|---|
Shapes.Ring(segments, innerRadius, colors, outerRadius = 1.0f, key = null) |
N rotation-wrapped Sector wedges arranged clockwise from up (12 o'clock). |
Shapes.RadialMenu(segments, innerRadius, colors, hoverColor, onSlot, outerRadius = 1f, key = null) |
an interactive Ring with per-wedge hover and click dispatch. |
the Ring overload that takes a ReadOnlySpan<Color> is preferred from animated Build() bodies - stackalloc Color[segments] avoids the per-frame heap allocation the Color[] overload incurs.
// stackalloc span avoids a heap allocation on each Build() call
Span<Color> colors = stackalloc Color[SlotCount];
for (int i = 0; i < SlotCount; i++)
colors[i] = i == _hovered ? HoverColor : BaseColor;
var ring = Shapes.Ring(SlotCount, innerRadius: 0.4f, colors);
stacking shapes must be absolute¶
sibling shapes default to flex flow, not overlap. to stack two or more shapes concentrically - a gauge track ring under a fill arc, layered Sector wedges - each sibling must set Position = PositionMode.Absolute, Top = 0, Left = 0. without it the engine lays the siblings out side by side and shrinks each to its share of the row, so two full-size shapes render as squashed half-width ovals that read as a single ring "tilted in 3D."
it is not 3D. a shape evaluates in unit-square UV with no transform in its render path, so a squashed look is always a layout problem, never an orientation one - do not chase PanelTransform.Rotate or camera framing. a shape also shears on a non-square box, so keep it in a square slot.
// a gauge: track ring under a fill arc. Stack is the positioned ancestor; Layer pins each child.
var gauge = Layout.Stack();
gauge.Children.Add( Layout.Layer( new Sector { /* track */ } ) );
gauge.Children.Add( Layout.Layer( new Sector { /* fill */ } ) );
// the same thing spelled by hand, which is what Stack/Layer bake in:
new Container
{
Position = PositionMode.Relative, // positioned ancestor for the absolute children (primer rule 12)
Children =
{
new Sector { Position = PositionMode.Absolute, Top = 0, Left = 0, /* track */ },
new Sector { Position = PositionMode.Absolute, Top = 0, Left = 0, /* fill */ },
},
};
this is the inverse of primer rule 12: rule 12 says absolute children need a positioned ancestor; this rule says shapes you intend to overlap have to be made absolute in the first place. Layout.Stack() + Layout.Layer(child) bake both halves (Layer has overloads for Container, Sector, Arc, and Polygon; its Absolute is appended last, so it wins over the child's own Position). Shapes.Ring already wraps each wedge the same way, so reach for a helper before hand-stacking.
animate shapes freely - geometry included¶
Sector and Arc carry their geometry in shader uniforms, so driving StartAngle/EndAngle on either, InnerRadius/OuterRadius on Sector, Radius/StrokeWidth on Arc, or a rotation, from a per-frame clock is cheap, with no texture re-bake. animate them however reads best.
Polygon (including Ngon/Star) is the exception: its points-texture is keyed by content, so a one-off shape is fine but driving the vertices every frame bakes a new texture each frame. the texture is tiny now (one texel per vertex, not a 512x512 mask), but for continuous polygon motion still prefer keeping the points fixed and moving the panel with a transform.
// a radar sweep: a Sector whose EndAngle is driven by a clock. GPU SDF, so this is free.
new Sector { StartAngle = 0, EndAngle = _t * 90f % 360f, InnerRadius = 0.1f, OuterRadius = 0.68f };
color is also cheap to animate everywhere: BackgroundColor on any shape is the ShapeColor shader uniform with no re-bake, so hue-shifting a ring's segments per frame costs nothing extra.
see also¶
- container - the full property surface that shape facades draw a subset from.
- panel transforms - rotate, scale, and skew a shape after you build it.
- sector - full property reference for
Sector. - arc - full property reference for
Arc. - polygon - full property reference for
Polygon. - drawing a gauge - a worked build: stacking
Sectorshapes into a value-driven dial. - goo for AI - the positioned-ancestor rule and the rest of the hard rules referenced above.
- styles - the style-surface rules shapes share with every other blob.