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