← Back to Docs
  • index.html
  • Clayground
  • Clayground.Canvas3D
  • OrbitInput3D
  • Clayground 2026.7
  • OrbitInput3D QML Type

    Turns drags, wheels and double-clicks into OrbitCamera3D moves, under one rule: the left button is never the camera's. More...

    Import Statement: import Clayground.Canvas3D

    Properties

    Signals

    Methods

    Detailed Description

    The rig knows how to move; this knows what a gesture means. Split in two because the half that decides "right drag turns, middle drag pans" is the half a scene wants to configure - while the arithmetic that turns pixels into degrees and metres is the same everywhere and was being re-derived, with different constants, in every scene that had a camera.

    There are no modes

    There were, twice. First a mode per activity - one for turning the view, one for measuring with it - which fails the moment a third instrument is imagined: five modes for one camera. Then two modes, build and use, which held up better and was still wrong, and for a reason worth writing down: a mode existed only because the camera wanted the left button. LMB-drag panned, so a scene that needed LMB had to be able to take it back, and the thing that took it back was the mode. Every symptom followed from that - two keys negotiating over one pointer, a switch on a circuit board that could not be flipped because the camera swallowed the press, a measurement destroyed by looking around.

    An RTS has none of these problems, and not because it is cleverer: it never puts panning on the left button. So neither does this.

    Non-visual, and deliberately not a MouseArea. A scene that also picks or drags objects already owns the pointer; it keeps its own MouseArea and asks wants() what this press means before deciding it is its own:

    OrbitInput3D { id: nav; rig: rig; view: view3d }
    
    MouseArea {
        anchors.fill: parent
        acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
        cursorShape: nav.cursorShape
        onPressed: (m) => {
            if (nav.begin(m.x, m.y, m.button, m.modifiers) !== "") return
            myTool.press(m.x, m.y)                    // the left button is yours
        }
        onPositionChanged: (m) => {
            if (nav.move(m.x, m.y)) return
            if (!pressed) nav.hoverAt(m.x, m.y)
            myTool.moveTo(m.x, m.y)
        }
        onReleased: { nav.end(); myTool.release() }
        onWheel: (w) => nav.wheel(w.angleDelta.y, w.x, w.y)
        onDoubleClicked: (m) => nav.recenterAt(m.x, m.y)
    }

    begin returns the drag it took ("orbit", "pan" or "" when it declined), so the scene's own gesture is simply the "" case - and with the default buttons an LMB press is always that case.

    What is under the cursor

    pickAt() answers "a place and a thing" for any pixel, and hovering keeps the same answer for wherever the cursor last was. Neither is a decision: this layer no longer has an opinion about what a click means, because the scene does. A tool asks when it wants to know.

    See also OrbitCamera3D.

    Property Documentation

    active : bool [read-only]

    A drag is in progress.


    anchor : var [read-only]

    The world point this orbit is turning about, or null.

    Taken at press and held for the whole drag - including the coast out of a flick, so a thrown orbit keeps spinning about the same thing.


    anchorOrbit : bool

    An orbit turns about the point under the cursor at press.

    Needs a view and a rig with reanchor; without either it turns about the pivot as before.


    clickSlop : real

    Pixels a press may travel and still count as a click.

    One button applies it here - the right one, which both turns the view and, unmoved, cancelled. Only the distance travelled tells the two apart. Scenes apply the same rule to their own gestures with the same default, so a click means the same thing everywhere.


    cursorShape : int [read-only]

    Closed hand while a drag runs, plain arrow otherwise.

    Deliberately almost nothing. With no modes there is no camera state left for a cursor to announce, and what a press will do is now the scene's business - it knows what is in the hand, and it owns the cursor while it matters. Bind a MouseArea to this and override it where the scene has something to say.


    flick : bool

    Let a fast drag coast for a moment after the button comes up.

    The rig's smoothMs already smooths each step; this is the other half of the feel - throwing the scene and watching it settle. It decays to a stop within about a third of a second and never moves further than the gesture was already moving.


    flickDecay : real

    Velocity kept per frame while coasting.


    flickThreshold : real

    Pixels per frame a drag needs to coast at all.


    gesture : string [read-only]

    The drag in progress: "orbit", "pan" or "".

    The only state this layer keeps about the pointer, and it lasts exactly as long as a button is down (plus the coast out of a flick).


    groundY : real

    Height of the plane groundAt() hits.


    hovering : var

    What was under the cursor at the last hoverAt, or null.

    The same { point, object, x, y } a pickAt returns, which is the point of it: a tool's two jobs - "what would happen here" and "make it happen" - read the same payload, so a preview and the act it previews cannot disagree.


    invertPitch : bool

    Flip the vertical orbit direction.

    The default is grab-the-scene: dragging down tips the scene down, which raises the camera. Two of the three labs had worked that out independently; the third had it the other way, which is exactly the kind of drift a shared layer is for.


    orbitButtons : int

    Buttons that turn the rig.


    panButtons : int

    Buttons that drag the world.

    The middle button alone, and that default is the whole design: adding Qt.LeftButton here is how a scene deliberately spends the left button on the view. A scene with nothing to select (a scene that only looks at something) should - left-drag panning is the most natural gesture there is, and it costs that scene nothing. A scene with a tool must not, and this layer will not do it behind its back.


    panModifiers : int

    Modifiers that make any button pan.

    Empty by default: the left button is the scene's, so a modifier-plus-drag would be exactly the kind of leftover this layer exists to abolish. Set it (to Qt.ShiftModifier, say) in a scene whose domain leaves that modifier free and that wants the escape hatch anyway.


    panSpeed : real

    Multiplier on the grab-the-ground pan.


    pickObjects : bool

    Ask the view what object a pick landed on, as well as where.

    On by default; a scene whose instruments only ever want the ground can turn the ray-cast off.


    pitchPerPixel : real

    Degrees of pitch per pixel dragged.


    rig : var

    The OrbitCamera3D to drive.


    springNav : bool

    Pan on the left button too, while this is true.

    The scene feeds it a held key (the labs use Space). It is the only state in this layer that changes what an input means, and it is a quasimode rather than a mode for the reason quasimodes exist: a key you are holding down cannot be forgotten about, so nothing else has to display it and no tool can be left starved by it.

    A key that gets stuck - focus lost mid-hold - is the scene's to clear, by writing false.


    universalPanButtons : int

    Buttons that always drag the world.

    The middle button, which no two-button tool can claim.


    view : var

    The View3D the gestures happen in.

    Supplies the viewport height that scales a pan, and the ray that turns a cursor position into a world point - which is what anchors an orbit and what the wheel zooms towards.


    yawPerPixel : real

    Degrees of yaw per pixel dragged.


    zoomStep : real

    Distance factor for one wheel notch inwards.


    zoomToCursor : bool

    The wheel zooms towards the point under the cursor.

    Needs the cursor position - nav.wheel(delta, w.x, w.y). Called with the delta alone it zooms towards the pivot, as it always did.


    Signal Documentation

    cancelled()

    A right click - "put it down".

    The one click this layer reports, and it reports no place and no thing, because it is not about either: it is the RTS cancel, and what it empties is the scene's to decide - the hand, a half-drawn wire, a selection. A right drag turns the view as ever and says nothing.

    Note: The corresponding handler is onCancelled.


    zoomedAt(var point)

    A cursor-anchored zoom just aimed at point (ground plane).

    What a marker listens to - the anchored orbit is readable from anchor and gesture, but a wheel tick is over in one call, so showing where it aimed needs this pulse.

    Note: The corresponding handler is onZoomedAt.


    Method Documentation

    string begin(real x, real y, int button, int modifiers)

    Starts a navigation drag; returns the drag taken, "" if none.

    A right press that this took is armed as a possible cancelled: it cannot be known which it is until the button comes up, so it starts as an orbit and end decides. That is the whole trick - the drag is identical either way.


    string beginAs(string g, real x, real y)

    Starts a drag the scene has chosen itself: "orbit" or "pan".

    For a scene whose own rule decides, so it does not have to fake a button to say so. An orbit started this way anchors at (x, y) exactly like one begin took.


    void cancel()

    Ends the drag with no coast.


    void clearHover()

    Forgets hovering - the cursor left.


    void end()

    Ends the drag, coasting if it was a flick.

    Also where a right click is decided: a right press that never travelled further than clickSlop was not a turn at all, and comes out as cancelled instead - with no coast, since nothing was thrown.


    var groundAt(real x, real y)

    The point on the groundY plane under viewport pixel (x, y).

    Null when the ray never gets there. Worked out from the ray rather than picked, so it answers for every pixel of the plane including the ones no geometry covers.


    var hoverAt(real x, real y)

    Recomputes hovering for viewport pixel (x, y).

    Does nothing while a gesture is running: mid-drag the cursor is driving the camera rather than pointing at anything, and a preview that chased it would flicker across the whole scene. The scene calls this from its move handler when no button is down.


    bool move(real x, real y)

    Continues the drag; false when no navigation gesture is running.


    var pickAt(real x, real y)

    What is under viewport pixel (x, y): a place and a thing.

    { point, object, x, y }. point is where the ray meets the groundY plane, worked out analytically so it answers for every pixel of the plane including the ones no geometry covers; object is the scene node the same ray hits first, or null.

    Both, from one gesture, because instruments want different halves of it: a tape measure asks where, a voltmeter asks what. Reporting them together is what keeps adding an instrument from adding an input path. Null only when there is no view to ask.

    A plain query, and nothing here calls it: this layer has no opinion about what a click means, so the scene asks when it wants to know - at a press it decided was its own, or on a move through hoverAt.


    bool recenterAt(real x, real y)

    Travels the pivot to the ground point under (x, y).

    What a double-click on empty ground should do, and the one bit of click handling this layer offers: a single click belongs to the scene, which knows what is worth focusing - it calls rig.focusOn() itself.


    string wants(int button, int modifiers)

    What this press would do, without doing it.

    "orbit", "pan" or "" - the question a scene asks before it decides whether the press is its own. Nothing this layer holds can change the answer for the left button: with the default panButtons it is "", now and in every state, which is what makes "the left button is yours" a rule rather than a promise.

    The order is the rule, read top to bottom: the middle button always pans; panModifiers pan if a scene declared them; the right button turns; and the left pans only where a scene asked for it in panButtons, or while springNav is held.


    void wheel(real angleDelta, real x, real y)

    One wheel event: zooms in on a positive angleDelta.

    Given the cursor position it zooms towards what is under it, which is how a wheel gets you somewhere instead of merely closer to the middle. Always live - see zoomToCursor.