3dtiled-to-3dtiles

Desktop app

3D Tiled desktop companion — runs the tile-server on-device and turns a local dataset into a paste-ready 3D Tiles URL.

The desktop companion (apps/desktop) is the run-the-middleware-on-device app: it starts packages/tile-server locally, then turns a dataset on your disk — or a public URL — into a live OGC 3D Tiles endpoint you can paste into Cesium for Unreal/Unity, QGIS, or CesiumJS.

Nothing is uploaded. The conversion happens on your machine, streamed on demand (cache=none) or cached to the local disk cache (cache=full). It is 3dtiled serve with a window, plus the globe-placement params and a stock-CesiumJS preview.

Installing it

Download the Windows build from the desktop-latest release (about 155 MB; Node and the tile-server are included). Unzip the whole archive, then run 3D Tiled-Setup.exe from the unzipped folder: it needs the .installer folder beside it, and answers "The installer package is incomplete" without it. The build is unsigned, so SmartScreen warns on first run. It installs per user under %LOCALAPPDATA%\com.iconem.3dtiled.desktop, and adds Desktop and Start menu shortcuts. Your own sets and the mosaic cache live in %LOCALAPPDATA%\3D Tiled, so updates keep them.

The Desktop app (Windows) workflow builds it on demand or on a desktop-v* tag, installs it on the runner with the system Node hidden, checks its tile-server serves a tileset, and replaces the release.

Running it from the repo

cd apps/desktop
$env:HUTCH_HOME = "$env:LOCALAPPDATA\hutch-home"   # Windows: give Hutch an explicit home
hutch electrobun dev                               # build + run
hutch electrobun build --env=stable                # distributable → build/stable-<target>/ + artifacts/

Run the build from PowerShell, not Git Bash/MSYS. Hutch shells out to tar, and MSYS tar reads a C:\… path as a remote host spec — the build fails with tar: Cannot connect to C: resolve failed. Windows' own tar.exe handles it fine.

A Windows build --env=stable produces in artifacts/: win-x64-3DTiled-Setup.zip (the distributable), the update bundle stable-win-x64-3DTiled.tar.zst, and stable-win-x64-update.json. Plain it is ~20 MB, and needs this repo and a system Node at run time. For one that works anywhere (~155 MB), bundle them first:

node scripts/prepare-bundle.mjs                                # bundle/: production tile-server + Node
$env:DESKTOP_BUNDLE_MAP = "$PWD\bundle\copy-map.json"
hutch electrobun build --env=stable

Test the zip, not build/stable-win-x64/: 3D Tiled-Setup.exe there has no .installer folder, and 3DTiled/bin/launcher.exe is only the installer stub, so both fail with an invalid installer error.

Why the Electrobun version is pinned to a beta

2.0.1 — the current stable — cannot package on Windows. It pins cottontail 0.5.0, whose windows-x64 release ships only cottontail.exe and not the cottontail-core binary that its own build step then copies, so electrobun build dies with CopySourceMissing. 2.0.2-beta.20 resolves cottontail 0.7.0-canary.2, which does ship it. Both dev and build were verified on that pin; revisit when 2.0.2 ships stable.

Signing — configured, not done

Signing is opt-in and off by default, because turning it on without credentials makes the build fail, which is better than silently shipping an unsigned app:

  • macOS — set ELECTROBUN_CODESIGN=1 (and ELECTROBUN_NOTARIZE=1) on a machine with a Developer ID in its keychain plus notarization credentials in the environment. That also switches the DMG on.
  • Windows — Electrobun exposes no signing option. Authenticode is a post-build step against the produced .exe (signtool sign /fd sha256 /tr <timestamp-url> …).

This repo holds no code-signing certificate, so no signed artifact has been produced or tested — only the configuration and the unsigned installer above.

Why Electrobun rather than Tauri or Electron

Taurineeds a Rust + MSVC toolchain this repo has no other use for
Electron~200 MB dependency in a monorepo whose CI runs pnpm install on every build
Electrobun ✅small native host (Hutch + the Cottontail JSC runtime), official Windows 11 / macOS 14 / Ubuntu 24.04 support, toolchain lands in .hutch/ (gitignored)

apps/desktop is deliberately excluded from the pnpm workspace, so pnpm install --frozen-lockfile — which the Dockerfile and every CI job run — is entirely unaffected by the desktop app.

Process model

Two Node children, both reusing scripts already in this repo:

  • packages/tile-server/server.js — the middleware.
  • scripts/static-server.mjs over scripts/ — serves cesium-harness.html, so Preview in Cesium is a plain http:// page rather than depending on views:// query-string handling.

If a 3dtiled server is already listening on a candidate port (e.g. you have pnpm dev up), the app adopts it instead of starting a second copy, and leaves it running on quit.

The three tabs

Build

  • File… / Folder… open the native OS picker over RPC to the main process. It has to be the main process: a webview <input type=file> yields a sandboxed File with no real path, and the middleware needs an actual filesystem path. Two buttons rather than one because tree formats are a folder (Potree's cloud.js directory, a streamed-SOG LOD folder) and single-file formats are a file (.copc.laz, .rad, .lcc) — a single Windows dialog cannot offer both.
  • Builds the URL against /tiles/tileset.json, the front door that dispatches to /stream for cache=none|hierarchy and /convert for cache=full — so the UI never re-encodes that routing rule.
  • Place a local dataset on the globe applies &lon/&lat/&alt/&heading as a root ENU transform in the tileset, so every client gets the placement — not just the preview. Sources carrying their own georeference ignore them.
  • Test endpoint fetches the tileset and reports asset version, geometric error, bounding-volume kind, root children, and whether a root transform was applied — a fast sanity check before you paste the URL into a heavier client.
  • Preview in Cesium opens the stock-CesiumJS harness, which has zero viewer-side fix-ups: whatever it shows is what any 3D Tiles client gets from the tileset alone.
  • Hierarchy graph opens the hierarchy explorer — the viewer app's multi-tileset one when a viewer is reachable, otherwise the tile-server's own single-tileset page.

Export to a folder

The Export to a folder card below the URL writes a finished, static OGC 3D Tiles tree for that URL into a local folder: the far end of the materialization continuum, after cache=none | hierarchy | full. Any web server, S3 bucket or Cesium for Unreal can host the folder without this app. Pick an empty folder with Folder…, optionally a max depth (blank = every level), and press Export.

  • Two methods.
    • Crawl downloads the tileset URL above as served, so it works for any source. Options: max depth, and keep implicit tiling. Keeping implicit copies the .subtree files and templated tiles as they are, under implicit/<hash>/, and rewrites the templates and availableLevels. That gives smaller JSON, and needs an implicit-capable client.
    • Offline converter runs the per-format converter on the raw source. It is faster for the formats it covers and has their options: tiling (implicit/explicit), scale, GE scale, GE layer, splat centroids.
  • Packing. Both methods can also pack the result as .3tz and/or .3dtiles inside the folder (packages/convert/pack.js, shared with scripts/pack-3dtiles.mjs).
  • Equivalent CLI. The card shows the command doing the same thing, updated live: 3dtiled export … for a crawl, 3dtiled <source> <dir> --format … (plus the pack script) for the converter.
  • Links. Export and CLI docs, the middleware's home page, Open the live tileset in the demo viewer, and after an export Open copy in demo viewer. The last two use the viewer's ?load=<url> start parameter.
  • Run in: tile-server or CLI subprocess. The card lets you pick where the export runs. The view never does the work itself.
    • tile-server (default): POST /export, polled with GET /export/{id}. The server is already running with every adapter loaded. It reports structured progress (tiles found and written, bytes), the job survives switching tabs, and the server serves the result back for Preview exported copy and Open copy in demo viewer. Cancel stops a crawl and leaves a partial copy. An offline convert runs inside the server and can't be stopped part-way.
    • CLI subprocess: the app's main process runs exactly the Equivalent CLI command (node packages/convert/cli.js … from the repo root). A convert that asked for packing is followed by node scripts/pack-3dtiles.mjs. The command is passed as an argument list, never through a shell. The main process only accepts those two scripts, and refuses serve and the -- converter passthrough. The export runs in a separate process, so a crash or a memory blow-up in a huge conversion can't take the tile-server down. The code is the same as the server's. The status line is the CLI's last output line (its \r progress redraws are collapsed), and a collapsible panel shows the last 200 lines. Cancel kills the process in both methods, and on Windows the whole process tree (taskkill /T). The preview buttons aren't offered, because the server has no job for this folder; Open folder still works.
    • Which is better today? Use the tile-server for everyday exports: you get richer progress, the preview links, and no second Node process. Use the CLI subprocess for very large conversions, or when you want the export isolated from the server you're using. With the offline converter it doesn't need the server at all, so it keeps going if the server is restarted. A crawl still downloads the tileset URL that the server serves, so a crawl still depends on the server.
    • The CLI option needs the desktop host. When the view is opened in a plain browser, it's disabled with a note.
  • What gets written. It works on the URL, not the source format, so every input the Build tab accepts is exported the same way: a local or remote raw dataset through /stream or /convert, as well as mosaics and proxied hosts.
    • Every tileset JSON (the root and each external tileset) is rewritten with relative uris.
    • Every content file is downloaded as is; nothing is re-encoded.
    • An implicit tiling is expanded to explicit first. The copy must not depend on URL templates whose query strings point back at this server, and with a depth cap only the kept levels' subtrees are read.
  • Layout. tileset.json, then tilesets/*.json for externals and tiles/<xx>/<name> for content, sharded by two hex digits of a hash so a million tiles never share a directory. Names keep the source file name, taken from the wrapped url= for /proxy and /stream URLs, and get a short hash prefix only when two sources share a name.
  • Failures. Files that fail after three attempts are listed in export-report.json, along with counts, bytes, timings and the source URL. Their uris stay in place, so the tileset remains structurally valid.
  • Afterwards. Open folder opens it in the OS file manager, and Preview exported copy loads the copy in the stock Cesium harness, served back by the tile-server from that folder (/export/{id}/files/…), i.e. exactly what a static host would serve.
  • When it's off. Writing to disk is off unless the server was started with TILED_ALLOW_EXPORT=1, and it answers loopback clients only. The app sets the flag on the server it starts; an adopted pnpm dev server keeps its own setting, and the card says so instead of failing on the first click.

Measured, through the app's own server:

SourceDepthFilesSizeTime
Iconem Geghard, Potree via /tiles (implicit, expanded)3488.6 MB5.3 s
Iconem Geghard via /stream (CLI)411125.0 MB22 s
Aerometrex Brooklyn, remote explicit b3dm (CLI)36920.3 MB11 s
/aggregate?set=aerometrex (Brooklyn + Hudson Yards, proxied)33 tilesets + 10 files1.0 MB1.6 s
Geghard, crawl, keep implicit + .3tz (desktop UI)381 files + 8.6 MB .3tz8.6 MB4.9 s
Potree lion_takanawa, offline converter, --tiling explicit + .3tz (desktop UI)all169 files + 7.9 MB .3tz15.7 MB42.5 s

The implicit copy renders from a static server in Cesium (48 tiles) and in 3DTRjs, and its .3tz renders through /3dtiles-self-contained (48 tiles).

The exported Geghard and Brooklyn folders were then served by a plain static server and rendered in the viewer (48 and 42 tiles). The same exporter is on the CLI as 3dtiled export.

Library

Datasets you want to keep: local paths and remote URLs, each stored as its source plus options rather than a built URL, so an entry can be re-opened into Build and edited (and so a future change to URL building doesn't strand the library).

Refresh stats walks every saved entry through /stats and caches tiles / depth / leaves on the row. Entries that hit the node budget are shown with ≥ and "node budget reached" in amber — the numbers are lower bounds, and 2 000 tiles, depth 8 otherwise reads as a real shape when it is only where counting stopped. The walk is sequential on purpose: it fans out to arbitrary third-party hosts, and firing 30 at once is precisely the unbounded-concurrency pattern that /aggregate had to fix.

The library lives in the webview's localStorage. That makes it durable per install with no RPC, no file-permission prompt and no migration story — but it is not shared with the CLI or the web viewer, and clearing the app's webview data clears it. Export / Import round-trip it as JSON, which is also the seam along which it would move to a real file if it ever needs to be shared.

Samples

One or more real, publicly-hosted datasets per source format — COPC, Potree, streamed SOG, LCC, RAD, I3S — with the origin credited on each row. Load drops one into Build with the right format already selected; Save adds it to the library. The URLs are copied from the viewer app's own FORMATS preset table, so the desktop app demonstrates exactly the sources the rest of the project is tested against.

The row icons are generated format glyphs, not thumbnails. A real preview would mean loading each tileset in a hidden Cesium view, which is far too expensive for a list; the glyph encodes the one thing knowable without fetching anything — whether it is a point cloud, splats or a mesh.

Window sizing, and why the width is conservative

The view measures its content and asks the host to resize (fitWindow over RPC) — only the view can measure, only the host can resize. Height auto-grows so a long tab needs less scrolling; it never shrinks below the default, so switching back to a short tab doesn't collapse a window you're using.

Width is deliberately left generous rather than fitted, because the scale factor is not observable from inside the app. Measured on the same machine, same code, two runs:

  • one run laid out at 1:1 — innerWidth: 1104, devicePixelRatio: 1, content 744 px, text at normal size, with empty space to the right;
  • another rendered the same layout ~1.5× larger and clipped at ~747 px.

That second case is Windows DPI virtualization: the launcher is DPI-unaware, so the compositor scales its output and only viewport ÷ scale CSS px are ever on screen. Both the webview and the host report numbers that agree with each other and are wrong about what's visible — devicePixelRatio says 1. So the app cannot compute the correction, and fitting the width to the content would silently clip it whenever virtualization is active. A little dead space on an unscaled display is the price of never losing the right-hand column.

The same constraint is why the content column is a fixed 720 px. Widening it to 1000 px did clip the Browse buttons off-screen, and no browser-based preview reveals this — only running the real app.

Dark-theme native controls

:root { color-scheme: dark } is load-bearing, not decoration. Without it WebView2 renders <select> drop-down popups, scrollbars and form controls with the light default — a white popup of pale options over a dark app, effectively unreadable. Styling option alone does not fix the popup on Windows, because the popup is an OS window rather than page content.

VIEWER_URL / DOCS_URL win if set; otherwise the app probes for a locally-running viewer (:3000) and docs (:3002/docs) and falls back to the public deployment. A developer with pnpm dev up gets their build; someone who installed only this app still gets working links instead of a dead localhost URL.

Not done yet

Packaging, signing and auto-update; pushing an export to remote storage (S3, Azure, the Iconem platform) rather than a local folder, and packing it as .3tz from the app (node scripts/pack-3dtiles.mjs <folder> <name> does it today); and bundling Node so the app runs without a repo checkout — it currently locates and runs the tile-server from the surrounding repo. Library entries are per-install, as above.

On this page