Aggregate Builder: hand-made mosaics (/builder)
Assemble your own mosaic of 3D Tiles, Potree and COPC datasets as a tree of groups, with per-node metadata and display modes, then export a 3D Tiles 1.1 tileset.json or a set manifest, or preview it through a server-side draft.
/aggregate builds a mosaic automatically: it groups datasets by a
quadtree cell, which is right for 700 datasets and says nothing about what they are. The Aggregate
Builder is the manual counterpart. You choose the grouping (a site, a campaign, a year), name and date
every node, attach your own metadata, and decide what a group shows. The result is still one plain
3D Tiles URL.
Open it at localhost:3000/builder, or with the 🧱 builder button in the viewer's top bar. When
an /aggregate mosaic (or a builder draft) is loaded, the button passes its selection along, and the
library opens on exactly those datasets.
| Query param | Effect |
|---|---|
?import=<index.json URL> | preloads the library with that selection (an /aggregate/…/tileset.json URL works too) |
?tileset=<tileset.json URL> | imports an existing aggregate or builder tileset as the tree, keeping its hierarchy |
&flatten=1 | with ?tileset=: import as one flat list instead (see Importing a tileset) |
&groupby=scene|project|provider|year | with ?tileset=: bucket every group's datasets after the import |
The three panes
Library (left). Under Sources, tick any number of sources at once: the sets of
/aggregate/demo.json (each with its count; 660/692 once loaded means 32 entries could not be
placed), the imported selection, and Added by URL. Each set is fetched once from
/aggregate/index.json?set=<name>, and all sets load concurrently. Rows are merged and
de-duplicated by id (the hash of the source URL), so open, which includes flai and
aerometrex, adds no duplicates when those sets are also ticked. A set that fails to load shows a
small error row with retry and remove. The other sets still list. On a first visit the
library opens on world (or the first set if there is no world). A remembered set that the server
no longer lists is dropped before anything is fetched.
Rows are narrowed in three ways:
- Search: the viewer's typo-tolerant dataset search over name, project, scene, format and date.
- Tag: a plain substring filter over project, scene, date, provider, attribution, licence and
set. Every word must match, e.g.
2024 iconem. - Chips for format, provider and set (the set row appears once more than one source
is ticked). With nothing chosen in a row, that row does not filter. Choices combine as OR within
a row and AND across rows:
provider = Iconemwithset = iconemgives the 660 Iconem datasets of that set, not the 23 Angkor sites thatworldalso lists. Each chip's count is what it would show given the other rows' choices. Values that would show nothing are dimmed and folded behind +N more. Clear filters resets chips and tag.
The chosen sources, chips, tag and list grouping are remembered in localStorage
(aggregate-builder:library). The list can be grouped by project, scene, provider or set. Rows show
provider · project · scene · date · radius, and the tooltip adds the attribution and the sets a row
was listed in.
Rows can be dragged, multi-selected with Shift/Ctrl, selected with all (every listed row), added with Add to "…" (the non-drag path, which targets the focused group), or added with a double-click. Rows hidden by a filter are never added, even if they were selected before. A group header drags all of its rows, and its + group button adds them as a new group.
Add URL accepts a tileset.json, a Potree cloud.js / metadata.json, or a .copc.laz.
Point clouds are wrapped through /stream exactly as the server wraps manifest entries (with
cache=none). via /proxy routes a 3D Tiles host that sends no CORS headers through the
tileset-aware /proxy. The row's position comes from a single-URL
/aggregate/index.json?url=<wrapped>, which does exactly the needed work: it fetches the child root,
derives its sphere and reports lon/lat/height/radius. A dataset in its own local frame comes
back 422 and is shown as an unplaced error row. An unreachable one is shown as failed.
Tree (centre). The root is the tileset. Below it are groups and dataset leaves. Drag from the
library into any group. Within the tree, drag to reorder or re-parent: the top quarter of a row drops
before it, the bottom quarter after it, and the middle drops inside (datasets split in halves).
An invalid drop, such as a group into its own descendant, shows no indicator and does nothing.
Each row shows its format dot, date, child count (shown/total when a display mode drops children)
and its mode or refine when not the default. Its ⋯ menu offers rename, new group,
group children by scene / project / provider / year, sort by name / date, duplicate and delete.
| Key | Action |
|---|---|
| ↑ ↓ Home End, Shift to extend | move / extend the selection |
| ← → | collapse / expand, or go to the parent |
| F2 or double-click | rename inline |
| Del | delete the selection |
| Ctrl+G · Ctrl+D · Ctrl+A | group selection · duplicate · select all |
| Ctrl+Z · Ctrl+Shift+Z (or Ctrl+Y) | undo · redo (outside text fields) |
| Ctrl+S | save the project JSON |
"Group by year" gives each new group that year as its date, so a parent in latest mode can choose
between them. Running a helper again reuses the groups it made earlier.
Inspector (right). Every node has a name, a date (YYYY, YYYY-MM, YYYY-MM-DD or an ISO
interval start/end, committed only when valid), notes and free key/value metadata. A dataset also
shows its child URL (read-only, copy / open), format, lon/lat/height/radius, editable
project / scene / provider / attribution / licence, and an optional geometric-error
override. Provider and attribution come from the index row (provider, attribution), so a
dataset keeps its credit line wherever you move it. With several nodes selected,
you can set one date on all of them.
Display modes and refine
3D Tiles has no "show one of these" switch, so a group's display mode decides what the export keeps:
| Mode | Export keeps | For |
|---|---|---|
all | every child | pieces of one site, or unrelated sites |
latest | the child with the newest date (its own, else its newest descendant's). If nothing is dated, the first child. | a diachronic series of one site (Mes Aynak 2010 → 2022) |
first | the first child | a manual pick among alternatives; reorder to change it |
Unlike the server's &latest=1, latest here needs no geometry test: you already said, by grouping
them, that the children cover the same ground.
Refine is written to the group tile as is. A group has no content, so REPLACE hides nothing.
What it changes is timing: Cesium refines a REPLACE parent only once every visible child is ready,
so siblings appear together. ADD is the usual choice.
What the export computes
tileset.json is built client-side with the same bounding rules as /aggregate, so a hand-built
mosaic refines exactly like a server-built one:
- Leaf sphere: the ECEF centre of the row's
lon/lat/height(WGS84), withradius + 0.5 m. The slack coversindex.json's rounding (1e-6°, i.e. ≤ 0.11 m, and 0.1 m on the radius). - Leaf geometric error: the override if set, else
max(radius, 1). The reference tile's GE gates when the child tileset loads, so it has to mean "the error of not showing this dataset", i.e. its size (see how a child's geometric error is chosen). - Group sphere: the minimal union of its exported children's spheres (the server's
unionSphere). Group GE:2 × max child GE, strictly decreasing on refine. Empty groups are left out, because 3D Tiles cannot express a volume that bounds nothing. - Root: the tighter of the union sphere and an Earth-centred sphere
(
max |centre| + radiusover the leaves). TilesetgeometricErroris2 × root GE. - Metadata (3D Tiles 1.1): every tile gets
metadata. Datasets use classdataset(id,name,project,scene,date,format,provider,attribution,licence,notes) and groups use classgroup(name,date,notes,mode). Empty values are left out of a tile's properties. Custom keys become extraSTRINGproperties, and the schema declares only the keys actually used. Property ids must match^[A-Za-z_][A-Za-z0-9_]*$, so keys are sanitised (survey team→survey_team,2nd→_2nd) and never shadow a built-in (name→x_name). asset.extras.builderrecords the version, the name, and the node counts in the whole tree (total) and in the file (exported); the two differ when display modes dropped children. A leaf's GE override is kept inextras.builder.ge, so an import restores it.
Set manifest is the { label, entries } shape of packages/tile-server/aggregate-sets/*.json.
It flattens the tree (after display modes) and gives each entry a group path such as
Angkor park / Temples. Entries hold the source URL: /stream and /proxy wrappers are undone
into format, crs and proxy: true, because the server re-wraps entries with its own origin.
provider and attribution are entry fields, as in the server's own set files. Custom metadata
goes under properties. Entries from a local set carry share links, which are
credentials, so treat the file like the local manifest.
Project JSON (kind: "aggregate-builder-project") is the lossless format. It keeps the whole
tree, including children a display mode leaves out, plus the rows added by URL. The project also
autosaves to localStorage. Undo keeps the last 100 states. Typing in one field within a second
counts as one step, and folding groups is not an undo step.
Importing a tileset
Import → tileset.json from URL (or ?tileset=, or a file) rebuilds a tree from any aggregate,
builder or draft tileset, keeping its hierarchy:
- Content tiles become datasets, placed from their bounding volume (sphere, box or region, with
tile transforms applied) and named from their metadata or their URL. The
datasetmetadata of an/aggregateleaf (id,name,project,scene,date,format,licence,provider,attribution) fills the matching fields; other keys become custom keys. - Every tile with children becomes a group, keeping its refine. A builder group keeps
name,date,notes,modeand custom keys. A server quadtree cell has no metadata, onlyextras.cell = { scheme, key, level }, so it is named after its cell:cell 13003003 (L7, geodetic). - Chains are collapsed. A group with no dataset and exactly one child group adds nothing: an unnamed cell is replaced by its child, and a named group (or the root) over a single unnamed cell adopts that cell's children. The server already path-compresses its cells; this also covers builder files and plain tilesets. The imported root becomes the document root.
For example a local Iconem manifest of the 23 Angkor sites, with &nest=geodetic, imports as 23
datasets in 17 groups (18 with the root, eight levels deep).
The URL dialog has two options (the Import menu also has Open tileset file, flattened…):
- Flatten — the old behaviour: unnamed cells are dropped and their datasets lifted into the parent, which gives one flat list for a server mosaic. Groups with metadata are still kept.
- Then group datasets by scene / project / provider / year — runs the same helper as the tree's
Group by… on every imported group (on the root only, in practice, after a flatten).
worldflattened then grouped by provider gives 11 provider groups.
Relative URIs resolve against the tileset URL (a file import has no URL to resolve them against). Children a display mode left out are not in the file, so they cannot come back.
Preview: drafts on the tile-server
Preview in viewer posts the exported tileset to the tile-server and opens the viewer on the result (Cesium or 3DTRjs):
POST /aggregate/draft body = tileset.json (≤ 5 MB) → 201 { id, url, expiresInS }
GET /aggregate/draft/<id>/tileset.json [&hide=] [&format=] [&from=] [&to=] [&latest=1]
GET /aggregate/draft/<id>/index.json same query → /aggregate/index.json rowsA blob: URL would not do. Renderers resolve the root through URL maths that a blob: base breaks,
and the viewer's search and picker need an index.json next to the tileset. The body must be a JSON
object with asset and root (else 400). Larger than 5 MB answers 413. Drafts live in memory:
at most 50, least recently used evicted first, each expiring 24 h after it was posted, with a random
UUID as the id. A restart clears them all. A draft is a preview, not a publication.
A draft's index.json has one row per content tile, read from its dataset metadata. group is the
builder group path, so the viewer's picker lists a hand-made group's datasets together, as it does a
site. The picker params work on drafts too: they prune leaves and then empty groups. Pruned groups keep
their original, still enclosing, bounds.
The viewer loads the draft through ?load=<tileset URL>&type=mesh|pointcloud (the builder picks
pointcloud as soon as one leaf is Potree or COPC, so the point-size control reaches it). The
?load= value applies to the mode and format the page opened with. Picking another preset afterwards
works as usual.
Measured on the dev stack: 10 Angkor datasets in 3 groups export as 14 tiles (root sphere 23 km, root GE 1 984). In Cesium the draft's root is ready about 5 s after load and shows 10/10 markers. From 1 800 m over Angkor Thom, the site meshes in view stream in.
Scale: all 692 Iconem tilesets
Both lists are windowed with a fixed row height, so only the rows in view are mounted: about 15 in the library and 34 in the tree, whether there are 45 rows or 2 000. The library's pipeline (merge → search → tag → chips → grouping) is split into memoised stages, so a chip click does not re-run the search. Search and tag input are debounced (120 ms), and search results are cached per query. Tree rows are memoised on primitive props, and the library no longer re-renders when a node is merely renamed. "Group by" is a single pass with one copy of the node map, and fold / unfold patches every group in one copy.
Measured in headless Chrome against the Vite dev build (React development mode, StrictMode),
with iconem (660 placed of 692), world (45) and flai (113) giving 818 unique rows:
| Action | Time |
|---|---|
tick iconem → 660 rows listed | ≈ 100–120 ms (fetch 40–60 ms) |
tick world + flai on top → 818 rows | ≈ 100 ms with warm server caches (a cold flai index takes 3.7–4.4 s on the server) |
| merge 818 rows · tag filter · chip counts | 0.2 ms · < 1 ms · < 1 ms |
search, per debounced query (temp, bayon, danemark …) | 3–10 ms below 4 letters, 30–85 ms from 4 letters (typo matching) |
| keystroke to next frame (input only) | 25–50 ms |
chips provider = Iconem + set = iconem → 660 rows | ≈ 35 ms |
| all (select 660) → Add to "My mosaic" | ≈ 40 ms → 130–240 ms (model op 2–3 ms, the rest is React dev rendering) |
toTileset of the 660-dataset tree | 2–6 ms (17 ms cold), 348 KB · toManifest 9–18 ms |
POST /aggregate/draft (348 KB) · draft index.json | 12–30 ms · 16–55 ms (660 rows, provider included) |
| group 660 by scene → 289 groups | 60–160 ms · export of the grouped tree 4–7 ms (290 groups) |
| group 1 478 datasets by provider → 27 groups | 50–125 ms |
| rename one node (949-node tree) · undo | 20–65 ms · 20–40 ms |
| collapse · expand 289 groups | ≈ 70 ms · 130–170 ms |
Preview of the flat 660-dataset draft in Cesium: the draft's index.json and tileset.json are
loaded about 3 s after navigation (dev build, cold page). The aggregate overlay shows 660/660
markers, and child tilesets start streaming right away (24 tiles ready 16 s in, looking at the
whole globe). The root sphere is Earth-centred (6 401 km), and its root GE is 47 876.
What remains slow: the typo-tolerant search costs 30–85 ms per new query of 4 letters or more on ~800 rows. It is debounced and cached, so it runs once per pause, not per key. Expand-all of a few hundred groups is ≈ 150 ms in dev. Production builds of React are several times faster on all rendering rows above.
Where the code lives
| File | Role |
|---|---|
apps/viewer/src/builder/tree.ts | the document model and every edit, as pure functions returning a new tree |
apps/viewer/src/builder/export.ts | tileset / manifest / project export, tileset import |
apps/viewer/src/builder/geo.ts | WGS84 ⇄ ECEF, sphere union, bounding volume → sphere |
apps/viewer/src/builder/selftest.ts | node --experimental-strip-types apps/viewer/src/builder/selftest.ts (77 checks, including provider round-trips and a 700-dataset group/export) |
packages/tile-server/aggregate-live.js | the draft store (handleDraft) |
Meta-tilesets: mosaicking N datasets into one endpoint (/aggregate)
MosaicJSON/STAC-style aggregation over N tilesets, with spatial auto-nesting in either a web-mercator or a geodetic quadtree — and why Morton order is not a projection.
Hierarchy explorer: reading a tileset's shape (/stats)
Depth, breadth, leaf counts, fan-out and geometric-error distribution for any 3D Tiles endpoint — one tileset in the server's own page, several side by side in the viewer's React explorer.