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.
| keyword | question it answers | default |
|---|---|---|
axis | which direction do the angles start from? | box +z |
inclination | how far has the camera moved away from that axis? | 0 |
azimuth | where around the axis does the camera sit? | 0 |
position_angle | how is the finished image rotated in its own plane? | 0 |
angle_unit | are 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:
inclination | what you see | a round disk looks like |
|---|---|---|
0 | straight down the axis, face-on | a circle, b/a = 1.00 |
30 | slightly tilted | b/a = 0.87 |
45 | half way | b/a = 0.71 |
60 | strongly tilted | b/a = 0.50 |
77 | close to edge-on, the M31 value | b/a = 0.22 |
90 | from the side, edge-on | a line, b/a = 0.00 |
120 | past the side, now seeing the far face | b/a = 0.50 again, mirrored |
180 | face-on from the opposite side | a 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:
azimuth | you stand on the | looking 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 from | needs |
|---|---|---|
| omitted | the box +z axis | nothing |
:x, :y, :z | that box axis | nothing |
:angmom | the object's own angular momentum L | data to compute L from |
:L | the same as :angmom, a shorter alias | data to compute L from |
[ax, ay, az] | any direction you choose | a 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:
| preset | angle form | is it the same view? |
|---|---|---|
direction=:z | inclination=0, axis=:z | yes, identical |
direction=:faceon | inclination=0, axis=:angmom | yes, identical |
direction=:edgeon | inclination=90, axis=:angmom | edge-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)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.slice — Function
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 arrayMera.offaxis_slice — Function
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_sequence — Function
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 radiusfov) are empty.aperture=:square— a slightly larger sphere (radius√2·fov, enclosing the±fovsquare 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.savemap — Function
savemap(p::DataMapsType, filename; verbose=true) -> String
loadmap(filename; verbose=true) -> DataMapsTypeSave / 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 pThe 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.loadmap — Function
loadmap(filename; verbose=true) -> DataMapsTypeLoad 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-projectJLD2 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_basis — Function
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_los — Function
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):
- explicit
los3-vector, inclination/azimuth— tilt the view away from a referenceaxisbyinclination(0 ⇒ looking straight down the axis, 90° ⇒ perpendicular to it) and rotate around the axis byazimuth.axisdefaults to the box:z; use:x/:y/:z, a 3-vector, or:angmom(alias:L) (the object's angular momentumL, for disks). The reference axis is kept pointing "up".- spherical angles
(theta, phi)about the box axes (los=[sinθcosφ, sinθsinφ, cosθ]). Deprecated in 1.8, removed in 2.0. Useinclination/azimuth, see the note below, - preset
direction::x/:y/:z,:faceon(look alongL),:edgeon(⟂L, up =L̂).
: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.
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.