How to best draw a 3D Torus?

Hi everyone,

for a thesis I am trying to draw a 3D torus to visualize some trajectories on there. I am trying to achieve something similar-looking as the peeps over on the Tex side did here: tex.stackexchange
So far I have a working prototype:

#let torus(θ, φ) = (
  (R + r * calc.cos(φ)) * calc.cos(θ),
  (R + r * calc.cos(φ)) * calc.sin(θ),
  r * calc.sin(φ),
)
#cz.canvas({
 import cz.draw: *
  ortho(x: 70deg, y: 0deg, sorted: true, {
    // “longitude” rings (constant θ)
    for i in range(0, n_major) {
      let θ = i * 2 * calc.pi / n_major
      let pts = (range(0, n_samples + 1)).map(j => {
        let φ = j * 2* calc.pi / n_samples
        torus(θ, φ)
      })
      line(..pts, close: true)
    }

    // “latitude” rings (constant φ)
    for k in range(0, n_minor) {
      let φ = k * 2 * calc.pi / n_minor
      let pts = (range(0, n_samples + 1)).map(j => {
        let θ = j * 2 * calc.pi / n_samples
        torus(θ, φ)
      })
      line(..pts, close: true)
    }
  })
})

This achieves the look of a wire-frame torus, but it is not quite the look I am trying to get. It is a bit straining to look at. I would love to see as few lines as possible, i.e. mainly just the silhouette with some extra “shading” lines that make it clear it is a torus (see above link for references).
Does anybode here have some ideas of how I could achieve the desired look? I would be grateful for any input!

Thank you!

1 Like

As far as I render your code, it seems like just set the stroke to be more transparent should be good.

#cz.canvas({
 import cz.draw: *
 set-style(stroke: 0.5pt + black.transparentize(80%))
 // your code
})

2 Likes

Hi. Read How to post in the Questions category before posting next time.

Your code does not compile, and even after trying to remove all errors, it doesn’t look like the image provided. See https://sscce.org.

1 Like

The solution is simple: use rectangles instead of lines. But I wouldn’t use Typst directly for high-resolution 3D stuff, because it’s not really optimized for this and can’t use GPU (yet?) to accelerate anything, if something can be accelerated. Moreover, the drawing part is done with CeTZ, which is an abstraction layer, which will hit performance for intensive tasks.

Since I don’t like geometry, Claude was able to whip up some algorithm for creating torus rectangles’ points, and also for adding shading. I after a bit of re-formatting:

#import "@preview/cetz:0.4.0"

#let draw-torus(
  outer-radius: 4,
  inner-radius: 1,
  theta-divisions: 100, // Steps around major circle.
  phi-divisions: 50, // Steps around minor circle.
  base-color: green,
  stroke: auto,
  light-direction: (1, 1, 1), // Light source direction
  ambient-light: 0.2, // Ambient light intensity (0-1)
  diffuse-strength: 0.8, // Diffuse lighting strength (0-1)
) = {
  import calc: cos, max, min, pi, pow, sin, sqrt

  let get-torus-point(theta, phi) = {
    let x = (outer-radius + inner-radius * cos(phi)) * cos(theta)
    let y = (outer-radius + inner-radius * cos(phi)) * sin(theta)
    let z = inner-radius * sin(phi)
    return (x, y, z)
  }

  /// Calculate surface normal at given theta, phi.
  let get-torus-normal(theta, phi) = {
    let nx = cos(phi) * cos(theta)
    let ny = cos(phi) * sin(theta)
    let nz = sin(phi)
    (nx, ny, nz)
  }

  let normalize-vector(vec) = {
    let (x, y, z) = vec
    let length = sqrt(pow(x, 2) + pow(y, 2) + pow(z, 2))
    if length == 0 { return (0, 0, 0) }
    (x / length, y / length, z / length)
  }

  /// Calculate dot product of two vectors.
  let dot-product(v1, v2) = {
    let (x1, y1, z1) = v1
    let (x2, y2, z2) = v2
    x1 * x2 + y1 * y2 + z1 * z2
  }

  /// Calculate lighting intensity using Lambertian shading.
  let calculate-lighting(normal) = {
    let norm-light = normalize-vector(light-direction)
    let norm-normal = normalize-vector(normal)

    // Lambertian (diffuse) shading.
    let diffuse = max(0, dot-product(norm-normal, norm-light))

    // Combine ambient and diffuse lighting.
    let intensity = ambient-light + diffuse-strength * diffuse
    min(1, intensity) // Clamp to [0, 1].
  }

  /// Interpolate between two colors based on intensity
  let shade-color(color, intensity) = {
    // Convert intensity to RGB scaling.
    // Minimum brightness to avoid pure black.
    let scale = max(0.1, intensity)

    // For built-in colors, create a lighter/darker version
    if type(color) == std.color {
      return color.lighten(100% * (intensity - 0.5))
    }

    // For custom colors, you might need different handling
    color
  }

  for i in range(theta-divisions) {
    for j in range(phi-divisions) {
      let theta1 = (2 * pi * i) / theta-divisions
      let theta2 = (2 * pi * (i + 1)) / theta-divisions
      let phi1 = (2 * pi * j) / phi-divisions
      let phi2 = (2 * pi * (j + 1)) / phi-divisions

      let point1 = get-torus-point(theta1, phi1)
      let point2 = get-torus-point(theta2, phi1)
      let point3 = get-torus-point(theta2, phi2)
      let point4 = get-torus-point(theta1, phi2)

      // Calculate normal at the center of the rectangle for lighting
      let mid-theta = (theta1 + theta2) / 2
      let mid-phi = (phi1 + phi2) / 2
      let normal = get-torus-normal(mid-theta, mid-phi)

      // Calculate shading intensity
      let intensity = calculate-lighting(normal)

      // Apply shading to color
      let shaded-color = shade-color(base-color, intensity)

      cetz.draw.line(
        point1,
        point2,
        point3,
        point4,
        close: true,
        stroke: if stroke == auto { shaded-color } else { stroke },
        fill: shaded-color,
      )
    }
  }
}

#cetz.canvas({
  import cetz.draw: *
  ortho(x: -70deg, y: 0deg, sorted: true, {
    draw-torus(light-direction: (0, -1, 1))
  })
})

This hi-res torus costed me 1 GiB of RAM and 13 seconds of compilation.

P.S. Light-direction is probably not in sync with ortho, maybe.

3 Likes

Thank you! I figured I would only post the code snippet showing how the actual torus is drawn up to show what technique I used, as the full code seemed too long to post within the question body.

This looks very promising, thank you for this! I will try this out and see if it’s a fit for my use case. I am hoping to still achieve a near instant preview update, so I’ll see if the extra compile time affects this.

Thanks. I have played around with that too, but still didn’t like the look of it too much…

In any case, the output should match the input.

Well, the issue is only to draw it once, then the updates should be fast again. To get the same polygon count with better shading, it takes about 1.3 s.