Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

OpenGLContext-editor

World-authoring toolkit for OpenGLContext.

OpenGLContext renders and streams a world. This package makes one: it takes an authored scene — terrain, roads, water, scattered vegetation — and bakes it into a 3D Tiles octree of glTF content that the engine's existing streaming runtime loads.

Three things live here, and they share one property: every editor wants them identically, and no shipped game wants them in its dependency tree.

  • The tile baker. Spatial partition of an authored world into an octree, a level of detail per node, and the tileset written out through the engine's glTF and 3D Tiles writers.
  • World generation. Road geometry and the alignment behind it — a drawn line slid onto ground a road can follow, settled for grade and cornering speed, and built as carriageway, causeway, viaduct or bore according to what the land under it will take. Water, DEM to splat control map, and refinement driven by distance to a road.
  • The editor UI toolkit. Tool modes, menus, gizmos and a top-down ortho map, built on OpenGLContext.ui.

A game imports OpenGLContext. An editor imports both.

Bake one now

The shipped world is a racing circuit in hill country, so the command that bakes it belongs to the game's tools:

pip install glisteel-editor
glisteel-bake --output /tmp/world   # hill country, a canyon, a lake, a forest
oglc-view /tmp/world/tileset.json   # walk around in it

glisteel-bake --view runs both steps. --extent, --depth and --resolution set how big the world is, how deep the tile tree refines and how many ground samples each tile spends; glisteel-bake --help lists the rest.

The baker itself is here, and takes any layers at all: OpenGLContext_editor.bake.driver.bake_world. oglc-bake remains for one release cycle, saying where the command went.

How the ground and the trees are carried is the choice that decides what a world costs to draw. --ground field writes the landscape once beside the tileset as a height image and a splat control map, which a viewer draws as one mesh with detail materials blended per pixel; --ground tiles meshes it into the tile tree instead, which is what a world too large to hold at once needs. --forest field writes the trees as one table and lets the runtime choose what to draw from how far each is from the camera; --forest tiles puts them in the tiles. Both default to field, which is what the shipped world wants.

A field forest carries three more things with it. The trees' own ground cover travels as a recipe rather than a scatter — a clump, a card, how dense, and which of the splat map's layers it grows on — because there is far too much ground to write a blade of grass for every square metre of it. The canopy's shade is worked out from the trunks and read by the ground, the trees, the grass and the road alike, so a clearing and a forest floor differ for all of them at once. And the road's own shade is written into its surface, because the trees do not move and neither does the sun.

Describe a world of your own

A world is a list of layers, and a layer answers one question: what is in this region, at this level of detail?

from OpenGLContext_editor.bake.bounds import BoundingBox
from OpenGLContext_editor.bake.driver import bake_world
from OpenGLContext_editor.bake.layers import HeightfieldLayer, InstanceLayer
from OpenGLContext_editor.world.scatter import scatter_on_heightfield, yaw_quaternions

ground = BoundingBox((-2048, 0, -2048), (2048, 0, 2048))
terrain = HeightfieldLayer(height_fn=my_heights, extent=ground, resolution=33,
                           color_fn=my_colours, water_level=0.0)

trees = scatter_on_heightfield(my_heights, ground, spacing=2.6, seed=11,
                               slope_limit=38.0, height_range=(2.0, 130.0))
forest = InstanceLayer(positions=trees.positions,
                       rotations=yaw_quaternions(trees.yaws),
                       scales=trees.scales,
                       lods=[(0.0, conifer), (8.0, impostor)])

print(bake_world([terrain, forest], '/tmp/world', depth=4).summary())

The full account of what the baker does and why -- the partition, the level-of- detail policy, the geometric-error ladder, and the limits -- is in the engine's Baking a world page.

Where the ground comes from

A world's height is a base and an ordered stack of edits on it:

from OpenGLContext_editor.world.height import HeightSource, ProceduralBase

source = HeightSource(base=ProceduralBase(relief=0.5))
ground = source.height_fn()          # an ordinary height function

height_fn() hands back one callable over (x, z), so everything that samples terrain — the tile mesher, the road generator, the scatter, a car asking where the ground is — is pointed at it and needs to know nothing about how it was composed. ProceduralWorld(source=...) takes one; a world given none is the shipped landscape at its own relief.

An edit is a HeightEdit: a rectangle it can reach (bounds()), and how much to add at each sample inside it (delta(x, z, height)), given the ground everything before it left. Applying them in order is what makes "raise this hill, then run a river down it" mean what a designer expects.

Every edit is vectorised and bounded, and both matter: a bake samples the height function across whole tiles and an editor across its whole plan view, so an edit that looped in Python — or that was consulted about ground a kilometre away — would put the cost of authoring into every frame and every tile of every world afterwards. Outside its rectangle an edit costs one comparison.

A source is JSON, so a designer's landscape survives being saved:

{"base": {"kind": "procedural", "relief": 0.5}, "edits": []}

A kind this version does not know is refused rather than dropped: reading half of a file loses work without saying so. Declare a new kind with register_base / register_edit.

Start from a landscape

The presets are tuned terrain profiles under a name and a sentence, so a game editor built on this toolkit gets the same starting points:

from OpenGLContext_editor.world.presets import PRESETS, PresetBase

source = HeightSource(base=PresetBase(name='canyon'))
print(PRESETS['canyon'].description)
Preset What you get
shipped hill country with a range, a river canyon and a lake basin
mountains ranges over most of the map, rising most of a kilometre
lakes low country dished into broad basins that flood
hills nothing steeper than a road can climb, anywhere
canyon a gorge three hundred metres deep across a high plain

relief multiplies a preset's height — which is how a landscape too tall for a road becomes one a road can be built through without changing what it looks like — and seed gives another landscape of the same description.

Or from real ground

DEMBase puts an elevation file under a world at a point on the Earth:

from OpenGLContext_editor.world.dem import DEMBase

source = HeightSource(base=DEMBase(path='N47E008.hgt', centre=(47.5, 8.5),
                                   datum=0.0))

centre is the (latitude, longitude) the world's origin stands on and is recorded in the project, so a reopened track resolves to the same ground. datum is the height that origin is given: real ground is hundreds of metres above the sea, and a world whose waterline is at zero would have all of it underwater. relief scales what is left, for a valley whose real sides no road can climb.

Format and limits. SRTM .hgt — raw big-endian 16-bit samples, one degree square, named for its south-west corner. Voids are filled from the ground around them. The mapping from degrees to metres is a local tangent plane about the centre, which holds over the few tens of kilometres a track covers and is not a projection to use across a continent. Nothing is fetched: the file is one the designer supplies. The facts behind all of it, with their sources, are in specs/ELEVATION-DATA.md.

Shape it by hand

A SculptStroke is one gesture of a brush, recorded as data on the edit stack:

from OpenGLContext_editor.world.sculpt import SculptStroke

source.edits.append(SculptStroke(centre=(120.0, -40.0), radius=150.0,
                                 amount=25.0, detail=0.35))

amount is metres at the centre, positive up; falloff is how sharply it dies away towards radius, reaching zero at the radius so a stroke never steps; and detail is how much of the lift is fractal variation rather than a smooth dome. The detail is a share of the lift, so a gentle stroke gets gentle detail and a raised hill sits in the same visual family as the land around it.

Put water on it

From a spring, follow the ground downhill until the water reaches the waterline, runs off the edge of the world, or arrives somewhere it cannot get out of — which is where a lake is, and is an answer rather than a failure:

from OpenGLContext_editor.world.hydrology import Spring, channels_for, flow_from

paths = flow_from(ground, [Spring(at=(-800.0, 700.0))], extent=2048.0,
                  water_level=0.0)
source.edits.extend(channels_for(paths))     # the beds the rivers cut

Rivers that meet merge: a path arriving on one already there stops and adds its water to it, so the river below a junction cuts a wider, deeper bed than either branch above it. Water also fills a hollow and spills: ground is not a smooth ramp, and a river that stopped in the first dimple of a noisy hillside would never reach anything.

The Channel is an ordinary height edit, so the river becomes part of the ground: a road crosses it as water rather than as a stripe, and sculpting the land upstream reroutes it the next time the flow is worked out. The path is worth keeping and the bed is not — a bed written to a file is the river as the land used to be.

Limits. The water surface is the world's own water layer, so a river is a carved bed with water in it only where it runs below the waterline; a stream running down a mountainside is a valley, not a ribbon of water. Flow is steepest-descent from a point, not a catchment model: it says where water from here goes, not how much of it there is.

Read the land off it

Iso-height lines over a height field, for a plan view to draw and a road to be held to a grade against:

from OpenGLContext_editor.world.contours import contours_of

for contour in contours_of(ground, extent=2048.0, interval=25.0, resolution=257):
    print(contour.elevation, len(contour.lines))   # (N,2) xz polylines

interval is the spacing in metres and resolution how finely the field is sampled to find the lines — the detail of the drawn line, and what it costs: the height function is asked once, for the whole grid. A line that comes back to where it started is a closed loop (a hilltop or a basin); one that does not runs off the edge of the ground. contours(heights, min_x, max_x, min_z, max_z, ...) takes an already-sampled grid instead.

The extraction is marching squares, so it is exact for a field that is linear inside a cell and converges on the truth as the sampling gets finer. It is for drawing: nothing here is a substitute for asking the height function itself where a particular elevation is.

Put a road through it

A RoadLayer carries a route across the world: it cuts the ground to meet the shoulder, splits the road so each tile owns the length inside it, and writes the centreline into the tileset so a game can find the track in what it streams.

from OpenGLContext.scenegraph.road import bank_profile
from OpenGLContext_editor.world.road import (
    RoadPath, RoadLayer, conform_terrain_at, follow_terrain,
)

course = follow_terrain(my_route, my_heights, spacing=5.0,
                        maximum_grade=0.075, design_speed=47.0,
                        minimum_height=2.5, closed=True)
path = RoadPath(course, bank=bank_profile(course, speed=47.0, closed=True))
ground = conform_terrain_at(my_heights, path)   # height fn -> height fn, per tile

terrain = HeightfieldLayer(height_fn_at=ground, extent=extent, resolution=33)
print(bake_world([terrain, RoadLayer(path=path)], '/tmp/world', depth=4).summary())

follow_terrain drapes a 2D route over the land and then makes it drivable:

  • smoothed, so the road carries the shape of the landscape rather than its every hummock;
  • held to maximum_grade, wrapping round the join for a closed circuit;
  • rounded off for design_speed (metres per second), so no change of grade is sharp enough to take the car's wheels off the road at that speed. A grade limit alone permits a road that climbs at its limit and descends at its limit a few metres later, and a car meeting that at speed is launched, because there is nothing under it. This is the vertical curve a real road has;
  • lifted onto a causeway where it would otherwise run below minimum_height, with its approaches raised to meet it.

bank_profile then says how far each corner leans. A superelevated corner puts part of the car's weight to work holding it on the line, so it is faster than the same corner flat — or as fast round a tighter radius, which is what lets an alignment follow a valley instead of sweeping across it. Each corner gets the lean that balances a car at the speed given, capped at MAXIMUM_BANK (0.10 — the upper end of what is built into a road, not an oval's banking), and the change from camber to full lean is spread over a transition of some seventy metres on the approach rather than happening at the corner's entry. The lean goes into the RoadPath, and from there into the surface, the earthwork beside it, the structures carrying it and the tileset a game reads. RoadPath() with no bank is a road whose corners are flat.

One road, several kinds of road

follow_terrain takes smoothing, maximum_grade and design_speed as one figure or one per point of the alignment, and that is what makes a road that is not the same road all the way round. OpenGLContext_editor.world.character works out what each of them should be:

from OpenGLContext_editor.world.character import corner_radii, road_character
from OpenGLContext_editor.world.route import hold_corners

plan = hold_corners(my_route, corner_radii(my_route, design_radius=270.0,
                                           seed=3), closed=True)
kind = road_character(plan, my_heights, design_speed=200 / 3.6, spacing=6.0,
                      closed=True, climbing_lane=3.6)
course = follow_terrain(plan, my_heights, spacing=6.0, closed=True,
                        smoothing=kind.smoothing,
                        maximum_grade=kind.grade_limit,
                        design_speed=kind.design_speed)

Nothing there is sprinkled about; every figure is derived from the corner the road is on, the land under it, or how far ahead a driver has to see:

the corners drawn from a mix (CORNER_MIX) so a lap has a hairpin and a sweeper as well as the corner it was laid out for. The plan's own turn angles are untouched — what changes is how hard each turn has to be taken
how fast a stretch is for what its own corner allows, never more than the road's design speed. A crest inside a hairpin rounded for the speed of the straight before it is a quarter of a kilometre of earthwork for a crest nobody meets at that speed
how steeply it may climb the ordinary limit, raised towards steep_grade where the land itself climbs harder than that over a quarter of a kilometre. Held to a gentle grade, a road across a hillside stands off it on an embankment for as far as the hillside lasts
how much it is smoothed with the speed. A bump taken at two hundred is a car in the air and has to go; the same bump at eighty is the road having some shape, and ironing it out costs the drive and buys nothing
how far the trees are cut back what a driver needs to see round the bend they are on. Sight round a bend of radius r past an obstruction clear to the side is about sqrt(8 · r · clear), so the corridor that buys a stopping distance falls out of it. It is the corners near the design radius that get opened out: a tighter one is taken slowly enough to see round already, a wider one is straight enough
where it is wide enough to be passed on the sustained climbs — 4% or more for four hundred metres — worst first, until lane_share of the road has been widened. Hill country asks for more climbing lanes than anybody builds, and a road widened along half its length is a wide road rather than a road with passing places on it

RoadCharacter.summary() reports the range each covers, and varies() says whether this came out as a road of one character after all.

conform_terrain_at returns the ground with the road built into it, at whatever sample spacing the tile being baked uses -- a cut narrower than that spacing falls between two vertices and never appears in the mesh.

Where the alignment is a few metres over low ground the road is instead carried on a causeway: fill retained at the width of the road, walled at each edge, with the ground either side left where it was found. The wall is low enough for a seated driver to see over — a causeway is built to cross something worth seeing.

An alignment that is not on the ground and not on a causeway is on an earthwork: fill runs down from the shoulder to where it meets the land, a cutting runs up to it, and how far out that is depends on how far the road is from the ground and on nothing else. Beside a banked corner the two verges are not at one height, and the ground meets each of them where it actually is. A road already on the land disturbs almost nothing; one carried forty metres over a valley builds an embankment as wide as it needs. Under the carriageway the ground sits a hand's breadth below the surface, because a road is built on a formation and surfaced on top of it.

What the road puts beside itself

A generated road knows what it is about to do, so the roadside is derived rather than authored. world.signs.warn_of reads curvature, grade and structures off the alignment and returns the warnings it wants, a stopping distance before each hazard; bake.signs.SignLayer writes them.

A circuit also needs its lap to be visible. world.gantry.start_finish takes the crown where the centreline begins, spans the carriageway and its shoulders, and measures the ground under each leg; bake.gantry.GantryLayer writes the frame and the chequered line painted under it as one mesh, and the two legs into the world's props so a car can hit them.

from OpenGLContext_editor.bake.gantry import GantryLayer
from OpenGLContext_editor.world.gantry import start_finish

GantryLayer(placement=start_finish(path, ground=my_heights))

Corners a designer drew

A drawn plan's vertices are corners: the road turns through the whole of one at a single point, which no car can take. There are two ways to answer that, and which one is right depends on where the corner came from:

hold_radius relaxes the line towards its chords until nothing is too tight — right for a route being found, where the corner is an artefact of the search
hold_corners rounds each corner in place, with a circular fillet tangent to both legs — right for a route that was drawn, where the corner is the point

A switchback is the case that decides it. A hairpin drawn up a mountainside is the only way to gain height where the slope is steeper than a road can climb; relaxing it puts the road somewhere else, while rounding it leaves the legs where the designer drew them and the hairpin still a hairpin — of the tightest radius a car can take, or the tightest the legs have room for.

ProceduralWorld applies hold_corners to any route it is given and ease_route to the circuit it invents for itself, which is the same distinction. A vertex turning less than fifteen degrees is a sample of a curve rather than a corner, and is left alone.

A drawn route keeps the corners it was drawn with. corner_radii is for the circuit ProceduralWorld invents for itself; a route a caller gives is held to the one design radius, because its corners are already a decision somebody made and re-drawing them from a mix is the generator overruling the designer. What a drawn route still gets is everything road_character derives, all of which follows from the line they drew rather than replacing it.

How tight "too tight" is depends on how far the corner leans. ProceduralWorld.corner_radius() is the floor, and it is cornering_radius(design_speed, bank=maximum_bank) with a twentieth over for what draping and re-sampling move — 270 m for the shipped circuit, against 315 m if the same speed had to be held flat. ProceduralWorld(maximum_bank=0.0) lays the circuit out level and gets the wider corners back. A plan whose legs are too short to fit the fillet keeps its corner and loses radius instead, and such a corner is slower than the design speed: the road says so, since what it is signed at comes from the radius and the lean it ended up with.

Where a lap begins travels with the road: ProceduralWorld(start_at=(x, z)) puts the gantry at the station nearest that point, and the tileset's extras carry it as roads[0]['start'] — a distance along the centreline, which is what the grid, the timing and the autopilot count from.

Baking is meant to be iterated

Baking the shipped four-kilometre world -- half a million trees, its roads and their structures, its signs, its start line and its boulders -- takes about 27 seconds. That is the number that matters: a world nobody can re-bake is a world nobody revises, and everything in this package exists so that a designer can change a route and drive it.

Nearly all of it used to be the tree scatter, and nearly all of that was questions asked about ground the answer did not depend on. Three things fixed it, and each is a rule worth following in a layer of your own:

  • Ask for a spacing, not a density, for anything that will be thinned to a minimum separation afterwards. Random candidates have to be several times too dense before the thinning saturates, and each one is paid for.
  • Run the filters cheapest-first, each on what the last one left. An elevation band is free once the height is known; a slope is four more height lookups apiece.
  • Do not ask the road about ground it never reaches. RoadPath's index drops whole cells of a query in one pass rather than iterating over the hundreds of thousands a fine grid makes.

Install for development

The package is developed inside the OpenGL-dev workspace, where a uv sync at the workspace root installs it editable alongside the engine. Standalone:

pip install -e ".[dev]"
pytest

Layout

Path Holds
src/OpenGLContext_editor/bake/ the tile baker: bounds, the octree, layers, the tileset writer, the bake driver
src/OpenGLContext_editor/world/ world generation: the height source, presets, DEM import, sculpting, hydrology, contours, scatter, roads, and the example world
src/OpenGLContext_editor/bin/ oglc-bake, which says the command is now glisteel-bake
tests/ the suite; pytest runs it
specs/ format and interoperability facts the code cites, and the clean-room procedure that governs how they are gathered

Status

The baker works: a world of terrain, forest, ground cover and roads bakes to a 3D Tiles octree the engine streams, and glisteel drives a lap of it. A road follows the ground, rides a walled causeway over low ground, spans a valley on a viaduct or runs through a bore, and carries the shade of the wood it runs through. Water and the editor UI toolkit are designed in GLISTEEL-WORLD-AUTHORING.md, which also records the division of labour between this package and the engine.

Licence

BSD-3-Clause; see license.txt.

What a bake writes beside the tiles

Every bake writes world.json next to the tileset: what the world is called, its seed and extent, how long its road is, and how many metres of that road are carried on bridges, bores and causeways. That is what anything offering a choice of worlds reads, since reading a tileset to find out means loading the world being chosen between. --name sets the name; without it a world is named after the directory it was baked into, so --output ashdown-forest gives Ashdown Forest.

The format is the engine's — OpenGLContext.loaders.tiles3d.manifest — so anything that loads a baked world can read one without depending on this authoring package. See openglcontext/docs/baking.html.

Metadata

Release files for OpenGLContext-editor 1.0.0a1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for OpenGLContext-editor 1.0.0a1
File Size Uploaded
openglcontext_editor-1.0.0a1.tar.gz 247.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for OpenGLContext-editor 1.0.0a1
File Interpreter ABI Platform
openglcontext_editor-1.0.0a1-py3-none-any.whl Python 3 none any Details

Total release size: 404.6 kB

Release files / openglcontext_editor-1.0.0a1.tar.gz

Download URL openglcontext_editor-1.0.0a1.tar.gz
Size 247.0 kB
Tags Source
SHA-256 checksum
How to use checksums
54901c990e2182f25a932f1f8d8a7bbb43c94cbd7d24073263cdda045b89301d
BLAKE2b-256 checksum
How to use checksums
c57ff8da14df224f8475be572659827b0c0c60c0aa016eef6e2f2b9981d857e0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 13, 2026.

Transparency log

Release files / openglcontext_editor-1.0.0a1-py3-none-any.whl

Download URL openglcontext_editor-1.0.0a1-py3-none-any.whl
Size 157.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
705d68eff4e56ea5877d42cdab7bc5a5ca059d222cb3f40bceb993c96631470d
BLAKE2b-256 checksum
How to use checksums
8d9604b28c39e3ec5eca6b109a371a12ed635b704ba298a3a4db89fd939c5a83
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 13, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.0.0a1 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page