Building an editor

A track editor, a level editor and a scene inspector all want the same things of the engine: a pointer that does a different job in each tool, a view from straight above to draw on, the point on the world under the cursor, and a way to drag it. None of that is about tracks, levels or scenes, so it lives in OpenGLContext.edit and the second editor does not have to write it again.

Nothing here is needed to play a world, so a shipped game imports none of it.

Tool modes: what the pointer does

An editor's pointer does a different thing in each tool — placing a point, dragging one, measuring, painting — and the camera wants the same pointer. The rule is that the tool in force is asked first and the camera gets whatever the tool did not want, so a tool that only cares about the left button leaves the right one orbiting.

from OpenGLContext.edit.tools import Pointer, ToolManager, ToolMode

class PlacePoint(ToolMode):
    def on_press(self, pointer):
        if pointer.button or not pointer.on_surface:
            return False          # not ours: let the camera have it
        self.route.append(pointer.world)
        return True

tools = ToolManager([PlacePoint(name='place'), DragPoint(name='drag')])

Every hook — on_press, on_drag, on_release, on_move, on_key — returns whether the tool used what it was given, and the base class uses nothing. So a subclass that overrides one hook leaves the rest of the pointer alone rather than swallowing it.

A tool that takes a press keeps the pointer until the release. A drag that wanders off whatever started it still ends where it should, and the tool cannot be switched out from under a gesture it is half-way through: select() answers False while one is going on. Escape abandons a gesture (ToolMode.cancel) and is left for the camera when there is nothing to abandon.

enter and leave bracket the time a tool is in force, which is where a preview goes on screen and comes off again.

The tool palette

A strip down the side of the window, one button per declared tool, the one in force lit, and a click puts the pointer into that tool. It is a panel rather than a HUD layer -- a HUD takes no events -- and it is not modal, so a click that misses it reaches the world underneath.

from OpenGLContext.ui.toolpalette import ToolPalette

palette = ToolPalette(tools=tools, reserved=MENU_BAR_ROOM)
context.overlays.push(palette)
status.reserved = (MENU_BAR_ROOM, 0.0, 0.0, palette.room(metrics))

reserved is how much of the window's top something else has already taken, in reference pixels, so the strip starts under a menu bar rather than through it; edge is 'left' or 'right'. room(metrics) answers how much width the strip wants, also in reference pixels, which is what a HUDLayer.reserved is told so its read-outs stay clear of it.

A button reads the manager rather than keeping a copy, so a tool chosen by a keyboard shortcut or by the application lights the same button a click would have, and a manager that refuses the change -- which it does part-way through a gesture -- leaves the strip saying what the pointer is really doing. Call rebuild() when the set of tools changes.

A press anywhere on the strip is the strip's, including the gaps between buttons: a palette stands over a document the pointer also draws on, and a click that missed a button by two pixels must not put a point down on the map underneath.

The pointer a tool is handed

A Pointer is where the cursor is on the screen (x, y) and in the world: world is the point on whatever surface is under it, or None over the sky. on_surface, shifted, controlled and alted save a tool from unpacking a modifier triple.

The point under the cursor

Where the user clicked on the world is already read back by the pick. The selection pass writes depth as well as an object id, so a mouse event arrives knowing how far away what it hit was; unprojecting that gives the point on the surface. It is exact, it costs nothing extra, and it works for streamed terrain because terrain writes depth like any other geometry.

from OpenGLContext.edit.surface import pointer_from
pointer = pointer_from(event)      # pointer.world is where they clicked

Dragging cannot use that depth, because once something is being dragged the depth under the cursor is the thing being dragged. A drag runs against a plane instead — usually the level plane through where it began, so a point moves across the ground without climbing whatever it passes over:

from OpenGLContext.edit.surface import horizon_plane, ray_from, ray_plane

origin, direction = ray_from(event)
moved_to = ray_plane(origin, direction, *horizon_plane(started_at[1]))

The ray is taken from the near and far planes rather than from the camera's position, so it is right under any projection: an orthographic map view has no eye point for rays to come from, and its rays are parallel.

The ground's slope comes from neither. Picking gives a point, not a normal; the normal is the gradient of the height function, which an editor has: surface_normal(height_fn, x, z).

Handles: moving a point along one axis

A drag against the ground plane is right for a point that lives on the ground. A point in the air — a control point, a light, a camera — needs a direction chosen for it, and that is what a gizmo is for: three arms stand at the point, one per axis, and grabbing one holds the whole drag to that axis. The point goes up, or east, and not somewhere diagonal that the eye ray happened to sweep through.

from OpenGLContext.edit.gizmo import TranslationGizmo

gizmo = TranslationGizmo(size=1.5)
group.children = list(group.children) + [gizmo.node]
gizmo.attach(point)                    # the arms appear there

def OnPress(self, event):
    if gizmo.press(event) is not None: # landed on an arm: a drag has begun
        return

def OnDrag(self, event):
    moved = gizmo.drag(event)          # None when no arm is held
    if moved is not None:
        self.put_it(moved)             # write it wherever it belongs

The arms are ordinary scenegraph nodes, so the pick hit-tests them like any other geometry and there is no second hit-test to keep in step with what is drawn. press reads the axis off the node path the selection pass resolved; release ends the drag and cancel abandons it, putting the point back where it was grabbed, which is what Escape does.

A gizmo works in the coordinates of the group it is put in. Editors put it beside the thing it is moving, and that group is often scaled or turned, so a drag measured in window pixels has to come back as a distance in the units the point is stored in. The gizmo takes the transform from the same node path, pushes its anchor and its axis out to root coordinates for the arithmetic, and answers in the units it was asked in.

That arithmetic is axis_parameter(origin, direction, anchor, axis): the closest approach of the eye ray to the arm's line, as a distance along the arm. It answers None for a ray running along the arm, where every point of it is equally near and the pointer is not saying where to go — so an edge-on drag holds still rather than lurching. What is recorded on the press is how far along the arm the pointer took hold, and the rest of the drag is measured from there, so the handle does not jump to the cursor.

Arms are a fixed length in the surrounding group's units, so a gizmo far from the camera is drawn small.

The control points of a NURBS surface

A NURBS surface is one shape, so a pick aimed at it answers "the surface" however carefully it was aimed: there is nothing in the frame that is the third control point. ControlNet supplies one — a marker standing on every control point — so a picked marker is a control point by name, and moving it is an edit to the geometry.

The lines between the points matter as much as the points. A marker on its own says where one control point is; the row and the column it lies on say which points it is between, and that is what tells a designer what a pull is about to do — the cage bends first and the surface follows it. So the net draws a polyline along every row and every column of the control grid (one polyline through the lot, for a curve) and moves them with the points. The cage is pickable=False: it is there to be read rather than aimed at, and a line lying over a marker must not swallow a click meant for the point or the surface behind it (see click-through picking).

from OpenGLContext.edit.controlnet import ControlNet

net = ControlNet(shape.geometry)       # any node with a controlPoint field
group.children = list(group.children) + [net.node]
...
index = net.index_for(event.getObjectPaths())
if index is not None:
    net.select(index)                  # the marker takes the selected colour
    gizmo.attach(net.point(index))
...
net.move(index, gizmo.drag(event))     # the surface retessellates

move assigns the controlPoint field rather than writing through it, because the cached tessellation depends on that field and a cache watches for it being set; an in-place write would leave the old surface on screen under the new net. controlPoint is the field name VRML97 gives both NurbsSurface and NurbsCurve, so a net serves either.

The markers share one geometry and one appearance, so a whole net is a single instanced draw rather than a draw per point (see Instanced rendering), and the cage is a second. The selected marker is the exception: it is given its own appearance for as long as it is selected, which is both what takes it out of the batch and what makes it visibly the one being worked on.

Four coloured NURBS surfaces meeting in a molehill, white spheres
            marking their control points and thin lines joining the points into
            a grid, a red-green-blue tri-axis handle standing on one selected
            point at the top of the red hill, and two control points far above
            the surfaces with long lines running down to their neighbours
python tests/molehill_edit.py — the Molehill surfaces with their control net made draggable. Click a marker to select it, then drag one of the three arms; the surface retessellates as the point travels. The red hill here has been pulled three units east along its own x axis. The two markers standing well above everything, with their long cage lines, are the control points Molehill raises the green and blue hills by: the cage is what makes that legible. Escape abandons a drag, or puts the handle away.

Reading relief off a plan view

A map lit from straight overhead is flat: every surface faces the light equally, and a ridge and a valley come out the same colour. A map is read, so the ground is shaded by which way it faces relative to a fixed low sun — the cartographic hillshade — and the shape of the land is legible with no shadow crossing the line drawn on it.

from OpenGLContext.edit.relief import shade_colors

colors = shade_colors(colors, normals)     # then draw the mesh unlit

The sun is a convention, not a light: it sits in the north west at 45°, where every printed relief map has put it for a century. Lighting relief from anywhere east of north makes hills read as hollows, and the illusion is strong enough to send a road along the wrong side of a ridge.

The shading goes into the vertex colours and the mesh is drawn unlit, so a plan view needs no lights, builds no shadow maps, and costs one dot product a vertex.

ArgumentMeansDefault
azimuthdegrees clockwise from north315
altitudedegrees above the horizon45
ambientlight reaching ground turned away from the sun, so nothing on the map goes to black0.35
exaggerationhow much steeper the land is made before it is lit4

The exaggeration matters more than it looks. Country a road can be built through is gentle — a one-in-twenty slope is a hard climb for a car and almost nothing to the eye — so shading it honestly leaves a flat green sheet. It is in the shading only: the ground is drawn at the height it really is, and a contour still says what that height is.

hillshade(normals, ...) answers the shading alone, from 0 to 1, for a caller colouring the ground some other way; steepen(normals, k) is the leaning-over on its own.

Putting a point on a contour

A road held to a grade round a hillside runs along an iso-height, so an editor that can put a point on one is the difference between drawing that road and approximating it. The shortest way to a contour is straight up or down the hill, which is a step along the gradient:

from OpenGLContext.edit.surface import height_gradient, snap_to_height

x, z = snap_to_height(height_fn, x, z, height=None, interval=25.0, reach=250.0)

height is the elevation to land on; with none, the nearest multiple of interval — the contour the designer is looking at. reach caps how far the point may be moved, because a contour half a kilometre away is not what the pointer meant and a snap that drags a point across the map is worse than no snap. Flat ground has no nearest contour, so the point stays where it is.

It is exact in one step for ground that rises evenly and takes a few more where the ground curves under it. height_gradient is the pair of rates on its own, for a caller that wants the slope rather than the snap.

The plan view

A route is drawn on a map. The camera looks straight down and the projection is orthographic, so a metre is the same number of pixels wherever it is on the screen and a click at the far end of a straight means the same thing as a click at the near end. A perspective view cannot promise that.

from OpenGLContext.edit.mapview import MapView, MapViewPlatform

view = MapView(centre=(0.0, 0.0), span=1200.0)   # 1200 m down the window
context.platform = MapViewPlatform(view)

span is how many metres fit down the window's height, so a wider window shows more world rather than the same world stretched. North (-z) is up the screen and east (+x) is right, as a map has it.

CallAnswers
world_from_screen(x, y, viewport) the world (x, z) under a window pixel — where the pointer is
screen_from_world(point, viewport) where to draw a marker for a world point
metres_per_pixel(viewport)the scale it is drawn at
pan(dx, dy, viewport) drag the map by a pointer movement; the world goes with the pointer
zoom(factor, at=..., viewport=...) scale it, holding the world point under at still
frame(minimum, maximum, viewport) put a region on screen, wholly inside the window

Moving the map is also a tool in its own right, so a designer can say "just move the map" and have the drawing tool stop guessing:

from OpenGLContext.edit.maptools import PanTool

tools = ToolManager([DrawTool(...), PanTool(view, context.getViewPort,
                                            on_change=map_moved)])

The drag is measured from where the pointer last was rather than from where it started, because the map moves underneath it and a fixed origin would make it accelerate away.

MapViewPlatform reads the view rather than copying it, so panning or zooming the map is what moves the camera and there is no second copy of where the editor is looking to fall out of step with the first.

Looking at it from an angle

A plan view is the right thing to draw on and the wrong thing to judge on. Shading and contours answer "how high is that"; they cannot answer "does that look right", which is the question a landscape is finally judged by. OrbitView is the camera for it — a point on the ground it looks at, and a heading, a pitch and a distance to look from:

from OpenGLContext.edit.orbitview import OrbitView, OrbitViewPlatform

view = OrbitView(centre=(0.0, 0.0), ground=42.0, distance=800.0)
context.platform = OrbitViewPlatform(view, context.getViewPort())
...
view.orbit(dx * 0.4, dy * 0.4)     # a drag: turn and rise, in degrees
view.dolly(1.0 / 1.25)             # a wheel notch: in or out

heading is degrees clockwise from north, and zero puts the camera to the south looking north — the way up a map is read. pitch is degrees above the horizontal and defaults to the three-quarter view somebody means by "let me look at it": high enough to read the plan, low enough to read the relief. Orbiting leaves the subject where it is, because an orbit that moved what it was looking at is a pan and loses the thing being inspected; the pitch stops short of overhead and of the horizon, where a camera has no unique up-vector and sees the ground edge-on.

frame(minimum, maximum, viewport) looks at a region from far enough off to see all of it, and look_at moves the subject. OrbitViewPlatform reads the view rather than copying it, as the map platform does; setting a position on it works out the heading, pitch and distance that put the camera there, so anything that moves a camera by position — a bookmark, a saved viewpoint — moves this one.

An editor holds both and swaps its platform between them: one window, one scene, two cameras. Rendering both at once is a different piece of work — the render pass takes the whole window, so a split view means a viewport and a scissor per pass and a second shadow-map and selection pass to pay for.

Seeing it work

A plan view looking straight down on green ground, five grey-roofed
            blocks casting short shadows, and five coloured markers dropped on
            the ground, one of them standing on the roof of the tallest block
python tests/editing_demo.py — a world edited in the plan view: five markers put down at the point under the pointer, one of them then dragged 66 m across the ground, by tool modes that are asked for the pointer before the camera is. Nobody moves the mouse while a screenshot is taken, so the demo drives its own clicks and its drag through the pick. Press o for the three-quarter view and m to come back; 1, 2 and 3 choose the drop, move and pan tools; u takes back what the tool in force last did.

Every scripted click is aimed at a world point, turned into a pixel by screen_from_world and answered by the pick, so the pair the demo prints is a round trip:

click  pixel (674, 510) <- world (22.0, -30.0)
drop   marker 2 at world (21.88, 20.00, -30.00)

That one is aimed at the middle of a 20 m block, so it comes back 20 m up and the marker stands on the roof.

What an application writes is the tool and the routing that gives it the pointer first:

from OpenGLContext.edit.surface import pointer_from
from OpenGLContext.edit.tools import ToolManager, ToolMode

class DropMarker(ToolMode):
    """Put something on the world where the pointer is."""

    def on_press(self, pointer):
        if pointer.button or not pointer.on_surface:
            return False          # not ours: the camera can have it
        print('drop at %.2f, %.2f, %.2f' % tuple(pointer.world))
        return True

class Editor(BaseContext):
    def OnInit(self):
        self.tools = ToolManager([DropMarker(name='drop')])
        ...

    def ProcessEvent(self, event):
        """The tool in force is asked before the camera is."""
        if getattr(event, 'type', None) == 'mousebutton' and event.state:
            if self.tools.press(pointer_from(event)):
                return None       # taken, so it never reaches the camera
        return super(Editor, self).ProcessEvent(event)

Returning without calling up the chain is the whole of "the tool took it": the movement sampler reads events on their way past (ViewPlatformMixin.ProcessEvent), so a press a tool has taken is a press the camera never hears about. Everything the tool leaves — the right button, the wheel, the keys it does not answer for — carries on to whatever handled it before.

What is not here yet