-- RENDER DIST: how much of the map the orbit rungs bother to draw.
--
-- lib/Diorama cuts a headset's model out of the world with a box the
-- player opens and closes with their hands. This is the same cut on the
-- flat screen, asked the other way round: not "how much world do I want to
-- be holding" but "how much world can this camera actually SEE" -- so that
-- nothing off screen is drawn, and nothing on screen is missing.
--
-- THE FOOTPRINT IS NOT THE WINDOW. That is the whole difficulty, and the
-- first cut of this file got it wrong: it took the flat game's own vw-by-vh
-- rectangle about the view centre, which is exactly right at 0 degrees and
-- wrong at every rung the mode actually has. Tilt the camera and the ground
-- it frames stops being that rectangle and becomes a TRAPEZOID -- reaching
-- much further north (the far edge of the frame is further away, so it
-- covers more ground per pixel), flaring much wider there for the same
-- reason, and pulling IN at the south edge, which is nearer the eye than
-- the focus is. A window-sized box cuts the north field and both far
-- corners off a world that is plainly on screen: gaps at the top and down
-- the sides, with sky showing through them.
--
-- So the footprint is derived from the camera rather than guessed. The
-- orbit is one number (see Voxel3D.viewProjection): eye at distance
-- FOCAL*vh, pitched `a` off straight down, looking at the view centre,
-- with a fov chosen so a straight-down camera frames exactly vh. Cast the
-- frame's own corner rays at the ground plane and the trapezoid falls out
-- in closed form -- see footprint() for the derivation, which is three
-- lines of algebra and no tuning at all.
--
-- The CUT is still a rectangle (the shader's box kind), so what is stored
-- is the trapezoid's bounding rect: never narrower than the picture, so it
-- can never take a bite out of it. It is off-centre in z, because the
-- trapezoid is -- the box sits north of the view centre at every rung but
-- the top one.
--
-- THE HORIZON IS WHY THERE IS A ROW AT ALL. Past about 63 degrees (exactly
-- atan(2*FOCAL), where the top of the frame lifts off the ground plane) the
-- trapezoid stops being finite: the camera can see to the horizon, and "all
-- the ground on screen" is an infinite answer. Something has to name a
-- distance, and that is what RENDER DIST names -- MAX_REACH view heights,
-- times the row's own multiplier. Below that pitch the row does nothing to
-- the picture at all, because the honest footprint is already smaller than
-- the reach; at 75 it is what decides where the world ends.
--
-- AND IT PAYS FOR ITSELF. A cut this file can describe in world pixels is
-- one VoxelScene can test a whole neighbour map against BEFORE drawing it
-- -- see shows() -- so a connected map that lands entirely outside the box
-- costs no terrain mesh, no water, no grass, no flowers and no shadow
-- pass. Most of the win is at the HIGH rungs, where the camera is nearly
-- overhead and the footprint is barely bigger than the window; at 75 it
-- sees half the region and skips almost nothing, which is the truth about
-- that rung rather than a shortcoming of the test.
--
-- WHAT IT DOES NOT TOUCH. The free-roam rungs (1ST and 3RD): the player is
-- standing IN the world there, and a box around a walking eye is a
-- fog-of-war circle rather than a model on a table. The rung tween into
-- them opens the box out with the blend rather than dropping it on a
-- frame, so diving into a head does not pop the sides away. A headset's
-- frame is not touched either -- lib/Diorama owns the cut there, and VR's
-- STANDARD rungs are a tabletop already.

-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...

local ModSetting = V.require("ModSetting")

local ViewBox = {}

ViewBox.KEY = "viewbox"
-- RENDER DIST rather than a V- name like the rows either side of it: what
-- the player is choosing is HOW MUCH WORLD gets drawn, which is the thing
-- every game with this row calls a render distance. The mode read -- the
-- slab with sides -- is what that buys, not what the row is asking.
ViewBox.LABEL = "RENDER DIST"

-- The ladder, as a multiplier on the footprint the camera actually frames.
-- Rung 0 is FIT and it is the default, and FIT means exactly that: the
-- ground on screen and no more. It cannot open a gap -- 1.0 times the
-- honest answer is the honest answer -- so the only thing the wider rungs
-- buy below the horizon pitch is margin around a cut nobody can see.
--
-- Where they DO decide the picture is at 75, and at the tween rungs either
-- side of it, where the footprint is infinite and MAX_REACH below stands in
-- for it: there the ladder is a real render distance and FIT is the closest
-- horizon of the four.
--
-- Geometric rather than even, for the reason WorldCurve's ladder is: what
-- the player sees change between two rungs is the AREA inside the box, and
-- that goes as the square -- even steps bunch the whole ladder at the
-- near end.
--
-- The last rung is 0, which is no cut at all. It sits at the TOP because
-- "everything" is where the ladder is going: FIT, wider, wider, wider,
-- all of it.
ViewBox.FRACS = { 1.0, 1.5, 2.25, 3.5, 0 }

ViewBox.setting = ModSetting.new(ViewBox.KEY, ViewBox.LABEL,
                                 { 0, 1, 2, 3, 4 },
                                 { "FIT", "WIDE", "WIDER", "WIDEST", "OFF" })

-- How far the world may reach when the camera can see the HORIZON and the
-- honest footprint is infinite, in view heights. Generous on purpose: this
-- is a backstop for an unbounded answer, not a curtain to draw across the
-- middle distance, and it wants to land well past the edge of the loaded
-- neighbourhood so the world runs out before the cut does. Multiplied by
-- the row, so a player who can see the seam can push it away.
ViewBox.MAX_REACH = 6

-- The rim under V-CURVE, as a fraction of the shorter half-extent, and for
-- the reason Diorama.FADE_FRAC exists: a bent world has no straight sides,
-- so a hard edge across one is a lie about what is being looked at. Flat,
-- the box keeps its hard edge -- that IS the sides.
ViewBox.FADE_FRAC = 0.16

-- How far outside the box a map may still have geometry inside it: the
-- border ring ChunkMesher meshes around a body (RING = 3 blocks of 32
-- world pixels), which is the one thing a map draws beyond its own
-- rectangle. A neighbour kept by this margin that turns out to be entirely
-- outside is drawn and then cut per fragment, which is what would have
-- happened without the test -- the margin can only cost a draw, never a
-- hole.
ViewBox.PAD = 96

function ViewBox.level()
  return ViewBox.setting:get() or 0
end

-- The multiplier in force, or nil for OFF -- which is also every caller's
-- "there is no cut this frame" answer.
function ViewBox.frac()
  local f = ViewBox.FRACS[ViewBox.level() + 1]
  if not f or f <= 0 then return nil end
  return f
end

-- Whether this rung is one the box is about: an ORBIT rung, which is every
-- level the mode has except OFF (level 0, where there is no 3D pass to cut)
-- and the two free-roam rungs (see the header).
function ViewBox.appliesTo(level)
  local ok, applies = pcall(function()
    local Voxel = V.require("VoxelState")
    local l = level or Voxel.level or 0
    return l > 0 and not Voxel.isFreeCam(l)
  end)
  return ok and applies or false
end

-- ------- what the live frame is
--
-- Set by VoxelScene for the length of one flat frame and cleared with it,
-- exactly as Diorama's is for a headset's. Nothing else writes it, and
-- every reader -- the shader uniforms, the neighbour skip -- hangs off this
-- one field being nil or not.
ViewBox.cull = nil        -- { x, y, z, r, rx, rz, invFade, kind }

local function curved()
  local ok, on = pcall(function()
    return V.require("WorldCurve").active()
  end)
  return ok and on or false
end

-- How far out of the orbit and into a walking head the rung tween has got,
-- 0..1. The box opens out by one over what is LEFT of the orbit, so it has
-- grown past every edge of the frame by the time the eye arrives in the
-- head and the cut is dropped -- rather than the sides vanishing on the
-- frame the rung number changed, which is a pop in the middle of a move.
local function orbitLeft()
  local ok, blend = pcall(function()
    return V.require("FirstPerson").blendEased()
  end)
  if not ok or type(blend) ~= "number" then return 1 end
  return 1 - math.max(0, math.min(1, blend))
end

-- ------- the ground this camera frames
--
-- The orbit (Voxel3D.viewProjection's else branch) is: eye at distance
-- k = FOCAL*vh, pitched `a` off straight down and due south of the focus;
-- focus on the ground at the view centre; a symmetric frustum whose
-- half-tangents are tanY = 1/(2*FOCAL) vertically and tanY*(vw/vh)
-- horizontally. Screen coordinates run sx, sy in [-1, 1] with sy = +1 the
-- TOP of the frame, which is north.
--
-- The ray through a screen point is forward + right*sx*tanX + up*sy*tanY,
-- and with the orbit's basis (right = +x, forward = (0, -cos a, -sin a),
-- up = (0, sin a, -cos a)) that comes out as
--
--     d = ( sx*tanX,  -cos a + sy*tanY*sin a,  -sin a - sy*tanY*cos a )
--
-- Drop it to the ground plane from an eye at height k*cos a and the whole
-- trapezoid collapses to ONE denominator,
--
--     D(sy) = cos a - sy*tanY*sin a
--
-- with (the sin^2 + cos^2 cancels most of the algebra away):
--
--     north of centre : (vh/2) * sy / D(sy)
--     half-width      : (vw/2) * cos a / D(sy)
--
-- because k*tanY is exactly vh/2 and tanX*k is exactly vw/2, whatever FOCAL
-- is. At a = 0 both reduce to vh/2 and vw/2 -- the flat window, which is
-- the case the first cut of this file mistook for all of them.
--
-- D shrinks as sy climbs, so BOTH grow toward the top of the frame, and
-- both blow up where D reaches zero: sy* = cot(a)/tanY, the row the horizon
-- sits on. Past 63 degrees that row is inside the frame and the answer is
-- infinite -- which is what `reach` is for.
--
-- Returns three DISTANCES from the view centre, all positive: how far the
-- picture runs north, how far south, and how far to each side.
function ViewBox.footprint(a, vw, vh, reach)
  local Voxel = V.require("VoxelState")
  local halfW, halfH = (vw or 320) * 0.5, (vh or 288) * 0.5
  local tanY = 1 / (2 * (Voxel.FOCAL or 1))
  -- the orbit never reaches level (75 degrees is the last rung) but a tween
  -- reads a live angle, and a cos of zero is a horizon through the middle
  -- of the frame rather than a number
  local ca = math.max(math.cos(a or 0), 1e-3)
  local sa = math.max(math.sin(a or 0), 0)
  -- The screen row the far edge is taken at: the TOP of the frame, or the
  -- row whose ray lands `reach` out, whichever comes first. Inverting the
  -- north formula for sy gives
  --
  --     sy = reach*cos a / (vh/2 + reach*tanY*sin a)
  --
  -- which is always strictly below the horizon row (it approaches it from
  -- underneath as reach grows), so D below is always positive -- with the
  -- horizon in frame this never even reaches 1 and the clamp is inert.
  local sy = reach * ca / (halfH + reach * tanY * sa)
  if sy > 1 then sy = 1 end
  local D = ca - sy * tanY * sa
  if D < 1e-3 then D = 1e-3 end
  return halfH * sy / D,                 -- north
         halfH / (ca + tanY * sa),        -- south: the sy = -1 row
         halfW * ca / D                   -- and the widest row is the far one
end

-- Open the frame's cut: the bounding rectangle of the ground this camera
-- frames, times the row's multiplier. Returns the cut, or nil when this
-- frame has none -- which is the row at OFF, a rung the box is not about,
-- and a camera that has finished its dive into a head.
function ViewBox.frame(cx, cy, vw, vh, level)
  ViewBox.cull = nil
  local frac = ViewBox.frac()
  if not (frac and ViewBox.appliesTo(level)) then return nil end
  local left = orbitLeft()
  if left <= 0.001 then return nil end
  frac = frac / left
  local Voxel = V.require("VoxelState")
  local north, south, side = ViewBox.footprint(
    Voxel.angle or 0, vw, vh, ViewBox.MAX_REACH * (vh or 288))
  north, south, side = north * frac, south * frac, side * frac
  -- The rectangle around it. Off-centre in z, because the trapezoid is:
  -- the camera looks NORTH from south of its focus, so there is far more
  -- picture ahead of the view centre than behind it -- at 35 degrees
  -- roughly twice as much, at 75 the whole frame.
  --
  -- The same floor Diorama.radius keeps: a box smaller than a couple of
  -- tiles is not a viewport, it is a hole the player is standing in.
  local rx = math.max(24, side)
  local rz = math.max(24, (north + south) * 0.5)
  local bent = curved()
  local fade = bent and math.max(1, math.min(rx, rz) * ViewBox.FADE_FRAC) or 0
  ViewBox.cull = {
    x = cx, y = 0, z = cy - (north - south) * 0.5,
    -- `r` is what the ball and the pillar kinds are sized by and the box
    -- is not; carried so the cut table has one shape whoever made it
    r = math.max(rx, rz), rx = rx, rz = rz,
    -- a zero band is a hard edge: half a pixel of ramp, which is one pixel
    -- of antialiasing rather than a stair (Diorama says the same)
    invFade = 1 / math.max(fade, 0.5),
    kind = V.require("Diorama").BOX,
  }
  return ViewBox.cull
end

function ViewBox.stop()
  ViewBox.cull = nil
end

-- ------- the coarse half of the cut
--
-- Whether anything inside the world-pixel rectangle (x0, z0)-(x1, z1) can
-- be inside this frame's box. True whenever there is no box, so a caller
-- may guard every draw with it unconditionally.
function ViewBox.shows(x0, z0, x1, z1)
  local c = ViewBox.cull
  if not c then return true end
  local pad = ViewBox.PAD
  return x0 - pad <= c.x + c.rx and x1 + pad >= c.x - c.rx
     and z0 - pad <= c.z + c.rz and z1 + pad >= c.z - c.rz
end

-- The same question about a connected neighbour, in the shape VoxelScene
-- keeps them: { map, ox, oy } with the offset in world pixels and the map's
-- own size in blocks of 32 (the shape prefetch's masks are built from).
function ViewBox.showsMap(nb)
  if not (nb and nb.map and nb.map.def) then return true end
  return ViewBox.shows(nb.ox or 0, nb.oy or 0,
                       (nb.ox or 0) + (nb.map.def.width or 0) * 32,
                       (nb.oy or 0) + (nb.map.def.height or 0) * 32)
end

-- What the shadow pass has to notice: WHICH neighbours it drew is now a
-- function of the row, and the row is the one input to that the sun's own
-- signature does not already carry (the centre, the view size and the
-- rung are all in it). Widening the box brings a neighbour back into the
-- light's frustum, and a map recorded without it must be redrawn.
function ViewBox.signature()
  return ViewBox.frac() or 0
end

function ViewBox.row()
  return ViewBox.setting:row()
end

function ViewBox.sync(value)
  ViewBox.setting:sync(value)
end

return ViewBox
