Brodgar client

The pointer and the ground: pixel, world, pick, click

Turning a screen pixel into a place and back, what a click resolves to, and where the client’s own dispatch can be intercepted. The scene those coordinates belong to is world-3d.md; which ground is drawn under them is terrain-raster.md.

The two conversions, and the space they both speak

What Where
Screen ↔ world screen → world (ground raycast) MapView.Maptest (Plob.Adjust.hit(Coord pc, Coord2d mc)); world → screen MapView.screenxf, or per NODE Homo3D.obj2clip(objc, state) → HomoCoord4f.toview(area) — object space through that slot’s own chain (placement, facing, obstate scale). Both speak MAP-VIEW-LOCAL DEVICE pixels: screenxf ends in toview(Area.sized(this.sz)), and Maptest(Coord pc) hands pc to checkmapclick in that same box — so either side is a Widget.rootpos() from a root coordinate and a UI.scale from a design one, and the two corrections compose in opposite orders on the two sides. What comes back out of Maptest.hit is a Coord2d in world units already — OCache.posres is not in this path at all. ⚠️ screenxf(Coord2d) projects at the player’s z, not the terrain height under the argument: it fills in getcc().z (MapView.getcc → player().getc()), so for a point on a slope it answers where that point would be at the player’s altitude, and a screen→world raycast back does not return to it. ⚠️ toview does the projective divide unguarded: check w > 0 first, or a point behind the eye answers with a plausible number on the wrong side. The state Pipe exists only inside a pass, so the way to ask is a PView.Render2D — which may draw nothing and exist purely to be handed it (fork: SurfaceDrawable). ⚠️ Nothing is culled anywhere in this path: ScreenList.draw walks every registered slot every frame and no gob is tested against a frustum — the GPU clips. So off-screen is not free, and a feature that needs it computes it itself. cur is a set of slots, not of nodes, so the same Render2D standing in the tree twice is drawn twice a frame and one that is not in it at all is drawn none — which makes a per-frame count of a gob-anchored one an exact read of whether that gob is in the scene
Placement snapping — placegrid/placeangle PlobAdjust / StdPlace (position, rotation); public plobpgran/plobagran; :placegrid/:placeangle cmds. The position half is the static MapView.placeSnap(Coord2d, modflags), which StdPlace.adjust calls (// addon: seam, so a gizmo snaps identically to a building). ⚠️ The modifier goes the way round you do not expect: with no SHIFT it is the coarse snap — mc.floor(tilesz).mul(tilesz).add(tilesz.div(2)), the tile centre, so a snapped world coordinate always sits at tilesz/2 inside its tile; SHIFT picks the finer plobpgran sub-tile grid, and SHIFT with plobpgran == 0 is free placement, returning mc untouched. Nothing here touches OCache.posres — it is world units in and world units out

The pick pass, and what a click reaches

What Where
Pick pass (clickable entities, drag handles) ClickMap, MapClick extends Clickable, Clicklist, ClickLocation, Gob.GobClick
⚠️ A pick off the view answers twice Clicklist.get(out, c, cb) with c outside the view, and fuzzyget when the fuzz box misses the view entirely, defer cb.accept(null) and then go on to issue the pixel read anyway — there is no return after the defer. get’s read answers cb a second time; fuzzyget’s indexes a null area inside its read callback. A caller whose cb is not idempotent, or who picks at a point that can be off the view, guards it themself
What a ground click resolves to The pick pass (clmaptree) holds ground only: each raster adds MapClick(cut).apply(cut.flat) — the cut’s flat mesh, whose per-vertex ClickLocation.vertex is the 0..1 place over the cut — and checkmapclick reads back which MapClick and where, then (ul + pos × sz) × tilesz. Fork: MapClick carries ul, sz and map rather than only the cut, so ground that has no MapMesh is clickable too (MapView.farclick(ul, sz, mesh): any model carrying ClickLocation.vertex, in session tile coords, map null so no member offset applies). The view distance’s recorded cuts (MapView.RecallClick, exactly the cuts the recorded raster has in the scene) and its far cells are in this pass, so a click on them is an ordinary wdgmsg("click", …) to that place. Where the pick finds no ground at all, Hittest calls nohit, which upstream leaves empty and which drops whatever the object pick found under the same pixel; fork: Hittest hands that on through nohit(pc, inf) (which falls back to nohit(pc) for every subclass that does not override it). Click.nohit sends an object found there as a click on that object at its own rc, the pair MiniMap.mvclick sends, and with no object sends the ground click anyway at MapView.groundless(pc): the pointer’s ray (the inverse of prj · cam · loc, NDC z −1 → 1, scene y negated back to map y) meeting the plane at the character’s height, nothing past the far plane or above the horizon, and nothing from a view that is no number. It is taken when the Click is built, on the UI thread with the camera the pick is drawn with, because the pick answers frames later on the callback thread
Click dispatch ← intercept point MapView.Hittest resolves pick → Click.hit → clickhit ends in wdgmsg("click", …); every fork tap on a map click hangs there (a ghost click, the voice move intent for a ground click, the click token). ⚠️ The pick is ASYNCHRONOUS, on a thread of its own: env.submit queues the readback, and the completion is run from GLEnvironment.callbacks by GLEnvironment.cbthread — the "Render-query callback thread", started on demand and exiting after 5 idle seconds, which is neither the UI thread nor the render thread. So the pick cannot serve anything that must answer inside the event (a press that takes a grab, for one), and — the half that is easy to miss — whatever the callback body touches, it touches off the UI thread, while the frame mutates it underneath: checkmapclick resolves its cut and its coordinate there, so any state it reaches has to be published for that thread rather than merely reachable from it. Fork: four // addon: hooks at the top of mousedown/mouseup/mousemove/mousewheel (+) run a synchronous test first and fall through when it misses

See also