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

    An orbit camera on a leash: circles a pivot, never dives through the floor. More...

    Import Statement: import Clayground.Canvas3D

    Properties

    Methods

    Detailed Description

    The camera every 3D lab and demo ends up hand-rolling. It looks at pivot from a yaw/pitch/distance rig, clamps itself so the viewer cannot get lost, and can frame a set of world points so a scene arrives properly composed. It also travels: panBy slides the pivot along the ground plane on a soft leash, and viewpoints gives the scene named places to go.

    The anti-clip rule is a minimum height above the pivot plane, not a minimum distance: a distance sphere wrongly blocks zooming onto a small focused object, while a height floor pushes the rig outward as the angle flattens and can never end up under the ground.

    The goal pose

    The rig has two poses, and the difference is the whole reason the mutators exist. yaw, pitch, distance and pivot are where the camera is - with smoothMs above zero they are animated, so mid-glide they hold an interpolant. goalYaw, goalPitch, goalDistance and goalPivot are where it is headed, and that is what the limits are applied to, what state() serializes and what the next orbitBy adds to. A rig read back mid-animation therefore round-trips to the move that was asked for, not to the frame it happened to be caught on.

    Note: Move the rig with orbitBy, zoomBy, zoomToward, setDistance, setPivot, reanchor, panBy, frame, fit, focusOn and goTo rather than by writing the pose properties. Every one of them computes the limited value first and writes it once, which is what makes an animated rig correct: a Behavior defers the write, so a write-then-clamp reads back what was there before and silently cancels its own move. Direct writes are for the declared initial pose (they are adopted as the goal while nothing is animating).

    Where you have been

    An endless ground has nothing to navigate back by, so the rig keeps two memories. pushJump / jumpBack / jumpForward are vim's jumplist: every deliberate jump records the place it left, travel records nothing. frameWithReturn is the shorter loop - dive onto something, press again, and the overview you left comes back exactly.

    Example usage:

    import Clayground.Canvas3D
    
    View3D {
        id: view3d
        camera: rig.camera
        OrbitCamera3D {
            id: rig
            pivot: Qt.vector3d(0, 0, 0)
            distance: 60
            panLeash: 120                      // how far you may wander
            viewpoints: ({ "top": { pitch: 84, distance: 140 } })
        }
    }
    OrbitInput3D { id: nav; rig: rig; view: view3d; mode: "use" }
    MouseArea {
        anchors.fill: parent
        acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
        onPressed: (m) => nav.begin(m.x, m.y, m.button, m.modifiers)
        onPositionChanged: (m) => nav.move(m.x, m.y)
        onReleased: nav.end()
        onWheel: (w) => nav.wheel(w.angleDelta.y, w.x, w.y)
    }

    See also Label3D and OrbitInput3D.

    Property Documentation

    aspect : real [read-only]

    Width over height of the frame; 0 while view is not set.


    camera : PerspectiveCamera [read-only]


    distance : real

    Distance to the pivot.


    fieldOfView : real

    Vertical FOV of the camera.


    follow : var

    What to keep in frame while it moves: a function returning a vector3d or an array of them, called every frame. Null is off.

    The subject may drift within the inner part of the frame without the rig reacting (followSlack); once any point crosses that zone the pivot pans along the ground - never zooms - just far enough to bring it back, over followMs. A frame, fit or goTo made while a follow is on still happens; the follow only corrects what leaves the picture after it. The pan leash still applies, so a subject that walks past panLeash does get left behind - back the shot off, that is what fit's pad is for.


    followMs : int

    How long one follow correction glides; the lag of the pan.


    followSlack : real

    How much of the half-frame the subject may cross before the rig pans: 0 pans at the first pixel, 0.25 (default) lets it reach three quarters of the way to the edge.


    goalDistance : real [read-only]

    Distance the rig is travelling to.


    goalPitch : real [read-only]

    Pitch the rig is travelling to.


    goalPivot : vector3d [read-only]

    Pivot the rig is travelling to.


    goalPosition : vector3d [read-only]

    Where the camera ends up, in world coordinates.

    The rig's own position is the interpolant; this is the same point computed from the goal pose, which is what reanchor and zoomToward do their arithmetic in.


    goalYaw : real [read-only]

    Yaw the rig is travelling to.


    gripMs : int

    Glide while gripped. Zero, and rarely anything else.


    gripped : bool

    A hand is driving the pose right now, so it does not glide.

    The distinction smoothMs was missing. A glide is right for a move the rig makes on its own - a focusOn, a scenario reframing itself, an arrow key - because there the eye needs to be carried along. It is wrong for a drag: grab-the-ground panning promises that the point under the cursor stays under the cursor, and a pose easing towards its goal over 150 ms mathematically cannot keep that promise. The ground trails the hand, and the rig reads as something heavy being nudged rather than as a sheet being pushed.

    OrbitInput3D sets this for the length of a drag (and of the coast out of a flick, which is the same gesture still finishing). Everything else keeps its glide.


    hasReturnPose : bool [read-only]

    A frameWithReturn dive is open, so the next one comes home.

    What a lab shows in its hint bar, so the second press of the framing key is offered rather than discovered.


    homePivot : vector3d

    The point panLeash measures from. Defaults to the origin.


    jumpDepth : int

    How many places back the jumplist remembers.

    Bounded because the alternative is a session-long leak of poses nobody will ever walk back to; the oldest entry falls off the bottom.


    jumpsAhead : int [read-only]

    Places jumpForward can still take you.


    jumpsBack : int [read-only]

    Places jumpBack can still take you.


    leashSoftness : real

    How much overshoot the leash allows, as a fraction of itself.

    Past panLeash the pull-back grows exponentially, so the pivot can never get further than panLeash * (1 + leashSoftness) but the drag never stops dead either.


    maxDistance : real

    Furthest allowed retreat.


    maxPitch : real

    Steepest allowed angle (< 90).


    minDistance : real

    Closest allowed approach.


    minHeight : real

    Lowest the camera may sit above the pivot plane.

    The leash: flattening the angle backs the rig off instead of letting it sink through the ground.


    minPitch : real

    Flattest allowed angle.


    minPivotY : real [read-only]

    How deep the pivot may sink: exactly as far as it can climb out of.

    The height floor is a rule about the camera, and _fitDistance enforces it by backing the rig off - but backing off runs out at maxDistance. From a pivot deeper than that, no legal pose keeps the eye above ground, and the floor silently stops being a floor.

    This is the missing half, and it is why the rule is a property rather than a line inside reanchor, which is where it used to live: only that one method consulted it, so orbitAround - which rotates the pivot rigidly and can therefore drive it hundreds of units under the ground - walked straight through. Every mutator goes through _fitPivot, so putting it there is what makes "never under the floor" true of the rig rather than of one method.


    panLeash : real

    Furthest the pivot may wander from homePivot; 0 is no limit.

    Measured in the ground plane (XZ), so height never counts against it.


    pitch : real

    Angle above the pivot plane, degrees.


    pivot : vector3d

    The point the camera looks at.


    smoothMs : int

    Glide time for every move, in milliseconds; 0 snaps.

    Built in rather than left to the lab so that all four pose properties ease together - a rig with a Behavior on distance and none on pivot swings while it zooms. Declaring your own Behavior on a pose property is a duplicate-binding error; change this instead.


    travelMs : int

    Glide time for a goTo / focusOn journey, in milliseconds.

    Longer than smoothMs on purpose: a hand-driven orbit wants to feel immediate, a jump across the scene wants to be followed with the eye.


    travelling : bool [read-only]

    A glide is in progress.


    view : var

    The View3D this rig renders through. Optional.

    What tells the rig the shape of its frame. Without it every fit is made against the vertical field alone, which is right for a landscape window and too tight for a portrait one; with it aspect is known and fit composes against the real picture. Nothing else reads it.


    viewpoints : var

    Named poses, as { name: {yaw, pitch, distance, px, py, pz} }.

    Any subset of the fields a state() carries; what is left out keeps its current value, so { pitch: 84 } is a legal "look straight down from wherever you are". goTo travels to one.


    yaw : real

    Angle around the pivot, degrees.


    Method Documentation

    void applyState(var s)

    Restores a pose produced by state(). Missing fields keep theirs.


    void clamp()

    Re-applies the limits to the pose the rig is in right now.

    For after a limit changes (a new maxDistance, a tighter minHeight, a shorter panLeash). It is not the way to apply a pose - see the note on setDistance.


    void clearJumps()

    Forgets everywhere the rig has been.

    For a scene that has been replaced under the camera - a scenario applied, a board cleared - where the recorded poses now describe places in a world that no longer exists. Walking the list cannot do this: jumpBack and jumpForward only move an entry from one side to the other, which is what makes them reversible.


    void clearReturn()

    Forgets the open frameWithReturn excursion.

    For a lab that ends one by other means - a scenario change, a scene rebuilt under the camera - where the pose the dive began from no longer describes anywhere the viewer would recognise.


    bool covers(var points, real margin, bool goal)

    Whether every point is inside the frame, margin (0..1, a fraction of the half-frame) away from its edges.

    The question a shot is verified by: not "did the camera move" but "is the thing still in the picture". goal as for project.


    bool fit(var points, var opts)

    Composes a shot: every point inside the picture, as close as that allows.

    The screen-space fit. points is an array of vector3d (or {x, y, z}); opts is an optional object:

    Where frame fits a sphere against the vertical field and keeps the pivot on the centre of the points, this projects every point and finds the nearest distance at which all of them sit inside the safe rectangle, shifting the pivot within the image plane to centre them there. The pivot may therefore leave the ground; minPivotY still bounds it. Returns false when there is nothing to fit. Clears the frameWithReturn excursion, like frame.


    void focusOn(var what, real pad, int ms)

    Travels to a point or a set of points and frames them.

    what is a single vector3d (or {x, y, z}) or an array of them. A single point keeps the current distance and only re-centres - framing a point has no extent to fit, and diving at it is never what was meant. ms overrides travelMs for this journey.

    The verb a lab's own picking calls: the input layer never decides what is worth looking at, it only offers the ride.

    A journey, so it pushJump s: this is where you were before you dived at something, and jumpBack is how you get back.


    void frame(var points, real pad, var angles)

    Centres on the given world points and backs off until they fit.

    points is an array of vector3d (or {x, y, z}); pad is a headroom factor (1.0 = tight, 1.3 = comfortable). Keeps the current yaw/pitch, so framing never disorients the viewer - unless angles asks for a specific one: an optional {yaw, pitch} (either field alone is fine) folded into the same single glide. For the framing that IS a deliberate change of viewpoint - a lab dropping to eye level while a character presents - where framing first and pitching second would move the camera twice.


    bool frameWithReturn(var points, real pad)

    Dives onto points, and the next call on the same thing comes back.

    The framing key pressed twice is a round trip: dive in to read a value up close, press it again, and the overview you left is restored exactly - including the yaw you had turned to, which is what "exactly" has to mean or the second press is just another reframing.

    Sameness is the extent, not the selection: this layer has never been told what a part is, and the centre and radius of what it was asked to frame is the whole of what it can compare. Two different selections that occupy the same box are one place as far as the camera is concerned, which is also how they look.

    Returns false only when there was nothing to do - no points and no way home. Any jump (goTo, focusOn, jumpBack) and any plain frame drops the return pose: those are departures, not the end of an excursion.


    bool goTo(string name, int ms)

    Travels to the named viewpoint; false if there is no such name.

    Yaw takes the short way round: a rig turned three times over does not unwind on the way to a viewpoint that says yaw: 0.

    Named places are jumps, so this pushJump s - and a name that does not exist does not, because it did not go anywhere.


    bool jumpBack(int ms)

    Travels to the place before the last jump; false at the end.

    False is the interesting half: nothing moves and the caller is expected to say so, because a key that silently does nothing reads as broken. The pose being left is put on the forward list, so the walk is reversible by jumpForward for as long as no new jump happens.


    bool jumpForward(int ms)

    Undoes a jumpBack; false when there is nothing ahead.


    void orbitAround(var anchor, real dYaw, real dPitch)

    Turns the rig about anchor instead of about the pivot.

    The point of the gesture: anchor keeps its place on screen while everything else swings around it, because the rig is rotated rigidly - camera and pivot together, about the axes through anchor. orbitBy turns about the pivot, so anything else you were looking at slides off; this is what "turn about what I am pointing at" actually means.

    The rotation is applied to the pivot, so the pivot generally leaves the ground plane - that is the price, and reanchor is what keeps it small: from a pivot already at the anchor's depth the two are almost the same point. Pitch clamping is honoured (only the allowed part of dPitch is applied to both), and the leash and the height floor still outrank the anchor - they are the only things that can shift it on screen. With no anchor it is orbitBy.


    void orbitBy(real dYaw, real dPitch)

    Turns the rig, then re-applies the leash.


    void panBy(real dRight, real dAway)

    Slides the pivot along the ground, in world units.

    dRight is screen-right and dAway is screen-up projected onto the ground - both relative to the current yaw, which is what makes a drag feel like it is moving the scene rather than the axes. The height of the pivot is untouched, and panLeash still applies.


    var project(var p, bool goal)

    Where a world point lands in the frame, without a View3D.

    Returns {x, y, depth}: x and y normalized so that -1..1 is the picture (y up), depth the distance in front of the lens - a point at or behind it has depth <= 0 and its x/y mean nothing. Computed from the rig's own pose, so it answers for a rig that is not rendering at all; goal true projects through the goal pose rather than the interpolant, which is what a follow correction needs.


    void pushJump()

    Records the pose the rig is in as a place worth coming back to.

    goTo, focusOn and frameWithReturn call it themselves; anything else that means a jump rather than travel calls it first - a lab's reset-the-view key, a flow step that aims the camera, a hint label selected and flown to. Travel deliberately does not: a jumplist that filled up with every drag would have nothing recognisable left in it, which is exactly why vim distinguishes the two.

    Clears the forward list, because history that was walked back and then left is no longer where you are going - unless the jump turns out not to move the rig at all, and then nothing about the memory changes. A key that went nowhere must leave no trace, or the next jumpBack answers for a press that did nothing.


    bool reanchor(var p)

    Moves the pivot to what you pointed at, without moving the camera.

    The turn-around-what-I-am-looking-at gesture: press over a rooftop and the orbit that follows circles that, not the middle of the scene.

    The pivot lands on the view axis at p's depth - the point of the axis nearest p - and the distance is re-derived so that goalPosition and the rig's rotation come out bit-for-bit unchanged. That last part is the whole point: a rig whose rotation is derived from the pivot cannot both aim somewhere else and keep the picture, so re-anchoring onto an off-centre point would swing the picked thing into the middle of the screen - exactly the jump this is meant to avoid. Anchoring at its depth turns about it instead, and the image does not move at all.

    Returns false when there was nothing to anchor to. The leash still applies: re-anchoring outside panLeash is pulled back like any other pivot move, and that pull is the only thing that can shift the camera here. A point so far away that the axis has already dived under the ground anchors as deep as the rig can still climb back out of - see minHeight.


    void setDistance(real d)

    Moves to d with the leash applied - the safe way to set it.

    Prefer this over writing distance directly: it limits the value before the single write, so it stays correct on a rig that animates its distance.


    void setPivot(var p)

    Moves what the camera looks at, on the leash.


    var state()

    Pose as a JSON-serializable object, for the viewState convention.

    The goal pose, so a rig serialized mid-glide restores where it was going rather than the frame it was caught on.


    var viewpointNames()

    The names goTo accepts.


    real worldPerPixel(real viewportHeight)

    World units one pixel covers at the pivot's depth.

    What turns a drag in pixels into a pan in metres. Exact in the middle of the view at the pivot plane, which is where a grab-the-ground drag is judged.


    void zoomBy(real factor)

    Multiplies the distance (0.9 zooms in, 1.1 out).


    void zoomToward(var p, real factor)

    Zooms along the ray to p, so p keeps its place on screen.

    Wheel-to-cursor. zoomBy pulls the camera towards the pivot, which walks whatever you were aiming at off the edge of the screen; this moves the camera along the line towards p instead and slides the pivot the same fraction, so the rotation is untouched and p stays on exactly the pixel it was on.

    factor is zoomBy's (0.9 in, 1.1 out), and the limits still cut it short: what the distance was actually allowed to do is what the pivot moves by, so a zoom that hits minDistance stops travelling too. With no p it is zoomBy.