panel transform

Container exposes a Transform init-property that accepts a Goo.PanelTransform value. Goo.PanelTransform is a fluent value type: each factory method returns a new value with that op appended. you chain ops together to build a composite transform.

this article assumes you have read the model, container, and styles.

usage

seven common patterns, one for each op family:

translateY - bouncing dots where each dot receives a per-frame vertical offset:

Transform = PanelTransform.TranslateY(Px.Of(-v * 24f)),

rotate - a looping spinner driven by an Animator:

Transform = PanelTransform.Rotate(_wheel.Value * 360f),

rotate - a compass needle swinging back and forth around its center:

Transform = PanelTransform.Rotate(angle),

scale - a heartbeat pulse that samples a custom gaussian curve each frame:

Transform = PanelTransform.Scale(1f + 0.22f * beat),

skew + scale - a jelly-wobble that composes two ops in one chain:

Transform = PanelTransform.Skew(skewX, skewY).Scale(scale),

perspective + rotate - a 3D card flip:

Transform = PanelTransform.Perspective(Px.Of(500)).RotateY(angle),

perspective + matrix - rotation around a non-axis-aligned axis (the one case the per-axis helpers cannot express):

Transform = PanelTransform.Perspective(Px.Of(800)).Matrix3D(matrix),

the clock that drives a transform can be a damper or animator field, a raw float accumulated with += dt, or any time source you choose. the pattern is: advance the clock in OnUpdate, call Rebuild(), then sample the value inside Build. see animations for the full animation model and build method for the Rebuild() / OnUpdate clock pattern.

namespace note

if your file also imports Sandbox.UI (which exposes its own Sandbox.UI.PanelTransform struct), the name PanelTransform becomes ambiguous. add an alias at the top of the file:

using PanelTransform = Goo.PanelTransform;

surface

static factories start a chain. instance methods (implemented as extension methods on the struct) append an op and return a new PanelTransform. the full set mirrors the engine's Sandbox.UI.PanelTransform op kinds:

op static factory instance method
translate (x, y) PanelTransform.Translate(x, y) .Translate(x, y)
translate (x, y, z) PanelTransform.Translate(x, y, z) .Translate(x, y, z)
translateX / translateY / translateZ PanelTransform.TranslateX(x) etc. .TranslateX(x) etc.
rotate Z (2D) PanelTransform.Rotate(deg) .Rotate(deg)
rotate (x, y, z) PanelTransform.Rotate(xDeg, yDeg, zDeg) .Rotate(xDeg, yDeg, zDeg)
rotateX / rotateY / rotateZ PanelTransform.RotateX(deg) etc. .RotateX(deg) etc.
scale (uniform) PanelTransform.Scale(factor) .Scale(factor)
scale (vector) PanelTransform.Scale(vector) .Scale(vector)
scaleX / scaleY / scaleZ PanelTransform.ScaleX(s) etc. .ScaleX(s) etc.
skew (x, y) PanelTransform.Skew(xDeg, yDeg) .Skew(xDeg, yDeg)
skewX / skewY PanelTransform.SkewX(deg) etc. .SkewX(deg) etc.
perspective PanelTransform.Perspective(d) .Perspective(d)
matrix3D PanelTransform.Matrix3D(m) .Matrix3D(m)

default(PanelTransform) and the empty case behave as no-op. setting Transform = default(PanelTransform) clears the engine value to default.

equality (why the wrapper exists)

the engine's Sandbox.UI.PanelTransform is a struct wrapping an ImmutableList<Entry>. its equality is reference-based on the inner list, so two structurally-identical engine PanelTransforms built in separate calls compare not equal. if Goo passed the engine value through directly, every Build() that re-built the transform inline would emit a redundant SetStyle(Transform) op on every frame, even when nothing changed.

Goo.PanelTransform solves this by implementing IEquatable<PanelTransform> directly. the struct walks its entries list entry-by-entry in its Equals method, comparing each op by type and parameters. you can author inline without memoizing and the diff still does the right thing:

// this emits zero diff ops on the second pass, even though the
// inner ImmutableList<Entry> is a fresh instance:
new Container { Transform = PanelTransform.Rotate(45) }

the engine struct's reference-based equality is a known engine behavior. Goo.PanelTransform exists specifically to paper over it with structural equality so per-frame inline construction is safe.

companion properties

TransformOriginX, TransformOriginY, PerspectiveOriginX, PerspectiveOriginY remain separate Length? init-properties on Container.

pan and zoom with Viewport

a pannable, zoomable canvas (a node graph, a map, a zoomable diagram) needs two things: the transform that places the world on screen, and the math that keeps zoom anchored under the cursor. Goo.Viewport is the second part, kept pure so it unit-tests without a Panel. it holds Pan and Zoom, you drive them from input, and you feed them into a PanelTransform on your world container.

the model is screen = world * Zoom + Pan, origin top-left:

member what it does
Pan translation in screen pixels (read-only; mutate via PanBy)
Zoom uniform scale, 1 = no zoom (read-only; mutate via ZoomAt), always clamped to [MinZoom, MaxZoom]
MinZoom / MaxZoom init-only clamps (default 0.1 / 10)
PanBy(screenDelta) pans by a screen-space delta, e.g. a drag movement
ZoomAt(screenAnchor, factor) multiplies zoom by factor (clamped) while keeping the world point under screenAnchor fixed: zoom-to-cursor
WorldToScreen(world) / ScreenToWorld(screen) map a single point between the two spaces

wire drag deltas into PanBy and the mouse wheel into ZoomAt, then build the transform from Zoom and Pan. pin the world container's transform-origin to top-left so its scale pivots at the local origin, matching the math:

using Goo;
using PanelTransform = Goo.PanelTransform;

public class CanvasUI : GooPanel<Container>
{
    readonly Viewport _view = new() { MinZoom = 0.25f, MaxZoom = 4f };
    Vector2 _lastMouse;

    protected override Container Build() => new Container
    {
        OnMouseMove = e =>
        {
            var delta = e.LocalPosition - _lastMouse;
            _lastMouse = e.LocalPosition;
            if ( e.Target.HasActive ) { _view.PanBy( delta ); Rebuild(); }   // pan only while pressed
        },
        OnMouseWheel = scroll =>
        {
            _view.ZoomAt( _lastMouse, scroll.y > 0 ? 1.1f : 1f / 1.1f );
            Rebuild();
        },
        Children =
        {
            new Container   // the world, drawn in world coordinates
            {
                TransformOriginX = 0,
                TransformOriginY = 0,
                Transform = PanelTransform
                    .Scale( _view.Zoom )
                    .Translate( Px.Of( _view.Pan.x ), Px.Of( _view.Pan.y ) ),
                Children = { /* world content */ },
            },
        },
    };
}

Viewport's math (WorldToScreen, ScreenToWorld, PanBy, ZoomAt, the clamp) is fully unit-tested. the Panel wiring above (the input events, the transform, the top-left origin) is the engine-touching part.

see also