Skip to main content

Perspective cameras and projection boundaries

Shared perspective projection, screen rays, ground intersection and current script boundaries.

The real SqsMap planar scene camera now uses the lightweight shared camera module for its basis, projection and rays. Rendering and picking share focal length and coordinates. The existing field of view is 45 degrees, with pitch from 0 to 65 degrees; free orbit retains its existing pose semantics. The math builds for native and WASM without GPU, UI or HTTP dependencies.

Rust perspective calculations

use sqs_map::camera::perspective::PerspectiveCamera;
let camera = PerspectiveCamera::new([1000., 2000., 50.], 800., 600., 2., 45., 30.)?;
let pixel = camera.project([1010., 2020., 50.]).ok_or("Behind camera")?;
let ground = camera.ground_at([pixel[0], pixel[1]]).ok_or("No ground intersection")?;

Focus, world coordinates and resolution must share render-space units. Viewport and pixel coordinates must both use logical or physical pixels. To change DPR while preserving the physical view, scale viewport dimensions up and resolution per pixel down by the same ratio. The third focus coordinate specifies the ground datum. A sky ray has no ground intersection; it is never presented as a z=0 hit. Back-facing points and non-finite inputs have no projected result.

Shared projection switching

map.setProjection({type:'globe'}) preserves center and bearing, normalizing the target zoom to 0–24. A newly created native map can therefore enter globe from its negative planar whole-world fit. Returning to planar keeps the actual spherical zoom. Direct spherical zoom commands below 0 or above 24 still return argument errors. Nonzero spherical padding still rejects explicitly, preserving the original camera. Events describe actual zoom changes using the existing start/subsequent snapshot rules.

The complete projection HTML includes every zoom, globe/flat, moveend, error and destroy handler. The shared script uses the same MapLibre-shaped calls. Syntax follows the public MapLibre camera API; the zoom limits and 45-degree field of view are current SqsMap rules, with no promise of pixel-identical output from other engines.

Current pitched-camera boundary

Rust scene rendering already uses the shared perspective mathematics. The explicit Rust perspective camera and binding are available as described below. Production native C and embedded QuickJS now render nonzero Mercator pitch; see native pitched maps. WASM/main/strict Worker now render shared pitched scenes; the desktop script adapter is connected; see desktop pitched maps. styleRasterPitchedSources remains false. These development hosts connect the common pitch API; older npm or native releases require matching builds. Spherical pitch and spherical animation remain available. Devices and formal releases require separate acceptance.

The example loads its background through the published style URL. It enables buttons when the public basemap status is ready; command confirmation alone does not mean resources have finished downloading.

The background style explicitly sets maxzoom to 33, covering planar zoom 32 and spherical zoom 24. A layer maxzoom excludes its own endpoint. The omitted default is 24, so that background layer would disappear exactly at zoom 24.

Rust alignment of pitched cameras and padding

PitchedMercatorCamera uses logical pixels for padding and anchors. It solves the actual camera focus from the ground ray at the padded center. Bearing, explicit world copies and DPR use the same focus; asymmetric padding is not a two-dimensional shift applied after perspective projection.

use sqs_map::camera::{CameraPose, Padding, Viewport};
use sqs_map::camera::pitched_mercator::PitchedMercatorCamera;
use sqs_map::basemap::Camera3d;
let camera = PitchedMercatorCamera::new(Viewport::new(800., 600., 2.)?,
    CameraPose {center: [181., 40.], zoom: 8., pitch: 40., bearing: 30.,
        padding: Padding {left: 100., right: 20., top: 40., bottom: 10.},
        ..Default::default()})?;
let pixel = camera.project([181., 40.])?;
let location = camera.unproject(pixel)?;
let anchored = camera.with_anchor([181.01, 40.01], [440., 315.])?;
let scene_camera = Camera3d::from_pitched_mercator(anchored)?;

The mathematical camera supports pitch 0–65 degrees, zero roll and the existing common camera calculation zoom range. physical_perspective scales viewport dimensions by DPR and resolution per pixel inversely, preserving the physical view. Sky, non-finite inputs and ground hits outside the Mercator latitude domain fail explicitly. Anchors cannot manufacture success by clipping the camera center latitude.

The actual Camera3d adapter currently requires zoom 0–24 and an actual focus within the Mercator domain; it refuses implicit clamping by the legacy renderer. Its viewport is logical; the host handles render-target scaling to the final physical viewport. Padded Rust perspective projection has been checked against real three-dimensional pixels. Native C and embedded QuickJS nonzero planar pitch is connected; WASM/main/strict Worker are connected; the desktop adapter is connected; styleRasterPitchedSources remains false.

Perspective bounds, fitting and renderer readback

Rust PitchedMercatorCamera now provides bounds, camera_for_bounds, raw and from_raw. These are shared mathematical building blocks for connecting the common camera; production C, embedded QuickJS, WASM and main/strict Worker now connect nonzero planar pitch; the desktop facade is connected.

use sqs_map::camera::{CameraPose, FitBoundsOptions, Padding, Viewport};
use sqs_map::camera::pitched_mercator::PitchedMercatorCamera;
let camera = PitchedMercatorCamera::new(Viewport::new(800., 600., 2.)?,
    CameraPose {pitch: 50., ..Default::default()})?;
let pose = camera.camera_for_bounds([179., 39., -179., 41.],
    FitBoundsOptions {bearing: 30., offset: [20., -10.],
        padding: Some(Padding {left: 80., right: 20., top: 30., bottom: 10.}),
        ..Default::default()})?;
let fitted = camera.with_pose(pose)?;
let visible = fitted.bounds()?;
let raw = fitted.raw()?;
let restored = PitchedMercatorCamera::from_raw(raw, 2., pose.padding, pose.pitch)?;

The southwest/northeast envelope uses west, south, east, north. If east is less than west, the target crosses the date line; 181 degrees remains that explicit world copy. Fitting retains the current pitch and uses the supplied bearing, padding and logical-pixel offset. It places the projected Mercator midpoint at the padded screen center plus offset and finds the largest zoom that keeps all four corners inside the padded rectangle. Its zoom limits default to 0–24. Degenerate point bounds use max_zoom. Invalid envelopes, padding, offsets or impossible fits return errors without changing the original camera. Near the latitude boundary, a valid finer-zoom fit remains available even when coarse zoom would require clipping the public center.

Visible bounds clip the ground footprint polygon against both Mercator latitude edges. Edge intersections retain their actual longitudes; clamping corner latitudes separately would include longitudes outside the visible ground. Sky points still have no unproject result. raw uses physical pixels and scale; from_raw requires the original logical padding and pitch separately, reconstructs the public center and rejects unsupported centers rather than silently clipping them. Perspective readback uses the checked mathematical zoom range −32–32; legacy 2D readback outside that range remains a separate PlanarCamera behavior.

The common naming follows the MapLibre camera API. SqsMap's retained pitch, checked latitude domain and exact fitting rules above are its current Rust semantics. Both 30- and 65-degree fits have been verified against actual scene pixels at DPR 2, including all four date-line corners. The existing Camera3d adapter still requires zoom 0–24 and an unclipped actual focus. Native C and embedded QuickJS rendering and ground queries are now connected; WASM/main/strict Worker are connected and the desktop facade is connected; styleRasterPitchedSources remains false.

Shared Rust camera commands and animation

A Rust host can opt into shared perspective calculations with PlanarCamera::from_perspective. The ordinary PlanarCamera constructors still reject nonzero pitch; connecting the math does not certify that a host draws a matching view. An opted-in camera routes project, unproject, anchors, bounds and fitting through the same checked PitchedMercatorCamera, and retains its perspective capability after resetting pitch to zero. Nonzero pitch cannot switch to geographic projection. A zero-pitch switch to geographic leaves the perspective math mode.

use sqs_map::camera::{CameraPose, PlanarCamera, Viewport};
use sqs_map::camera::pitched_mercator::PitchedMercatorCamera;
use sqs_map::camera::motion::CameraMotion;
let camera = PlanarCamera::from_perspective(PitchedMercatorCamera::new(
    Viewport::new(800., 600., 2.)?,
    CameraPose {center: [181., 40.], zoom: 8., pitch: 20., ..Default::default()})?);
let action = CameraMotion::default().prepare(camera, "easeTo", &[
    serde_json::json!({"pitch":60., "linear":true, "duration":1000.})], 0.)?;
let middle = action.next.advance(camera, 500.)?;
assert_eq!(middle.camera.pose().pitch, 40.);

Motion planning retains pitch at every sample. Pitch changes emit pitchstart, pitch and pitchend using historical start and actual intermediate/end snapshots. Cancellation closes the pitch sequence at the current view. An external pitch change interrupts the animation instead of being ignored. Around anchors remain at their logical screen point while pitch, zoom and bearing change. A host clock remains monotonic; the library supplies no timer.

For actual drawing, CameraBinding::connect_perspective explicitly connects a Mercator renderer host. Its prepare/advance methods return immutable candidates; candidate_scene_camera provides the exact camera after renderer f32-scale quantization, so resources can be prepared before commit. After commit, scene_camera returns that same actual camera. Rejecting a candidate, unsupported scene range, quantized latitude overflow or stale viewport/projection preserves the old visible camera. A host still reserves its output/event resources before committing. It draws the returned scene camera at the actual render-target DPR; keeping pitch only in application state is insufficient.

The binding's nonzero-pitch scene requires zoom 0–24 and an unclipped Mercator focus. Standalone mathematical getMinZoom/getMaxZoom still describe the shared calculation range −32–32; the connected host must apply the scene constraint before writing. Pitched flyTo defaults minZoom to 0 and rejects a negative minZoom, including endpoint ranges that cannot use that floor. The planner validates 65 preflight samples and the target; this finite preflight does not prove every instant of a nonlinear path. Every actual advance is checked again before mutation. Sky, non-finite and invalid ground positions are still errors.

sqs.scaleAt uses an analytic horizontal ground-ray derivative for pitched views. Its result remains an approximate local spherical ground scale in metres per CSS pixel, independent of DPR. It is not a terrain-distance measurement. The calculation avoids subtracting large absolute Mercator positions at high zoom.

The complete Rust renderer host includes device creation, every camera call, candidate resource upload, commit, actual three-dimensional drawing, pixel checks and event output. In the matching core checkout run cargo run --locked -p sqs-map --example shared_pitched_camera. It uses public APIs, has no UI/network dependency and fails when no renderer adapter is available. The example and binding have been checked against actual DPR-2 pixels at 32.5 and 65 degrees.

This stage supplies the shared Rust host contract. Production native C and embedded QuickJS now use connect_perspective, actual rendering and ground queries. WASM/main/strict Worker are connected, the desktop adapter is connected, and styleRasterPitchedSources remains false. Those bindings and device acceptance continue in the next stages.

Real pitched drawing in the shared basemap host

HostBasemap and BasemapSession now provide poll_pitched and snapshot_pitched_rgba for a checked PitchedMercatorCamera. They reuse the existing scene, text, imagery and business-layer depth target, with no separate renderer or downloader. The logical camera controls style expressions, ground coverage and tile detail. Only the draw lens and target dimensions scale with DPR; physical zoom does not change layer minzoom/maxzoom evaluation.

scene_only draws business layers without a remote style, using a caller-selected RGBA background and issuing no network requests. Its internal background does not publish a caller-owned style/source/layer API. This complete Rust drawing function receives the map and GPU handles from its host. Retaining HostBasemap across frames reuses resources.

use sqs_map::basemap::{host::HostBasemap, session::BasemapGpu};
use sqs_map::camera::{CameraError, CameraPose, Viewport};
use sqs_map::camera::pitched_mercator::PitchedMercatorCamera;
fn draw(map: &sqs_map::MapRenderer, gpu: BasemapGpu<'_>)
    -> Result<Option<(u32, u32, Vec<u8>)>, CameraError> {
    let camera = PitchedMercatorCamera::new(Viewport::new(160., 120., 2.)?,
        CameraPose {center: [181., 40.], zoom: 19., pitch: 65.,
            ..Default::default()})?;
    let mut host = HostBasemap::scene_only(camera, [255, 255, 255, 255])?;
    host.poll_pitched(gpu, camera, map, &[])?;
    Ok(host.snapshot_pitched_rgba(camera))
}

For a remote style, create HostBasemap with start, serve requests/respond using the caller's downloader, and continue poll_pitched. The returned delay controls the next poll; success does not certify completed resources or background geometry. snapshot_pitched_rgba reads only a frame actually drawn for the requested pose and DPR, refusing planar, globe, different-pose or invalidated basemap targets. Interactive streaming can remain incomplete. Poll after externally changing business data or imagery; camera equality alone cannot certify an external data update.

Scene content zoom is currently 0–24. Physical lens zoom is logical zoom plus log2(DPR) and must lie within 0–26; physical dimensions must be integers of at least one pixel. Unrepresentable downsampling, high-DPR/high-zoom combinations and fractional physical dimensions fail before changing the download queue or frame. The standalone mathematical Viewport still permits DPR 0.125–16, which does not certify every combination in the legacy scene renderer.

Flat style imagery is clipped to conservative ground coverage in f64 before rebasing into local f32 vertices. This fixes low-level whole-world tiles disappearing at high zoom from homogeneous depth precision loss. Crops retain both child and parent-fade UVs; globe rendering retains its curved mesh. Six actual frames check DPR 1/2, pitch 30/65 degrees, the 181-degree world copy, asymmetric padding, scene-only drawing and standard style imagery. This section describes the Rust basemap host. Production native C and embedded QuickJS now connect it too; see native pitched maps. WASM/main/strict Worker are connected; the desktop adapter is connected; styleRasterPitchedSources remains false.

WASM, main-thread and strict Worker maps now render actual shared pitched scenes; see web pitched maps. The desktop pitched facade is connected. High-DPI density, complete style/3D queries and legacy/cross-entry audits continue in later stages.