Hello USD - Part 19: I just plain skipped over latitude and longitude

This article is part of a series.

Taking an easy day today while I ponder how to refactor Canvas3D to be composable like OpenUSD.

It’s pretty hilarious I went straight for a fibonacci spiral instead of lat/long style Spheres placement. I wanted even and the fibonacci style makes distribution even. Latitude and longitude style coordinates cannot be even as they try to lay a cartesian grid on a surface that’s not flat.

I made two styles of spheres. One style findCoordinate_dy treats the parallels as slices, moving the same 𝚫y down the spindle each time. The other style, findCoordinate_dtheta, creates the classic latitude rings; the polar angle gets incremented.

One gets gappy at the poles, the other gets gappy at the equator.

How they looked when I was done:

First Pass

//
//  LatLongSatellites.swift
//  
//
//  Created by Carlyn Maw on 7/26/23.
//

import Foundation

//http://www.songho.ca/opengl/gl_sphere.html

public struct LatLongSatellites {
    public init(meridianCount:Int, 
                parallelCount:Int, 
                radius:Double, 
                ratio:Double = 0.1
    ) {
        self.meridianCount = meridianCount
        self.parallelCount = parallelCount
        self.radius = radius
        self.ratio = ratio
    }
    let meridianCount:Int
    let parallelCount:Int
    let radius:Double
    let ratio:Double
    
    let tau = Double.pi * 2
    
    public func buildStage() -> Canvas3D {
        let sun_color = 0.9
        let sphere_radius = radius*ratio
        
        let base_lat_dy = 2.0/Double(parallelCount-1)
        let base_polar_deflection = Double.pi/Double(parallelCount-1)
        let base_long_dphi =  tau/Double(meridianCount)
        
        return Canvas3D {
            Sphere(radius: sphere_radius)
                .color(red: sun_color, green: sun_color, blue: sun_color)
            for i in 0..<parallelCount {
                for j in 0..<meridianCount {
                    Sphere(radius: sphere_radius)
                        .color(
                            red: 0.3, //(Double(j)/Double(meridianCount)),
                            green: 0.3,//(Double(i)/Double(parallelCount)),
                            blue: (Double(i)/Double(parallelCount))
                        )
                        .translateBy(findCoordinate_dy(parallel: i, meridian: j))
                }
            }
        }
        
        //Assumptions: 
        // - Y is up, 
        // - polar value is inflection up to Y. <==== THIS IS WRONG!!!
        func findCoordinate_dy(parallel:Int, meridian:Int) -> Vector {
            let polarAngle = asin(1-Double(parallel) * base_lat_dy)
            
            let azimuthal = Double(meridian) * base_long_dphi
            // print("y: \(Double(parallel) * base_lat_dy)")
            // print("polar angle:\(polarAngle), azimuthal:\(azimuthal)")
            let y = sin(polarAngle)
            let x = cos(polarAngle) * cos(azimuthal)
            let z = cos(polarAngle) * sin(azimuthal)
            //print(x, y, z)
            return Vector(x: x, y: y, z: z).scaled(by: radius)
        }

        func findCoordinate_dtheta(parallel:Int, meridian:Int) -> Vector {
            let polarAngle = Double.pi / 2 - Double(parallel) * base_polar_deflection
            let azimuthal = Double(meridian) * base_long_dphi
            print("polar angle:\(polarAngle), azimuthal:\(azimuthal)")
            let y = sin(polarAngle)
            let x = cos(polarAngle) * cos(azimuthal)
            let z = cos(polarAngle) * sin(azimuthal)
            print(x, y, z)
            return Vector(x: x, y: y, z: z).scaled(by: radius)
        }
    }
}

This code works, but has a conceptual mistake in it that I may have made in some of my code yesterday. Generally the polar angle is meant to be deflection DOWN from vertical, not up from the azimuth. That would be an elevation angle. It’s fine to use an elevation angle, but that’s not what I’ve been calling it. I re-wrote the code to be more inline with what Wikipedia says the ISO standard says.

        //Polar Angle is deflection down, not inflection up.
        //Y is up.
        func findCoordinate_dy(parallel:Int, meridian:Int) -> Vector {
            let polarAngle = acos(1-Double(parallel) * base_lat_dy)
            let azimuthal = Double(meridian) * base_long_dphi
            // print("y: \(Double(parallel) * base_lat_dy)")
            // print("polar angle:\(polarAngle), azimuthal:\(azimuthal)")
            let sin_polar = sin(polarAngle)
            let x = sin_polar * cos(azimuthal)
            let y = cos(polarAngle)
            let z = sin_polar * sin(azimuthal)
            //print(x, y, z)
            return Vector(x: x, y: y, z: z).scaled(by: radius)
        }
        
        func findCoordinate_dtheta(parallel:Int, meridian:Int) -> Vector {
            let polarAngle = Double(parallel) * base_polar_deflection
            let azimuthal = Double(meridian) * base_long_dphi
            //print("polar angle:\(polarAngle), azimuthal:\(azimuthal)")
            let sin_polar = sin(polarAngle)
            let x = sin_polar * cos(azimuthal)
            let y = cos(polarAngle)
            let z = sin_polar * sin(azimuthal)
            //print(x, y, z)
            return Vector(x: x, y: y, z: z).scaled(by: radius)
        }

I also added a coordinate conversion pair.

    //MARK: Coordinate conversion. matched set.
    //Assumes Y is up. Polar coord is deflection down. 
    func sphericalCoordinate(to:Vector, from:Vector = Vector(x: 0, y: 0, z: 0)) ->
    (radius:Double, polar:Double, azimuthal:Double){
        let dx = to.x-from.x
        let dy = to.y-from.y
        let dz = to.z-from.z
        let v_radius = (dx*dx + dy*dy + dz*dz).squareRoot()
        
        let polar = acos(dy/v_radius); //theta
        let azimuthal = atan(dz/dx); //phi
        return (v_radius, polar, azimuthal)
    }

    func cartesianCoordinate(polar:Double, azimuthal:Double, radius:Double = 1) ->
    Vector {
        let sin_polar = sin(polar)
        let x = sin_polar * cos(azimuthal)
        let y = cos(polar)
        let z = sin_polar * sin(azimuthal)
        return Vector(x: x, y: y, z: z).scaled(by: 1)
    }

I’ll probably add these to the Vector type down the line.

No repeat spheres

The poles have a lot of Spheres all stacked up in one place. I made a simplistic “only unique spheres” validation by adding a coordinateSet to my build function. The Sphere will only draw if the coordinates proposed are novel.

    public func buildStage() -> Canvas3D {
        //...

        //Vector conforms to Hashable. 
        var coordinateSet:Set<Vector> = []
        
        return Canvas3D {
            for i in 0..<parallelCount {
                for j in 0..<meridianCount {
                    makeSphere(parallel: i, meridian: j)
                }
            }
        }
        
        func makeSphere(parallel: Int, meridian: Int) -> Sphere? {
            let coords = findCoordinate_dtheta(parallel: parallel, meridian: meridian)
            let result = coordinateSet.insert(coords)
            if result.inserted {
                return Sphere(radius: sphere_radius)
                    .color(
                        red: 0.3, //(Double(j)/Double(meridianCount)),
                        green: 0.3,//(Double(i)/Double(parallelCount)),
                        blue: (Double(parallel)/Double(parallelCount))
                    )
                    .translateBy(coords)
            }
            
            return nil
        }
        //...

Also, added to Canvas3DBuilder to handle the optional Sphere:

public enum Canvas3DBuilder {
    //...
    public static func buildExpression(_ expression: Sphere?) -> [Sphere] {
        if let expression { return [expression] }
        else { return [] }
    }
    //...
}

My usd file went down from 1115 lines to 1016, which is some good savings.

References

This article is part of a series.