Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

.nornir/map3d-guide.md

map3d — the 3D spatial subsystem — guide

Class: guide · audience: a techie driving facett-map3d, facett-map3d-flight, or facett-model3d. The what is in map3d-design.md; the why in map3d-reasoning.md. This file is the workflow.

f0 — the end-to-end workflow (the whole)

  1. Get Ways (real OSM via facett_osm::read_ways, or a synthetic fixture).
  2. Build a Map3D (Map3D::from_ways / from_geometry / synthetic) — this runs the extrusion pipeline once and picks the CPU or GPU RenderBackend.
  3. Drive it: either paint it (facett_core::Facet::ui), or drive it headless through Msgs via facett_core::harness and read state_json().
  4. If you're building the planetary flight-sim layer instead of the embeddable map, drive facett_map3d_flight::FlightCamera + FloatingOrigin + the tile planner directly (it is a CPU library, not yet a Facet).
  5. For a model viewer (not a map), use facett_model3d::Model3D the same way.

f1 — map3d: building, backends, the render-backend seam

Build a 3D map

use facett_map3d::Map3D;
use facett_core::Facet;

// Synthetic (znippy-rabbit skyline), no data needed:
let mut city = Map3D::synthetic("my city");

// Real OSM data:
let ways = facett_osm::read_ways("ways.parquet", 0, &facett_core::Theme::default())?;
let mut city = Map3D::from_ways("liechtenstein", &ways, 0.18);

// Real OSM data, auto-subset to the densest town + auto-framed:
let mut city = Map3D::from_geometry("liechtenstein", &ways);

egui::CentralPanel::default().show(ctx, |ui| { city.ui(ui); });

The korp layer seam

facett_map3d::load_layers(&ways) classifies a flat Way list into FeatureLayers { buildings, streets, landuse, water }. Call .into_map3d(title) to build a Map3D directly from the layered bundle — the same entry a korp connector will plug into later:

use facett_map3d::load_layers;
let city = load_layers(&ways).into_map3d("korp city");

Choosing / swapping a RenderBackend

Map3D picks CpuPainterBackend by default. Enable the wgpu feature and call with_gpu(true) to swap in WgpuVelloBackend:

facett-map3d = { path = "...", features = ["wgpu"] }
let city = Map3D::synthetic("city").with_gpu(true);

To drive a scene through a backend directly (headless, no Map3D widget) — the same conformance seam tests/backend_conformance.rs uses:

use facett_map3d::{CpuPainterBackend, NullBackend, RenderBackend, RenderScene, Viewport, SceneFlags, Map3D};

let m = Map3D::synthetic("probe").with_clock(1.0);
let scene = RenderScene {
    mesh: m.mesh(),
    water: &Default::default(),
    camera: m.render_camera(),
    sun: m.sun(),
    viewport: Viewport::from_rect(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 300.0))),
    bg: egui::Color32::BLACK,
    flags: SceneFlags::default(),
    clock_s: 1.0,
};
let cpu_stats = CpuPainterBackend.analyze(&scene);
let null_stats = NullBackend.analyze(&scene);
assert_eq!(cpu_stats.visible_triangles, null_stats.visible_triangles);

A new backend only needs to implement caps() and analyze() — paint() defaults to the CPU painter, so it draws something on day one; override paint() once the real device pipeline exists.

Reading altitude / LOD / water

let tier = city.lod_tier();          // Ground | Low | Mid | High
let m = city.altitude_m();           // real-world metres, calibrated from the data
let on = tier.reflective_water();    // true only below ~300 m (with hysteresis)

city.state_json() surfaces altitude_m, lod_tier, reflective_water, camera (full pose), visible_triangles, fps, used_gpu — the introspection a robot-UI / test-matrix consumer reads.

Headless driving (the harness seam)

use facett_core::harness;
use facett_map3d::{Map3D, Msg, ViewPreset};

let mut m = Map3D::synthetic("x").with_clock(1.0);
let model = harness::snapshot(&mut m, [
    Msg::Preset(ViewPreset::Iso),
    Msg::Dolly { scroll: -2.0, cursor: None },
    Msg::Settle,
]);
assert!(model.distance > 0.0);
assert!(harness::headless_render(&mut m).drew());

Every gesture (drag, wheel, keyboard, preset button) is a Msg — the live canvas and a scripted test reach the identical transitions.

The GPU-fallback self-test (offscreen render)

A GPU render can't be checked from a return value, so examples/selftest.rs renders offscreen and counts lit pixels:

cargo run -p facett-map3d --example selftest --features testmatrix

Standalone demo bins

cargo run -p facett-demo --bin osm3d_demo                          # synthetic city
FACETT_OSM_PARQUET=/tmp/li_geo/ways.parquet \
    cargo run -p facett-demo --bin osm3d_demo --release            # real OSM

facett-demo/src/osm_tabs.rs wires the same Map3D::synthetic / from_geometry constructors into the shared facett-demo deck as the "3D" tab.

f2 — driving map3d-flight

facett-map3d-flight is a pure-CPU library today (no Facet/window yet) — you drive it directly:

use facett_map3d_flight::{FlightCamera, WorldPos, FlyInput, tiles_in_view, InMemoryTileSource, plan_tiles};

let mut cam = FlightCamera::free_fly(WorldPos::from_lonlat(18.06, 59.33, 500.0)); // Stockholm, 500 m
cam.integrate(&FlyInput { forward: 1.0, ..Default::default() }, 0.1);
let origin = cam.make_origin(128.0);          // f64 -> small f32 rebase point
let eye = cam.eye_relative(origin);           // [f32; 3], jitter-free near the camera

let in_view = tiles_in_view(origin, 12, 5_000.0); // nearest-first Sweden tile keys

Drive the atmosphere the same way, and bridge it onto the shared SunSky the map already knows how to render:

use facett_map3d_flight::{Atmosphere, atmospheric_sunsky};
use facett_map3d::SunSky;

let atmos = Atmosphere::earth();
let (sky, horizon_tint) = atmospheric_sunsky(SunSky::noon(), &atmos, 8000.0, 12.0);

Tile loading is a pure planner + a pluggable source — swap InMemoryTileSource (tests / small preload) for DirTileSource::new(root, theme) (on-disk {z}_{x}_{y}.parquet) without touching plan_tiles.

f3 — driving model3d

use facett_model3d::Model3D;
use facett_core::Facet;

let mut skull = Model3D::skull("status").with_status(0.7); // 1.0 green .. 0.0 red
egui::CentralPanel::default().show(ctx, |ui| { skull.ui(ui); });
skull.set_status(0.3); // programmatic update, same Msg path as the slider

Load an arbitrary glTF instead of the bundled fixture:

let bytes = std::fs::read("/path/to/model.glb")?;
let mut model = Model3D::from_glb("my model", &bytes)?;

Demo bins:

cargo run -p facett-model3d --example skull_demo   # spinning skull + status slider
cargo run -p facett-model3d --example view_glb -- /path/to/model.glb

Headless driving

use facett_core::harness;
use facett_model3d::{Model3D, Msg};

let mut m = Model3D::skull("x").with_clock(0.5);
let model = harness::snapshot(&mut m, [Msg::SetStatus(0.1), Msg::SetTilt(0.4)]);
assert!((model.status - 0.1).abs() < 1e-6);
assert!(harness::headless_render(&mut m).drew());