facett-core/src/render/camera.rs
//! **The shared L0 camera** — the seam where the two domain skins (map + graph)
//! meet one navigation model. It is the **superset** of the two cameras that exist
//! in the skins today:
//!
//! - **graphview's 2D pan/zoom** (`facett-graphview::model::Camera`): a world point
//! projects as `p * zoom + pan`. The arch/dep/release boards drive this.
//! - **map3d's 3D `OrbitCamera`** view math (`view_proj` / `view_space` /
//! `project_view` / `NEAR_PLANE`): a turntable orbit with a real perspective
//! transform.
//!
//! Both are reproduced here **bit-for-bit** (the `camera_seam` test goldens the
//! moved math against the originals) so a future renderer can drive map and graph
//! through one camera and a shared z-ordered layer stack. The skins keep their own
//! camera types this milestone (zero behavior change); this is the additive seam
//! the render kernel will consume.
//!
//! [`InputFeel`] (the per-OS **FEEL**: orbit/pan/dolly sensitivity + damping) lives
//! here now — it was in `facett-map3d::camera`; that module re-exports it back, so
//! map3d's public API and all its tests are unchanged.
// ── FEEL (moved from facett-map3d::camera; re-exported back there) ─────────────
/// How the camera should feel for this OS / input device — derived from
/// facett-core's look presets so a macOS trackpad orbits/pinches naturally while
/// a Windows wheel zooms in discrete steps.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct InputFeel {
/// Orbit radians per screen pixel of drag.
pub orbit_per_px: f32,
/// Pan world-units per screen pixel (scaled by distance at use site).
pub pan_per_px: f32,
/// Multiplicative dolly per wheel/scroll unit (e.g. 0.0015 ⇒ gentle).
pub dolly_per_scroll: f32,
/// Damping rate `k` in `1 - exp(-k·dt)`. Higher = snappier, lower = floatier.
pub damping: f32,
/// Trackpad two-finger scroll is "natural" (content follows fingers) — invert
/// the pan sign so a macOS trackpad pans the right way.
pub natural_scroll: bool,
}
impl Default for InputFeel {
fn default() -> Self {
// A solid mouse-and-wheel default (Windows-like).
Self {
orbit_per_px: 0.008,
pan_per_px: 0.0022,
dolly_per_scroll: 0.0015,
damping: 16.0,
natural_scroll: false,
}
}
}
impl InputFeel {
/// The macOS trackpad feel: a touch more sensitive, natural-scroll panning,
/// floatier damping (pinch-to-zoom reads as continuous).
pub fn macos() -> Self {
Self {
orbit_per_px: 0.009,
pan_per_px: 0.0024,
dolly_per_scroll: 0.0020,
damping: 13.0,
natural_scroll: true,
}
}
/// The Windows mouse feel.
pub fn windows() -> Self {
Self::default()
}
}
// ── 3D math helpers (mirror facett-map3d::camera, the goldened superset half) ──
/// A 3-vector helper — **the ONE definition**; `facett_map3d::camera::V3` re-exports it
/// (it used to be a verbatim twin there, retired 2026-08-22). All the math the camera
/// API needs, kept as a plain struct so the crate-facing type stays `{x, y, z}` and
/// deterministic; the heavy algebra (`View`'s look-at / projection / inverse) runs in
/// `glam` behind the `From` impls below. `dot` / `cross` / `scale` / `len` are
/// bit-identical to glam's scalar `Vec3` (same association order); `normalized` floors
/// the length at `1e-9` where glam would return a non-finite vector.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct V3 {
pub x: f32,
pub y: f32,
pub z: f32,
}
impl V3 {
pub const fn new(x: f32, y: f32, z: f32) -> Self {
Self { x, y, z }
}
#[allow(clippy::should_implement_trait)] // public by-value vector op; an `Add` impl would not replace the published method
pub fn add(self, o: V3) -> V3 {
V3::new(self.x + o.x, self.y + o.y, self.z + o.z)
}
#[allow(clippy::should_implement_trait)] // public by-value vector op; a `Sub` impl would not replace the published method
pub fn sub(self, o: V3) -> V3 {
V3::new(self.x - o.x, self.y - o.y, self.z - o.z)
}
pub fn scale(self, s: f32) -> V3 {
V3::new(self.x * s, self.y * s, self.z * s)
}
pub fn dot(self, o: V3) -> f32 {
self.x * o.x + self.y * o.y + self.z * o.z
}
pub fn cross(self, o: V3) -> V3 {
V3::new(
self.y * o.z - self.z * o.y,
self.z * o.x - self.x * o.z,
self.x * o.y - self.y * o.x,
)
}
pub fn len(self) -> f32 {
self.dot(self).sqrt()
}
pub fn normalized(self) -> V3 {
let l = self.len().max(1e-9);
self.scale(1.0 / l)
}
}
/// `V3` ↔ `glam::Vec3`, component-wise — a pure relabel, no arithmetic, so a value
/// round-trips bit-for-bit. This is the seam between the crate-API vector and the
/// algebra engine; the view core (`render::view`) takes and returns glam and the
/// cameras hand it `V3`.
impl From<glam::Vec3> for V3 {
#[inline]
fn from(v: glam::Vec3) -> Self {
V3::new(v.x, v.y, v.z)
}
}
impl From<V3> for glam::Vec3 {
#[inline]
fn from(v: V3) -> Self {
glam::Vec3::new(v.x, v.y, v.z)
}
}
/// A point projected into screen space + its camera-space depth (for sorting +
/// near-plane clipping). Mirrors `facett-map3d::camera::Projected`.
#[derive(Clone, Copy, Debug)]
pub struct Projected {
/// Pixel x.
pub x: f32,
/// Pixel y.
pub y: f32,
/// Camera-space depth (distance in front of the eye, +ve = visible).
pub depth: f32,
/// Whether the point is in front of the near plane (visible).
pub visible: bool,
}
// ── 2D pan/zoom half (mirrors facett-graphview::model::Camera) ────────────────
/// A 2D point in world space (caller-owned layout coordinates). Mirrors
/// `facett-graphview::model::Pos`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Pos {
pub x: f32,
pub y: f32,
}
impl Pos {
pub const fn new(x: f32, y: f32) -> Self {
Self { x, y }
}
}
/// The shared camera — the **superset** of the 2D pan/zoom and 3D orbit cameras.
///
/// In **2D mode** (the graph board) a world point projects as
/// `center + pan + p * zoom`, exactly as `facett-graphview::model::Camera`.
///
/// In **3D mode** (the orbit map) the eye sits on a sphere of `distance` around
/// `target` at `(azimuth, elevation)`, and `view_space` / `project_view` /
/// `view_proj` reproduce `facett-map3d::camera::OrbitCamera`'s math (the
/// `camera_seam` test goldens this).
///
/// The skins keep their own concrete camera types this milestone; `Camera` is the
/// additive seam a future renderer drives both domains through.
#[derive(Clone, Copy, Debug)]
pub struct Camera {
// ── 2D pan/zoom ──
pub pan_x: f32,
pub pan_y: f32,
pub zoom: f32,
// ── 3D orbit ──
/// Point the camera orbits / looks at.
pub target: V3,
/// Turntable yaw about +Y.
pub azimuth: f32,
/// Polar lift; `+PI/2` looks straight down, `0` is level.
pub elevation: f32,
/// Eye-to-target distance (the dolly radius).
pub distance: f32,
/// Vertical field of view (radians).
pub fov_y: f32,
/// Per-OS input feel (shared FEEL).
pub feel: InputFeel,
}
impl Default for Camera {
fn default() -> Self {
Self {
pan_x: 0.0,
pan_y: 0.0,
zoom: 1.0,
target: V3::new(0.0, 0.0, 0.0),
azimuth: 0.0,
elevation: 0.0,
distance: 3.2,
fov_y: 50f32.to_radians(),
feel: InputFeel::default(),
}
}
}
impl Camera {
/// The **view-space near plane** (camera-space depth, design units). Identical
/// to `facett-map3d::camera::OrbitCamera::NEAR_PLANE` — geometry with
/// `cz <= NEAR_PLANE` sits on or behind the eye and must be clipped before the
/// perspective divide.
pub const NEAR_PLANE: f32 = 0.02;
// ── 2D pan/zoom projection (mirrors graphview::model::Camera::project) ─────
/// Project a 2D world point to screen pixels (pan/zoom affine), the graph
/// board's transform. `p * zoom + pan` — `center` is folded into `pan` by the
/// caller (matching graphview), so this is the raw affine.
///
/// GFX_V2 item 6: delegates to [`view_2d`](Self::view_2d), i.e. to the *same*
/// [`super::view::View`] the 3D half uses, under the 2D constraints. Bit-exact
/// against the affine it replaced — the graph board and the terrain camera are
/// now one projection writer, not two.
#[inline]
pub fn project2d(&self, p: Pos) -> (f32, f32) {
let s = self.view_2d().project_ground([p.x, p.y], (self.pan_x, self.pan_y), 1.0);
(s.x, s.y)
}
// ── 3D orbit view math (mirrors OrbitCamera::eye/basis/view_*/project_*) ───
/// The bounded **far plane** — mirrors `OrbitCamera::far_plane` (`distance + 4`).
pub fn far_plane(&self) -> f32 {
const FAR_PAD: f32 = 4.0;
self.distance + FAR_PAD
}
/// **The unified view this camera's 3D half IS** (GFX_V2 item 6) — a
/// [`super::view::View`] in perspective mode. Every 3D method below delegates
/// to it, so this struct no longer carries a second copy of the projection.
#[must_use]
pub fn view(&self) -> super::view::View {
super::view::View::from_orbit(self.target, self.azimuth, self.elevation, self.distance, self.fov_y)
}
/// **The unified view this camera's 2D half IS** — the same type under the 2D
/// constraints. `project2d`'s `p*zoom + pan` affine is exactly this view's
/// orthographic projection with `pan` as the pixel centre, which is the whole
/// claim of GFX_V2 item 6 stated for the graph board.
#[must_use]
pub fn view_2d(&self) -> super::view::View {
super::view::View::ortho_2d_map([0.0, 0.0], [self.zoom, self.zoom])
}
/// The eye position, derived from `target` + spherical offset.
pub fn eye(&self) -> V3 {
let e = self.view().eye();
e.into()
}
/// Forward (eye → target), right, and up basis vectors of the view. The unified
/// core's closed form — an exact algebraic identity with the
/// `normalize(fwd × world_up)` look-at, but well-conditioned looking straight
/// down, where that cross product vanishes and silently mirrors east/west.
pub fn basis(&self) -> (V3, V3, V3) {
let (f, r, u) = self.view().basis();
(f.into(), r.into(), u.into())
}
/// A world point transformed into **view space** (`x=right, y=up, z=forward`,
/// relative to the eye). The near-plane clip operates here, before projection.
pub fn view_space(&self, p: V3) -> V3 {
let c = self.view().view_space(p.into());
c.into()
}
/// Project an already-**view-space** point to screen pixels with perspective.
pub fn project_view(&self, c: V3, center: (f32, f32), half_h: f32) -> Projected {
self.view().project_px(c.into(), center, half_h)
}
/// Project a world point to screen pixels with perspective (3D orbit).
pub fn project_view_world(&self, p: V3, center: (f32, f32), half_h: f32) -> Projected {
self.project_view(self.view_space(p), center, half_h)
}
/// The **view-projection matrix** for the GPU depth-tested path, column-major
/// `[f32; 16]` for a wgpu uniform.
///
/// GFX_V2 item 6: this was 35 lines of hand-assembled `P * V`, duplicated
/// verbatim in `facett-map3d::camera::OrbitCamera::view_proj`. Both copies are
/// gone — `super::view::View` is the one writer (LAW #5).
pub fn view_proj(&self, aspect: f32) -> [f32; 16] {
self.view().view_proj(aspect).to_cols_array()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// INJECT-ASSERT: the moved FEEL presets keep the exact constants the skins
/// rely on (a `with_os_feel` host expects these values verbatim).
#[test]
fn input_feel_presets_keep_their_constants() {
let w = InputFeel::windows();
assert_eq!(w, InputFeel::default());
assert!(!w.natural_scroll);
let m = InputFeel::macos();
assert!(m.natural_scroll, "macOS trackpad pans natural");
assert!(m.damping < w.damping, "macOS floatier");
assert!((m.orbit_per_px - 0.009).abs() < 1e-9);
assert!((w.orbit_per_px - 0.008).abs() < 1e-9);
}
/// INJECT-ASSERT (camera_seam, 2D): the shared camera's pan/zoom projection
/// matches graphview's `center + pan + p*zoom` affine exactly.
#[test]
fn camera_seam_2d_matches_graphview_affine() {
let cam = Camera { pan_x: 30.0, pan_y: -12.0, zoom: 2.5, ..Camera::default() };
let p = Pos::new(10.0, 4.0);
let (sx, sy) = cam.project2d(p);
// Reference: the graphview model affine.
let rx = p.x * 2.5 + 30.0;
let ry = p.y * 2.5 - 12.0;
assert!((sx - rx).abs() < 1e-6 && (sy - ry).abs() < 1e-6, "2D affine matches graphview");
}
/// INJECT-ASSERT (camera_seam, 3D): properties of the shared orbit math.
///
/// ## This test used to claim something it could not check — read before editing
/// Its doc comment said it computed the OrbitCamera formulas "with a hand-rolled
/// reference and assert[ed] bit-equality, so the seam is a faithful superset",
/// and GFX_V2 item 3's camera phase 2 was **deferred on the grounds that this
/// bit-equality was too brittle to touch**. Measured on 2026-08-01: it never
/// referenced `OrbitCamera` at all, and it could not — `facett-core` is *below*
/// `facett-map3d` in the dependency graph, so the type is not visible from here.
/// There is no hand-rolled reference in the body and no `assert_eq` on any
/// projection output; the assertions are properties (radius, centring,
/// foreshortening order, depth range), every one of which a *different* correct
/// camera would also satisfy. It could not have failed on drift, and the pose it
/// used (`azimuth = 0, elevation = 0`) is near-identity besides.
///
/// The real cross-crate guard now lives where both types ARE visible:
/// `facett-map3d/tests/gfx_v2_item6_parity.rs`, which asserts bit-equality
/// between `OrbitCamera` and this module at non-trivial poses. The properties
/// below are kept — they are worth having — but they are no longer *claimed* to
/// be a drift guard. Do not re-add that claim here; it cannot be honoured from
/// this crate.
#[test]
fn camera_seam_3d_holds_the_orbit_projection_properties() {
let cam = Camera {
azimuth: 0.0,
elevation: 0.0,
distance: 5.0,
target: V3::new(0.0, 0.0, 0.0),
fov_y: 50f32.to_radians(),
..Camera::default()
};
// eye on a sphere of radius distance.
let r = cam.eye().sub(cam.target).len();
assert!((r - 5.0).abs() < 1e-4, "eye radius == distance");
// project the target → screen centre; near point projects wider (perspective).
let center = (400.0, 300.0);
let mid = cam.project_view_world(V3::new(0.0, 0.0, 0.0), center, 300.0);
assert!(mid.visible);
assert!((mid.x - center.0).abs() < 1.0 && (mid.y - center.1).abs() < 1.0, "target → centre");
let near_pt = cam.project_view_world(V3::new(0.5, 0.0, 2.0), center, 300.0);
let far_pt = cam.project_view_world(V3::new(0.5, 0.0, -2.0), center, 300.0);
assert!(
(near_pt.x - center.0).abs() > (far_pt.x - center.0).abs(),
"nearer projects wider (perspective, matching OrbitCamera)"
);
// a point behind the eye is culled (NEAR_PLANE matches OrbitCamera's).
let behind = cam.project_view_world(cam.eye().add(cam.basis().0.scale(-1.0)), center, 300.0);
assert!(!behind.visible, "behind-eye culled");
assert_eq!(Camera::NEAR_PLANE, 0.02);
// view_proj orders depth (nearer = smaller normalised clip-z) in [0,1].
let m = cam.view_proj(800.0 / 600.0);
let apply = |p: V3| {
let v = [p.x, p.y, p.z, 1.0];
let mut o = [0.0f32; 4];
for row in 0..4 {
let mut s = 0.0;
for col in 0..4 {
s += m[col * 4 + row] * v[col];
}
o[row] = s;
}
o
};
let nr = apply(V3::new(0.0, 0.0, 2.0));
let fr = apply(V3::new(0.0, 0.0, -2.0));
assert!(nr[3] > 0.0 && fr[3] > 0.0, "both in front");
let zn = nr[2] / nr[3];
let zf = fr[2] / fr[3];
assert!(zn < zf, "nearer smaller depth");
assert!((0.0..=1.0).contains(&zn) && (0.0..=1.0).contains(&zf), "depths in [0,1]");
}
}