Changelog
Every notable change, newest first (mirrors CHANGELOG.md at the repository root)
All notable changes to 3dtiled-to-3dtiles are documented here. Format loosely follows Keep a Changelog.
[Unreleased]
Changed — 2026-11-03 (deploy check)
- Harmless marker in the viewer, tile-server and docs, to time how long a push to main takes to reach production.
Added — 2026-11-02 (desktop app release)
- Windows desktop app on GitHub Releases (
desktop-latest), built by theDesktop app (Windows)workflow. It now carries its own Node and tile-server, so it no longer needs this repo or a system Node; the workflow installs it on the runner with the system Node hidden before publishing.
Changed — 2026-11-02 (licence)
- MIT licence for the repository.
Security — 2026-11-02 (API keys out of the repository)
- Google and Cesium ion keys are no longer in the source or in git history. Set them in
apps/viewer/.env.local(see.env.example), or asGOOGLE_MAPS_API_KEYandCESIUM_ION_TOKENon the viewer container, which keeps them out of published images. Without them, Google 3D and Cesium World Terrain are greyed out. History was rewritten, so existing clones must be reset to the newmain.
Fixed — 2026-11-02 (Terrarium globes, follow the terrain, crop box)
- Mapterhorn and AWS terrain failed tile by tile in 3DTRjs whenever imagery was on: since
fd46d51the globe registered an undefinedload-modellistener, and three's event dispatch threw on every loaded tile. AWS Terrain Tiles work again. - Mapterhorn 404s left holes in the globe. Open ocean now reads as sea level, and a deep tile without a regional DEM is cut out of its nearest ancestor.
- Auto (follow the terrain) on mosaics:
&snap=used Mapterhorn even on WGS84, and never changed with the terrain. It now follows it, with a new&snap=wgs84; each dataset's samples are cached apart from the terrain, so switching takes 0.08 s on the 660 Iconem datasets (22 s for the first build). - Auto on a single tileset: Google 3D and World Terrain mounted after the listener was attached, so a first failed measurement was never retried, and the ray could hit anything in the scene; it now reads the terrain renderer only. A failed measurement also retries as the data loads, on any terrain.
- Set box to view (3DTRjs): its "east" axis was not east, which turned and mis-sized the box; a flat view gave a box with no height, hence empty extracts. It now uses true east/north, 80 % of the view as labelled, and pads the heights.
- 3D crop box: it did not show until an unrelated re-render, ignored the numbers, and a drag never reached them, so the extract ignored it. It now follows the sidebar and writes back on release.
- Switching controls: a frame between the mount and the pivot could leave Orbit's target at the Earth's centre.
Changed — 2026-11-02 (terrain list, controls Auto, point colour)
- 3DTRjs is the default renderer, in the viewer, the aggregate builder and the desktop app's links.
?renderer=cesiumstill opens Cesium; a link withoutrenderernow opens 3DTRjs. - Terrain list: WGS84, Google 3D, Mapterhorn, AWS Terrain Tiles, Cesium World Terrain. In 3DTRjs, Mapterhorn is now mapterhorn.com's own Terrarium webp tiles (sea level) rather than Re:Earth's ellipsoidal mirror (z14). A georeferenced dataset therefore floats above it by the geoid undulation until snapped. Its depth follows the location, z12 to z16, each missing tile being cut out of its nearest ancestor.
- Controls mode Auto: Globe from far away, Orbit below 3 km above the ground, back to Globe above 6 km.
- Point colour (RGB, one colour, height) in every material mode, replacing X-ray's own setting.
- Debug: Cells only, without the dataset roots' bounding spheres.
- Native Potree leaves on by default.
- Auto (follow the terrain) is the first control of Local Frame.
Changed — 2026-10-30 (level Trackball and Fly, faster Orbit damping)
- Trackball and Fly keep the camera level, like Orbit: the roll each accumulates (Trackball
rotates
camera.upwith the drag, Fly composes pitch and yaw in the camera's frame) is removed after every update, against the ellipsoid normal on the globe. Trackball stops 2° short of the poles; Fly's Q E roll no longer applies. - Orbit damping 0.05 → 0.3: it settles six times faster.
- Mosaic children never drain upfront.
&cache=hierarchyon/aggregatenow asks each child for the new/streamcache=refine: open with nothing prefetched, then drain the whole hierarchy in the background on the first tile request below the root, so only the datasets a user zooms into are drained and nothing waits (Bastia: root in 186 ms, drain at ~270.hrc/s, tile and subtree requests under 100 ms meanwhile).&cache=nonepasses&prefetch=Nthrough. The top bar keeps none/hierarchy for mosaics (noall), and every cache pill and the prefetch input now explain their upfront cost and what they buy on hover. - Point colours as normalized
u16instead off32:/streamand the offline converters writeCOLOR_0as normalized unsigned short VEC4 (8 B a point instead of 16; a Bastia tile 28 → 20 B a point), lossless for 8-bit sRGB sources.&colors=u8|u16|f32per/streamrequest,POINT_COLORSserver-wide;u8halves again but bands the darkest values (glTF colours are linear). The vendored 3DTRjs Potree loader stores linearUint16too (24 → 18 B a point). - Max SSE shows what auto is for the loaded tileset (
auto · 2for points).
Fixed — 2026-10-31 (data stopped refining: the globe filled the shared tile cache)
- Measured, a globe tile holds a 512² RGBA imagery texture (1.05 MB), its share of a 514² Float32 elevation grid (1.06 MB, shared by up to 16 tiles) and a 33×33 mesh (~70 KB). 3DTRjs's cache charges every tile for the whole shared grid: 161 tiles counted 350 MB for 223 MB really held (169 MB imagery, 43 MB for 41 grids, 11 MB meshes).
- 3DTRjs shares one LRU tile cache (0.4 GiB) between every renderer, and a renderer whose cache is full requests nothing more. The terrain and imagery globe weighs ~2.2 MB a tile: measured over Bastia at 150 m, 161 globe tiles held 350 MB of the 429 and the dataset's 211 point tiles 66 MB. A pitched view or a long browse filled the cache with terrain and the data stopped at whatever it had, often the root. The globe renderers now have their own cache; the data renderer and its native-leaf children keep the default one to themselves.
Added — 2026-10-30 (baked mosaic bounds, &cache= on mosaics, tile-count table)
- Docs: "What a mosaic costs" in the meta-tilesets guide: where a dataset's sphere comes from
(baked, cached, or probed), what the client fetches and when, what a lazy
/streamopen reads, whatcache=hierarchydrains (one 2.45-billion-point Iconem cloud: 46,993.hrcfiles, 210 s before the first answer, against 0.2 s lazy), and a table of every cache with its lifetime. - Aggregate presets wait for the server's set list:
?preset=agg-iconem-allused to load the open-data mosaic when the list arrived after the first render, and stay there. - Uploaded manifests keep their set name when baked: the upload hash ignores
bounds. - Points without RGB are white (streaming and offline), not intensity-as-grey, which came out near black for the usual small 16-bit intensity values.
- Imagery None draws the 3DTRjs terrain unlit and black instead of a white hillshade.
- X-ray points: RGB or one colour with a colour picker (white by default).
scripts/aggregate-bake-bounds.mjswrites each manifest entry's placement (bounds: { sphere, geometricError, native? }) into the manifest;/aggregatethen builds the mosaic and its index without fetching any child root. The Iconem index (692 datasets) answers in 60 ms on a cold server with no cache. Every committed manifest is baked;iconem-share-links.mjsandopenheritage3d-manifest.mjskeep bounds across regenerations.&ttl=0and&curved=ignore them.&cache=none|hierarchyon/aggregate, passed to every/streamchild, and the viewer's top-bar cache pill reaches the aggregate presets. Default staysnone: measured cold on Iconem share links,hierarchytook 183 s and over 300 s for a cloud's first root (it drains the whole hierarchy), against 0.1–0.2 s lazy, and point tiles are built per request either way.- The child-root cache key ignores
cache=, so probing never drains a hierarchy and both modes share one cache. - Docs: measured terrain and imagery tile counts per camera pitch, nadir to 110°.
Fixed — 2026-10-29d (COPC geometric error was the node diagonal: everything refined to 2 px)
/streamCOPC tilesets declaredgeometricError = cube diagonal / 2^level(explicit and implicit trees alike), 100–200× the point spacing a tile actually carries, where Potree streams declare the spacing. With a 2 px error target that refines every node until it is two pixels wide: theopenmosaic seen from orbit marked 14 829 tiles used and queued 7 591 downloads in 3DTRjs — the national flai overviews (one with a 5 486 km cube) traversed to depth 13 — which is the "seeing the whole of France is slow" cost, in Cesium as much as in 3DTRjs. COPC tiles now declarespacing / 2^levelfrom the COPC info VLR (fallback cube/128, the Potree convention): same view, 146 used, 39 queued. The mosaic's leaf error is unchanged (it takes the larger of the dataset's tileset-level error and sphere radius), and the viewer's adaptive point size now reads COPC tiles' spacing from the error directly, as it does for Potree.
Added — 2026-10-29d (3DTRjs navigation, picking, loading and marker styling)
- One picking rule (
apps/viewer/src/lib/picking.ts) for double-click, middle-drag and the controls' own drag/rotate/zoom pivots (GlobeControls/EnvironmentControls_raycastis wrapped per instance): never a hidden object (a picker-hidden dataset, the inactive native/stream variant), a wireframe (the reference sphere with imagery off), a transparent material or a back face; the data wins over the terrain when both are under the cursor, the nearest terrain hit otherwise. - Footprint gate (
lib/footprintGate.ts, acalculateTileViewErrorplugin): a mosaic dataset root is not requested until its bounding sphere spans 24 px on screen — the marker stands for it — so a national mosaic from orbit no longer fetches 100+ external roots and their root content (native Potree leaves included: the child renderer never mounts for a 5 px dataset). - Stale download abort (
lib/staleLoads.ts): 3DTRjs only cancels tiles still QUEUED; tiles downloading or parsing while unused for 12 updates are now removed from the cache (fetch aborted, parse discarded) on the mosaic, its native-leaf children and every globe renderer — the "loads every tile along the camera path" stall on a fast fly-to. The imagery globe refines to 2 px, not the plugin's recommended 1 px. - Terrain behind data (on): terrain first, depth cleared, then the data (a render-order 0.5 hook; content models get render order 1), so datasets under the DEM stay visible. The camera roll is left to the controls (GlobeControls: local vertical near the surface, north only from far away).
- Controls freeze: two causes handled — a gizmo unmounted mid-drag leaving drei's
controls.enabled = false(re-enabled whenever no gizmo is shown), and EnvironmentControls' staleuprefusing near-parallel drags after the camera travelled (its up follows the ellipsoid normal at the camera every frame). - Motion LOD (
lib/motionLod.ts): for the duration of a scripted fly-to (flight.active) the globe renderers' error target is multiplied by 6, so the frames of the flight request only coarse imagery and terrain and the frame after arrival requests the destination at the normal target. Globe renderers only, flights only: a first version boosted every renderer from the camera's speed, which made the points and the terrain flicker between levels while panning or orbiting. - Picker: the in-view list is a tree — country (ISO 3166-1 alpha-3) → agency / project → site —
with one checkbox per level for everything under it and single-child levels collapsed; the
provider "markers" pills are now source pills (the mosaic's server sets, or its providers when it
has one) that hide the datasets themselves, tiles and markers. A hidden dataset is masked in the
traversal too (
FootprintGatePlugin.isHidden): its tiles stop loading and leave the cache instead of merely being drawn invisible./aggregate/index.jsonrows carrycountry/countryName(from the manifest entry'scountry, else the "(XX)" alpha-2 in its project or name); the committed manifests (flai, cities, Esri, Open Heritage 3D, Aerometrex, Vexcel) and RESOURCES.md entries are tagged. - Z offset: Auto (follow the terrain) keeps a tileset snapped to the ground — the dataset's own
low bins against the selected terrain source (Mapterhorn/Terrarium sampled, World Terrain / Re:Earth /
Google 3D by ray, WGS84 as 0), re-measured when the terrain under it refines, when the terrain mode
changes and when a new tileset loads; on a mosaic it toggles the server-side per-dataset
&snap=. Undo after a snap restores the previous offset (or removes the mosaic's&snap=): there was no way back before. Clearance above ground (10 m default) is added by Snap and Auto, so a scan lies above most of the terrain rather than half inside it. - Child-root cache persisted (
.local/aggregate-cache/child-roots.json,AGGREGATE_CACHE_DIR): a mosaic's child roots survive restarts and deploys, and a stale entry is served while it refreshes in the background — the Iconem mosaic no longer re-fetches 692 roots from the share-link API (9 s locally, a minute on prod) after every restart or 24 h. - Controls: Trackball (free rotation about the target) and Fly (W A S D / R F / Q E, drag
to look, speed from the height above ground; runs the frame loop while active) join Globe, Orbit and
Environment. Bare
controls.update()calls skip FlyControls (its update needs the frame delta; an undefined delta turned the camera to NaN). - X-ray has an opacity slider (points; meshes at 60 % of it) and RGB / white points.
- Sidebar regrouped into six foldable sections that remember their state (localStorage): Basemap & Terrain, Camera & Controls, Tileset & LOD, Material & Points, and Crop & Extract / Local Frame, which start folded. Material (3DTRjs): Default / X-ray (additive, no depth write: meshes as a tint, points at 35 %) / Wireframe, applied to every loaded tile and native Potree leaf.
- Open Heritage 3D manifest rebuilt from the source (
scripts/openheritage3d-manifest.mjs): 441 point clouds from 243 projects (was 10 hand-picked), with title, country, licence and the project map's coordinates; 146 projects have no Potree viewer yet and 70 name clouds that were never uploaded (every URL is probed). Terrain behind data is now on by default. - Globe parse queue (
GLOBE_PARSE_QUEUE, 32 jobs) for the imagery and Terrarium globes: theirparseToMeshis a network wait (elevation grid, then overlay image) and 3DTRjs ran it through the shared 5-job parse queue sized for Draco/glTF decoding, so a close view's ~300 terrain tiles drained five round-trips at a time — measured headless, fine imagery finished 8-10 s after arrival at a 400 m view while each fetch took ~10 ms. The data renderers keep the shared queue. (The camera tween never held requests back; the parse queue did.) - Fixed: Orbit controls crashed the 3DTRjs viewer (
installFilteredRaycast: drei's OrbitControls has no_raycastto wrap — skipped now). - Fixed: native Potree leaf "Cannot read properties of undefined (reading 'childMask')" after a fast camera move (a controls switch): the vendored loader marked a v1 hierarchy chunk as loaded before its fetch finished, so an aborted chunk (the tile left the cache) was never re-requested and the node's children were later expanded against a hierarchy that did not know them. The chunk is marked loaded once parsed, and a node without a hierarchy entry is a leaf.
- Fixed: middle-drag zoom "did not react" on a point cloud, especially right after flying to a
marker: the points pick tolerance was one world radius for the whole ray, sized at the dataset's
distance, so a stray point two metres in front of the lens matched and, being nearest, became the
pivot. A point now counts only within 15 px at its own distance (
pickPoint, also for the controls' own pivots). - Basemap: Mapbox Satellite is the default when the build has
VITE_MAPBOX_TOKEN(a git-ignoredapps/viewer/.env.local), Esri World Imagery otherwise; Esri tiles are requested withblankTile=false, so a level with no imagery answers 404 and the parent's imagery stays instead of the grey placeholder. - Markers: 13 px pastel discs with a bold dark anti-aliased ring (8 px grey when picker-hidden), the
same in Cesium (outline 2.5); one colour for every format by default (new
uniformcolour mode, the per-format palette is pastel too). A marker now hides only when its dataset spans more than 10 % of the viewport and has tiles on screen (sampled every 10 frames fromvisibleTiles), or fills 60 %: a nearby cloud that has not loaded keeps a clickable marker. Hover card redesigned (header with format badge, key/value grid: extent, captured, provider, licence; mosaic cell and snap in a quiet row). - Basemaps: Esri World Imagery without labels is the default (both renderers); Esri + labels
stays; Mapbox Satellite is listed when
VITE_MAPBOX_TOKENis set at build time. - Potree lab: switching control schemes keeps the camera in place (the new scheme adopts the
current focus: orbit target / pivot, else the dataset point under the screen centre, else the view
axis); middle-drag dolly and filtered picks as in the viewer. Fixes a
tilestemporal-dead-zone error when the page opened with?controls=globe. - Not done: a Web Mercator / planar view. 3DTRjs's planar mode is for its generated image surface only; 3D Tiles content stays ECEF and would need per-vertex reprojection plus matching bounding volumes (see viewer.mdx, "Navigating in 3DTRjs").
Docs — 2026-10-29c (changelog in the site, curved placement design, handoff)
- The changelog is now a page of the docs site (
/docs/changelog, first entry of the sidebar and a card on the index):apps/docs/scripts/sync-changelog.mjsmirrors this file intocontent/docs/changelog.mdxbefore everydevandbuild(MDX-escaped), and the output is committed so a checkout without a build step has it too. - Curved placement, the design behind it (
meta-tilesets.mdx, "Why this shape, and what was not done"): the options table — one root transform, one transform per tile, exact per point, sampled per tile (chosen), regions instead of boxes — with cost and verdict for each, and why the root frame is kept for the whole dataset. The explicit-tree paragraph now states the 8-corner box fit under 20 km and the per-source cache (2026-10-29b). - Handoff note (
planning/handoff-2026-10-29.mdx) gained the table of exactly which vendored files, local plugins, imports and docs paragraphs to delete or change when PR #1780, PR #1782 and #1783 land upstream, and what to keep until each does.
Fixed — 2026-10-29b (the prod stall: curved COPC trees built 113 times on the event loop)
- Root cause of "3dtiled stalls on the open mosaic", found after the hang-up fix landed and prod
got slower, not faster: curved placement (2026-10-26) builds an EXPLICIT tree for every
country-scale COPC, 15–25 k tiles with a 5×5×2 proj4 sample grid per tile — ~1 s of CPU locally,
~8 s on prod — and
/aggregate'schildInfofetched that whole 6–10 MB tree from our own/streamjust to read the root bounds, 113 times forflai, on the single event loop. Before the hang-up fix most of those loads failed fast, which hid the cost behind holes in the mosaic; with every dataset loading,set=opentook over five minutes on prod and the root page five seconds. /stream/tileset.json?…&depth=0returns the root tile alone (copc-live: from the header and root page already in the context, no drain, no prefetch, a curved root box from the cube corners);childInfoappends it to every/streamchild URL.curvedBoxsamples only the 8 corners of tiles under 20 km (thesagterm already covers the chord dip; the fine grid stays for the few tiles above), and the finished curved tree is cached per context and CRS, so the first client to open a dataset builds it once (~150–250 ms locally).- Measured on a cold local server: Liège root-only 766 B in 0.35 s; full curved tree 6 MB in 0.4 s;
flai(113 COPCs) 2.4 s cold, 15 ms warm, 113/113 roots, no failures;open3.8 s. S3 still reset 4 range reads during that run; all 4 were retried successfully byrange-getter.js.
Changed — 2026-10-29 (ADD-skip regression filed upstream, public reproducer repo)
- Upstream issue #1781 and PR
#1782 for the ADD-skip traversal
regression (fork branch
fix-implicit-add-skip,53a4415), after Garrett asked for a dedicated issue in the #912 thread.lib/implicitAddFix.tsnow points at them. - New public samples repo jo-chemla/3dtiles-samples
(GitHub Pages, CORS
*):lion_takanawa/implicit-octree/tileset.jsonis the Lion/streamtileset exported static with3dtiled export --keep-implicit(no depth cap needed: 167 glb, 128 subtrees, 7.8 MB, 9.5 s), so the upstream reproducer no longer depends on our server. The headless harness gives identical numbers against the Pages URL (master 0 tiles beyond 0.5 m, fix 1).tileset-x100.jsonnext to it carries a root transform of scale 100 and geometric errors ×100 (3DTRjs divides the raw geometric error by distance, it does not scale it by the transform), so the 464 m model is on screen at the kitchen-sink example's 690 m start and vanishes past ~1 km on master. - Feature request #1783, adaptive point size for implicit ADD octrees (the generic form of our child-box sizing), with a proposed implementation shape; PR offered, pending Garrett's direction.
- PR #1780 review follow-up: the R3F
TilesPluginargs note moved from the generatedAPI.mdinto the JSDoc (2878f781); Potree's own material hasminSize2 /maxSize50, ourmaxPointSizestays 0 (off) by default unless Garrett prefers 50.
Fixed — 2026-10-29 (tile-server: S3 "socket hang up" no longer sticks)
- Prod logs showed repeated
FetchError … socket hang upfrom node-fetch inside thecopclibrary while theopenmosaic loaded the DEFRA / NOAA overview COPCs. The errors were caught per request, so nothing crashed, but three things made one transient hang-up permanent until a restart:memoizeAsynccached a failed root load forever (the dataset answered 500 for the life of the process),ensureForSubtree/drainAlldeleted a hierarchy page frompendingPagesbefore reading it (a failed read lost the page and every subtree under it came back empty, silently), and a curved-placement tileset of a 900 km COPC drained its whole hierarchy with one unpooled, un-retried request per page (96 pages for DEFRA 2008) while the aggregate fanned out 16 such loads. - New
packages/tile-server/range-getter.jsreplaces the library'sGetter.httpfor COPC: range reads issued in the same tick are sorted and coalesced when adjacent or less than 64 KB apart (capped at 8 MB per merged request), capped at 32 in flight per host (TILED_RANGE_MAX_PER_HOST), retried three times with backoff on resets / hang-ups / timeouts / 429 / 5xx, and any other non-2xx throws instead of being parsed as hierarchy or points. Measured: Liège overview, curved, 54 pages → 4 requests total (was 57); DEFRA 2008, 96 pages → 4 requests (was 99); Autzen implicit → 3. memoizeAsynccaches the promise (concurrent first calls for one source share a single build) and takes{ failureTtlMs };copc-liveandpotree-livepass 60 s, so a broken source still fails fast under polling but a hang-up during the root read heals by itself. Other adapters keep the permanent failure cache.- copc-live hierarchy pages are loaded through one in-flight map: concurrent subtree requests
share a page read instead of one of them answering with an empty block, a failed read puts the
page back in
pendingPages, and a faileddrainAllresets so the next request finishes it. Verified by failing the first drain read three times: all 54 pages restored, next request loads them in one read. Background prefetches (copc-live,potree-live) now log failures instead of surfacing asunhandledRejection(the oldtry { return promise } catch {}caught nothing).
Changed — 2026-10-28b (3DTRjs point-cloud patches proposed upstream)
- Upstream PR #1780 (branch
jo-chemla/3DTilesRendererJS:pointcloud-fixes), off upstream master (which has not touched the Potree / point-cloud plugins since0.5.3shipped them on 2026-09-18), one commit per patch, each with a CHANGELOG line in upstream's format and passing upstream's lint: Potree 1.8 attribute objects (c3381669), a clear error for LAS/LAZ node files and BROTLI encoding (8cb78c1e), a thrown error when a hierarchy or node download fails instead of decoding the error page as points (6b9071f5, found while testing: the loader had "loaded" a 403 body as 11 points per node),maxPointSize(ed4bcbb3),sizeAttenuation = false(4e313f88), and an R3F docs note thatTilesPluginargsare compared shallowly, so inline callbacks rebuild the plugin every render (aecb1edd). Verified against the unpatched loader on a public Potree 1.8 cloud from Iconem's STAC catalogue (32 of the first 80 items are 1.8): upstream throwsCannot read properties of undefined (reading 'name'), the branch parses r=3409, r0=2788… points, same shape as the 1.7 Lion control. - Vendored copies re-synced to the branch.
PotreeLoader.jsis the branch file verbatim; the material and effects plugin keep their patches with comments now naming the upstream commit, and only the child-box ADD sizing remains marked local (its generic form, an active-nodes driver for implicit octrees, is a separate feature request). Docs updated: the plugins are released in 0.5.3, the copies stay for the patches. - Not in the PR: the per-renderer EDL pre-pass (dropped) and per-point LOD for non-Potree octrees (kept for later).
- The ADD-skip traversal regression (#1673) is fixed on its own fork branch,
jo-chemla/3DTilesRendererJS:fix-implicit-add-skip(53a4415): the additive parent error skip inmarkUsedTilesnow requires a parent with renderable content, as CesiumJS'smeetsScreenSpaceErrorEarlydoes. Reproduced and verified headlessly in Node (no WebGL) with.local/addskip-harness.mjson the public Lion Takanawa/streamtileset at 3dtiled.iconem.com: master shows 0 visible tiles at 3, 10, 30 and 100 m (root content child error 13.8 px under the 16 px target); fixed, the root is drawn at every distance and 3 tiles at 0.5 m. Upstream lint and the core/three/r3f suites pass (Babylon/PlayCanvas tests fail on pristine master too).lib/implicitAddFix.tsstays until a release ships it.
Changed — 2026-10-28 (3DTRjs: native leaves switch in place, round points, ADD-refinement sizing)
- Native Potree leaves no longer reload the mosaic. An
/aggregateURL is always requested with&native=1(the mosaic is the same either way), andNativeLeavesPluginnow mounts, per Potree leaf, both aPotreePluginchild and a plain/streamchild renderer, created lazily: the checkbox flips which one updates and draws. Nothing is re-requested; the inactive reader keeps its tiles until the shared LRU evicts them, so flipping back is instant. The/streamchild gets the parent's plugin set (implicit tiling, the ADD-refine fix, glTF extensions) and its models are picked, styled and sized like the parent's own. - One point renderer in 3DTRjs:
PointCloudEffectsPlugineverywhere. The main viewer now registers 3DTilesRendererJS's (vendored, unreleased)PointCloudEffectsPluginon the parent renderer and on every/streamchild, and the native leaves'PotreePluginextends the same class, so one sidebar block drives all of them: Point shape (round / square / sphere, new, 3DTRjs only) and Eye-dome lighting with its strength (previously Cesium-only in the main viewer; the lab had it). The hand-writtenPointsMaterialshader patch is gone. - Two local patches on the vendored
PointCloudMaterial(markedLOCAL PATCH, candidates for upstream):maxPointSize, a screen-size cap for attenuated points (three has none; Cesium hasmaximumAttenuation), set to 8 × the px size; andsizeAttenuation = falsesupport, so the fixed pixel size works through the material.PointCloudEffectsPlugin.processTileModelkeeps the source material'ssizeAttenuation. - ADD-refinement point sizing. A
/streamPotree/COPC cloud refines additively, so a coarse tile kept drawing its points at its own spacing under its finer children: big blobs among small points ("weird adaptive point sizing"). Potree's per-point sizing needs an octree texture onlyPotreePluginfills;AddRefinePointSizePlugindoes it at tile granularity for any ADD tileset: each tile's material gets the boxes of up to 8 visible children (looking through implicit subtree roots) and the size to use inside each — the child's size scaled to the finest spacing visible in its subtree — and the vertex shader picks it per point (the child-box uniforms are the second local patch on the material).
Fixed — 2026-10-28 (3DTRjs: native leaves surviving re-renders, zoom gestures, point-size cap)
- Native Potree leaves vanished on a terrain, point-size or controls change.
<TilesPlugin>re-instantiates its plugin whenever itsargsobject changes, and an inline literal with a fresh callback did that on every render of the scene: the Potree child renderers were disposed and the new plugin never re-claimed the already-parsed leaves. The args are now created once. - Middle-drag zoom goes toward the point under the cursor (content or terrain), drag down zooms in, and OrbitControls' own middle-button dolly (which pulled the other way at the same time) is off. In globe/env mode the clicked point also becomes the pivot.
- Double-click zooms toward content and terrain in every control mode, the controls' built-in double-tap zoom is off (it raced the animated one: a jump, then the tween), any damping still in flight is cancelled first, and the tween starts from the camera's real orientation (the roll no longer snaps on the first frame). OrbitControls' cached up vector follows at the end.
- Point size cap: attenuated points are clamped to a screen size (8× the slider, 12–48 px) in
the vertex shader. Zooming close into a dense cloud used to draw tens of thousands of points
hundreds of pixels wide each, which stalls the GPU on fill rate (what a "freeze past a given zoom
level" looks like); Cesium has
maximumAttenuationfor the same reason. - Default view over AlUla (most Iconem projects are in Europe and MENA): a world-sized mosaic root used to be framed from the Canvas default on +Z, i.e. over the north pole.
- Globe on/off is a checkbox beside the Terrain select (the terrain choice is kept) instead of a "None (no globe)" terrain mode.
?load=without&type=on a/streampoint-cloud format loads as a point cloud (2 px error target, not the mesh default of 16).
Fixed — 2026-10-27 (3DTRjs navigation, terrain "none", markers)
- 3DTRjs Environment controls: left/right drag works on the terrain, not only on the loaded content: the controls now raycast the content and the globe/terrain renderers, and the fallback plane (a drag that hits nothing) is the local horizontal at the pivot instead of world Y = 0.
- Framing faces north (the camera used to sit north of the tileset, looking south).
- Orbit/env near plane also follows the camera's height above the ellipsoid, so content a few metres away is not clipped when the pivot was left kilometres behind (the tileset or the terrain "disappearing" in orbit).
- Double-click fly: the view direction and the roll ease from the current state (no jump on the first frame).
- Adaptive point size on 3D Tiles point clouds (Angkor): a tile's geometric error is only used as the point spacing when it is one; otherwise the spacing is estimated from the tile's own points.
- Dataset markers are composited on top (no depth test, far side of the globe culled in the shader): no more z-fighting with the ground.
- Terrain "None (no globe)" in both renderers, switched in place (the loaded tileset is untouched), for datasets that sit under the terrain or clouds seen from below.
Fixed — 2026-10-27 (Azure SAS access for live Potree)
- Azure SAS support (live Potree 1.x/2.0 only): preserves the metadata URL query and forwards reusable Azure container/account SAS to same-origin child files without replacing child queries. This is not general signed-URL inheritance. Object-bound signatures and arbitrary metadata queries are not forwarded.
Fixed — 2026-10-26 (country-scale COPC placement, 3DTRjs controls, viewer)
/streamCOPC: curved placement past a 20 km extent. Points are written through proj4 → ECEF → the root frame instead of "source minus origin" on the tangent plane, which floated Corsica 30 km above the ground in the France-wide IGN overview (d²/2R) and misplaced Catalonia and the Canaries. Such a source is served as an explicit tree with per-tile boxes fitted to the placed corners.scripts/check-curved.mjsverifies a tileset's farthest tile. The placement is sampled per tile on a ≤ 505 m grid and interpolated (1 cm), not computed per point;&curved=on|off(viewer: the curved: select) overrides the 20 km rule, and a mosaic forwards it to its COPC children.- 3DTRjs orbit/env controls: switching controls keeps the point last looked at as the pivot and takes "up" from the ellipsoid there (not the tileset's centre, degrees off once far from it); near/far clip planes now follow the pivot distance each frame (they were frozen at the first framing, so zooming into a facade clipped it).
- 3DTRjs native Potree leaves + picker: re-showing a dataset also restyles the models its child renderer loaded while it was hidden (they stayed invisible).
- 3DTRjs picker "in view" list: tested against the view pyramid alone, not the live clip planes (after framing a world mosaic the near plane sat at 200 km until the controls' next tick, so the picker listed nothing on screen).
- Viewer: everything clickable shows a pointer cursor (Tailwind v4 preflight left buttons at
default); the 📂 manifest button moved into the Aggregate row, where each uploaded manifest is a pill before the server sets, named by the page URL (preset=agg-upload-<hash>); a?load=link to an uploaded mosaic shows that pill too.
Added — 2026-10-23 (upload your own set manifest)
POST /aggregate/settakes a local-set manifest ({ label, entries: [{ url, format, name? … }] }) and answers withset=upload-<hash>, usable with every/aggregateparameter. In memory, 24 h after last use, at most 20 × 10 MB; never listed indemo.json. The name hashes the entries, so the same file always gives the same URL.- Viewer: 📂 manifest button next to builder: pick a
.jsonfrom your disk, it is uploaded and loaded as a mosaic. The page URL gets?load=<mosaic>and the file stays in localStorage, so a reload after a server restart re-sends it first.
Changed — 2026-10-23 (no Iconem share links in the repository)
- The
angkorandiconem-openaggregate sets moved out of code into machine-local manifests (.local/aggregate-sets/angkor.json,iconem-open.json, gitignored). They appear only where those files exist; the Iconem — demo pill now requires its set. worldis every committed dataset:opendata+cities+esri-3d+ Aerometrex + Vexcel.- Geghard is gone from the Potree presets, the desktop catalogue, the potree lab and the vanilla
viewer; the lab's aggregate example is now Open Heritage 3D, the Potree debug script defaults to
Lion Takanawa. The Angkor entries in the hierarchy explorer became the
openset.
Added — 2026-10-23 (native Potree leaves in mosaics)
/aggregate?native=1: each Potree leaf also carries its source incontent.extensions.ICONEM_content_source(format,version,encoding,uri, and atransformfrom the cloud's own coordinates to the leaf frame), listed inextensionsUsedonly.content.uriis still the/stream3D Tiles wrapper, so Cesium and other clients are unaffected. On the full Iconem manifest all 628 Potree leaves qualify (337 × 1.7, 291 × 1.8, all.bin)./streamPotree roots reportextras.native(version, encoding, origin), which the aggregate reads from the child root it already fetches.- Viewer, 3DTRjs mode: Native Potree leaves (
?native=true). A plugin (lib/nativeLeaves.ts) mounts aPotreePluginchild renderer per visible Potree leaf, sharing the mosaic's cache and queues. Hide, tint, point size and raycast picking work on native points. - Measured: identical nodes, points and placement to
/streamat normal error targets (Geghard, six Angkor Thom clouds). At 0.25 px, native kept refining to 3.7 M points while/streamstopped at 1.7 M: under 3DTRjs,/streamPotree never passed level 3, its first child-subtree boundary. That is a separate/streambug, logged in docs Meta-tilesets.
Noted — 2026-10-20 (spec direction)
- glTF#1809 (Draco point compression) closed as superseded by
KHR_meshopt_compression; Cesium's benchmark puts meshopt point GLBs at 1.01 GiB vs 1.19 GiB for.pnts+Draco on 313 M points, same load time. Confirms/stream?transform=meshopt; follow-ups in docs TODO → Spec direction. - 3D Tiles 2.0 will be a set of glTF 2.1 extensions (3d-tiles#803, CesiumGS/glTF
3d-tiles-2.0branch);3DTILES_georeferencecovers local → ECEF via a tangent plane, which is what the&lon/&lat/&alt/&headingplacement already computes.
Added — 2026-10-01 (open data sources)
- Three new committed sets, all in the Open data mosaic:
cities(swisstopo, 3D BAG, PDOK, PLATEAU Japan, Helsinki, 12 more CRAIG meshes),esri-3d(nine Living Atlas integrated meshes as 3D Tiles, two I3S layers) andopenheritage3d(ten Potree 2.0 heritage scans, licences per entry). - Looked at and NOT added, with why (docs: Meta-tilesets): OpenTopography (per-dataset Potree viewers are per-job and gone; only four sample EPTs persist, and the server reads no EPT), Cesium ion (no public search API; Google/OSM/Bing assets need a token per request), Google/Bing 3D (global tilesets would draw over everything — they stay terrain choices), city-model ledgers (awesome-citygml, TU Delft 3D open cities STAC: CityGML/CityJSON downloads, no streamable tilesets).
Changed — 2026-10-01 (lab controls feel · loading knobs · point controls on mixed mosaics)
- Potree lab: inertia slider (0.05 s default instead of 3DTRjs's 0.15 s), double-click to pivot and zoom with every control scheme, and loading knobs (parallel downloads, parses per frame, tile cache) with live queue/cache readouts in the HUD.
- Point controls apply to any tileset holding points, whatever the preset's type (the Open data
mosaic is
meshbut holds 113 COPC clouds); Cesium's style size is now the floor under attenuation. - Point clouds default to 2 px SSE (was 4) in both renderers: a Potree root's spacing otherwise only refined below ~1 km.
iconem-openis labelled demo (© Iconem, shared for demos), not public.
Added / Changed — 2026-09-18 (debug cells · point controls · two pills · lab controls · external open)
- Debug select replaces the debug checkbox: cells + dataset roots draws the loaded tileset's own
declared tiles (a mosaic's quadtree cells by depth, dataset roots in white;
lib/mosaicOutlines.ts, both renderers); all tiles is the renderers' full debug view. - Point-cloud controls in the sidebar for both renderers: point size, adaptive sizing (Cesium attenuation; 3DTRjs world-space size from the tile's geometric error), and eye-dome lighting + strength on Cesium.
- Aggregate pills are now two: Iconem — all formats first, then Open data (new
openset: RESOURCES.md + Aerometrex + flai + Vexcel, 145 datasets). Iconem — public (newiconem-openset: Angkor + Geghard) stands in where the Iconem manifest is absent. The Angkor / Mes Aynak / per-format pills are gone — the picker does that. - Picker: the in view list opens by default; the colour mode colours markers only; ⧉ opens a dataset alone in a new tab (potree lab for Potree, the 3DTRjs viewer otherwise).
- Potree lab: a controls select — EnvironmentControls, GlobeControls or three's OrbitControls
(
?controls=). - 3DTRjs: double-click and fly-to-sphere keep the camera upright (local up, or north when looking straight down) instead of three's default +Y roll.
Added / Changed — 2026-09-17c (per-dataset snap · cell default · in-place picker · Vexcel · Esri · Mapterhorn default)
&snap=on/aggregatesnaps each dataset separately. Each leaf gets its own translation along the local up, computed server-side from the dataset's own top geometry against Mapterhorn.- New modules:
packages/core/dataset-ground.js(shared with the viewer) andpackages/core/terrain.js(Terrarium in Node, WebP via WASM). - Mes Aynak's seven captures: +3.2 to +6.5 m. The viewer's snap button now uses this on mosaics; snapping the mosaic as one tileset was why "Mes Aynak multi" did not snap.
- Ground bins are now 8 × 8 (were 12 × 12).
- New modules:
bv=cellis the default. Group tiles are their quadtree cells.index.jsonrows now carrycellanddepth; the hover card and picker show where each dataset's root sits.- Picker changes no longer reload the mosaic. The renderers keep the unfiltered tileset and hide
datasets in place by their tile-metadata
id.- Cesium:
tileVisiblesetsmodel.showandmodel.color. - 3DTRjs:
scene.visibleand the materials. - Rapid clicks no longer lose changes while the index loads.
- Cesium:
- Picker controls:
- colour
format | provider | datasetcolours the markers and the data (one colour per Mes Aynak capture); - provider pills show or hide markers;
- bounds and snap are segmented controls;
- switching a format off removes its markers instead of greying them.
- colour
- Vexcel:
- 10 Australian collections in a
vexcelset and inworld(55 datasets); /proxyinjectsVEXCEL_API_KEYserver-side and strips it from every URL it returns;- the server reads machine-local secrets from the gitignored
.local/tile-server.env; requiresEnvhides the set where the key is missing.
- 10 Australian collections in a
- CRS placement:
georefFromCrsbuilds a 1 m trihedron through proj4 instead of a plain ENU frame, keeping grid convergence and scale. The COPC offline converter uses it too. - Viewer defaults:
- Esri World Imagery + labels basemap in both renderers;
- terrain Mapterhorn (auto): quantized mesh in Cesium, Terrarium in 3DTRjs;
- Re:Earth's z0 ellipsoid tile returns HTTP 500, so it is substituted;
- 3DTRjs fly-to is north-up.
- Aggregate Builder: importing a tileset keeps its hierarchy (Angkor: 18 groups, 23 datasets, depth 8), with Flatten / group-by options.
- Potree lab: choosing a dataset in the select actually loads it (lil-gui
options()dropped the change handler). - Desktop: exports run either in the tile-server or as a CLI subprocess (allow-listed scripts, cancel, output tail, chained pack step).
Added / Fixed — 2026-09-17b (terrain · snap · bounds · export options · providers · 3DTRjs findings)
- Terrain:
- Added Mapterhorn in three forms: Re:Earth quantized mesh (ellipsoidal), Re:Earth Terrarium (ellipsoidal) and mapterhorn.com Terrarium (sea level), next to the AWS Terrain Tiles, in both renderers.
- Cesium's raster provider now falls back to the nearest available ancestor tile.
- The docs explain the datum: 3D Tiles are ellipsoidal, and sea-level DEMs are off by the geoid undulation.
- Snap to ground reads the dataset's own geometry. It decodes the root content (glb/b3dm/pnts), bins it 12 × 12 and takes each bin's 10th-percentile height as the ground; the Z offset is the 70th percentile of terrain − ground. Both renderers now agree exactly: Geghard −53.5 m on Mapterhorn ellipsoid, 88/88 bins, i.e. the dataset floats ~55 m high. Picking remains as the fallback for compressed content.
&bv=celland&bv=box: groups bounded by their actual quadtree cells (geodetic 2×1 or web-mercator, widened only where a dataset pokes out), or by ENU-oriented boxes. Angkor pills for both.- Fixed: the geodetic cell key re-encoded the hemisphere bit and dropped the finest longitude bit, so cells were twice as wide as their key said.
- Nesting tests now cover cell and box containment, plus a 2,000-point
cellAt→cellBoundsround trip.
- Providers and attribution on every dataset:
- carried in index rows and tile metadata, and set in manifests and code sets;
- the viewer shows "Data: …" credits for what is on screen;
- the
worldset now includes the 12 Aerometrex cities (45 datasets), credited.
- Aggregate Builder:
- several sources at once, with provider/set/format chips and a tag filter;
- provider metadata end to end;
- windowed tree;
- tested on all 692 Iconem datasets (660 rows in ~100 ms; export of 660 datasets in 2–6 ms).
- Export:
keepImplicit(copies subtrees and templated tiles);method: "convert"(the per-format offline converter, with its options);pack: ["3tz", "3dtiles"]via the newpackages/convert/pack.js;- CLI
--keep-implicit --pack; - the desktop card gains method/option controls, a live "equivalent CLI" block, and links to the
docs, the middleware home and the demo viewer (
?load=).
- Fixed: fly-tos lost at start-up in both renderers.
- Cesium dropped a fly-to issued before the viewer existed.
App's renderer-switch reload also ran on mount and re-framed the whole tileset.- Cesium's preset fly-to now looks at its point (it used to look
heightm north of it).
- Found: 3DTRjs
PotreePluginfails on every Potree 1.8 cloud (attribute objects; 95% of Iconem's clouds). The lab's vendored loader carries a local patch. COPC and BROTLI Potree 2.0 are unsupported upstream, so COPC keeps going through/stream. - Upstream research, the ADD skip:
-
It is a 3DTRjs v0.5.1 regression (#1673).
-
CesiumJS avoids it (
meetsScreenSpaceErrorEarlyreturns early for implicit/tileset-content parents). -
It is not reported yet. Draft issue:
Traversal: implicit ADD tilesets render nothing at distance — the ADD-parent error skip applies to SUBTREELoader's content child (regression in 0.5.1). Load an implicit tileset with
refine: "ADD"(e.g. a point cloud) and move back until the root's SSE ≤errorTarget: nothing renders.markUsedTiles(traverseFunctions.js, #1673) skips a child whenerror × parent.geometricError / child.geometricError ≤ errorTarget, andSUBTREELoader.expandSubtreemoves the root content into a clone with the same geometric error under an unrenderable parent, so the ratio is 1. Suggested fix, as CesiumJS does: apply the skip only when the parent has renderable content.
-
Added — 2026-09-17 (export to folder · aggregate builder)
- Added: export any tileset to a local folder.
POST /exporton the tile-server,3dtiled exporton the CLI, and an Export to a folder card in the desktop app. It crawls the URL, so/stream,/convert,/tiles,/aggregate,/proxyand remote 3D Tiles all work.- The output is a static, self-contained tree: relative uris, implicit tilings expanded (depth-capped
via the new
implicit-to-explicitmaxLevels), sharded tile folders, and failures listed inexport-report.jsonafter retries. GET /export/{id}/files/…previews the copy.- Writing to disk needs
TILED_ALLOW_EXPORT=1, which the desktop app sets, and answers loopback clients only. - Verified: Geghard via the desktop UI (48 files in 5.3 s), Geghard and Brooklyn via the CLI (both copies render from a static server), and a proxied Aerometrex mosaic over HTTP. The Electrobun build succeeds.
- The output is a static, self-contained tree: relative uris, implicit tilings expanded (depth-capped
via the new
- Added: Aggregate Builder (
/builder, 🧱 in the viewer's top bar), pure React + shadcn with no new dependencies.- Layout: a library of datasets (server sets, or any URL; fuzzy search), a drag-and-drop tree with
groups, and an inspector for name, date, notes, custom metadata, refine, and display mode
(
all/latest/first). - Undo/redo, autosave, and project import/export.
- Exports a 3D Tiles 1.1
tileset.json(sphere unions, GE doubling, tile metadata) or an aggregate set manifest. - Preview in viewer posts an in-memory draft (
POST /aggregate/draft, 50 drafts, 24 h) and opens it through the viewer's new?load=<url>&fly=<lon,lat,height>start parameters. - Drafts also get
index.jsonand the picker's filters. 62 self-checks cover the tree operations and export.
- Layout: a library of datasets (server sets, or any URL; fuzzy search), a drag-and-drop tree with
groups, and an inspector for name, date, notes, custom metadata, refine, and display mode
(
Added / Fixed — 2026-09-16d (open sets · proxy · terrain · snap · 3DTRjs implicit fix)
- Added:
set=flai: 113 national open-LiDAR campaigns from flai-ai/open-lidar-data as COPC overviews; all place and none fail. They are generated from the flai README byscripts/flai-open-lidar-set.mjs. - Added:
set=aerometrex: 12 US city meshes through/proxy. Committed public manifests now live inpackages/tile-server/aggregate-sets/; entries may carryproxy,crs,licenceand ISO date intervals. - Corrected: the "missing tileset-aware proxy" was never missing.
/proxyalready rewrites child URIs; Brooklyn's 1,003 tiles stream through it. The docs said otherwise and are fixed. - Added:
/stream …&crs=EPSG:<n>for COPC/Potree files that declare no CRS.core/geo.jsgains definitions for 15 national grids plus UTM rules (crsDefinition). - Added: dataset names in the 3D Tiles output. Aggregate leaves carry 3D Tiles 1.1 tile
metadata (class
dataset). - Added: build timings in
asset.extras.build(childRootsFetched,fetchMs,treeMs). Iconem: a cold build fetches 692 roots in 10.8 s and builds the tree in 16 ms; a warm rebuild in another scheme takes 257 ms end to end. - Added: an Angkor — 23 sites (regions) preset (
bv=region); it renders in both Cesium and 3DTRjs. - Docs: a step-by-step account of how a mosaic is built, and why the output is an explicit quadtree with no height split.
- Fixed: 3DTRjs showed nothing for implicit point clouds at normal distances. This is an upstream
traversal bug (the ADD-parent skip ignores unrenderable parents);
ImplicitAddRefineFixPluginworks around it. - Fixed: 3DTRjs preset fly-tos were lost about half the time:
flyToCityreached the in-Canvas scene before it mounted. Such calls are now queued until the scene exists. - Added: Terrarium terrain (open AWS tiles) in both renderers. Cesium gets it through a custom heightmap provider.
- Added: ⤓ Snap to ground in both renderers: 9 × 9 top-surface vs terrain samples, 80th percentile, two passes. On Geghard the renderers agree within 5 m.
- Added: ✺ potree lab button in the top bar. The lab gains a
tilesetmode (any URL, e.g. an aggregate), COPC input and#crs=. - Added:
scripts/build-3dtr-master.mjs+npm run dev:3dtr-master: the whole viewer on a from-source 3DTilesRendererJS build, with a Windows fix for upstream's library build. Verified at70847eb. window.tiles3dtrexposes the 3DTRjs renderer for inspection, like Cesium'swindow.viewer.- The repo now pushes to both
jo-chemla/3dtiled-to-3dtilesandiconem/3dtiled-to-3dtiles(originhas two push URLs).
Added / Fixed — 2026-09-16c (aggregate picker · dates · regions · 3DTRjs markers)
- Added: a dataset picker for loaded mosaics. It lists what is on screen, grouped by platform scene, with per-dataset checkboxes and capture dates. Per-site latest and all buttons cover diachronic series (Mes Aynak) and multi-part sites (Venice). It also has format chips derived from the index, a global latest only switch and a capture-date range. Every action is a URL param, so the view stays one shareable tileset.
- Added:
/aggregateparams&hide=<id>,&latest=1,&from=/&to=,&bv=region. Each dataset has a stableid.latestkeeps the newest capture among datasets on the same ground (geometric test, mixed date precision handled); on the Iconem set it keeps 625 of 660.bv=regionbounds quadtree groups with lon/lat/height regions, verified to enclose every descendant. - Added: capture dates. The share-link script records the platform
date, or parses Iconem names (_dYYYYMM,YYYY-MM, bare years within 1990–2035; the_pprocessing date is ignored), along with project, scene and asset ids: 399 of 692 datasets are dated.index.jsonreturnsid,groupanddate. - Added: hover cards on markers (name, scene, project, format, date, size) instead of the native
canvas tooltip. Cesium hover now uses
drillPick, so markers under already-loaded data still respond. - Added: markers in 3DTRjs: one
THREE.Pointslayer with the 10%-of-viewport hand-off in the vertex shader, screen-space hover/click and a fly-to. The 3DTRjs overlay plugins drape onto surfaces, so they don't fit this. - Added: typo-tolerant dataset search ("mes aynek 2011", "venezzia"), and an Iconem — latest captures preset that flies to Mes Aynak.
- Fixed: 3DTRjs flung worldwide mosaics off the globe. Their Earth-centred root sphere looked like local, unplaced content, so the viewer re-anchored it to the local frame. Planet-sized spheres are now treated as global.
Added / Fixed — 2026-09-16b (Potree 1.8 · aggregate search + markers)
- Fixed: every Potree 1.8 dataset rendered nothing. 1.8's
pointAttributesare objects with asizefield; the decoder readbyteSize, so the point stride wasNaNand every node decoded to zero points — the root tile 404'd ("no such node") and only the bounding sphere showed (e.g. Quéribus). Sweep of 60 Iconem Potree datasets: 27/60 → 60/60 (all 33 1.8 ones fixed). Affects both/streamand the offline converter. Unknown attribute sizes now throw instead of silently producing empty tiles. - Added:
/aggregate/index.json— one row per placed dataset (name, project, scene, format, lon/lat/height, radius) plus unplaced/failed, sharing the tileset's selection and cached mosaic. - Added: search box + dataset markers in the demo app for any loaded mosaic: accent-insensitive
dataset search plus OSM place search (Photon), and fixed-size format-coloured markers on the
ellipsoid that hide once a dataset spans >10% of the viewport (per-point
DistanceDisplayCondition); hover names, click flies. Cesium only for markers. - Changed: worldwide mosaics get an Earth-centred root sphere when it is tighter than the greedy union (Iconem: 9 526 → 6 401 km).
- Changed: Iconem pills only appear when the server has the set, replacing the "(local)" suffix; Angkor demo entries carry distinct names ("PhnomKulen 02-1 OuThmorDab" instead of four "PhnomKulen").
Added / Fixed — 2026-09-16 (Iconem share links · point colours · Potree lab · 0.5.2 · aggregate pill)
- Ran the share-link script against production: 692
single-meta-tilesetlinks created (658 Potree, 17 3D Tiles, 17 COPC) across 139 projects, 0 failures; a second run reused all 692 and created none. Every sampled link is public with CORS; COPC links answer range requests (206 LASF).?set=iconemmosaics them: 692 children, 0 failed. - Confirmed the COPC container finding empirically, not just from source. In one project, COPC
share A's token reads COPC file B (
206 LASF), because alasshare's container is the project. Blob names are UUIDs and nginx drops client query strings (no listing), which limits practical exposure, but a COPC link is still not scoped to its own file. - Fixed: every point cloud rendered washed out. glTF
COLOR_0is linear; all point writers wrote raw sRGB. Found by rendering Geghard through 3DTRjs'sPotreePlugin(correct colours) and through/stream(pale) side by side. Colours are now linearised on write via a 16-bit table in the core writer, the offline COPC writer, and the.pntsupgrader (which also moves to normalized uint16 so linear shadows don't band). Splat centroids, already linear, opt out. 11 unit checks./convertcache keys gain an output version so pre-fix conversions are rebuilt instead of served forever. - Fixed: the Angkor mosaic loaded nothing. Iconem's site tilesets declare geometric error √3 for
~240 m sites; the reference tile copied it and every group doubled from there, so nothing refined
beyond ~100 m. The reference tile's error is now
max(tileset GE, root GE, bounding radius). Verified in the demo app: sites load at Angkor Thom from 1.8 km. - Fixed: datasets without a georeference polluted mosaics. Their centre is the Earth's core, so
they landed in arbitrary cells and inflated the root past the planet's radius. Now excluded and
listed in
asset.extras.unplaced(32 of 692 on the Iconem set). An aggregate with nothing placeable answers422with the lists and a placement hint instead of500. - Added:
/aggregate&format=potree|copc|3dtiles: per-format variants of one mosaic (Iconem: 660 all, 17 3D Tiles, 628 Potree, 15 COPC). - Added: 🧩 Aggregate pill in the demo app: world / open data / Angkor (both nestings, PhnomKulen filter) and the four Iconem variants; Angkor presets fly close enough for sites to load.
- Changed: viewer on
3d-tiles-renderer0.5.2 (was 0.4.28). Typecheck, build and point-cloud rendering verified. Splat rendering is identical on both versions under headless GL (A/B tested by reverting to 0.4.28), so the upgrade is neutral there, but splats remain unverified on a real GPU. - Added:
/potree-lab: one dataset at a time with 3DTRjs's unreleasedPotreePluginandPointCloudEffectsPlugin(vendored at a pinned commit, Apache-2.0): adaptive point size, shapes, eye-dome lighting, debug colours, plus astreammode rendering our/streamoutput through the same effects for comparison. Iconem datasets are listed from the local manifest, served to loopback clients only. Finding: per-tile point sizing from geometric error is not enough for additive tilesets (ancestors draw blobs), so passing density down to plain 3D Tiles needs Potree's per-point active-node scheme, which our implicit(level,x,y,z)keys could drive (not built). - Added:
cdp-screenshot.mjs --print-eval, used for every headless check above.
Added / Fixed — 2026-09-12 (aggregate demo sets · truncation reasons · native file picker · packaging)
- Added:
/aggregate?set=opendata|angkor|world— curated demo mosaics of real, public, key-less tilesets, defined server-side so the viewer, docs and desktop app share one list instead of three copies drifting apart.GET /aggregate/demo.jsonindexes them;?set=composes with?url=.opendatais the ten datasets from 3D Tiles' ownRESOURCES.md(four continents, and all threeboundingVolumekinds —region,box,sphere);angkoris Iconem's campaign as 23 individual site tilesets;worldis both. All 33 probed before being added; 33/33 reachable. - The Angkor set is the partitioning proof. The sites are not uniformly spread — 19 inside the Angkor park, four at PhnomKulen ~30 km north-east — and nothing tells the aggregator that; it sees 23 bounding spheres. It splits them at the root anyway, and the innermost Angkor group it forms is Bayon + Baphuon + Phimeanakas, i.e. exactly the monuments inside Angkor Thom's walls. Root fanout 23 → 2, depth 8 geodetic / 7 mercator. Against the hand-authored merged root in the explorer: same 23 datasets, max fanout 23 → 8, costing 3 levels of depth.
- Fixed:
/statssaid “truncated” without saying why. Three budgets can stop a walk and the fix differs for each, so the response now carriestruncatedBy: ["nodes"|"depth"|"external"]plus thelimitsactually applied, and&maxExternal=is tunable (was a hidden constant of 64). This was not cosmetic: RAD Coit reported 2 296 nodes under a 20 000-node budget and the reason was invisible — it had exhausted external-tileset refs at 64, not nodes./aggregatemosaics hit the same cap, being made of external refs. - Answered with data, not guesses: the small tile counts are mostly real. LCC Big Mirror → 3
tiles,
truncatedBy: [],maxFanout: 1. RAD Tastier → 11 tiles is a genuinely small 500 k-splat scene. Only RAD Coit was misleading, per the item above. Correction (same day): an earlier version of this entry explained Big Mirror as "LCC is not an octree, so the adapter emits a LoD chain". That is wrong. LCC is hierarchical —index.binpacks each Unit as a grid cell, and the adapter emits root → one child per Unit → a REPLACE LoD chain per Unit, so root fan-out is the Unit count. Big Mirror is a single-Unit capture. Guan Temple (11.4 M splats) through the same adapter: 155 nodes, fan-out 41, depth 4. - Added: explorer depth cap, external cap, Reload all, and demo presets. The depth cap doubles as a display control — capping at level 8 keeps a deep tileset's top structure legible instead of burying it under 4 000 leaves. Options apply to newly added tilesets and Reload all re-walks the rest, rather than auto-refiring N remote walks on every dropdown change.
- Added: a
⌗ hierarchybutton in the viewer top bar that forwards the currently-loaded tileset. It sends the resolved URL, not the input field: inraw urlmode the field holds a bare dataset (…/cloud.js), which/statscannot walk — only the wrapped middleware URL can be. - Fixed: two explorer labels.
?set=mosaics showedaggregate ×?(nothing to count, since the set expands server-side) and a UUID filename showed raw; both now resolve to something readable. - Added: a native file picker in the desktop app — File… / Folder… over RPC to the main process.
It must be the main process: a webview
<input type=file>yields a sandboxedFilewith no real path, and the middleware needs a filesystem path. Two buttons because tree formats are a folder and single-file formats are a file, and one Windows dialog cannot offer both. Construction is guarded so the same bundle still renders outside an Electrobun host (the buttons hide). - Packaging now works, and the blocker was upstream. Electrobun 2.0.1 (current stable) cannot
package on Windows: it pins cottontail 0.5.0, whose windows-x64 release ships
cottontail.exebut not thecottontail-coreits own build step copies →CopySourceMissing. Pinned2.0.2-beta.20(cottontail 0.7.0-canary.2), and verified bothdevandbuildon it — producing3D Tiled-Setup.exe, a 20 MB installer zip and an update bundle. Also: the build must run from PowerShell, not MSYS, wheretarreadsC:\…as a remote host. Signing is configured but off by default and unverified — no certificate in this repo. - Fixed: a HiDPI regression I had introduced. Widening the desktop container from 720 px to 1000 px re-broke the documented Windows clipping — on a 1120 px window at 150 % scaling the right column, including the new Browse buttons, was simply never on screen. A plain-browser preview cannot reveal this (no DPI mismatch); only running the real app did. Reverted, with the constraint written into the CSS so it does not get "improved" again.
Added / Fixed — 2026-09-11c (geodetic nesting · hierarchy explorer · desktop library · Potree write-up)
- Added:
/aggregate&nest=geodetic, and the distinction it exists to make. "Morton order" and "web-mercator quadtree" are not the same thing: Morton (Z-order) is an addressing scheme — interleave a cell's x/y bits — and says nothing about the projection. Bing's quadkey is the base-4 Morton code of a web-mercator cell; 3D Tiles implicit tiling (and therefore Cesium) subdivides the root bounding volume in its own coordinate space — for aregion, "x,y, andzrefer tolongitude,latitude, andheight", bisected arithmetically — i.e. a geodetic quadtree (Cesium'sGeographicTilingScheme, 2×1 cells at level 0) merely indexed with Morton codes. So Cesium's Morton order is not a mercator quadtree. Both schemes are now offered (mercatorstays theautodefault;geodetic/morton/implicit/cesiumselects the other), and every group and dataset carries anextras.cellbreadcrumb (scheme, pathkey,level,morton) so "why is this grouped here" is answerable from the tileset alone. - Fixed:
/aggregatesilently lost more than half its children at scale. Firing all children at once with a plainPromise.allSettledis fine for 12 and broken for 500: a 500-dataset mosaic came back with 232 datasets and 268 barefetch failedentries — still answering200, because failed children degrade intoasset.extras.failedby design. It looked fine and was missing half the world, and the survivors varied run to run, so the tree was not even reproducible. Child fetches now run through a 16-way pool: 500/500, identical tree cold and warm. - Fixed: flat
/aggregateroots violated the decrease-on-refine rule. The flat root tookmax(child GE), tying it to its largest child so both became selectable at the same SSE. Now ×2, matching what the nested path always did. Caught by the new test's monotonicity assertion. - Added: mosaic caching with a day-scale TTL (
&ttl=, default 86 400 s), keyed on the exact child set plus scheme; each child's fetched root is cached the same way, failures at 60 s so one dead host cannot pin a degraded mosaic for a day.&ttl=0bypasses the cache in both directions (an earlier pass skipped only the write, which still served stale data — the one thing the flag exists to prevent). Measured at 500 datasets: cold build ~156 ms (loopback, dominated by the N round-trips), tree build 2.4 ms, root fanout 4, depth 10. - Added:
scripts/debug-aggregate-nesting.mjs— 19 assertions over synthetic children served from a throwaway localhost server, so it is reproducible and host-independent: flat/mercator/geodetic shapes, Paris-groups-apart-from-Rome, country-scale-stays-shallow, GE monotonicity, cache-state independence, and the 500-dataset scale run. It found both bugs above. - Added: a React/shadcn hierarchy explorer (
apps/viewer/hierarchy.html, served at/hierarchy) over/stats— the multi-tileset counterpart to the server's own single-tileset page. Top-down tidy trees (d3-hierarchy) plus overlaid per-level charts for breadth, leaves and geometric-error distribution, a comparison table, and content-sample panels. Layout comes from d3, drawing does not: links are concatenated into one<path>and nodes batched one path per depth level, so a 4 000-node tree is ~20 DOM elements and several fit on screen at once. Hue identifies the tileset across every chart; lightness encodes depth. It is a separate Vite entry, not a route in the main app — confirmed by the build, which emits a 25 kBhierarchychunk with no Cesium/three/Spark. Truncated walks are badged "hit node budget" so a budget cap never reads as a shallow tileset. - Added: desktop app — Library and Samples tabs. Library tracks datasets as source + options
(not a built URL, so entries stay re-editable), with a cached
/statscheck per row; budget-capped walks render as≥N … (node budget reached)in amber rather than as fact. Refresh is sequential by design — it fans out to arbitrary third-party hosts, the same unbounded-concurrency trap/aggregatejust fixed. Samples ships a real public dataset per format (COPC, Potree, SOG, LCC, RAD, I3S) with origins credited. Row icons are generated format glyphs, not thumbnails — a real preview would mean a hidden Cesium view per row. Docs/viewer links resolve local-first: env var → reachable localhost → public deployment. - Docs: the Potree 1.x
.hrctruncation bug is now written up in the format reference, not only the changelog — chunk trees, why boundary nodes are the only edge between chunk generations, and why a re-entry guard that returns an empty list truncates a lazy walk atstepSize − 1with no error at all (Geghard: level 4 against a real depth of 11). New pages: Meta-tilesets (/aggregate) and Hierarchy explorer (/stats). - Docs: transform-gizmo future work. A free 6-DoF gizmo that bakes back into the
&lon/&lat/&alt/&heading/&scaleparams, with WilliamLiu's 3DTiles-Inspector credited as the closest prior art (same interaction; the difference worth keeping is ending at a stateless URL rather than an edited tileset), plus the two traps: composing on top of each adapter's up-axis flip, and origin-vs-centre anchoring.
Added / Fixed — 2026-09-11b (aggregate spatial nesting · stats content sampling · Bing raster basemap)
- Added:
/aggregatespatial auto-nesting (&nest=auto, on from 8 children). A flat root is fine for a dozen datasets and wrong for 500: every child sat directly under the root, so the moment the root refined, all 500 childtileset.jsonwere candidates. Nesting builds a web-mercator quadtree from nothing but each child's root bounding sphere — the only thing knowable without opening the datasets — the same idea as 3D Tiles implicit tiling and Bing's quadkeys. Each dataset is placed at the deepest level whose cell still comfortably contains it (4× slack), so a 200 m scan lands in its city cell while a country-wide dataset stays shallow and is never split; the trie is then path-compressed, giving continent → country → city only where datasets actually diverge. Culling does the work at render time, but the geometric error is what stops the fetch: a group's GE is 2× its children's (also satisfying the spec's monotonic-decrease rule), so from a world view a site-sized group subtends a fraction of a pixel and is never traversed. Verified with 28 datasets worldwide — root fanout 28 → 4, depth 8, 20 groups, Paris nesting as Eiffel+Louvre (r≈2 km) → Sacré-Cœur (3 km) → Versailles (10 km), GE strictly monotonic.&nest=0restores the flat root. - Added:
/statscontent sampling (&sample=N) plusleafNodes, per-levelleaves, andmaxFanout. The sampler picks N content tiles stratified across depth (leaves and intermediate nodes) and parses only the GLB JSON chunk — primitive counts, extensions, texture MIME types and compression are all declared there, so no Draco/meshopt/SPZ decode is needed. Off by default; the rest of the walk still reads nothing but tileset JSON and subtree buffers. Verified across content types: Potree → points, no triangles; Bing → 512 triangles / 1536 vertices / JPEG, exactly the globe shell's 16×16×2 grid; WL Elevator → ~24.7 k splats/tile withKHR_gaussian_splatting+_compression_spz_2+KHR_materials_unlit+EXT_splat_opacity. - Changed: the Bing globe shell now defaults to Bing's own raster tiles. Low zoom was never a gap
in
tf=3dv4— it is a different product: Bing Maps itself draws imagery-on-terrain there and only switches to photogrammetry around z13. Those raster tiles are keyless, quadkey-addressed, and on the same host as the mesh, so the shell now matches what it hands over to.&basemap=osmand any{z}/{x}/{y}/{q}template remain available; attribution resolves from the requested basemap. - Fixed: two dead viewer splat presets. The 3DGS-plugin repo renamed its samples
(
data/gaussianSplat1|2→Elevator-3dtiles/SvirnasAlyt-3dtiles); both preset URLs had been 404ing. Found by the new/stats?sample=reporting HTTP 404 for every sampled tile.
Added / Fixed — 2026-09-11 (Bing low-zoom globe · desktop app · L-GSC docs · branch consolidation)
- Added:
apps/desktop, an Electrobun companion app that runs the tile-server on-device and turns a dataset (local path or URL) into a paste-ready 3D Tiles endpoint — the "companion app" roadmap item, previously reshaped into3dtiled serve. Framework choice was constrained: Tauri needs a Rust + MSVC toolchain this machine lacks, Electron would add a ~200 MB dependency to a monorepo whose CI runspnpm installon every build, and Vercel's Native SDK compiles to Zig with no webview and no JS runtime, so it cannot host the existing web UI at all. Electrobun is webview-based with official Windows 11 / macOS 14 / Ubuntu 24.04 support.apps/desktopis excluded from the pnpm workspace so--frozen-lockfileinstalls stay untouched. An already-running 3dtiled server is adopted rather than duplicated; the Cesium preview is served by the existingscripts/static-server.mjsso it is a plainhttp://page. - Added: Bing Maps 3D low-zoom globe shell (
bing-globe.js). Bing'stf=3dv4mesh does not exist above face-local level 12 — measured, not assumed, with the newscripts/debug-bing-coverage.mjs(levels 0–11 return 0-byte bodies; level 12 returns a 155 KB GLB over London). Twelve empty levels means every zoomed-out view of/bing/tileset.jsonwas blank. Tiles with no Bing mesh at/above&globe(default 11) now carry a basemap-draped ellipsoid patch; because a Bing face-local(level,x,y)is the global mercator tile at(level+1), the raster lookup is 1:1 — no resampling, no reprojection.REPLACErefinement hands over to real mesh where it exists and keeps the shell where it never does (oceans), so there is no hole at the transition. Deliberately no tiletransform(a transform is inherited by the Bing subtree, whose GLBs bake their own ECEF into the glTF node matrix) — absolute ECEF swizzled(X, Z, −Y)instead, so Cesium's glTF→Z-upRX(+90°)restores it. Basemap is a URL template (&basemap=,BING_GLOBE_BASEMAP, default OpenStreetMap, attribution emitted inasset.copyright);&globe=offrestores the previous behaviour. Verified in stock CesiumJS with Cesium's own globe hidden, so all visible geometry came from the tileset. - Fixed:
scripts/cesium-harness.htmlreadiness loop —Cartographic.fromCartesianreturnsundefinedfor a degenerate centre, which a whole-globe tileset genuinely has; the unhandled throw killed the loop sowindow.READYnever flipped and screenshots timed out despite rendering fine. - Added:
KHR_gaussian_splatting_compression_lgsc(Qualcomm L-GSC v1.0) documented alongside_spz_2. The rename during review (..._lgsc→..._compression_lgsc) confirms Khronos treats splat compression as an open set of interchangeable children under one base, SPZ being first rather than only. The PR is open and README-only, so the JSON nesting/bufferVieware explicitly not asserted here, and the codec publishes no benchmark numbers. - Changed: WilliamLiu's
3DGS-PLY-3DTiles-Converterwent closed source — the repo no longer resolves; dead links unlinked and the geometric-error model it inspired is now written out in-page, since the source is no longer citable. - Fixed: README claimed no public deployment. Docs/viewer/middleware are live via the GHCR images
behind Traefik. Domains appear only in the README — served artifacts still build every link from
DOCS_URL/VIEWER_URL/REPO_URL, so the no-committed-domains policy holds where it matters.docs-pages.ymlis nowworkflow_dispatch-only: Pages is not the canonical deployment and is not enabled on the repo, so its push trigger failed every run. - Repo hygiene:
mainfast-forwarded toorigin/main;t3code/feature-roadmap-explorationmerged with main (theopenapi.jsonoverlap auto-resolved — the roadmap branch expandedpathswhile423f963touched onlyinfo.description) and pushed as a tracked remote branch. Five local branches plus the remotestream-cachewere deleted after verifying each held zero commits not already reachable fromorigin/main.
Added / Fixed — 2026-08-22 (roadmap batch: potree depth fix · globe placement · Pages docs · /aggregate · /stats+/hierarchy · bench · serve CLI)
- Fixed: Potree 1.x live streaming truncated at hierarchy-chunk depth (
potree-live.js). An already-loaded.hrcchunk returned empty boundaries fromloadOne, so any subtree walk re-entering a loaded chunk root (the level-3 subtree walking through chunkrafter the root subtree loaded it shallowly) never reached the next chunk generation — availability silently stopped at levelstepSize−1. Geghard (302 M pts, stepSize 5) refined to level 4 only; real depth 11+. Boundaries are now cached per chunk so later, deeper walks traverse THROUGH loaded chunks. Verified live with a client-style subtree BFS (scripts/debug-potree-depth.mjs): availability through level 11, deep tiles decode, Potree 2.0 unaffected. - Added: tileset-level globe placement —
&lon=&lat=[&alt=][&heading=][&scale=]on/stream,/convertand/tilestileset.jsonattaches a LOCAL dataset to a real location as a root ENU transform composed ON TOP of each adapter's local-upright flip (SOG RX(−90°), RAD RX(180°), LCC upright), so up-axis normalization + placement live in the tileset itself and any client (Cesium for Unreal/Unity, QGIS, CesiumJS) streams it in place. Own-georef sources win (params ignored + warning).core/geo.js:mat4Multiply,placementMatrix,isEcefPlaced,applyPlacement. Verified in stock CesiumJS (scripts/cesium-harness.html): lion_takanawa upright at the Eiffel Tower, Cesium's own computed lon/lat matching the params. - Added: docs static export + GitHub Pages deploy —
DOCS_STATIC_EXPORT=1flipsapps/docstooutput:'export'(repo-name basePath, trailingSlash); search becomes fumadocs' static Orama index (works identically in dev);.github/workflows/docs-pages.ymldeploys on push to main (one-time repo setup: Settings → Pages → Source: GitHub Actions). - Added:
/aggregatemeta-tileset endpoint (MosaicJSON/STAC-style) — one tileset whose children external-reference N given tileset URLs, bounding spheres derived from each child root (box/sphere/region × transform), failures skipped intoasset.extras.failed. Verified: Geghard + placed lion + Marseille in one endpoint, rendered on two continents from the same URL in stock Cesium. - Added:
/statshierarchy walker +/hierarchyvisualization — walks explicit trees, 3D Tiles 1.1 implicit tiling (.subtreedecode via newcoreparseSubtreeBuffer), and external refs; renders a cosmograph-style radial depth/breadth graph + per-level chart/table (light+dark, validated ordinal ramp). Doubles as a live-adapter diagnostic — the pre-fix Geghard truncation reads as a ring that stops at level 4. - Added: reproducible converter benchmark (
scripts/benchmark-converters.mjs+ docsguide/planning/benchmarks) — first numbers: potree2 lion 1.01 M pts/s, RAD 0.94 M splats/s, SOG 8.3 MB/s (single-core; the WorkerPool tier remains unwired headroom). - Added:
3dtiled servesubcommand — starts the tile-server and prints the ready-to-paste 3D Tiles URL (localhost + LAN) for a given dataset,/streamvs/convertauto-picked, with--lon/--lat/--alt/--headingplacement flags — the local-first "point Cesium for Unreal at it" one-liner (covers most of the Tauri-companion idea's value). - Verification tooling committed under
scripts/: stock-Cesium harness, CDP screenshot driver (real readiness waits, no puppeteer), Pages-emulating static server.
Fixed — 2026-07-07 (main: 3DTRJS wrong up-orientation on repeat Auto-zoom)
- Root cause:
frameToTileset(apps/viewer/src/components/Viewport/ThreeDTRjsViewer.tsx) decides whether a tileset needs the synthetic ENU anchor (for non-georeferenced local content, e.g. Potree "Lion 2.0") by checking whether its bounding sphere — transformed bytiles.group's CURRENT matrix — sits near world origin. Sound on the first call (fresh load), but the anchor branch then movestiles.grouponto the real ellipsoid, so any LATER call (e.g. clicking "Auto-zoom to tileset" again, with zero navigation in between) sees that same sphere near the real ellipsoid surface — indistinguishable from genuinely georeferenced content — and wrongly resets the rotation to identity while leaving the position at the anchor point. Reproduced in every controls mode (Globe/Orbit/ Environment) since the bug lives entirely before any controls code runs. - Fix: decide (and cache) whether a tileset needs the ENU anchor only once, on the fresh-load call
(
frameToTilesetnow takes anisFreshLoadflag), and reuse that cached decision on every later re-frame instead of re-deriving it from an already-moved sphere. - A preceding, separate real bug was also fixed along the way:
GlobeControls/EnvironmentControls(from3d-tiles-renderer) track azoomDirectionSet/zoomPointpair internally that's only ever cleared by real pointer input — after free-navigating far from a tileset and then hard-teleporting the camera back (frameToTileset), the controls' next tick re-raycast along that stale direction from the camera's new position and reoriented around a garbage point.setControlsPivotnow optionally resets that state, used specifically byframeToTileset's hard camera teleport. - Verified live: Lion 2.0 stays upright across repeated Auto-zoom clicks in all three controls modes; real georeferenced content (COPC Autzen) is unaffected across the same repeat-click test.
Docs — 2026-07-07 (main: vitepress theme, sidebar regroup, full MDX conversion, Scalar theming)
- Switched the Fumadocs UI theme to the
vitepresspreset. - Sidebar reorganized: "Guide" and "Per-format reference docs" are now plain (non-clickable) separator labels with their pages inlined; the per-format clusters (3DTiles+glTF, Pointclouds, Mesh/Generic, 3DGS Splats, Others) are real collapsible folders, open by default; planning/roadmap docs (dependencies, TODO, candidate formats, sample data, design log, appendix) live in a new "Reference & Planning" folder, collapsed by default.
- Large content reorg/cleanup pass across
guide//references/— reordered and regrouped CLI usage, ambition/supported-formats, related tilers, TODO/future-work, and candidate-source-formats sections; moved sections between pages to their more logical home (e.g. the offline-tilers catalogue into "Existing tilers", I3S open-data samples into "Sample Data"). - Converted every
content/docs/*.mdfile to.mdxso Fumadocs'InlineTOCcomponent (now on all large guide/reference pages) actually renders — fumadocs-mdx silently strips all raw JSX/HTML in plain.mdfiles, whichInlineTOCneeds. Escaped/fixed everything that broke on the conversion: GFM<https://...>autolinks (MDX's tag-sniffer mis-parses the URL's/), same-line<details><summary>...(MDX's JSX tag-balancer needs<details>on its own line before a blank-line + fenced-code boundary), a raw<!-- -->HTML comment (→{/* */}), and one bare struct-literal-shaped brace expression in prose that was valid enough JS to compile but threwReferenceErrorat build time (stream-sogparses as a subtraction). Verified with a full production build, not just a parser check. - Re-themed the embedded (
/docs/api-reference) and external (tile-server/playground) Scalar API reference totheme: 'none'+ a hand-written--scalar-*variable set matching the vitepress palette exactly (same primary/background/border/foreground values, Inter font) — both now read as the same product as the surrounding docs instead of a visually distinct third-party widget. - Fixed the "Demo Viewer" card on
/docs(was hardcoded to the API Reference link by mistake) to use the expandedprocess.env.VIEWER_URL. - Removed two orphaned early-prototype scratch files (
ref-convert-core.js,ref-gsplat1.json) and the stale duplicate rootviewer.html/vite.config.mjs(the real, Docker-shipped copy has lived inapps/viewersince an earlier "consolidate dev ports" pass) along with their rootpackage.jsondeps. - Removed the
VITE_TILE_SERVER_URLproduction-domain build-arg fromdocker-publish.yml— per- deployment tile-server/docs origins are already configurable at container start via the runtimeTILE_SERVER_URL/DOCS_URLenv vars, so a published, reusable image shouldn't have one deployment's domain baked in at build time.
Fixed/Added — 2026-07-03 (main: uniform crop, CORS proxy, I3S SLPK+textures, Agisoft pill)
- Uniform splat-crop flip. Replaced the hardcoded
isLccspecial-case inextract-live.jswith a documentedSPLAT_FLIP_BAKED = {lcc, sog}table +splatFlip()— discovered SOG had the identical latent bug LCC did. Verified against real tile data for LCC, SOG, and RAD. - Generic CORS proxy (
packages/tile-server/proxy-live.js, new file):/proxy?url=<source>, tileset-aware (recursively rewrites nestedcontent.urito route children back through the proxy, inheriting the parent's query string — Agisoft Cloud's access token lives only on the root URL). Fixes the Agisoft Jam pill (its host sends no CORS header on any resource). - I3S SLPK detection fixed: only ever matched
.slpkliterally in the URL; ArcGIS Online's own download endpoint (.../content/items/<id>/data) has no.slpkanywhere in the URL. Now sniffs the PKZIP magic bytes via a ranged GET when the URL doesn't already look like JSON. - I3S textures implemented (previously geometry-only, a documented scope-gate):
materialDefinitions/textureSetDefinitionslookup picks a browser-nativejpg/pngformat when available (DDS/KTX2/ Basis-only stays gated, same as Draco/LEPCC); texture bytes fetched via the same per-node resource bucket geometry uses, trying the bare resource name first (live ArcGIS REST) then the real extension (SLPK archives have no bare-name alias); a magic-byte check guards against ArcGIS's CDN occasionally returning HTTP 200 with a JSON error body instead of a real 404.glbBuild.jsgained aTEXCOORD_0accessor + embedded image/material. - Real pre-existing bug found while wiring up textures:
glbBuild.js's binary-buffer assembly reset the running byte offset to each bufferView's own padded length (offset = padded) instead of accumulating (offset += padded) — silently correct for 2-view outputs (POSITION+COLOR_0 point clouds) but corrupting every bufferView from the 3rd onward for mesh outputs (POSITION+NORMAL+COLOR_0, worse once TEXCOORD_0+image were added) — the actual root cause of the reported all-black textures, not a texture-specific bug at all. Fixed; both offline (i3s-to-3dtiles) and live (i3s-live.js) paths share the fix via the samebuildGlb(). - I3S LEPCC decompression researched (not implemented): no public bitstream spec, no known open decoder — verdict hard/impractical without Esri publishing the format.
- Added the Agisoft Cloud tileset pill (routed through
/proxy).
Docs — 2026-07-02 (main: README accuracy pass + new TODO items)
- Fixed a stale
/packageendpoint name throughout README (renamed to/3dtiles-self-containeda while back, but several examples/prose references never got updated) — heading, example URLs, and cross-references now all agree. - Documented
/bing/fragment/<face>-<level>-<x>-<y>.json?g=<genid>, the lazy-continuation route behind/bing/tileset.json(previously undocumented despite being load-bearing for Bing streaming). - Documented
/3dtiles-self-contained's local-filesystem-path support (?url=resolves relative to the tile-server's own CWD, not fetched over HTTP — unique among the adapters here). - Clarified
viewer.html's Tools →packagesgrouping routes to/3dtiles-self-contained, not a real/3dtiles-tools?tool=packagesvalue. - Expanded the
/stream?transform=table row withktx/ktx-uastc(previously only mentioned in prose). - Documented that Arrival.Space's
.lodpayload carries no georeferencing metadata (checked directly). - New TODO items: migrate
/threemx//geosplatsinto/tiles; find a real open-data.3tz/.3dtilessample (currently only our own repo-packed one); a new "Inspected, no tiled splat/generic format found" section (Scaniverse, ESRI beyond I3S, other Bentley services, ReCap, Leica/Faro/Matterport, Gauzilla Pro).
Changed — 2026-07-02 (main: /dump renamed to /extract)
packages/tile-server/dump-live.js→extract-live.js,handleDump→handleExtract,X-Dump-*response headers →X-Extract-*,viewer.html'sdumpCrop/getDumpMatrix/"Crop & Dump" →extractCrop/getExtractMatrix/"Crop & Extract". No back-compat alias — the old/dump/*paths are gone.
Changed — 2026-07-02 (main: RAD and I3S always fully materialize — no more lazy-fragment mode)
- RAD: removed the 4-level lazy-fragment continuation (
buildRadLazySubtree,/stream/rad/subtree/<n>.json). RAD has no persisted spatial index, so a "lazy" read still needs each chunk's full bytes — measured against the 1.28 GBcoit.radsample, eager (all chunks) took 9.7s vs the old lazy fragment's 7.6s, not enough of a win to justify a whole extra code path.cache=noneandcache=hierarchynow both eager-scan once (cached per URL, samemakeTileseteither way). - I3S: same simplification — removed
buildI3sFragment,/stream/i3s/node/<n>.json, and the&lazy=1tile mode. I3S node pages are cheap JSON metadata (not geometry), so eager cold start (page 0 sync + remaining pages in the background, capped at a 3s wait) was already fast; the lazy path bought little for the added complexity.cache=none/cache=hierarchyare now equivalent for I3S too. - Both mirror the Potree 1.x
tiling=explicitsimplification from earlier this session — one fully-materialized-explicit-tree code path instead of eager+lazy forks.
Fixed — 2026-07-02 (main: /dump/splat LCC crops placed splats dozens of meters off)
dump-live.js's generic Y-up→Z-up flip (YUP_ZUP) double-applied for LCC sources, sincelccGeoref's[East,−Up,North]root-transform columns already bake that same flip in (unlike every other adapter's plain[East,North,Up]georef, which needsdump-live.jsto supply it separately). Net effect: a real rotation-about-local-X error in every LCC/dump/splat(and/dump/zip) crop. Quantified against 10 sampled Guan_Temple tiles' own declared bounding boxes: the old default placed splat centroids 50–246 m outside their own ~31 m-half-diagonal tile box; the corrected rotation lands them 11–25 m off (plausible centroid-vs-box-center noise, not error) — this is the "horse statue cropped a few dozen meters off" bug.dump-live.jsnow detects an LCC source (format=lccin the nested/streamURL, or an/LCC/path segment) and uses the inverse rotation (YUP_ZUP_P90) for it; every other no-node-matrix format is untouched.&rx=90|-90still force either rotation for testing.
Fixed — 2026-07-02 (main: LCC scenes with no EPSG rendered lying on their side)
lccGeoref()returningnull(meta.epsg 0/unknown — Guanlan Ancient Town, religious_center_L2_P) leftroot.transformunset entirely, so the splat GLB content stayed in its raw Y-up frame whileboundToZUpBoxhad already (unconditionally) RX(+90°)-flattened the bounding boxes — the box/content axis mismatch rendered as "lying vertical instead of flat" (confirmed: Guan_Temple has a real EPSG 4547 and rendered correctly; the other two haveepsg: 0and didn't).- Added
LCC_LOCAL_UPRIGHT(packages/core/geo.js) — the same RX(+90°) flip baked intolccGeoref's[East,−Up,North]columns, but with identity East/Up/North and zero translation (no real ENU placement).lcc-live.jsandlcc-to-3dtilesnow fall back to it wheneverlccGeoref()returnsnull, so ungeoreferenced LCC scenes still render flat/upright at the local frame origin — just not geolocated.
Changed — 2026-07-02 (main: streaming endpoint consolidation, no back-compat)
/streamnow readscache=none|hierarchy, nothierarchy=lazy|eager— matches the/tilesvocabulary, which now passes its query string straight through instead of translating it./tilesdispatches in-process to/stream//convertinstead of a 307 redirect.- Removed the direct per-format endpoints —
/copc,/potree,/sog,/lcc,/rad,/i3sare gone. Verified they were a strict subset of/stream/tileset.json?format=<fmt>(for COPC/Potree they didn't even forwardhierarchy/prefetch, socache=hierarchywas silently unreachable through them).viewer.htmland all sample pills updated to call/streamdirectly. - Potree 1.x's
&tiling=explicitsimplified: now fully materializes the tree in one response (draining every.hrcchunk first) instead of lazily fragmenting it across per-chunk/potree/hrc/<name>.jsoncontinuation requests — one fewer moving part, and the removed direct endpoint would have broken that continuation chain anyway. Fine size-wise since Potree is already well-chunked; implicit tiling (the default) remains the path for genuinely huge datasets. - RAD's and I3S's lazy-fragment continuation (needed for their core
cache=nonestreaming, since neither has a true implicit-tiling representation to range into) moved from/rad/subtree/<n>.json//i3s/node/<n>.jsonto/stream/rad/subtree/<n>.json//stream/i3s/node/<n>.json(+/stream/i3s/tile/<n>.glb, which needs&lazy=1threaded through to hit the right cached context). - No backwards-compatibility shims — this project has one user right now. If that changes, the old query param / endpoints would need explicit aliasing back in.
Docs — 2026-07-02 (main: /dump documented, endpoint docs re-synced, ordered TODO)
- Added a proper
/dumpsection (bbox + geometric-error crop/extract) to the README's Vision chapter — previously only mentioned once in passing in the Ambition paragraph despite being a real, separate endpoint family (not a/convertspecialization). - Re-synced the "Endpoint rework", "Two modes", and RAD streaming-roadmap sections to the now-implemented reality above (they'd described this as a "future" proposal, and separately still described RAD as unstreamable — both stale: RAD is streamed, just via a metadata-only parallel pass over every chunk rather than persisted per-tile bounds).
- Reordered
TODO / Future workper plan: the streaming consolidation above (done) → the deferred, more invasive composabletiling=explicitvia/3dtiles-tools?tool=implicit-to-explicit→ Docker/ GHCR/Traefik packaging → Fumadocs migration → the 3DTilesRendererJS-native plugin idea → pinned open-data test fixtures.
Docs — 2026-07-02 (main: broader README staleness pass, 5 parallel audits)
- RAD LoD geometric-error doc still described only the legacy
featuresizebasis as if it were current — added a note pointing at the now-defaultspatialbasis (kept the detailed writeup, since--ge-basis=featuresizestill exists and behaves exactly as described). .3tzpackaging vs tileset-upgrade claims contradicted each other: one section said packaging "is not built in," another correctly documented the in-repo packer (scripts/pack-3dtiles.mjs) and/packageserving. Split the claim: packaging is built in; tileset-level transforms (upgrade,combine) still aren't, and still point at3d-tiles-tools./geosplats/tile.glb's documentedgrid=values were missing512(the endpoint accepts1|8|64|512; the HLOD tileset builder just doesn't request 512 automatically) — fixed.- "Potree 1.x always lazy" was wrong — it supports
hierarchy=eager(drainAll) exactly like every other converter with a hierarchy; fixed the "wired for" list to include it. - A
--3tz/--3dtilesCLI flag was documented forrad-to-3dtiles/copc-to-3dtilesthat doesn't exist —@cesium/3d-tiles-toolsis an optional dependency but isn't wired to any flag. Reworded in both the converter note and the Dependencies table to point at what actually works today (scripts/pack-3dtiles.mjs/npx 3d-tiles-tools convert). - RAD's LoD fanout numbers were swapped and one was fabricated — the source spec gives Tiny-LoD a base β≈1.75 and leaves Bhatt-LoD's fanout unspecified; the README had them backwards ("BhattLod ~1.75 / TinyLod ~1.5"). Corrected and noted the ~1.5 as our converter's own observed value, not a spec'd number.
Docs — 2026-07-02 (main: second README staleness pass)
i3s-to-3dtilesmislabeled "UNTESTED" in the docs-references summary table while its own dedicated section already documents it as validated onBuildings_NewYork_v18.slpk(5882 tiles, placed correctly in NYC) — fixed the summary to match.- "Performance & parallelism" described conversion as still single-threaded/sequential, with a
worker/bounded-concurrency pool as "the clear next optimization... not yet done" — that work is
already done (all five operational converters use
parallelMap+ async gzip, tier 1) and is accurately described in the separate "Parallelism (per converter)" section a few hundred lines earlier; this section just never got updated. Corrected in place (kept, since other headings link to it) rather than deleted, and pointed at the accurate section. - RAD
geometricErrorundershoot listed as an open bug with only a--ge-scaleworkaround —rad-to-3dtilesalready defaults to a spatial GE basis (bboxDiag/∛count,--ge-basis=spatial) that fixes the underlying ~4× undershoot; the featureSize basis this bullet described as the only option is now opt-in legacy (--ge-basis=featuresize). Marked resolved.
Docs — 2026-07-02 (main: README staleness pass)
- Fixed several stale claims flagged during review: the meta-converter dispatch note and two
"Missing features" bullets described converters as not-yet-moved under
packages/<fmt>/and dispatch as child-process-only — both are already done (in-processimport()by default, child-process only as anextraArgsfallback; workspaceworkspace:*deps already declared, so the old "needsnpm installfirst" caveat no longer applies). The/potreesection described the default tileset as "explicit" and Potree 1.x as not range-served — both wrong: implicit tiling is the default (&tiling=explicitopts in), and 1.x's.hrcchunks are lazily range-served through the same implicit-subtree machinery as 2.0 (the streaming-roadmap table just below already said so correctly — the prose above it hadn't caught up). - Reworked the Ambition section's "where this is going next" paragraph to call out Docker/GHCR/Traefik
packaging and a Fumadocs migration as concretely addressable near-term items (not just aspirational),
and note that the Fumadocs move is mostly chunking material that already exists in
docs-references/*.mdrather than writing new docs.
Fixed — 2026-07-02 (main: Potree 2.0 hierarchy race, Cesium Local-Frame Z-offset, README overhaul)
- Potree 2.0 live streaming (
/potree,/stream) could serve incomplete implicit subtrees on a cold cache.loadChunk2()marked a hierarchy chunk as loaded and removed it from the pending set before awaiting its actual fetch — a second, concurrentensureBlock2()(routine on first load, when many.subtreerequests arrive in parallel) could see the chunk as "already loaded" and index availability before that chunk's nodes had actually landed, so its subtree came back with that branch looking empty. 3d-tiles-renderer doesn't retry a subtree it already resolved, so the branch stayed empty until something else (e.g. reloading via explicit tiling) happened to fully populate the shared per-URL cache. Fixed to share one in-flight promise per chunk across concurrent callers — the same fix already applied to the Potree 1.x.hrcpath (loadOne) previously, but missed here. - Cesium "Z offset" silently un-placed non-georeferenced tilesets (most test splat data has no
real CRS and auto-places via "Local Frame", i.e.
Cesium.Transforms.eastNorthUpToFixedFrame).applyZOffset()replacedtileset.modelMatrixwith a bare translation, discarding that ENU rotation outright — looked exactly like "Z offset does nothing" for splats specifically, since georeferenced point-cloud/mesh test data never went through the Local-Frame path. Now composes the vertical nudge on top of whatever placement (georef or Local Frame) is already in effect. - README: major reorganization — Ambition/format-comparison/related-tilers move to the front;
/streamlazy-loading status moves up next to the meta-converter docs; a newTODO / Future worksection (Docker/GHCR/Traefik packaging, a 3DTilesRendererJS-native plugin, pinned open-data test fixtures, Fumadocs) sits right afterMonorepo & usage; the materialization-continuum architecture doc moves ahead of the individual per-format converter docs; per-format schema diagrams move to a new end-of-document appendix. Format tables: added 3D Tiles packages (.3tz/.3dtiles), 3MX and Bing Maps 3D as a new "generic tiled formats" table (removing them from the stale "candidate/roadmap" list, where 3MX in particular was wrongly still listed as unsupported); dropped plain LAS/LAZ from the comparison table (no native tiling/LoD — point clouds there are now just COPC + Potree); removed a stalecopc-to-3dtilesTODO for explicit tiling — both the offline converter and the live/copcendpoint already support--tiling/?tiling=explicit. Added a distilled local mirror of the COPC spec (docs-references/copc-format.md) alongside the existing per-format mirrors.
Fixed — 2026-07-02 (main: splat/GeoSplats export & viewer follow-ups)
/dump/splatY-up→Z-up regression on third-party tiles (WL Grange) fixed. The 2026-07-01 auto-pick (hasNodeMatrix ? YUP_ZUP_P90 : YUP_ZUP) meant to give node-matrix-authored tiles real CesiumY_UP_TO_Z_UP— but the two constants' inline labels were swapped (verified against a realCesium.Axis.Y_UP_TO_Z_UPmultiply in Node):YUP_ZUPis the one that equals it, notYUP_ZUP_P90. So third-party tiles got the inverse rotation — fixed the earlier vertical flip (a Y-histogram only checks up/down) but silently inverted the horizontal/depth axis instead. Both branches now default toYUP_ZUP;&rx=90|-90still forces either for one-off testing. Confirmed unaffected: every/dump/*export kind (las/copc/glb/zip/splat/tileset) shares onetraverse()bbox+GE+refine walker — this was isolated to the splat content-decode step, not a codepath divergence.- GeoSplats Cesium Z-offset (
⊕ Local Frame → Z offset) lag on splat tiles. Cesium's native Gaussian-splat primitive caches its own ENU anchor and only rebuilds it after ~2 stable frames (or a ~30-frame/~0.5s stall fallback while a tileset is still streaming) — up to half a second of apparent "did nothing".applyZOffset()now also resets the primitive's internal anchor so the nudge is visible on the next frame. - 3DTRJS large/slow tilesets (e.g. GeoSplats' aerial model) looked stuck. Only the Cesium load path had
tile-load progress feedback (
tlog/setStatus); the 3DTRJS path set a status message once and then went silent for the whole (potentially multi-minute, single-threaded live-conversion) stream. Added the same first-tile/streaming-count/tiles-ready feedback used by Cesium. - 3DTRJS globe mode: ortho could only zoom out, and mousewheel felt like it rotated instead of zoomed.
GlobeControlsdefaultsmaxZoomto0.01, calibrated for its own earth-scale ortho camera; our ortho camera is sized from the loaded tileset instead and starts at three.js's defaultzoom=1(~100× over that cap), so every zoom-IN tick was silently rejected — leaving only the incidental "tilt towards centre while zooming out near the surface" behaviour visible, which read as "wheel controls orientation". Removed the cap (minZoom=0,maxZoom=Infinity). - 3DTRJS ortho↔perspective toggle was an instant cut. Perspective and ortho can't share one camera object, but the outgoing/incoming projection matrices are now cross-faded over ~350ms so the switch reads as an animated transition instead of a pop.
- 3DTRJS "env" control mode hid the globe entirely, even under georeferenced content where it's a useful
ground/basemap reference (and gives
EnvironmentControls' ground-raycast something to hug). Now shown whenever the loaded tiles are actually ECEF-placed; still hidden for local/non-georeferenced content. - GeoSplats pills renamed from generic "model A (Baltic)"/"model B" to their actual place names — "Jastrzębia Góra, Poland" and "Biltmore Hotel, Miami".
- README: restated the project's ambition (converter → live streaming middleware → crop/export tool →
planned splat-editor UI / geolocator / cross-platform aggregator), documented the GeoSplats SDK
reverse-engineering findings (scale formula incl. the
cos(lat)latitude correction, orientation frame, heading, altitude) in a new/geosplatssection, and noted a Fumadocs migration as a follow-up (this file has grown large enough to want a real table-of-contents).
Fixed — 2026-07-02 (main: 3DTRJS orthographic camera)
- 3DTRJS orthographic toggle now works. The ortho frustum was sized from
controls.target, which in globe mode is ~earth-centre → an earth-sized frustum (whole continent in view). Now sized from the distance to the loaded tileset's world centre. Returning to perspective reconstructs the eye from the scene focus + ortho zoom (GlobeControls moves the ortho camera far along its forward axis, so its position isn't a usable perspective eye). Splats DO render under 3DTRJS ortho (unlike Cesium). Transition is an instant cut — a smooth 1 s persp↔ortho viaCameraTransitionManageris a possible future enhancement. - Cesium ortho + splats: documented Cesium limitation.
PrimitiveGaussianSplatVS.glslcomputes the EWA covariance Jacobian asfocal / t.z(perspective-only), so splat quads collapse under an ortho frustum and nothing draws. Ortho works for basemap/mesh/points in Cesium; use 3DTRJS for ortho splat comparison.
Added — 2026-07-02 (main: viewer show/hide + orthographic camera)
- "Show asset" toggle in the sidebar — hide/show the loaded 3D asset (splat / mesh / point cloud) without
unloading, to compare its footprint against the basemap. Works in CesiumJS (
tileset.show) and 3DTRJS (tiles.group.visible); persists across loads. - "Orthographic camera" toggle — plan/true-scale projection for distortion-free footprint checks, in both
renderers (Cesium
switchToOrthographicFrustum; 3DTRJS swaps in aTHREE.OrthographicCamera+ rebinds controls/tiles/globe). NB Cesium's Gaussian-splat shader does not draw under an ortho frustum — ortho is for basemap/mesh/point-cloud comparison; splats show in perspective.
Fixed — 2026-07-02 (main: GeoSplats per-model heading)
- GeoSplats compass heading was wrong (model B / Biltmore rotated vs the map). The SDK stores a per-model
heading in
global_position.offset.y_rot; in the enu2 frame the aligning yaw isy_rot − 180°(B y_rot=217 → 37°: tennis courts land east, golf west, matching satellite; A y_rot=−178 → ≈+2°, negligible). Applied automatically;&yaw=<deg>fine-tunes on top. The&yawknob now rotates the ENU basis about Up (a true heading) instead of the old content-Y spin.
Fixed — 2026-07-02 (main: GeoSplats real-world scale + upright orientation)
- GeoSplats models had no real-world scale (B — the Biltmore Hotel, ~486 m — rendered ~4 m).
readSogunits are per-model and don't match;model_scalenormalises them. Decoded from the MapTiler SDK: the world-unit→metre factor is512·ZOOM_TO_METER[14]= 2445.98, so real size ≈raw · model_scale · 2445.98. Now baked as a uniform scale into the root transform's ENU basis (all geometricErrors ×S to keep LOD consistent). Verified: A ≈ 2 km (Jastrzębia Góra), B ≈ 486 m (Biltmore).&scale=<f>overrides. - GeoSplats rendered upside-down with the earlier
[East, North, Up]frame (flat but inverted — only obvious once B was scaled up to a recognisable building). After the renderer's glTF Y-up→Z-up correction the content's up is −Z, so the correct frame isenu2=[East, −North, −Up](a 180° flip about East, a proper rotation). Now default. N/S heading is flipped 180° (SDK offset Euler still off);&yawnudges.
Fixed — 2026-07-01 (main: splat crop-export vertical flip + GeoSplats orientation)
- Splat crop export (
/dump/splat) flipped third-party 3D-Tiles splats vertically (WL GSplat: the electric pole pointed away from the ground; adjacent tiles looked vertically offset). The dump baked a fixedRX(−90)Y-up→Z-up, but Cesium/3d-tiles-renderer useRX(+90)(Y_UP_TO_Z_UP) for standard Y-up glTF. Our own converters (buildSpzGlb) emit a bare{mesh:0}node and bake orientation into the tileset root transform, so they needRX(−90); standard tilesets bake per-tile placement into an explicitnode.matrixand needRX(+90). The dump now auto-detects per tile (explicitnode.matrix→ RX(+90), else RX(−90));&rx=90|-90forces it. Verified via Y-histogram: WL and LCC exports both land ground-down. - GeoSplats rendered as a vertical slab in Cesium and 3DTRJS (models A and B). Both renderers apply the
glTF Y-up→Z-up correction before the tileset transform, so the frame must be the standard ENU
[East, North, Up]— the previous[East, Up, −North]double-rotated it upright→sideways. Now default;&frame=selects variants. (B is a genuinely small ~4 m capture, not mis-scaled.)
Fixed — 2026-07-01 (main: GeoSplats now render in Cesium + correct altitude)
- Cesium failed every GeoSplats tile with
attribute.value.length must be in the range [1, 4]. Root cause: the tileset.json didn't declare the3DTILES_content_gltf→KHR_gaussian_splattingextension, so Cesium routed our splat GLBs to its mesh loader (which chokes on the splat-only SCALE/ROTATION attributes) instead of its native Gaussian-splat renderer. The tileset now emits that extension block — the same one every other splat converter here already emits (GaussianSplat3DTileContent.tilesetRequires GaussianSplattingExtgates on it). GeoSplats render in both Cesium and 3DTRJS; the force-3DTRJS workaround inviewer.htmlis removed. - Scene was buried ~300 m underground.
global_position.altis in MapTiler'smodel_scale-normalised MapLibre frame, not our 1:1 ellipsoidal metres (model Aalt=−307.6;alt·model_scale ≈ −0.2 m ≈ 0). Placement height now defaults to 0 (origin on the ellipsoid ≈ ground for these near-sea-level captures);&alt=<m>overrides. Verified in Cesium: scene sits on the ground, correctly oriented (measuredX→East, Y→Up, Z→North). - Sporadic tile failures. Transient MapTiler rate-limiting (429) now retried with exponential backoff;
deep octants whose
counts[]claim a LOD with no bakeddelta_dataentries now fall back to the highest available LOD instead of returning HTTP 500.
Added — 2026-07-01 (main: GeoSplats HLOD octree)
/geosplatsnow emits an HLOD octree from each sub-model'svoxel_grids(grid_1 → grid_8 → grid_64), instead of one tile per sub-model. Parent→child by spatial containment (child octant centre inside parent bbox — independent of the octant-id bit layout);geometricError= octant diagonal (monotonically decreasing → correctREPLACErefinement); finer grids fetch higher progressive LODs (grid_1→LOD4, grid_8→LOD6, grid_64→LOD8, clamped to a LOD that has splats). Only occupied octants become tiles. Verified: model A → 1892 tiles, depth 3, GE 7880→722→378→38, every level a valid_spz_2GLB. grid_1's cell omits a bbox in the metadata, so it's synthesised from the sub-model bounds.
Fixed — 2026-07-01 (main: GeoSplats orientation)
- Scene was mis-oriented (appeared vertical): the
global_position.offsetEuler is per-model (real orientation, not a constant), and — decoded from the SDK — isRx(x_rot)·Ry(y_rot)·Rz(z_rot)applied in a Y-up model world (X=East, Y=Up, Z=South). The converter now applies exactly that (was wrong order / dropped) in the[East, Up, −North]placement frame.model_scalestays un-applied (readSog already yields real metric units; A ≈ 500 m aerial, B ≈ 4.6 m object). Heading may still need a visual tweak.
Added — 2026-07-01 (main: MapTiler GeoSplats → 3D Tiles live converter)
/geosplatsstream converter (packages/tile-server/geosplats-live.js) — turns a MapTiler GeoSplatsmodel.jsoninto OGC 3D Tiles, reverse-engineered from the format + SDK bundle (seedocs-references/maptiler-geosplats.md):GET /geosplats/tileset.json?url=<model.json>[&lod=1..8]→ georeferenced tileset, one tile per sub-model (grid_1/octant_0), placed viaglobal_position(lat/lon/alt) in a local Y-up ENU frame ([East, Up, −North]), recentred by the union bbox of all sub-models.GET /geosplats/tile.glb?…&m&grid&oct&lod→ fetch the octant bundle from the API proxy (…/<modelId>/<baseurl>/<grid>/<octant>/<lod>= theentriesfiles concatenated in array order), slice by the TOC,readSog→KHR_gaussian_splatting_compression_spz_2GLB (buildSplatTileGlb).- Keys are origin-restricted, so the upstream fetch runs server-side with
Referer: https://www.maptiler.com/— the browser only ever talks to our middleware. - Scale:
readSogalready decodes means to real local units, somodel_scale(SDK raw-texture path, even/=256) is not applied; positions are placed 1:1 (was shrinking the scene sub-metre). - HLOD over the
voxel_gridsoctree is a TODO (MVP = one tile per sub-model).
- Viewer: ✨ GeoSplats format pills (models A/B) →
/geosplats/tileset.json. README + a reverse-engineered format mirror added underdocs-references/.
Fixed — 2026-07-01 (main: splat dump orientation + GeoSplats render)
- Splat dump emitted ENU Z-up → ~90° tilt in SuperSplat ("grass aligned along some directions"):
splat-transform's engine space is PlayCanvas Y-up (
Transform.PLYis only Rz180). The/dump/splatoutput frame is now local Y-up ([East, Up, −North]). Verified: LCC Guan height lands on Y. - Multi-tile splat dumps stacked all tiles at the origin — each tile's node-local SPZ was never baked
into a common frame, so
combine()(identical transforms) baked nothing. Now each tile'sDataTableis tagged with itsworldM·YUP_ZUP·nodeMplacement (→ positions + quats + scales + SH baked), anchored at the source scene's own root origin so multiple crops share one coordinate system. - Cesium crashed on splat tilesets (fragment-shader compile error,
POINTS+UNLIT): the shadeless CustomShader was being attached to splats. It's now mesh-only (Bing/3MX) — Cesium rendersKHR_gaussian_splattingGLBs natively, so GeoSplats/other splats load there without the crash.
Fixed — 2026-06-30 (main: 3DTRJS gizmo drag + mousewheel crash)
- Crop gizmo drag was inverted/"applied twice":
_closestOnAxis(closest point of the mouse ray to a handle's world axis) had a sign error — it computed(f − b·e)/(1−b²)= −s, reflecting the dragged point through the current handle, so a face/arrow moved the wrong way by the same magnitude. Now(b·e − f)/(1−b²). Verified numerically (ray over x=5 → closest (5,0,0), was (−5,0,0)). Affected both the face-resize handles and the translate arrows. (The old_cropGizmoInfoself-test missed it — it bypasses_closestOnAxis.) - 3DTRJS mousewheel crash (
Cannot read properties of null (reading 'layers'), spamming every frame):GlobeControls.update()raycasts the whole scene on zoom (intersectObject(scene)); a tile disposed mid-frame leaves a transient null in the traversal.controls.update()is now wrapped in try/catch (log-once), mirroring the existingtiles.update()guard. - Gizmo handles smaller (handle 0.06→0.03 of max half-extent; arrows 1.6→1.1× + shorter head) — they were bulky. Auto-shown now after "⬚ Set box to view" in 3DTRJS mode.
Fixed — 2026-06-30 (stream-cache cont.: splat-dump output corruption + dump/viewer feedback)
/dump/splatproduced corrupt SPZ/SOG/PLY — three independent@playcanvas/splat-transformbugs, all now fixed + verified end-to-end with a standalone DataTable harness (seememory/splat-transform-output-gotchas.md):- Buffer over-read (all 3 formats):
mfs.results.get(name)is aUint8Arrayview into a 5 MB scratch buffer (MemoryWriter.close→new Uint8Array(buffer.buffer, 0, cursor)).Buffer.from(out.buffer)wrapped the whole 5 MB → trailing garbage. → SPZ NaN positions (SuperSplattranslate(NaN)), SOG EOCD buried 5 MB from the end ("End of central directory not found"), bloated PLY. Now emitsBuffer.from(u8.buffer, u8.byteOffset, u8.byteLength). - SOG was not a bundle:
writeSogneedsbundle: trueto emit a single self-contained.sogzip; without it, loose.webp+meta files were written andcrop.sogwasn't a zip. - PLY wrote SPZ bytes: the
elsebranch routedply→writeSpzinto a.ply("invalid file header"). Now useswritePly({ plyData: { elements: [{ name: 'vertex', dataTable }] } }).
- Buffer over-read (all 3 formats):
- Dump now honours the viewer's extra modelMatrix (
&matrix=<16 col-major>): a Z-offset nudge (e.g. Geghard −1800 m) or local-frame ENU placement applied in the viewer is no longer invisible to the dumper —traverse/forEachPointseed the world chain with it, so the crop matches the on-screen position. Cesium readstileset.modelMatrix; 3DTRJS reports the globe-mode Z-offset (translate along ECEF up). - 3DTRJS "⬚ Set box to view" now works in 3DTRJS mode (was a Cesium-only no-op): raycasts the inner-80% canvas corners against the loaded tiles (fallback: globe surface), ECEF→carto → geographic crop bbox.
- 3DTRJS globe zoom no longer resets as nested external tilesets stream in: framing is now one-shot per
load (external
*.jsonchildren also re-emitload-tileset); the ↗ Fly-to button re-frames on demand.
Added — 2026-06-30 (stream-cache cont.: 3DTRJS point size + Bing city pills)
- 3DTRJS point-size slider (Crop & Dump-adjacent control, 3DTRJS mode): sets the PointsMaterial
size(screen px, sizeAttenuation off) on POINTS tiles — applied to loaded tiles and each as it streams in. - Bing 3D city pills — Roma / Washington / Berlin load the same world tileset and fly the camera to the city (re-clicking a city when the world tileset is already loaded just flies, no reload). Works in both renderers.
- Note on mesh GLB streaming: a single merged-GLB 2-pass stream stays deferred — the GLB JSON chunk must
declare every accessor's byte layout up front (needs all geometry and texture sizes, i.e. fetching
textures, before any bytes are written; gltf-transform has no stream-writer).
/dump/zipalready streams meshes out-of-core (one tile in RAM at a time).
Added — 2026-06-30 (stream-cache cont.: splat per-splat clip + PDAL via pixi + PivotControls)
/dump/splatper-splat clip finished and default ON (&clip=0to disable): drops individual splats whose centroid falls outside the box. The earlier version under-clipped because it ignored the splat GLB's node matrix; SPZ positions are node-local, so they're now placed in ECEF via the same chain meshes use —M = worldM · YUP_ZUP · nodeM(nodeM = Rx180 spz→glTF axis fix + per-tile translation). Verified: SPZ centroids land inside their tile box only with this matrix (82.8% vs 0%); a georeferenced E2E run keeps 100% under a wide bbox, a central 60.9% under a tight one. NewX-Dump-Splats-Raw/X-Dump-Clippedheaders + a viewer "clip splats outside box" checkbox.- PDAL for
/dump/copcvia pixi (not conda):pixi global install pdal(2.10.2) →~/.pixi/bin/pdal. The server resolves it as$PDAL_BIN→ pixi-global shim →pdalon PATH. pixi is the right installer — PDAL is a conda-forge native binary, not a pip package (uv is Python-only, so it can't supply it). - 3DTilesRendererJS bbox PivotControls gizmo (
⬚ 3D box gizmoin Crop & Dump, 3DTRJS mode): a vanilla-three port of the drei/threepipe PivotControls idea, purpose-built for the crop box — a translucent box with 3 ENU translate arrows (move the whole box) + 6 face handles (drag a face to resize that geographic bound; the requested face-gizmo bonus). Placed via the globe ellipsoid'sgetEastNorthUpFrameunderecefRoot, so it aligns with the tiles; bounds are re-derived exactly from each dragged handle's position (getPositionToCartographic). Shares the same DOM bbox the Cesium box and the dumper read. Verified in-browser: correct ECEF placement (centre at Earth radius), 12 handles, drag of the east face +100 m → +0.001352° lon (matches100/(111320·cosφ)), only that bound changes.
Added — 2026-06-30 (stream-cache cont.: dumper out-of-core outputs + splat merge)
- Disk-free out-of-core LAS (
/dump/las?...&stream=1): 2-pass (count+bbox, then stream 8k-record chunks with backpressure) — O(1) RAM, no temp file. Byte-identical to the disk path (verified 498 868 pts). /dump/copc: reuse the LAS temp file,pdal translate … crop.copc.laz, stream it; graceful 501 if no PDAL./dump/tileset: no-decode filtered tileset (in-bbox tiles + transforms, original GLB URIs)./dump/zip: out-of-core streamed STORE-mode ZIP (one tile in RAM at a time) → tileset.json + tiles — the answer to "is the crop zipped?" + mesh-out-of-core single download. Works for mesh/splat/points./dump/splat: merge in-bbox splat tiles → one supersplat-ingestable SPZ/SOG via@playcanvas/splat-transform(embeds the Niantic SPZ decoder). Extract each tile's SPZ blob straight from the GLB (gltf-transform can't read the required spz extension),readSpz→combine→writeSpz. Verified: LCC Guan_Temple → 41 tiles → 6 414 134 splats → 102 MB valid SPZ.- Viewer Crop & Dump: LAS / COPC / GLB / Tileset / Splat(SPZ) / ZIP buttons + "disk-free stream" toggle.
- Remaining: single-file mesh merge out-of-core (streaming GLB needs 2-pass byte layout); zip64 for >4 GB.
Added — 2026-06-30 (stream-cache cont.: dumper out-of-core + implicit + crop UI handles)
- Generic 3D Tiles bbox dumper matured: ingests any tileset (our /stream outputs, /bing, /threemx,
external) and branches points→LAS / mesh→merged-GLB.
- Out-of-core LAS: streams points to a temp file via the O(1)-RAM
LasWriter(header back-patched on close) then pipes it to the response — constant RAM regardless of cloud size. (Mesh merge is still in-memory; huge-mesh out-of-core → USD streaming is future.) - Implicit-subtree traversal: walks OCTREE/QUADTREE
.subtreeavailability (morton index, cell-box subdivision, template substitution) — so COPC/Potree/implicit tilesets dump directly. Verified on the implicit georeferenced vol_total: full 498 868 pts / 42 tiles / 18 MB valid LAS; half-bbox → 230 356. - Viewer Crop & Dump UI: "Set box to view (80%)", a translucent box on the globe, editable W/E/S/N
- minH/maxH, max-GE, ⬇ LAS / ⬇ GLB. Cesium drag handles (yellow W/E/S/N face points dragged on the ellipsoid resize the box live).
/dump/copc: shells out topdal translate crop.las crop.copc.laz(reuses the out-of-core LAS temp file), streams the COPC; graceful 501 when PDAL isn't on PATH./dump/tileset: no-decode crop — emits a filtered 3D Tiles tileset referencing only the in-bbox tiles (each leaf keeps its world transform + the original tile GLB URI). The clean splat crop (gltf-transform can't even read the SPZ-compressed splat GLBs — required extension fails validation), and works for points/mesh too. Verified: georeferenced LCC splat → 41 in-bbox tiles.- Remaining: single-file splat merge (raw container-level GLB concat preserving the SPZ extension, since gltf-transform refuses it), mesh out-of-core (USD streaming), NASA-3DTRJS/R3F TransformControls box (drei-style PivotControls, cf. repalash/threepipe), and LAS streaming-to-client without disk (two-pass).
- Out-of-core LAS: streams points to a temp file via the O(1)-RAM
Added — 2026-06-29 (stream-cache cont.: bbox crop engine + shadeless rendering)
- bbox crop / subsample extract — engine (point clouds → LAS). New
/dump/las?url=<potree>&bbox= minx,miny,minz,maxx,maxy,maxz[&maxLevel=N]traverses the octree (ADD: accumulate every point inside the bbox from all nodes down tomaxLevel), filters to the bbox, and streams an uncompressed LAS (core/las-writer.js; offset = points' min so int32 quantization stays in range). Verified on Potree lion: full = 272 768 pts / 167 nodes (valid LASF), a half-X bbox = 94 417 pts clipped exactly at the cut. First increment of the BLOSM-style crop feature. Next: COPC source, 3D-Tiles-mesh→merged glb/USD, lazy traversal for huge remote clouds (non-lazy full-hierarchy load is slow on big datasets), and the in-viewer draw-bbox UI (→pdal writers.copc/untwinefor LAZ/COPC). - Viewer shadeless (unlit) + brightness — Bing/3MX bake lighting into textures, so a Cesium
CustomShader(LightingModel.UNLIT + brightness multiply, default 1.6, on by default) renders them bright/clear instead of dark. Toggles in the Tileset & LoD panel.
Added — 2026-06-29 (stream-cache cont.: server-side per-cache progress)
- Server-side progress for long ops —
core/progress.jsregistry +GET /progress?key=<srcUrl>(JSON{label, done, total, finished, elapsedMs}). Hooked the Potree eager-drain (cache=hierarchy): ticks per.hrcchunk loaded. The viewer polls it (1.5s) duringcache=hierarchy/cache=allloads and surfaces it in the timing-log modal (server … drain: N). Verified: drain reports 252→844→1776 chunks. (COPC/Potree-2.0 drains + convert follow the same pattern — easy to extend.)
Fixed — 2026-06-29 (stream-cache cont.: Bing geometry renders)
- Bing 3D now renders at the right location (verified: photogrammetry mesh of Rome, 900+ tiles). Two
bugs: (1) the viewer's auto-placement treated Bing's world-spanning
regionas local data (its bounding-sphere centre sits at the earth's centre) and applied a San-Francisco local framemodelMatrix→ shifted everything off-globe. NowisLocalCoordsalso requires a compact bounding radius (< 1e6), so global/continental georeferenced tilesets are left at identity. (2) A wronggltfUpAxis:"Z"I'd added — Bing GLBs are standard Y-up with ECEF in the node matrix, so Cesium's default Y-up→Z-up already places them correctly; setting Z put geometry in the Indian Ocean. Reverted.
Added — 2026-06-29 (stream-cache cont.: in-process KTX2 texture encode)
- In-process KTX2 encode (
KHR_texture_basisu) —compressGlb({ktx, uastc})and/stream?transform=ktx(ETC1S) /ktx-uastc(UASTC), via thektx2-encoderWASM basis encoder + our pngjs/jpeg-js source decoder (the in-NodeimageDecoderit needs). No nativetoktx— closes the encode gap. Verified end-to-end on a real textured 3MX tile (encoded.ktx=1, round-trips through the decoder). Pinned@gltf-transform/*to^4.4.0. (WebP texture encode still needs a WASM webp encoder.)
Added / Changed / Fixed — 2026-06-29 (stream-cache cont.: Bing td1 rewrite)
- Bing rewritten to the
td1implicit manifest — the oldmtx<quadkey>HTTP-probe quadtree couldn't discover Bing's deep-only tiles (intermediate LODs are 0-byte, so probing from a shallow root pruned the whole tree → "only one request, world shows nothing"). Now: fetchtd1, strip theMICROSOFT_*extensionsRequired, rewrite themtx/sttemplates to proxy through/bing/{tile,subtree}/{face}-{level}-{x}-{y}with a standard{y}, and flipy→reverseY(= 2^level−1−y) when fetching from Bing. Subtree availability is standardsubtmorton(x,y) → passed through untouched. Verified end-to-end: manifest rewrites (MS exts stripped, templates routed), subtrees proxy with real availability, and a known-good tile (/bing/tile/1-14-1141-4206.glb→ reverseY 12177) returns the real 203 KB glTF. No API key needed. Viewer: one "Bing 3D — world (td1)" pill (navigate/zoom to any city) replacing the root-quadkey pills. - Bing webmercator fix — convert implicit→explicit lazy fragments with correct regions. Passing Bing's
implicit tiling through made Cesium subdivide each face's latitude linearly (it can't do
MICROSOFT_webmercator_subdivision), so deep regions were wrong and tiles were culled ("zoom to Rome shows nothing"). Now/bingwalks Bing'sstsubtree availability itself and emits explicit tiles whoseregionis computed with proper web-mercator math (4 faces = quadrants; lon linear; lat = atan(sinh(mercator-y))), as lazy/bing/fragmenttilesets (a few levels each, external-ref deeper). Verified: root = 4 faces; a level-12 fragment near Rome yields 85 content tiles, one at 12.566°E / 41.900°N (Rome), fetching a real 203 KB glTF.
Added / Changed / Fixed — 2026-06-29 (stream-cache cont.: 3MX georef/orientation, Z-offset, diagnostics)
- Fix 3MX orientation — node GLBs stored SRS Z-up
(E,N,U)verbatim, so Cesium's glTF Y-up→Z-up rotation tipped meshes 90° (vertical instead of horizontal). Now swizzled(E,N,U)→(E,U,−N)like the point paths, matching the ENU root transform + bounding box. - Fix 3MX georef for compound CRS —
core/geo.jsgeorefFromCrsnow unwrapsCOMPD_CS[...]WKT (horizontal PROJCS/GEOGCS + vertical) that proj4 can't parse. Verified: Tende village (dept06, EPSG:2154 Lambert-93 + NGF-IGN69) now georeferences to 7.59°E / 43.9°N instead of sitting at the local origin. - Viewer vertical Z-offset — a "Z offset (m)" control nudges an already-georeferenced tileset along
geodetic up (local vertical, not ECEF Z) via
tileset.modelMatrix, composing with the dataset's own georef (for wrong-altitude datasets like Geghard). Verified it applies without replacing the georef. - README: noted Arrival.Space
.loduses the streamed-SOG format (SOG adapter reads it directly). - Diagnostics (no viewer fix this pass): the Potree implicit "stops refining" report — verified server-side is correct (BFS reaches subtree level 9 / tile level 11, 161 subtrees, 0 errors; deep tiles load; implicit and explicit produce identical geometric errors). Any remaining under-refinement is Cesium-side and needs live repro; workaround = the viewer's tiling→explicit toggle.
Added / Changed — 2026-06-29 (stream-cache cont.: pills, Arrival=SOG, timing modal, dev port)
- Arrival.Space
.lod= SOG (confirmed by fetching the live fixture): identicallod-meta.jsontree and per-chunkversion:2webp splats (means_l/u,quats,scales,sh0,shN_*). The SOG adapter reads it directly — no Arrival converter needed. Added an Arrival jaffa3 pill (verified streaming). - Viewer pills: Geghard (iconem Potree, remote), Marseille
root.json(3D Tiles, CORS-ok), Tende village 3MX (dept06 — a real georeferenced 3MX; the earlier "white" was the SRS-less fixture), Arrival. Pills can now be absolute remote URLs (isRemoteUrl/srcUrl); offline mode hides assets with no output. - Timing log merged into the
#statusmodal (was a separate bottom-left panel overlapping the status overlay) —#status=#status-line+#timing-log. - Dev port fix: the preview harness injects
PORT=3000intopnpm run dev, collapsing the tile-server onto vite's port.server.jsnow resolves--port > TILE_PORT > PORT > 3001;serve:tilespasses--port 3001.pnpm run dev→ vite 3000 + tiles 3001 under the harness. - Findings (answers, see Future work for the resulting tasks): (a) the Float32 world-coord precision
bug was Potree-only — COPC subtracts origin in float64 before the f32 cast, and LCC/SOG/RAD store
local model coords + georef via the root transform. (b) Bing: the
mtx<quadkey>probe model is wrong — Bing serves a standard implicit-tiling 3D Tiles root attd1(QUADTREE,subtreeLevels:7,availableLevels:21, tile templatemtx{face}-{level}-{x}-{reverseY}, subtree templatest{face}-…); the probe only catches sparse deepmtxtiles (0-byte at low LOD) → "subsample"/"world shows nothing". (c) No in-process KTX2 encode (nobasis_encoder/toktx;threeships only the transcoder) and no webp encoder (would pullsharp) — both need a new WASM dep. (d) Nira has no proprietary tiled format (adopting 3D Tiles.3tz/b3dm). (e) tastier.3tz/.3dtilesare already under the 📦 Packages pill.
Added / Changed — 2026-06-29 (stream-cache cont.: encode/transform, texture format, LAS writer)
- Per-tile encode +
&transform=on/stream— closes the implicit-octree gap the/gltfproxy can't reach (it URL-rewrites explicit tilesets;/streambuilds each GLB itself).transform=meshopt(EXT_meshopt — works on POINTS, where Draco no-ops; verified Potree tile 203 KB → 65 KB, ~32%),transform=draco(mesh tiles),transform=decompress(decode draco/meshopt/KTX2,+&tex=jpg|png). Newcore/gltf-compress.jscompressGlb({meshopt|draco}); verified end-to-end through the live server- round-trip decode. Viewer: transform dropdown next to cache/prefetch (before formats).
- Configurable KTX2 decode output —
core/gltf-decompress.js+/gltf?tex=jpg|png. Default jpg (pure-JSjpeg-js, no native/sharp): measured ~30% smaller and ~2.6× faster than the priorpngjspath. - Out-of-core LAS writer (
core/las-writer.js) — hand-written LAS 1.4 / PDRF7 (no maintained JS/WASM LAS writer exists). StreamingLasWriter(O(1) RAM — header back-patch for count/bbox, so a 500 GB cloud needs ~constant memory) + in-memorywriteLasBufferfor the browser. Hand off tolaszip/pdal writers.copc/untwinefor LAZ/COPC. Self-tested (LASF/PDRF7/count/bbox round-trip). - Bing: default genid
15340 → 15530; any root quadkey works (full-world); viewer World/Rome pills. - Viewer: raw-url field re-resolves on toggle and on pill click; KTX→decode demo pill (Bing via
/gltf). Docs:/stream?transform=+/gltf?tex=in the endpoints table; cache disk/RAM/none persistence note; blosm#498 + CesiumGS public-data refs. - Viewer permanent timing log (bottom-left) — persistent per-load milestones with elapsed times (context: cache mode + prefetch + transform + format + renderer; then tileset resolved / first tile / streaming N / tiles ready N / failure). The timing-log half of the requested progress reporting.
- Offline georef baked — the prebuilt LCC + Potree converters now write
root.transformfrom the data's own CRS, sharing the live adapters' math (newcore/geo.jscgcs2000Lon0/lccGeoref; Potree viageorefFromCrs). Regenerated Guan_Temple (EPSG:4547) + vol_total (Swiss somerc) — verified offline placement == live/streamadapter. Clouds with no CRS (Guanlan EPSG:0) correctly stay local. - Still deferred (unverifiable here / greenfield): server-side per-cache progress (eager-drain /
convert need a progress endpoint — the other half of progress reporting); Arrival.Space
.lodadapter; Potree live zoom-stop above a level (needs live-viewer repro); 3MX visual debug (public fixture has no SRS); KTX2 texture encode (needs nativetoktx).
Added / Changed / Fixed — 2026-06-29 (stream-cache branch: lazy hierarchy, cache continuum, new adapters)
- Lazy hierarchy on demand for every multi-page format (geometry was already per-tile): Potree 1.x
(
.hrcchunks → lazy implicit), Potree 2.0 (hierarchy chunks), COPC (hierarchy pages), RAD (4-level explicit fragments), I3S (node-page fragments). Huge clouds: Potree 1.7 302 M ~80 s → ~0.2 s; RAD 50 M 9.6 s → 0.015 s. Lazy paths verified to reach the same node set as eager (Potree 2.0 byte-identical 1624 nodes; RAD 765/765; I3S 5882/5882). - Single
cacheaxis (replacedmode × hierarchy):cache=none(live, lazy) ·cache=hierarchy(whole tree in RAM, tiles per-request) ·cache=full(whole dataset → disk,/convert). No "prebuilt" middleware mode. Unified/tilesendpoint 307-routes bycache;/stream&/convertare aliases.prefetch=N(0/N/-1) background-warms lazy hierarchy. Bounded-parallel lazy fragment builds (parallelMap, capmin(cpus−1,16)). - New
core/shared services:geo.js(ENU/ECEF georef, was 4× dup),source.js(Source: range/parallel-ranges/full/probe),http.js(CORS + send helpers),octree.js(buildImplicitTileset),gltf-decompress.js. - New adapters/tools:
/gltfgeneric per-tile glTF-Transform pipeline (ops=decompress|draco|meshopt|ktx, decode today — gltf-transform + draco3dgltf + Basis WASM fromthree+pngjs, no sharp, no nativektx);/bingBing Maps 3D (tf=3dv4GLB) → quadtree (verified live keyless, Berlin);/threemxBentley 3MX → lazy fragments + OpenCTM MG1 decode (vendored js-openctm,packages/threemx-to-3dtiles/). centroids(¢roids=1) for SOG/LCC/RAD — POINTS preview of splat centres.- Fix — Potree live float32 precision striping: decoders now build positions in Float64Array (world); origin subtraction casts the small local result to float32 (verified: iconem 1.x tile positions local ±351 m, not UTM ~5e5). 1.x & 2.0, implicit & explicit.
- Viewer: 3 modes (Live streaming · Prebuilt offline · Tools);
cachepill +prefetchinput placed right after the mode, before formats; Bing 3D + 3MX pills; tastier.3tz/.3dtilesmoved to Packages; public CesiumGS samples on the 3D Tiles pill; raw-url checkbox (re-resolves on toggle, auto-off in offline). centroids toggle for splats. - Docs: README materialization continuum, multi-page-vs-monolithic-index, tools & endpoints, candidate-
formats roadmap, future work;
docs-references/streaming-architecture-design-log.md,streaming-adapter-plans.md; fullCONVERSATION.md. - Deferred (noted in README/CHANGELOG): glTF-Transform encode ops;
&transform=in/stream(implicit per-tile transform); offline-georef regen (LCC Guan, Potree vol_total) + Potree implicit offline output; 3MX visual debug (needs a georeferenced 3MX — public fixture has no SRS); live progress reporting; browser-native bbox crop → LAS →pdal/untwineCOPC; fastest JS point-cloud tiler.
Fixed — 2026-06-26 (server stability, pills, intensity, Potree 1.x convert, SOG rotation)
- Server crash fix (
ERR_HTTP_HEADERS_SENT): catch blocks no longer write a 500 after streaming has started (if (!res.headersSent)guard).laz-perfWASMabort()handlers overridden so they log instead of re-throwing. Non-LAS/copcrequests (e.g. wrong URL) fail fast with a clear error (LASF magic check) instead of hanging and killing the process. - Potree 1.x stream fix:
cloud.js(pure JSON) was being transformed by Vite's JS module pipeline (HMR footer appended), corruptingJSON.parse. Added a Vite plugin (raw-sample-data-js) that serves anysample-data/**/*.jsrequest as raw bytes, bypassing module transformation. Tolerant brace-balanced parser inpotree1.jsas defence-in-depth. - Potree 1.x convert via middleware:
/convertcan now fully mirror a remote Potree 1.x dataset (walkscloud.js→.hrchierarchy → all per-node.bin/.lazfiles) before converting. Both inline (v1.4) and chunked-dir (v1.7) layouts handled. First request is slow (downloads the tree); subsequent requests hit the cache instantly. - Implicit/explicit tiling on
/stream: both COPC and Potree stream adapters now default to implicit (implicitTiling+ on-the-fly subtrees) and support&tiling=explicit. Dedicated-package CLI shims gained--tilingflag. All four surfaces (dedicated CLI, meta CLI,/convert,/stream) are uniform. - Viewer stale-tileset bug: clicking a new pill before the previous tileset finished loading left
a ghost tileset in the scene. Fixed with a
_loadGencounter; stale async completions remove their tileset immediately. - URL bar on its own line:
#url-barusesflex-basis: 100%so it always wraps below the pill rows. - Intensity greyscale fallback: when a point-cloud tile has no RGB channels, COPC and Potree 1.x
decoders now emit intensity as greyscale (
Intensity/65535→ rgb). Covers/streamtiles. Potree 1.x.binnodes also detect theINTENSITYattribute. - SOG root transform (Cesium):
sog-live.jsnow includesroot.transform = RX(+90°)so that streamed-SOG scenes (PlayCanvas Y-up) appear upright when the user applies a local ENU frame in CesiumJS. Bounding volumes already usedboundToZUpBox; content rotation now matches. - LCC georef:
lcc-live.jsconvertsmeta.lccepsg+offset→ ECEFroot.transformvia proj4 (CGCS2000 Gauss-Krüger EPSG 4513–4554). Handles CM-based vs zone-prefixed easting.
Added — 2026-06-26 (3TZ/.3dtiles package server, I3S converter draft, docs)
/packageendpoint (tile-server): serves.3tz(ZIP with@3dtilesIndex1@random-access index) and.3dtiles(SQLitemediatable) packages as if they were unpacked tileset directories.GET /package/tileset.json?url=<local-path.3tz>→ parses the index and rewrites all tile URIs so follow-up requests route back through/package/<key>?url=<same-url>. Mirrorsnpx 3d-tiles-tools servebut in-process, with CORS and no CLI dependencies..3dtilesrequiresbetter-sqlite3(optional; 501 + install hint if absent). No extraction needed — random-access reads via file descriptor + the ZIP index.i3s-to-3dtilesdraft package: reads Esri I3S SLPK (extracted or REST-mirror directory) and converts to OGC 3D Tiles 1.1 explicit tree. Supported: IntegratedMesh / 3DObject / PointCloud layers with uncompressed DefaultGeometrySchema. ENU-frame shared root transform. Already wired into3dtiles-convertmeta-converter REGISTRY and/convertmiddleware. Unsupported by this draft: Draco compression, LEPCC point clouds, textures, per-feature attributes.
Added — 2026-06-26 (pill bar restructure, FORMAT selector, docs)
- Pill bar restructure: mode selector (⚡ Stream / 💾 Offline / 🛠 Convert) × FORMAT selector (COPC / Potree / SOG / RAD / LCC / I3S / 3D Tiles) × tiling toggle (⬡ implicit / ⬢ explicit, shown only for COPC/Potree). Asset pills show only the selected format's assets. Switching mode or tiling reloads the active asset. RAD/I3S disable in Stream mode (no stream adapter).
- README: implicit/explicit documentation (all surfaces, defaults, octree key convention); 3D Tiles → 3D Tiles transcoding section (implicit→explicit, legacy upgrade, tools reference); LCC X/Y-only quadtree warning for tall scans; consolidated format-specification links.
Added — 2026-06-23d (implicit/explicit tiling option; LCC live streaming; core/tiling)
- Implicit ⇄ explicit tiling, both converters. New
--tiling=implicit|explicit(CLI) /opts.tiling(API) /&tiling=(/convert). COPC gained an explicit nested-tree mode (was implicit-only); Potree gained an implicit subtree mode (was explicit-only). Defaults unchanged (COPC implicit, Potree explicit). Potree implicit derives each node's(level,x,y,z)from its box vs the root box and validates that the implicit-derived box equals the explicit box before writing (0 mismatches on lion_p2; 116 subtrees, 6 levels). - Factored shared tiling into
3dtiles-convert-core/tiling:keyToCoords/coordsToKey,octreeChildKeys,octreeSubCube,cubeToBox,buildExplicitTree,buildSubtreeBuffer(moved from copc, BigInt Morton decode — fixes the >level-10 wrap),subtreeRootKeys. COPC'ssubtreeBuilder.jsis now a thin re-export. Used by both copc + potree converters. - LCC live streaming (
/lcc+/stream):meta.lccgrid (cellLengthX/Y+boundingBox) +index.bin(cell packed x16/y16, per-LOD{points,offset,size}) → tileset built without decoding; each tile range-reads its[offset,size)slice ofdata.bin→ SPZ-v2 GLB. Verified (Guan_Temple: 41 units, 154 runs).
Added — 2026-06-23c (true on-the-fly streaming: SOG + Potree 1.x; unified /stream)
- Unified
/streamdispatcher — one front-door that detects the format and delegates to its range adapter; 501 (→ use/convert) for formats without one. Each streamed format is now a uniform*-live.jsmodule (COPC extracted out ofserver.js). - streamed-SOG live streaming (
/sog+/stream): tileset built fromlod-meta.json(bounds + leaf runs persisted, no decode); each tile fetches just that chunk's WebP set, decodes once (LRU-cached), extracts the[offset,count)run → SPZ-v2 GLB. Verified (skatepark). - Potree 1.x live streaming:
potree1.jsrefactored to read through an injectable reader (fs offline, fetch live) so the same authoritative reader serves both./potree+/streamnow handle 1.x (.hrc/inline,.bin+ per-node.laz) and 2.0. Verified live (lion_takanawa.bin167 nodes, lion_takanawa_laz 200 nodes, potree.org 2.0 1624 nodes). - Potree 2.0 true range-streaming (
/potree): metadata.json + hierarchy.bin → tree; one HTTP-206 range read of octree.bin per tile → POINTS GLB.potree2.jsfactored into purepreparePotree2Schema/parsePotree2Hierarchy/decodePotree2Node(shared offline + live). - Streaming roadmap documented: a format streams only if it persists per-tile bounds — COPC, Potree
2.0/1.x, SOG, LCC do; RAD doesn't and isn't spatially tiled →
/convertonly (docs-references/rad-format-notes.md, distilled from the SparkJS-team GitHub replies). Added the loaders.gl@loaders.gl/tile-converterreference (bidirectional I3S⇄3D Tiles).
Added — 2026-06-23b (generic /convert live middleware; --centroids-only)
/convert— live any-format → 3D Tiles (preprocess+cache): converts the whole dataset once on first request (cached on disk bysha1(format|src)), then serves static; rewrites the root tileset's relative URIs to absolute/convert/<key>/…. Works for every meta-converter format.<SRC>may be a remote URL or local path; remote directory formats are mirrored with the sibling files each converter needs.- Remote URLs across CLI + JS API + middleware via shared
packages/convert/fetch-source.js(3dtiled https://…/metadata.json out/,convert('https://…', out)); name-based detection (detectFormatByName) for remote inputs. --centroids-only/?centroids=1: splat formats (RAD/SOG/LCC) emit a plain glTF POINTS tileset of splat centroids (POSITION+COLOR_0, noKHR_gaussian_splatting) so point-cloud-only renderers can display tiled-splat scenes. Sharedcore.buildSplatTileGlb+ aswizzleoption onbuildPointsGlb. Offline pills (RAD/LCC/SOG centroids) + live¢roids=1.
Fixed — 2026-06-23a (dev ergonomics; viewer placement of local point clouds)
pnpm run devnow starts the viewer (:3000) AND tile-server (:3001) together viaconcurrently(dev:web+serve:tiles;serve:livealias added).- Vite no longer crawls —
server.watch.ignored: ['**/sample-data/**'](sample-data moved into the repo is ~119k files / 29 GB; watching it stalled startup & HMR). Still served on demand. - Local point clouds now auto-place & zoom in CesiumJS (centroid / non-georef Potree tilesets previously loaded but never framed — only splats auto-placed). Now any local-origin tileset is placed at the local frame and flown to.
- pnpm workspace fix:
3dtiles-convertnow declares the<fmt>-to-3dtilespackages asworkspace:*deps (itimport()s them by bare name; npm auto-links all workspaces, pnpm doesn't).
Added — 2026-06-22g (live COPC→3D Tiles middleware; I3S geometricError; Potree CRS)
tile-server— live COPC → 3D Tiles middleware (packages/tile-server, the "TiTiler for 3D"):GET /copc/tileset.json?url=<COPC>builds a 3D Tiles tree from the COPC hierarchy (HTTP-range), andGET /copc/tile/<D-X-Y-Z>.glb?url=<COPC>range-reads one octree node and returns a POINTS GLB on the fly (core.buildPointsGlb). One ENU→ECEF root transform from the COPC WKT CRS (proj4).npm run serve:tiles(:3001, CORS open). Verified: CesiumJS renders Autzen geo-referenced over Oregon, streamed live — the viewer only sees 3D Tiles. Added viewer pill 🛰 COPC→3DT (live middleware).- I3S geometricError now = half the tile's bounding-box diagonal (shrinks with depth: 33 km root → 0 leaves), so the renderer refines by camera distance instead of loading every tile always.
- Potree CRS recovery: readers surface
cloud.js.projection(1.x) /metadataCRS (2.0) ascrsfor downstream geo-referencing. potree-to-copcassessed, deferred: read side ready (octree + points + CRS), but COPC needs LAZ and there's no pure-JS LAZ encoder (laz-perfWASM is decode-only). Path documented (Rustcopc-rs/laz-rs→ WASM, or PDALwriters.copc/ copc-lib).
Fixed — 2026-06-22f (I3S ENU frame — upright on the globe)
- I3S buildings now sit flat & upright on the globe (were tilted ~40° / looked like a plane
perpendicular to the ground, with weirdly-rotated boxes). Root cause: geometry stored in ECEF axes,
which are tilted vs the local horizon at NYC. Fix: a single local ENU frame — geometry in
East/North/Up metres, with the root
transform= ENU→ECEF ([East,North,Up,origin]). glTF POSITION is pre-swizzled to(E,U,−N)so the renderer's Y-up→Z-up RX(+90°) yields tileset(E,N,U); boxes are AABBs in that ENU frame. Verified: root box ENU half-extents = 23.5 km × 23.1 km × 0.3 km (a flat city slab, not a vertical plane); buildings follow the Manhattan street grid (Central Park visible), ground-aligned boxes. This is the "custom local CRS via tile transform" approach (vs converting every vertex to absolute ECEF).
Fixed — 2026-06-22e (Potree 1.x scatter, I3S boxes/orientation)
- Potree 1.x no longer scatters into duplicate clusters. Two coupled bugs: (1)
POSITION_CARTESIANis node-relative —raw·scale + THIS NODE's box.min(the spec's "boundingBox.min" is the node's subdivided box; the global cloud min piles every node at the corner); (2) the octant→sub-box convention was wrong — Potree'screateChildAABBis bit0→Z, bit1→Y, bit2→X, not bit0→X (an X/Z swap that put deep nodes in the wrong octant). Fixed both (potree1.js + potree2.js childBox). Verified: child→root NN 0.03 m, 60/60 nodes' geometry inside their box, lion_takanawa renders as one coherent statue with geometry filling the debug boxes. Addeddocs-references/potree-format.md(1.x.hrc/.bin+ 2.0 binary octree, with the decode rules + source links). - I3S bounding boxes now contain the geometry (no weird rotation). The I3S OBB quaternion is in a local frame that doesn't match our per-vertex-geodetic placement, so OBB-derived boxes were rotated wrong and didn't enclose the mesh. Now each tile's box is the axis-aligned AABB of its actual decoded geometry, unioned with children (parent ⊇ children). Verified: 40/40 sampled content tiles contain their geometry; NYC buildings render tangent to the globe over Manhattan and refine on zoom.
Fixed — 2026-06-22d (I3S transform compounding)
- I3S tiles no longer scatter / vanish on zoom. The converter set a per-tile
transformtranslating to each node's ECEF centre (~6.4M m), but 3D Tiles composes transforms down the tree, so child translations compounded (root + child + grandchild ≈ N×6.4M) and flung deep tiles off the planet. Fix: one shared ECEF origin (root OBB centre) — all tile geometry and bounding boxes are stored relative to it, and only the root carries atransform. Verified on NYC: buildings concentrate over Manhattan and refine on zoom (627 tiles vs the old 36 + scatter). (Potree 1.x re-verified correct via debug-box overlay — boxes wrap the point cloud; earlier "wrong" look was pre-fix / sparse ADD LoD.)
Added — 2026-06-22c (in-process meta dispatch, offline Vite viewer)
- Meta-converter now dispatches IN-PROCESS — every converter exports a uniform
convert(input, outDir, opts)plus afileURLToPath-guarded CLI shim. The registryimport()s the module (verified: 0 child processes spawned); it falls back tospawnonly whenopts.extraArgs(format-specific CLI flags) are passed. rad's argv-consts became a settableOPTSobject +parseArgvOpts. - Viewer is now offline / self-contained via Vite — added
cesium,three,3d-tiles-renderer,@sparkjsdev/spark,3d-tiles-rendererjs-3dgs-pluginas deps +vite/vite-plugin-cesium. Removed the importmap + CDN<script>s; Cesium is imported as ESM and exposed as thewindow.Cesiumglobal the non-module scripts use (init deferred via acesium-readyevent).npm run dev→http://localhost:3000/viewer.html. Verified 0 CDN requests (Cesium, 3DTRJS globe, and Gaussian-splat streaming all from node_modules);threede-duplicated viaresolve.dedupe. The dev server also servessample-data/so the presets resolve same-origin. pnpm supported:pnpm-workspace.yaml+.npmrc(node-linker=hoisted, soimport('rad-to-3dtiles')resolves),convert's core dep uses theworkspace:*protocol, and a rootindex.htmlredirects/→/viewer.html.pnpm install && pnpm run devverified.
Fixed / Added — 2026-06-22b (decode bug fixes, RAD spatial GE, packages/ migration)
- RAD geometricError is now SPATIAL by default —
geBasis: 'spatial'=2 × bboxDiagonal/∛(splatCount)(mean representative-splat spacing). Elevator root GE 1.22 → 4.15 (≈ William's 4.66), fixing the under-refinement that showed as coarse/"missing" tiles at distance. Legacy basis via--ge-basis=featuresize. - Potree 2.0 BROTLI decode FIXED — was rendering as a flat line. The decompressed buffer is
attribute-planar (not interleaved) and position/rgb are Morton-encoded (16 B / 8 B per point);
ported Potree's
dealign24b+ planar plane offsets. lion_p2 now decodes to a correct 3D lion with colour. - Potree 1.x parent/child offset FIXED — points are quantised relative to each node's bbox min, not the root's (root min shifted deep nodes → misaligned clusters). Verified points now nest in their node box.
- Potree 1.4 inline-hierarchy + tiled LAZ implemented (was fast-fail). Reads the inline
hierarchyarray + flatdata/<name>.{bin,laz}; LAZ nodes decoded with laz-perf (viacopc).lion_takanawa_laz→ 200 tiled-LAZ tiles,vol_total→ 42. (This is the tiled LAZ path; plain untiled LAS/LAZ stays in copc.) - I3S mesh placement FIXED — geographic-layer vertices are
(Δlon°, Δlat°, Δheight_m)relative to the OBB centre; converted per-vertex to ECEF (stored centre-relative for float32 precision). NYC buildings now land at ≈ −74°, 40.7°.detectFormatalso now recognises extracted SLPK dirs (3dSceneLayer.json.gz/nodepages/). - All six converters moved under
packages/<fmt>-to-3dtiles/(workspace members); core imports repointed (../../core), meta-converter registry paths updated. Meta dispatch retested on all formats. (In-processconvert()export remains the next step; dispatch is still child-process.) - Viewer/inspector: added Potree LAZ pill +
inspect:lion-laz; regenerated all changed outputs (rad GE, potree decode, i3s placement) incl. theoutput-meta-converter/set. - README: format table notes tiled-LAZ;
.3tz/upgrades now point to CesiumGS 3d-tiles-tools; added loaders.gl I3S test-data to open-data samples; Missing-features section trimmed to what's still open.
Added / Changed — 2026-06-22 (parallelism rollout, I3S + Potree on real data, LAS/LAZ, output reorg)
- Parallelism rolled out to all five operational converters.
rad,sog,copc,potreejoinedlccon the sharedcoreasync-gzip +parallelMaptier (bounded concurrency = cores−1). Verified equivalent output: rad byte-identical, copc byte-identical (autzen 23.0→16.9 s), potree byte-identical (lion 6.2→5.0 s), sog content-identical (same 2054 tiles / 10,995,501 splats; tiles renumbered in task-order, deterministic across runs).sog/spzGlb.jsdeleted (now imports core). - I3S converter validated on real SLPKs.
Buildings_NewYork_v18(3DObject mesh, v1.8) → 5882 glTF TRIANGLES tiles. Fixed the load-bearing encoding detector (was gating the whole layer as Draco when buffer 0 is uncompressed).SMALL_AUTZEN_LAS_All(PointCloud, LEPCC) → fast-fail (no decoder). CRS/ENU placement remains the known caveat. Status 🟡 draft → mesh works. - Potree validated on real datasets (iTowns
lion2.0 BROTLI = 3,954,696 pts exact;lion_takanawa1.7 = 167 tiles). Potree 1.4 inline-hierarchy and 1.x LAZ now fast-fail with stale-format messages (exit 1, no output) instead of writing an invalidroot:nulltileset. - Plain (non-COPC) LAS/LAZ support added to
copc-to-3dtiles(plainLas.js): detect via missing COPC VLR → flat tiling (≤2 M pts/tile, no native LoD), LAZ vialaz-perf. Single-tile LAS tested; multi-tile- non-COPC LAZ end-to-end UNTESTED (no sample).
- Meta-converter retested on every format via the
3dtiledCLI →sample-data/output-meta-converter/(auto-detect + dispatch): rad 11 · sog 2054 · lcc 154 · copc 278 · potree 1624 · i3s 5882 tiles. FixeddetectFormatto recognize extracted SLPK dirs (3dSceneLayer.json.gz/nodepages/). Note: the meta-CLI requiresnpm installfirst (workspace symlink) — without it the bare3dtiles-convert-coreimport fails withERR_MODULE_NOT_FOUND. - Output dirs reorganized
sample-data/output-<fmt>/→sample-data/output/<fmt>/; viewer presets (15), inspector scripts, and README paths repointed. Viewer pills added: Potree ×2, I3S (NYC), and a meta-converter group (🔀 ×6) pointing atoutput-meta-converter/; matchinginspect:*scripts. - README: format-comparison table split into Output / point-cloud&mesh / Gaussian-splat; added a Parallelism (per-converter) table and a Missing features & unsupported code-paths section.
- Naming: kept the
<fmt>-to-3dtilesconvention and the3dtiledCLI name; purged@3dtiled/*from docs/source (shared packages are unscoped3dtiles-convert-core/3dtiles-convert).
Added — monorepo, shared core, meta-converter, parallelism (see ARCHITECTURE.md)
- npm workspaces root (
packages/*).3dtiles-convert-coreholds the shared machinery: the canonical SPZ-v2 + GLB splat encoder (now async gzip for parallelism), POINTS GLB writer,parallelMap(bounded concurrency) +WorkerPool(worker_threads), anddetectFormat. 3dtiles-convert— the meta-converter:convert(input, outDir, opts)JS API +3dtiledCLI that auto-detects the format and dispatches. Detection covers rad/sog/lcc/copc/potree/i3s. Dispatches to the standalone converters today (non-breaking); migrates to in-processconvert()as each converter moves intopackages/.- Parallelism wired into
lcc-to-3dtilesas the first adopter: two-phase (build tree + collect tasks →parallelMapdecode/encode/gzip/write); uses3dtiles-convert-core. Validated identical output (11,424,293 splats), ~20% faster on Guan_Temple (more on gzip-heavy formats like SOG). - README: format-comparison table + a Runtime (Node / browser-capable) table per converter (decoders mostly portable / WASM; Node-only gates are I/O + gzip/brotli + workers).
Added — converters
- sog-to-3dtiles — PlayCanvas Streamed SOG → 3D Tiles. Walks the binary spatial tree
(interior = grouping, leaf = REPLACE LOD chain); decodes each chunk via
@playcanvas/splat-transform'sreadSog(CPUwebp.wasm, no GPU) into aDataTable, re-encodes to SPZ-v2 GLB. Validated onexample_skatepark_02(2,054 tiles) andexample_roman_parish_02(37,725 tiles). Memory-bounded (decode chunk → write → release).scripts/fetch-sog.mjsdownloads a full streamed-SOG dataset. - lcc-to-3dtiles — XGRIDS LCC → 3D Tiles. Reverse-engineered the uncompressed 32-B/splat
Data.binlayout (pos f32×3 · color rgba8 (a=opacity) · rot u32 10-10-10-2 · scale u16×3), parsesmeta.lcc+Index.bin(Unit grid × LODs). Validated onGuan_Temple(154 tiles, 11,424,293 splats =totalSplats). Rotation matches the LCC-Web-SDKQUAT_TAB. Scale decode under active refinement (geometric vs. v5 codebook-LUT). SH not yet emitted. - i3s-to-3dtiles — 🟡 draft (UNTESTED, no SLPK sample): node-tree → tileset, OBB→box,
DefaultGeometrySchemamesh / PointCloud → glTF. Draco/textures/attributes/legacy index gated out. - potree-to-3dtiles — 🟡 Potree octree point cloud → 3D Tiles glTF POINTS. Auto-detects 2.0
(
metadata.json/hierarchy.bin/octree.bin, incl. BROTLI) and 1.x (cloud.js/.hrc/.bin). Octree→tree,refine: ADD, GE = spacing/2^depth. 2.0 decode validated on a synthetic dataset (positions + RGB exact); 1.x best-effort; both untested on real data; output not geo-referenced.
Fixed — rad-to-3dtiles (the "spiky render" saga)
- SPZ v2, not v3 —
KHR_gaussian_splatting_compression_spz_2is locked to SPZ v2 (3-byte "first-three" rotations). Emitting v3 mis-strided the rotation block in CesiumJS's decoder. - RAD column byte-layout (the root cause, confirmed from SparkJS Rust
rad.rsencoders):f16/oct88r8are interleaved,ln_0r8/r8_deltaplanar,f32_lebytesbyte-transposed. Reading alpha & orientation as planar corrupted opacity + rotation (positions stayed correct, so the shape showed through the noise). Fixed; scales correctly reverted to planar. lodOpacity— RAD encoding params live undermeta.splatEncoding(notmeta.encoding); the missedlodOpacity: truemeant opacity wasn't doubled. Cross-validated vs William's PLY pipeline: keepα×1(--alpha-scaleflag added for A/B).- geometricError — uniform-per-depth, anchored to a featureSize/spatial proxy with natural
halving (
--ge-scale/--ge-layer, default 1; an earlier ×16/×8 hack over-refined everything). - Bounding volumes — emitted in the Z-up tileset frame (
RX+90°), nested bottom-up (parent ⊇ children); rootRX(180°)transform corrects the inverted-up artifact. - Noise filtering made opt-in (
--opacity-filter,--max-scale-mult, default off) — SparkJS renders the RAD without discarding splats, so we don't either by default. - Processing-time estimate + actual-time logging.
Added — viewer (viewer.html)
- Renderer toggle (CesiumJS / 3DTilesRendererJS-via-SparkJS); preset pills for every sample (RAD, SOG, LCC, COPC, PLY-comparison, alpha variants).
- SparkJS render settings on the 3DGS plugin (
maxStdDev=√8,blurAmount=0.3); reaches the shared SparkRenderer to setlodInflate/lodSplatScale. 🪲 Debug tiles (boxes coloured by depth, dimmed). Max-SSE and FOV inputs; orbit/globe/env controls; Google-hybrid basemap default.
Added — tooling & docs
tools-inspector/(WilliamLiu 3DTiles-Inspector) withnpm run inspect:*scripts.- Local build-lod build recipe (rustup GNU +
rust-mingw+--no-default-features) for the PLY→RAD→3DTiles comparison vs3dgs-ply-3dtiles-converter. docs-references/:spz-format,KHR_gaussian_splatting(+_spz_2),sog,streamed-sog,lcc-format,i3s-spec,nexus-format, plus a top-of-README format-comparison table + per-format Mermaid diagrams.
Known limitations
- Spherical harmonics not yet emitted (DC only) on any path — the largest remaining fidelity gap.
- LCC scale decode (v3 geometric vs v5 codebook) and SH still being finalised.
- Tile conversion is single-threaded / sequential (see README "Performance"); a worker pool is the obvious next optimization.
3dtiled-to-3dtiles
Offline Converter Packages (CLI/js SDK) and a live streaming middleware from tiled point-cloud/mesh/Gaussian-splat formats to OGC 3D Tiles
3dtiled Supported Formats
Set of Node.js SDK/CLI, standalone-packaged, converters: and middleware on-the-fly HLOD conversion (hierarchy/tile content)