Off-axis Projection & LOS API Reference

Docstrings for the off-axis projection and line-of-sight tools. The narrative guide is in Off-axis Projection; the pipeline (camera basis, deposit kernels, kinematics) is described in the docstrings below; off-axis views are selected through the same projection call documented in the Projections API.

Choosing the view angles

Think of the camera as sitting on a sphere around your object, always looking at the centre. Two angles say where on that sphere the camera is, and one keyword says what the angles are measured from.

keywordquestion it answersdefault
axiswhich direction do the angles start from?box +z
inclinationhow far has the camera moved away from that axis?0
azimuthwhere around the axis does the camera sit?0
position_anglehow is the finished image rotated in its own plane?0
angle_unitare the angles degrees or radians?:deg

position_angle is a roll. It turns the picture, not the camera, so it never changes which part of the object is in front.

What inclination does

inclination is the tilt away from the reference axis. Zero means you look straight down the axis. Ninety degrees means you look from the side. The image "up" direction is always the reference axis, drawn as flat as the view allows, so the object does not spin as you tilt.

A round, thin disk seen at inclination i appears as an ellipse with axis ratio

\[b/a = \cos i\]

which is how inclination is measured from an observed image. That gives you a direct feel for the number:

inclinationwhat you seea round disk looks like
0straight down the axis, face-ona circle, b/a = 1.00
30slightly tiltedb/a = 0.87
45half wayb/a = 0.71
60strongly tiltedb/a = 0.50
77close to edge-on, the M31 valueb/a = 0.22
90from the side, edge-ona line, b/a = 0.00
120past the side, now seeing the far faceb/a = 0.50 again, mirrored
180face-on from the opposite sidea circle again

So the useful range is 0 to 180. Below 90 you look at one face, above 90 at the other. As soon as the view is tilted, the reference axis is what points up in the image, so changing the tilt does not also spin the picture. At inclination=0 the axis points straight at you, so there it cannot define up, and Mera keeps the orientation continuous with the tilted views next to it.

What azimuth does

azimuth moves the camera around the axis. The tilt does not change, only which side you stand on. With axis=:z the camera walks around the box like this:

azimuthyou stand on thelooking toward
0-y side+y
90+x side-x
180+y side-y
270-x side+x

For a round, axisymmetric disk azimuth changes nothing you can see. It matters when the object is not axisymmetric: a bar, a spiral arm, a merger, a filament. Then azimuth is what decides whether you catch the bar end-on or side-on. It is also the angle you sweep to make a turntable movie.

Through all of it the image up stays on the reference axis, which is what keeps a rotation sequence steady instead of tumbling.

What axis does

axis decides what the two angles are measured from. These are all the accepted values:

axis=measures angles fromneeds
omittedthe box +z axisnothing
:x, :y, :zthat box axisnothing
:angmomthe object's own angular momentum Ldata to compute L from
:Lthe same as :angmom, a shorter aliasdata to compute L from
[ax, ay, az]any direction you choosea non-zero 3-vector, normalised for you

Use :z to work in box coordinates. Use :angmom to work relative to the object itself: Mera takes the angular momentum of the data you passed and measures both angles from it. For a disk galaxy :angmom is usually what you want, because a disk is rarely lined up with the box. This is also what makes inclination mean the familiar thing: measured from L, inclination=0 is face-on and inclination=90 is edge-on, exactly as an observer would use the word.

axis is only for inclination/azimuth. It has no effect on los=, and combining it with direction=:faceon or :edgeon is an error, because those presets already use L.

Using your own axis vector

axis=[ax, ay, az] lets you measure the angles from any direction you like. You do not need to normalise it, Mera does that. This is the tool for anything the presets cannot name:

# 1. a filament or an outflow whose direction you already know
projection(gas, :sd, inclination=90, axis=[1.0, 1.0, 0.0])

# 2. the line joining two objects, for example a merger or a satellite
sep = [x2 - x1, y2 - y1, z2 - z1]
projection(gas, :sd, inclination=90, axis=sep)      # look across the merger axis

# 3. freeze the frame across a time series
#    :angmom is recomputed per snapshot, so the disk can wobble between frames.
#    Compute L once and reuse it as a fixed axis, and the movie stays steady.
L0 = getvar(gas, [:lx, :ly, :lz], center=[:bc])
Lfix = [sum(L0[:lx]), sum(L0[:ly]), sum(L0[:lz])]
projection(gas, :sd, inclination=60, axis=Lfix)

The third case is the common one in practice. axis=:angmom is convenient, but it measures the angular momentum of whatever data you passed, so a growing disk or a passing satellite shifts the axis a little from snapshot to snapshot. Passing a fixed vector removes that motion.

Recipes

# face-on and edge-on, relative to the object's own spin axis
projection(gas, :sd, inclination=0,  axis=:angmom)
projection(gas, :sd, inclination=90, axis=:angmom)

# a 60 degree tilt, in box coordinates
projection(gas, :sd, inclination=60, azimuth=0)

# same tilt, viewed from the other side
projection(gas, :sd, inclination=60, azimuth=180)

# turn the finished image without moving the camera
projection(gas, :sd, inclination=60, position_angle=30)

# radians instead of degrees
projection(gas, :sd, inclination=pi/3, angle_unit=:rad)

For a full turn, rotation_sequence varies one angle on one snapshot, and getmovie takes angles and sweep across a series of snapshots.

How this relates to direction=

The direction presets are shortcuts. These pairs give the same line of sight:

presetangle formis it the same view?
direction=:zinclination=0, axis=:zyes, identical
direction=:faceoninclination=0, axis=:angmomyes, identical
direction=:edgeoninclination=90, axis=:angmomedge-on in both, but from a different side

The first two rows give exactly the same line of sight. The third does not. Every edge-on view is perpendicular to the spin axis, but there are many of them, one for each point around the disk. :edgeon picks one for you, and inclination=90 lets you choose with azimuth. Use the preset when any edge-on view will do, and the angles when you need a particular one.

Give exactly one line-of-sight specifier: los=, or inclination/azimuth, or direction=. Passing two raises an error instead of quietly picking one.

The older theta/phi pair

theta and phi are the usual spherical angles about the box axes. They cover the same directions, but they do not start from the same place. phi is measured from the +x axis. azimuth starts a quarter turn later, so that inclination=0 matches the image orientation of direction=:z. For axis=:z the conversion is:

# these two give the same line of sight
projection(gas, :sd, theta=60, phi=30)
projection(gas, :sd, inclination=60, azimuth=30 + 90, axis=:z)
`theta`/`phi` is deprecated in 1.8

It still works, and it still returns the view it always did. Mera prints a note once per session pointing at inclination/azimuth and giving the conversion. The pair will be removed in 2.0, so move your scripts over when convenient. Do not simply rename the keywords: without adding 90 to the angle the picture comes out turned by a quarter turn, and nothing reports an error.

Line-of-sight maps

slice is the cutting-plane function and the name the documentation uses: with axis-aligned keywords it returns the covering-grid cut, and with any off-axis view keyword (los/inclination/azimuth/…) it returns the camera-plane cut along that line of sight. offaxis_slice is an alias of it, kept so existing scripts keep working.

Mera.sliceFunction
slice(obj, var, [unit]; ...) -> CoveringGridResult  (axis-aligned)  |  NamedTuple  (off-axis)

A single, non-integrated cutting plane through AMR cell data (HydroDataType, GravDataType, RtDataType). One name, two modes, chosen automatically from the keywords:

Axis-aligned (default). slice_axis=:z, slice_pos=0.5, slice_unit=:standard, lmax=obj.lmax, center=[0.,0.,0.], xrange, yrange, zrange, range_unit=:standard, max_bytes=4e9, pos_unit=:standard, verbose=true. A single-cell-thick cut at slice_pos along slice_axis (:x/:y/:z), resampled to a uniform level-lmax buffer (cf. covering_grid for the 3-D version, projection for the integrated map). slice_pos is in slice_unit (:standard ⇒ a fraction of the box). Returns a CoveringGridResult whose grid[var] is a 2-D array.

Off-axis (cutting plane along any line of sight). Triggered by passing any off-axis view keyword — los/inclination/azimuth/axis/theta/phi/direction=:faceon/:edgeon/position_angle/up, or the output controls res/pxsize. The field is sampled on the camera plane through center for an arbitrary orientation (the same view keywords as projection), but as a nearest-cell sample, not an integral — resolution-dependent and not mass-conserving. Returns a NamedTuple with .map, .extent and the camera basis. Empty (NaN) pixels are expected where the plane carries no cell; pass xrange/yrange to fill the frame, or use projection for a conserved map. One variable at a time.

Empty (NaN) pixels are expected in off-axis mode, for two distinct reasons. (1) Without xrange/yrange the frame is the axis-aligned bounding box of the rotated view, and the plane∩box polygon cannot fill that rectangle — the corners and border are NaN. Pass a window inside the box (xrange=…, yrange=…) and the frame fills (0 % empty on a uniform grid). (2) At fine pxsize over coarse AMR cells, nearest-cell sampling leaves sub-percent pixel-scale gaps at refinement boundaries. For a gap-free, mass-conserving map use projection.

offaxis_slice is an alias of this function for the off-axis mode; slice is the name the documentation uses.

sl = slice(gas, :rho, :nH; slice_axis=:z, slice_pos=0.5)          # axis-aligned mid-plane n_H map
sl[:rho]                                                          # 2-D array
oa = slice(gas, :rho, :nH; inclination=60, axis=:angmom,         # off-axis cutting plane
           xrange=[-16,16], yrange=[-16,16], range_unit=:kpc, pxsize=[0.3,:kpc])
oa.map                                                            # 2-D camera-plane array
Mera.offaxis_sliceFunction
offaxis_slice(dataobject, var [, unit]; <view & range kwargs>, res=256, pxsize=nothing)

Alias of slice — kept so existing scripts keep working, and for when you want the off-axis intent spelled out at the call site. slice(obj, var; los=…/inclination=…/…) dispatches here automatically whenever an off-axis view keyword is given, so the two are interchangeable and return the same NamedTuple.

offset / offset_unit move the plane along the line of sight, which is what lets a cutting plane travel through an object: offset=0 (the default) puts it through center, and sweeping offset produces the frames of a fly-through. offset_unit defaults to range_unit. The axis-aligned path spells the same idea slice_pos/slice_unit.

Prefer slice: it is the one name for a cutting plane, axis-aligned or off-axis, and it is what the documentation uses. See slice for the full description, the view keywords, and why empty (NaN) pixels are expected.

Sequences, storage & export

rotation_sequence varies the angle on one snapshot, with a fixed frame so the object cannot drift between frames. To vary time instead, or both at once, see getmovie, which takes angles for a full turn at each snapshot and sweep for one moving angle across a series.

Mera.rotation_sequenceFunction
rotation_sequence(dataobject, var, [unit]; sweep=:azimuth, angles,
                  axis=:angmom, inclination=0, fov=nothing, fov_unit=:standard,
                  aperture=:circle, parallel_frames=false, center=[:bc], res=256, <projection kwargs>)
    -> Vector{AMRMapsType}

Render var from a sequence of viewing angles for an orbit movie, all sharing ONE truly fixed field of view so successive frames do not jitter or zoom. sweep selects which angle varies (:azimuth, :inclination, or :position_angle) and angles is the list of values (degrees by default).

Because the off-axis camera is orthographic (parallel rays, observer at infinity), the only control over what is in frame is the FOV, not a camera distance. The FOV must be rotation- invariant or the frame would breathe with angle, so a sphere of radius fov is selected about center. Omit fov (fov=nothing) to auto-fit the galaxy: the mass-enclosed 99% radius (so the frame fits the object rather than chasing the few sparse outermost cells / a diffuse halo), capped so the selection stays inside the box. The aperture chooses how the sphere is framed:

  • aperture=:circle (default) — the sphere shows as a circular aperture; the rectangular frame's corners (beyond radius fov) are empty.
  • aperture=:square — a slightly larger sphere (radius √2·fov, enclosing the ±fov square at every angle) is selected and each frame cropped to that square → a full rectangular frame with no circular aperture and no data dropped inside it.

Threading. By default each frame's projection multithreads internally and the frames run sequentially. With parallel_frames=true the frames run concurrently (Threads.@threads) and each projection is single-threaded — this fills all cores when there are ≳ nthreads() frames and is typically ~1.5–2× faster for an orbit movie (it runs that many projections at once, so it uses proportionally more transient memory; results are identical to round-off).

Returns a Vector of map objects — one per angle — ready to montage or animate.

Mera.savemapFunction
savemap(p::DataMapsType, filename; verbose=true) -> String
loadmap(filename; verbose=true) -> DataMapsType

Save / load a projection result (an AMRMapsType/PartMapsType from projection) to a JLD2 file — a lightweight, Julia-native persistence format (the .jld2 extension is added if missing). The whole object round-trips: every map and its unit, the extent/pixsize, the off-axis camera basis, and the simulation info — so a reloaded map still plots, re-projects, and carries provenance.

p = projection(gas, [:sd, :vx])
savemap(p, "maps.jld2")
p2 = loadmap("maps.jld2")        # AMRMapsType, identical to p

The file uses the HDF5 container but stores the Julia object and is LZ4-compressed by default, so h5py cannot reconstruct the map from it. Reload with Mera; to hand a map to Python, write the array out yourself or use export_vtk. Older HDF5 readers.

Mera.loadmapFunction
loadmap(filename; verbose=true) -> DataMapsType

Load a projection result saved with savemap from a JLD2 file (the .jld2 extension is added if missing). The whole AMRMapsType/PartMapsType round-trips — maps, units, geometry, camera basis, and info — ready to plot, re-project, or carry provenance.

Save a projection result the Julia-native, JLD2 way:

p = projection(gas, [:sd, :vx])
savemap(p, "maps.jld2")     # all maps + units + geometry + provenance
p2 = loadmap("maps.jld2")   # → AMRMapsType, ready to plot/re-project

JLD2 files use the HDF5 container, but they store the Julia object rather than plain arrays and are LZ4-compressed by default, so h5py cannot reconstruct a map from one. Reload with Mera; to hand a map to Python, write the array out yourself or use export_vtk.

Camera kinematics (internal helpers)

These are not exported but underlie every off-axis call; documented for reference.

Mera.build_camera_basisFunction
build_camera_basis(los, up=nothing; roll=0.0) -> (right, up, w)

Construct a right-handed orthonormal camera basis from a line-of-sight vector los (the viewing direction) and an optional up hint.

Returns three unit 3-vectors (right, up, w) where w = los/‖los‖ is the viewing direction, and right, up span the image plane (image x = right, image y = up). The basis is right-handed with right × up = w.

If up is nothing — or (anti)parallel to los — a deterministic auto-up is chosen (the world axis least parallel to los), so the result is fully reproducible.

roll (radians) rotates the image plane about the line of sight — i.e. it sets the orientation of the image on the "sky" (the astronomical position angle / camera roll). It leaves w unchanged and rotates (right, up) together, so it composes with any way of choosing los.

Convention check: los=[0,0,1], up=[0,1,0]right=[1,0,0], up=[0,1,0], matching the axis-aligned direction=:z mapping (image x→sim x, image y→sim y).

Mera.resolve_losFunction
resolve_los(; los, theta, phi, inclination, azimuth, axis,
              direction=:z, angle_unit=:deg, up=nothing, L=nothing) -> (los_vec, up_hint)

Resolve a user-facing view specification into a (los_vec, up_hint) pair. Give exactly one of the alternatives below (a second one raises an error — no silent precedence). All angles are in angle_unit (:deg by default, or :rad):

  1. explicit los 3-vector,
  2. inclination/azimuth — tilt the view away from a reference axis by inclination (0 ⇒ looking straight down the axis, 90° ⇒ perpendicular to it) and rotate around the axis by azimuth. axis defaults to the box :z; use :x/:y/:z, a 3-vector, or :angmom (alias :L) (the object's angular momentum L, for disks). The reference axis is kept pointing "up".
  3. spherical angles (theta, phi) about the box axes (los=[sinθcosφ, sinθsinφ, cosθ]). Deprecated in 1.8, removed in 2.0. Use inclination/azimuth, see the note below,
  4. preset direction: :x/:y/:z, :faceon (look along L), :edgeon (⟂ L, up = ).

:faceon/:edgeon and axis=:angmom need the pre-computed L; the projection wiring supplies it via getvar(obj,[:lx,:ly,:lz]). The image roll (position_angle) is applied separately in build_camera_basis, so it is not a line-of-sight specifier here. Pure, touches no data.

`azimuth` is not `phi`

Options 2 and 3 cover the same directions when axis=:z, but their zero point differs by 90 degrees:

inclination = theta,  azimuth = phi + 90        (for axis=:z)

inclination and theta are the same angle: both measure the tilt away from the reference axis. azimuth and phi both turn around that axis, but they do not start from the same place. phi is measured from the +x axis, in the usual spherical convention. azimuth starts one quarter turn later, so that a view with inclination=0 has the same image orientation as direction=:z. Passing azimuth=phi gives a picture rotated by 90 degrees.

The two options are not equally capable. Use inclination/azimuth unless you specifically want angles measured about the box axes: it accepts any reference axis (including :angmom, the object's angular momentum) and it returns an image "up" direction, so the roll of the picture is defined. theta/phi is always about the box axes and leaves the roll to the automatic choice.

Deposit kernels (internal)

The three engines behind binning=:cic/:ngp, :overlap and :exact, how a rotated AMR cell becomes pixel values. The trade-offs are demonstrated visually in Off-axis Projection.

Mera.deposit_rotated_cells_to_grid!Function
deposit_rotated_cells_to_grid!(grid, weight_grid, x_cam, y_cam, values, weights, extent, res; kwargs...)

Centre-only deposit kernel for off-axis projections (binning=:cic/:ngp): each rotated cell centre lands on its nearest pixel (:ngp) or is spread bilinearly over the 4 surrounding pixels (:cic). Fast preview quality — cells wider than a pixel leave speckle/moiré; the footprint-aware kernels below fix that. Exactly weight-conserving. Internal; see the "Off-axis Projection: How It Works Internally" docs page.

Mera.deposit_rotated_cells_overlap!Function
deposit_rotated_cells_overlap!(grid, weight_grid, x_cam, y_cam, cellsize, values, weights, cam_right, cam_up, extent, res; nmax=64, max_threads=...)

Footprint deposit kernel for off-axis projections (binning=:overlap, the default): each cube is split into ns³ sub-points (ns = ⌈cellsize/pixel⌉, capped at nmax), every sub-point rotated into the camera frame and CIC-deposited with weight 1/ns³; cells coarser than the cap deposit each sub-cube as a footprint-sized top-hat, so the camera plane tiles without holes at any viewing angle. Converges to the :exact kernel. Per-cell contributions sum to exactly 1 — the kernel is weight-conserving by construction. Threaded over contiguous cell chunks into per-thread grids (summed at the end). Internal; see the "Off-axis Projection: How It Works Internally" docs page.

Mera.deposit_rotated_cells_exact!Function
deposit_rotated_cells_exact!(grid, weight_grid, x_cam, y_cam, cellsize, values, weights, cam_right, cam_up, cam_w, extent, res; max_threads=...)

Analytic deposit kernel for off-axis projections (binning=:exact): for every pixel a cell covers, the line-of-sight chord length through the rotated cube is integrated over the pixel area (polygon clipping against the six cube faces), i.e. the true column integral — the fidelity reference the other kernels are compared against. Sub-pixel cells fall back to a CIC 4-pixel stencil. Weight-conserving; threaded like :overlap. Internal; see the "Off-axis Projection: How It Works Internally" docs page.