home

Spherical Volume Element

Creator: Juan Falgueras-Cano

Spherical volume element dV = r² sin θ dr dθ dφ, with polar angle θ, azimuth φ, position vector r, and edge lengths dr, r dθ, and r sin θ dφ.


Spherical Volume Element

Download

PNG PDF SVG

Code

spherical-volume-element.typ (182 lines)

// Spherical differential volume by Juan Falgueras-Cano, 2026-10-04.
#import "@preview/cetz:0.5.2": canvas, draw
#import draw: (
  arc, circle, content, floating, get-ctx, hide, line, merge-path, on-xy, on-xz, rect, rotate,
  scope,
)

#set page(width: auto, height: auto, margin: 8pt, fill: none)

// theta is colatitude from +z; phi is azimuth in the xy plane.
#let spherical(radius, theta, phi) = (
  radius * calc.sin(theta) * calc.cos(phi),
  radius * calc.sin(theta) * calc.sin(phi),
  radius * calc.cos(theta),
)
// === Curves and volume-element geometry ===
#let (radius_min, radius_max) = (3, 3.5)
#let (theta_min, theta_max) = (55deg, 67deg)
#let (phi_min, phi_max) = (50deg, 64deg)
// Boundary and the two inner projected corners for the fixed 30deg/10deg view.
#let ring = (
  (radius_max, theta_min, phi_max),
  (radius_max, theta_max, phi_max),
  (radius_min, theta_max, phi_max),
  (radius_min, theta_max, phi_min),
  (radius_min, theta_min, phi_min),
  (radius_max, theta_min, phi_min),
)
#let far_corner = (radius_min, theta_min, phi_max)
#let near_corner = (radius_max, theta_max, phi_min)
// Draw an edge along one changing spherical coordinate: radius, theta or phi.
#let edge(start, end, ..style) = {
  let (radius, theta, phi) = start
  if radius != end.at(0) {
    line(spherical(..start), spherical(..end), ..style)
  } else {
    let polar = theta != end.at(1)
    scope({
      if polar { rotate(z: phi) }
      let plane = if polar { on-xz } else { on-xy.with(z: radius * calc.cos(theta)) }
      plane({
        arc(
          (0, 0),
          radius: if polar { radius } else { radius * calc.sin(theta) },
          start: if polar { 90deg - theta } else { phi },
          stop: if polar { 90deg - end.at(1) } else { end.at(2) },
          anchor: "origin",
          ..style,
        )
      })
    })
  }
}

#let geometry = {
  let sphere_radius = 4
  let axis_mark = (end: "stealth", fill: black, scale: .6)
  let length_arrow = (end: "stealth", start: "stealth", fill: blue, scale: .4)
  let guide = (paint: gray, thickness: .5pt)
  let hidden_guide = (paint: gray.lighten(25%), thickness: .5pt, dash: "dotted")
  // Darker than Typst red/gray so angle and projection labels stay legible on the light page.
  let red = rgb("#d62a1e")
  let angle_stroke = (paint: red, thickness: .5pt, cap: "round")
  let visible_edge = (paint: blue, thickness: 1pt, cap: "round", join: "round")
  let hidden_edge = (paint: blue.lighten(60%), thickness: .6pt, dash: "dotted", cap: "round")
  let length_stroke = (paint: blue, thickness: .5pt, cap: "round", join: "round")

  circle((0, 0), radius: sphere_radius, stroke: guide)
  scope({
    rotate(x: -80deg)
    rotate(z: -120deg)
    // === Volume fill and sphere guides ===
    merge-path(close: true, fill: blue.transparentize(90%), stroke: none, {
      for (idx, start) in ring.enumerate() {
        edge(start, ring.at(calc.rem(idx + 1, ring.len())))
      }
    })
    // Use the drawing transform itself to split the guides into front/back halves.
    // Meridian centers use colatitude atan2(view.z, view.x/y); edge converts to 90deg - theta.
    get-ctx(ctx => {
      let view = ctx.transform.at(2)
      for (base, axis, center) in (
        ((sphere_radius, 90deg, 0deg), 2, calc.atan2(view.at(0), view.at(1))),
        ((sphere_radius, 0deg, 0deg), 1, calc.atan2(view.at(2), view.at(0))),
        ((sphere_radius, 0deg, 90deg), 1, calc.atan2(view.at(2), view.at(1))),
      ) {
        for (offset, stroke) in ((90deg, hidden_guide), (-90deg, guide)) {
          let start = base
          start.at(axis) = center + offset
          let end = start
          end.at(axis) += 180deg
          edge(start, end, stroke: stroke)
        }
      }
    })

    // === Angles and axes ===
    edge((.5, 0deg, phi_min), (.5, theta_max, phi_min), stroke: angle_stroke)
    content(spherical(.3, theta_max / 2, phi_min), text(red)[$theta$], anchor: "east", padding: .1)
    // Draw -z before phi so it cannot cover the angle arc.
    line((0, 0), (0, 0, -1.1 * sphere_radius), stroke: hidden_guide)
    edge((1.2, 90deg, 0deg), (1.2, 90deg, phi_min), stroke: angle_stroke)
    content(spherical(1, 90deg, phi_min / 2), text(red)[$phi$], anchor: "north", padding: 0)
    for (direction, label) in (((1, 0, 0), $x$), ((0, 1, 0), $y$), ((0, 0, 1), $z$)) {
      if direction != (0, 0, 1) {
        line((0, 0), direction.map(value => -1.1 * sphere_radius * value), stroke: hidden_guide)
      }
      line((0, 0), direction.map(value => (sphere_radius + .8) * value), mark: axis_mark)
      content(direction.map(value => (sphere_radius + 1.1) * value), label)
    }

    // === Volume edges, hidden ones first ===
    for idx in (0, 2, 4) {
      edge(far_corner, ring.at(idx), stroke: hidden_edge)
    }
    // Extend inner radial edges without duplicating the position vector.
    for point in (ring.at(4), far_corner, ring.at(2)) {
      line((0, 0), spherical(..point), stroke: (
        paint: gray.lighten(60%),
        thickness: .6pt,
        dash: "dotted",
      ))
    }

    for (idx, start) in ring.enumerate() {
      edge(start, ring.at(calc.rem(idx + 1, ring.len())), stroke: visible_edge)
      if calc.odd(idx) { edge(start, near_corner, stroke: visible_edge) }
    }

    // === Edge lengths and position vector ===
    for (start, end) in (
      ((radius_min, theta_max * 1.03, phi_min), (radius_min, theta_max * 1.03, phi_max)),
      ((radius_max, theta_min, phi_max * 1.03), (radius_max, theta_max, phi_max * 1.03)),
    ) {
      edge(start, end, stroke: length_stroke, mark: length_arrow)
    }
    let vector_tip = spherical(radius_min, theta_max, phi_min)
    let foot = spherical(radius_min * calc.sin(theta_max), 90deg, phi_min)
    let projection_stroke = (dash: "dashed", paint: gray, thickness: .3pt)
    line(vector_tip, foot, stroke: projection_stroke)
    line((0, 0), foot, stroke: projection_stroke)
    line((0, 0), vector_tip, stroke: 1.2pt, mark: axis_mark)
    for (position, label, paint, anchor, padding) in (
      ((2, 90deg, phi_min), $r sin theta$, rgb("#666666"), "north", 0),
      ((2, theta_max, phi_min), $arrow(r)$, black, "south-east", 0),
      ((radius_max, theta_min, phi_min), $d r$, blue, "east", .1),
      ((radius_max, (theta_min + theta_max) / 2, phi_max), $r d theta$, blue, "west", .15),
      (
        (radius_min, theta_max, (phi_min + phi_max) / 2),
        $r sin theta d phi$,
        blue,
        "north-west",
        .08,
      ),
      (
        ((radius_min + radius_max) / 2, theta_min, (phi_min + phi_max) / 2),
        $d V = r^2 sin theta d r d theta d phi$,
        blue,
        "south-west",
        .3,
      ),
    ) {
      content(spherical(..position), text(paint, label), anchor: anchor, padding: padding)
    }
  })
}

// Crop limits are (x_min, y_min, x_max, y_max) in cm from the axes' origin.
// Pass crop: none to show the full sphere.
#let volume_element(crop: (-2.75, -1.45, 5.4, 5.2)) = {
  set text(size: 12pt)
  box(clip: crop != none, canvas({
    if crop == none { geometry } else {
      let (x_min, y_min, x_max, y_max) = crop
      hide(rect((x_min, y_min), (x_max, y_max)), bounds: true)
      floating(geometry)
    }
  }))
}

#volume_element()