Land overlay — geo-anchored 3D models on <LayerViewer> (hive spec)
This is not a new component. It's a capability extension of
<LayerViewer>(seedocs/components/layer-viewer.md, which this doc assumes and cross-references throughout): a newmodels?: GeoAnchoredModel[]prop that composites real 3D glTF meshes onto the same georeferenced map<LayerViewer>already owns, anchored at a real lat/lon so a model reads as genuinely "in the land" rather than a flat icon. There is no<LandOverlay>import — you use<LayerViewer models={...} />.
Design discussion (RESOLVED architecture — read first):
.pHive/epics/land-overlay/docs/design-discussion.md — this doc documents
those decisions, it does not re-derive them.
Why (operator intent — honor this)
CBA's target pipeline (nadir passes → WebODM → orthomosaic + DSM + point cloud
- glTF mesh) and CLAUDE.md's 2026-08-07 vision expansion both point at the
same next step after
<Model3D>shipped the free-floating glTF viewer: composite that mesh onto the map, not just in its own orbiting canvas.<Model3D>'s own doc deliberately deferred inventing a geo-anchoring type (ModelDef {id, url, title}carries noscale/position/lat-lon — seedocs/components/model3d.md's "Deliberately deferred" section) until an epic that actually needed it existed. This epic is that epic, and it's also the confirmed prerequisite for what's queued next (the Minecraft voxelizer / content-engine work — CLAUDE.md's priority order: Model3D → land overlay → Minecraft voxelizer → telemetry-driven video overlay), which will reuse the same "3D content composited into the map" muscle this epic built.
The type — GeoAnchoredModel (lib/geo-model-types.ts)
export interface GeoAnchoredModel {
id: string;
url: string;
title: string;
lat: number;
lon: number;
altitudeMeters?: number; // default 0 (ground level)
rotationDegrees?: number; // yaw about the vertical axis, default 0
scale?: number; // multiplier on top of meters-per-glTF-unit, default 1
}
This is deliberately a different shape from <Model3D>'s ModelDef, not
a superset or a shared base — ModelDef is scene-space (a mesh at the origin
of its own free-floating <Canvas>); GeoAnchoredModel is geo-space (a mesh
draped onto a real map at a real lat/lon, the same way every entry in
lib/layer-types.ts's LayerDef registry is real-world-anchored). Do not
import, extend, or alias one from the other — lib/geo-model-types.ts's own
header comment states this explicitly, and it's asserted by the field-level
diff having no overlap beyond {id, url, title}.
scale is not a raw Mercator-space multiplier. createModelLayer already
computes a meters→Mercator-units factor from the anchor's latitude
(MercatorCoordinate.meterInMercatorCoordinateUnits()); scale sits on top
of that and answers "how many real-world meters should one unit of the
glTF's own coordinate space represent" — i.e. it corrects for a glTF authored
in some arbitrary unit rather than meters. The sample duck (an oversized,
non-metric export — its position-accessor min/max span roughly 165×154×115
raw units) needed scale: 10 to read as a recognizable, non-speck,
non-frame-swallowing silhouette; that number was arrived at empirically
(Playwright pixel readback at scale: 0.1, 5, 10, 50), not guessed —
see app/(showcase)/components/land-overlay/page.tsx's SAMPLE_MODEL_SCALE
comment for the full search.
How the custom-layer engine works (lib/maplibre-model-layer.ts)
<LayerViewer> doesn't render 3D models itself. createModelLayer(model: GeoAnchoredModel): ModelLayer builds a MapLibre CustomLayerInterface — a
layer type where MapLibre hands you its raw WebGL context and calls your own
onAdd/render/onRemove once per frame, instead of MapLibre drawing
anything for you. LayerViewer.tsx just calls
map.addLayer(createModelLayer(model)) / map.removeLayer(model.id), the
same arm's-length way it already wires in @geomatico/maplibre-cog-protocol
(see layer-viewer.md's "Tech" section) — it does not reimplement any of this
inline.
Why raw three.js, not @react-three/fiber (unlike <Model3D>): r3f's
<Canvas> assumes it owns the render loop and the WebGL context. A MapLibre
custom layer has to render into MapLibre's existing loop and context
instead, so this is a genuinely different code path from <Model3D>'s r3f
approach, not a style choice — lib/maplibre-model-layer.ts's own header
comment is explicit about this.
The pipeline, per frame:
onAdd(map, gl)(called once, synchronously, when the layer is added): kicks off a lazyimport("three")+GLTFLoader+maplibre-gl'sMercatorCoordinate(mirrorsLayerViewer.tsx's ownloadMapLibreModules()lazy-load pattern —maplibre-gltouches browser-only globals at module-load time and would crashnext build's server-side prerender if imported eagerly). Once resolved, it builds aTHREE.Scene+ a minimal two-light rig (ambient + one directional — glTF PBR materials render solid black with zero scene lights; this was found live during the showcase-page story, not anticipated up front), aTHREE.CamerawhoseprojectionMatrixis fully overwritten every frame (its constructor params are irrelevant), and aTHREE.WebGLRendererconstructed with{ canvas: map.getCanvas(), context: gl }— sharing MapLibre's own GL context rather than owning a separate one, withautoClear = falseso it doesn't wipe what MapLibre already drew this frame (the basemap + raster/geojson layers, drawn earlier in the frame). It also converts{lat, lon, altitudeMeters}to Mercator space viaMercatorCoordinate.fromLngLatand builds the model's object-space → Mercator-space transform (buildModelMatrix, pure/unit-tested — see below), then kicks offGLTFLoader.load(model.url, ...).render(gl, matrix)(called by MapLibre once per map frame,matrix= MapLibre's current projection matrix for that frame): multiplies MapLibre's matrix by the model's own transform (combineMatrices, pure), hands the result to the three.js camera as itsprojectionMatrix, and callsrenderer.render(scene, camera). This one multiply is what makes the model automatically track pan/zoom/tilt/bearing — MapLibre owns all the camera math every frame; three.js only ever draws into the matrix it's handed.onRemove(): disposes every geometry/material/texture the loaded glTF created (three.js does not garbage-collect GPU resources just because a reference is dropped) and clears all internal references.
The five corrections a design review forced into the "naive happy path" recipe
MapLibre's own official custom-layer example is a straight-line happy path —
onAdd loads synchronously, render always draws, cleanup is an
afterthought. None of that survives a real, ref-counted, remount-tolerant
React component. Every one of these is load-bearing, not defensive
boilerplate — a future maintainer touching this file should not "simplify"
any of them away:
- Ready-guard.
onAddis synchronous; loadingthree/GLTFLoaderand fetching/parsing the glTF are both async.render()checksif (!ready) return;first, before touching anything else — MapLibre will callrender()before the model has loaded on a real map, and without this guard that's a null-dereference or a draw call against a half-built scene. - Cancelled-guard. Checked twice: once before the lazy module import
resolves, and once — the case that actually matters — before the
in-flight
GLTFLoader.load()success callback callsscene.add(). Both guard againstonRemove()firing (component unmounts, or themodelsprop drops this entry) while an async step is still in flight; without it, a load that resolves post-unmount adds a mesh to a scene nobody will ever render or dispose again — a leak, not just a wasted draw. resetState()after everyrender(), before returning to MapLibre. three.js'sWebGLRenderermutates GL state (depth/blend/cull/viewport) as a side effect ofrender(). Left unreset, MapLibre's own subsequent layer draws — the ortho/hillshade/parcel-boundary raster/geojson layers, drawn after this custom layer in z-order — can render corrupted. This is the single correction with its own dedicated regression test (see "GL-state-pollution regression" below).- Full disposal on
onRemove(). Every geometry, material, and texture the loaded glTF created gets.dispose()'d explicitly — three.js objects don't auto-release GPU buffers/textures/programs just because a JS reference is dropped. Textures are found via a fixed list of known texture-slot property names (map,normalMap,roughnessMap, …) since they live as named properties on a material, not in a traversable collection. - Scene-graph-wide opacity. Raw three.js has no single "layer opacity"
knob the way MapLibre's
raster-opacitypaint property is one number.setOpacity()walks the whole loaded scene graph and stampstransparent = true; opacity = valueonto every mesh's material(s) individually (a mesh may carry one material or an array of them).
Plus the lighting fix found live during the showcase-page story (the
duck rendered as a solid black silhouette — position/scale were correct,
only lighting was missing) and getMap()/isReady(), added by the
test-suite story purely so Playwright could reach the live Map instance and
wait deterministically for "the model has actually rendered a frame" instead
of guessing with a fixed timeout (see below).
The pure, unit-tested core vs. the WebGL-dependent shell
lib/maplibre-model-layer.ts's own header comment lays out a "TESTABILITY
MAP" worth repeating here, because it explains why this doc cites two
different kinds of test evidence:
buildModelMatrix/combineMatrices/multiplyMat4and themake*Mat4helpers are 100% pure array arithmetic — no three.js, no maplibre-gl, no WebGL — fully covered by ordinary Vitest unit tests.applyOpacityToSceneGraph/disposeSceneGraphare pure recursive traversals typed against minimal structural shapes (Object3DLike/DisposableObject3DLike, notTHREE.Object3Ditself), so they're exercised with plain object literals in unit tests, while realTHREE.Group/Mesh/Materialinstances satisfy those shapes for free at runtime (structural typing) — same functions, no separate "real" version.createModelLayer()'s ownonAdd/render/onRemovecontrol flow (do the guards fire in the right order? isresetState()actually called? does disposal walk the right objects?) is unit-tested by mocking the"three"andGLTFLoadermodule specifiers with lightweight call-recording fakes — this proves the wiring, not that the resulting pixels are correct. jsdom has no WebGL; a realnew THREE.WebGLRenderer(...)throws the instant it queries a GL context under jsdom, so this file'snpm testcoverage stops at wiring by construction, not by oversight.- Whether the rendered pixels actually land in the right place on screen is Playwright's job exclusively — see the next section.
The numeric verification approach actually used
The epic's design discussion was explicit, after adversarial review, that
qualitative "looks roughly right" checking is not sufficient for the hardest
rendering work in the project — a matrix/unit mistake in this code tends to
fail silently (nothing renders, or renders at the wrong scale/position)
rather than erroring loudly. lib/maplibre-model-layer.placement.test.ts
(run via npx playwright test, not npm test — jsdom has no WebGL) is
where that commitment is honored. Concretely, against the real
/components/land-overlay showcase page:
- Reaching the live map instance.
page.evaluateruns in the browser with no way to reach into a React ref from outside the component tree, and<LayerViewer>doesn't otherwise expose its internalMap. The showcase page stashes itsLayerViewerHandleref ontowindow.__layerViewerHandlein aNODE_ENV !== "production"-gated effect, purely for this —LayerViewerHandlegrew agetMap()accessor specifically to make that handle useful for this, andModelLayergrewisReady()so the test can wait for "the model has actually rendered a frame" instead of a fixed timeout (map.getLayer("duck").implementation .isReady()). - Reading back real rendered pixels.
<canvas>.getImageData()on a WebGL canvas only returns real content if the readback happens synchronously inside the map's own"render"event callback, before control returns to the browser's event loop/compositor (<LayerViewer>'s context isn't created withpreserveDrawingBuffer: true, matching MapLibre's own default) — verified live: asetTimeout/microtask readback after"render"reliably came back all-zero/transparent; a synchronous readback inside the"render"callback reliably captured the real frame. Every capture usesmap.once("render", () => { /* synchronous getImageData */ })+map.triggerRepaint(). - Locating the model in the captured frame. A duck-colored-pixel mask
(
R - B > 60per RGBA pixel) isolates the sample duck (the classic Khronos "Duck" glTF, yellow-bodied/orange-beaked) from every background this scene can show at its anchor coordinates (satellite tiles, Esri's "no imagery" placeholder, the sample parcel's green boundary fill) — those cluster atR - Bin roughly[-15, 35]; the duck clusters in roughly[140, 255]. The masked pixels' intensity centroid and bounding box are the model's actual rendered position. - The comparison, at each camera state, is two independent numeric
checks:
- Containment (the story's literal ask):
map.project(lngLat)of the model's real anchor — the expected screen position, straight from MapLibre's own camera math — must land inside the duck's actual rendered bounding box (±20px slop for anti-aliasing/mask-threshold edge effects). This holds for any correct rigid transform, because the anchor is verified (by reading the glTF's own accessor min/max) to sit within the mesh's local horizontal footprint. - Golden-centroid distance (tighter, more sensitive to rotation/axis-sign bugs specifically): the measured centroid must be within 15px of a previously-measured reference value for that exact camera state — rendering here is fully deterministic, so two independent live measurements against known-correct code reproduced each golden value to sub-pixel precision.
- Containment (the story's literal ask):
- The pitch/bearing sweep is mandatory, not optional. Both checks above
run at pitch 0/bearing 0 (default view) and pitch 45/bearing 90
(tilted/rotated) — a matrix bug that cancels out at the default view is a
real, common failure mode this sweep exists specifically to catch. This
wasn't just asserted to matter: dropping the fixed glTF-Y-up →
Mercator-Z-up rotation term from
buildModelMatrix's multiply chain was deliberately injected during development and re-measured — it shifted the golden-centroid distance to ~90–95px at both camera states (i.e. it does not conveniently cancel out at the default view for this asset), comfortably outside the 15px tolerance. The same injected bug is also caught more directly by a unit test asserting the Y-axis handedness sign. - GL-state-pollution regression (Correction 3 above, made concrete):
compares a pixel fingerprint of the ortho layer's own appearance on a
page that never adds the model layer at all
(
/components/layer-viewer, used as ground truth) against the same fingerprint on/components/land-overlayafter the model layer has rendered at least one frame and the ortho layer has been toggled off then back on through the real<LayerControl>-equivalent imperative-handle path (toggleLayer). Every sampled pixel must match within a small decode/anti-aliasing tolerance (8per channel) — zero samples are allowed to exceed it. A genuinely separate model-free page is used as the "before" state rather than an early-as-possible capture on the same page, specifically because the duck's glTF is a small local asset that can load fast enough to race an early-page-load capture — a construction-based ground truth sidesteps that instead of relying on timing.
Current limitation — glTF meshes only, no point-cloud overlay
GeoAnchoredModel/createModelLayer only render glTF/glb meshes via
three.js's GLTFLoader. There is no path here for draping a raw point cloud
(COPC/LAZ, potree) onto the map — that's CBA's separate, later
point-cloud-rendering phase, tracked against <Model3D> too (see
docs/components/model3d.md's Phase fit: "P3 — point-cloud rendering as an
alternative or companion data source to the glTF mesh path"). Nothing in
lib/maplibre-model-layer.ts assumes or blocks a future point-cloud custom
layer being added alongside this one; it just doesn't exist yet.
Usage
import { LayerViewer, LayerControl } from "@/components/LayerViewer";
import type { LayerDef, LayerViewerHandle } from "@/components/LayerViewer";
import type { GeoAnchoredModel } from "@/lib/geo-model-types";
const viewerRef = useRef<LayerViewerHandle>(null);
const [layers, setLayers] = useState<LayerDef[]>([]);
const models: GeoAnchoredModel[] = [
{
id: "duck",
url: "/model3d-samples/duck/model.glb",
title: "Duck (sample glTF)",
lat: 73.46748426410694,
lon: -56.808326092516914,
altitudeMeters: 0,
scale: 10,
},
];
<LayerViewer
ref={viewerRef}
manifest="/layer-viewer-samples/2806-prado/layers.json"
models={models}
onLayersChange={setLayers}
/>
<LayerControl
layers={layers}
onToggle={(id, toggle) => viewerRef.current?.toggleLayer(id, toggle)}
onOpacityChange={(id, opacity) => viewerRef.current?.setOpacity(id, opacity)}
/>
models is fully optional and backward-compatible: omitted entirely (every
pre-existing <LayerViewer> usage), no model-layer code runs at all —
createModelLayer is imported but never called. The prop is diffed by id
against what's currently added to the map on every change (an entry added
gets map.addLayer(createModelLayer(model))'d, one removed gets
map.removeLayer(id)'d, which triggers onRemove's own cleanup).
The showcase demo lives at /components/land-overlay
(app/(showcase)/components/land-overlay/page.tsx), public/ungated, same as
every other app/(showcase)/components/* page — deliberately a separate page
from /components/layer-viewer, not a modification of it, since this is a
distinct capability worth its own demo (the same reasoning <Model3D> got
its own page rather than being bolted onto <LayerViewer>'s).
Fixed — @geomatico/maplibre-cog-protocol misreported WGS84 bounds for non-EPSG:3857 COGs
Status: fixed (2026-08-08), by reprojecting the sample COGs. This section previously documented an open bug; kept here (renamed from "Known open issue") since the root-cause writeup below is still the reference for why any future non-3857 sample/real COG would hit the same failure mode.
This bug was not specific to land-overlay — it lived in
<LayerViewer>'s own COG-loading path (@geomatico/maplibre-cog-protocol,
wired in by the layer-viewer epic) and affected /components/layer-viewer
too. It was pinned down precisely during this epic's ground-truth placement
work: verifying the sample duck's real-world anchor against map.project()
required knowing the sample ortho's actual geography, which is when the
mismatch became impossible to miss.
Root cause: @geomatico/maplibre-cog-protocol v0.4.0 hardcodes a Web
Mercator assumption for every COG it loads — its minified bundle constructs
new SphericalMercator({size:256, antimeridian:true}) and derives tile
bounds via bbox(x,y,z,false,"900913") (the "900913" EPSG:3857 alias),
unconditionally, regardless of the COG's actual embedded CRS. It never reads
the file's real coordinate-reference-system metadata. So a COG whose pixel
grid isn't already in EPSG:3857 has its raw projected-meter values
misinterpreted as if they were Web Mercator meters.
public/layer-viewer-samples/2806-prado/ortho.tif was a real, valid COG in
EPSG:32621 (UTM zone 21N) — verified directly with rasterio, its true
WGS84 extent is ~73.47°N, 56.8°W (high Arctic — matches the sample
duck/parcel coordinates used throughout this epic). Before the fix, the
library's RasterTileSource.bounds for this file, live-verified via
map.getSource("layer-ortho").bounds, came back as lon 3.35–5.74 / lat
58.25–59.49 — off the coast of Norway — matching the Web Mercator
inverse projection of the COG's raw UTM meter values. The actual tile
fetches shared the same bug, so the ortho imagery itself painted near
Norway, not at the real parcel location.
The fix: reprojected both ortho.tif and hillshade.tif from
EPSG:32621 to EPSG:3857 ahead of time (rio warp --dst-crs EPSG:3857 --resampling bilinear, then re-cogged with rio cogeo create and validated
with rio cogeo validate), rather than patching the library. This sidesteps
the library's hardcoded assumption entirely and matches CLAUDE.md's own
target pipeline, which already tiles/reprojects WebODM output before it
reaches <LayerViewer> (target format is PMTiles/COG in a web-safe
projection already). Post-fix, ortho.tif's computed WGS84 bounds are
[-61.288, 72.230, -52.302, 74.663] — correctly containing the known
duck/parcel coordinates (lon -56.808, lat 73.467). Both showcase pages
(/components/layer-viewer, /components/land-overlay) now land on the
correct real-world location by default, with no manual pan/zoom or
map.jumpTo() workaround needed.
Side effect: hillshade.tif's pixel dimensions shifted from 512×512 to
549×522 and its value range from [78,216] to [0,213] due to bilinear
resampling during reprojection. public/minecraft-samples/2806-prado/heightmap.json
(minecraft-content-engine epic) derives from this file, so it was
regenerated in lockstep using the same 32×32 average-pool +
linear-quantize-to-[1,8] approach as originally used — see
docs/components/voxel-terrain.md for that derivation's own notes. Both
public/layer-viewer-samples/2806-prado/manifest.test.ts and
public/minecraft-samples/2806-prado/heightmap.test.ts re-derive/validate
against the actual committed files at test time (not hardcoded CRS/bounds
values), so both continued passing with zero test-file edits.
Any future real or sample COG that isn't already EPSG:3857 will hit this
identical bug — reproject ahead of time (as done here) rather than relying
on the library to handle it. If someone wants to fix it at the library layer
instead, the specific code to patch is @geomatico/maplibre-cog-protocol's
_e=({x,y,z})=>Le.bbox(x,y,z,false,"900913") (or its equivalent in whatever
version is installed) — it needs to read the COG's actual CRS and reproject
accordingly rather than assuming "900913" unconditionally.
Phase fit
- P1 (
<Model3D>, done): free-floating glTF mesh viewer, scene-space only, no geo-anchoring. - P2 (this epic):
GeoAnchoredModel+createModelLayer+<LayerViewer>'smodelsprop — a glTF mesh composited onto the real georeferenced map at a real lat/lon, numerically verified againstmap.project()across a pitch/bearing sweep, with a GL-state-pollution regression guard. - P3 (per CBA, not built here): point-cloud rendering (potree/COPC) as an alternative or companion data source to the glTF-mesh path this epic ships.
- Next (per the operator's 2026-08-07 priority order): the Minecraft
voxelizer/content-engine epic reuses this epic's "3D content composited
into the map" muscle — and, per
.pHive/CONTEXT.md's Conventions entry, its likely need for its own MapLibre custom layer should follow this module'sresetState()/ready-guard/cancelled-guard pattern rather than rediscovering it.
Real data update (2806 Prado, real georeferenced-fix story)
The underlying <LayerViewer> sample data (ortho/hillshade/parcel/contours) was replaced
with the operator's real, rights-cleared 2806 Prado St photogrammetry — see
docs/components/layer-viewer.md's own "Real data update" section for the full
provenance/GPS-bug writeup. The sample duck's anchor moved to the new real parcel's
centroid (~30.262°N/-97.708°W) and scale/DUCK_ZOOM/the golden-centroid values in
lib/maplibre-model-layer.placement.test.ts were all re-verified LIVE against the new
ortho (not blindly copied) — the default-camera golden centroid changed (from
{345.25, 235.82} to {363.32, 221.82}, deterministic across two independent live runs);
the rotated-camera golden and the R-B>100 duck-color mask both still held unchanged.
The demo model deliberately stayed the duck, not the real reconstructed Prado mesh now
used by <Model3D>'s own showcase (public/model3d-samples/prado/model.glb): that mesh
is Z-up in its raw ODM/OBJ export, and this component's absolute map-alignment requirement
needs a real axis-correction pass (verified live, same as everything else in this file) —
budget for that as its own follow-up, don't guess at it.
v2 update
The underlying ortho was replaced again with v2 (nadir + oblique orbit — see
docs/components/layer-viewer.md's "v2 update" section). The duck's anchor and all golden
pixel-centroid values above held unchanged — v2 was cropped to within ~15m of v1's original
extent specifically to keep this placement test valid without re-deriving new goldens, and
that was verified live (re-ran the full e2e placement suite against the v2 ortho, not
assumed). The Z-up mesh/axis-correction follow-up noted above is still open.