Hello USD - Part 8: Multiball moves to a Package

I’ve been punking code and examples into USDHelloWorld. I will still be putting example usd files and support scripts in that repo, but I wanted something more reusable/evolvable.

Enter new dual-product repo SketchPad

This new repo contains a Library and and executable CLI. This post will be pretty thin on the USD stuff because I’m just, once again, recreating the multiball example.

Other public repos with ArgumentParser:

Spheres of various colors and sizes strewn about a blank canvas

Tomorrow will focus more on using ResultBuilder to help make creating a generative USD easier.

ArgumentParser References

Getting Started

My usual process for creating an executable package:

mkdir $NAME
cd $NAME
git init
git branch -M main
touch README.md
swift package init --type executable
touch .gitattributes
#touch .env #<- not used every repo
swift run
## Update .gitignore and .gitattributes
git add .

git commit -m "hello project"
## Options for making a remote:
## https://cli.github.com/manual/gh_repo_create  (brew install gh)
#gh repo create $NAME --public
#git remote add origin $REPO_URL  ## <- links an existing repo to git
#git remote -v #checks to see if it worked
## Potential GOTCHAs - https://docs.github.com/en/authentication/troubleshooting-ssh/error-permission-denied-publickey#make-sure-you-have-a-key-that-is-being-used
git push -u origin main

.gitignore

# ------ generated by package init.
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

## ----------- added

# VSCode
.vscode

# Secrets & Environment
.env

# Swift additional
Package.resolved

# Python (not used but its been known to sneak in...)
__pycache__/
*.py[cod]
*$py.class

.gitattributes

Helpful for multi-platform work. Gets ugly when not there from the beginning.

# Auto detect text files and perform LF normalization
* text=auto

Changes to the Package File

Because I want a library and and CLI I made some changes to the Package file. In the code base the files for each target have their own folder named the same as the target name. The library must be listed as a dependency for the CLI.

Typically I’d have the Library in its own Package, but since this is all very fast changing for now they live together.

Sources -------- SketchPad
            |
             --- SketchPadCLI
let package = Package(
    name: "SketchPad",
    // TODO: This might make somethings easier. 
    // platforms: [
    //     .macOS(.v10_15),
    // ],
    products: [
        .library(name: "SketchPad", targets: ["SketchPad"]),
        .executable(name: "sketchpad", targets: ["SketchPadCLI"])
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.2")
    ],
    targets: [
         .target(
            name: "SketchPad"
         ),
         .executableTarget(
            name: "SketchPadCLI",
            dependencies: [
                "SketchPad",
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
            ]
         )
    ]
)

Recreate Multiball - CLI

main.swift

Again taking advantage of the convenience of a main.swift, but this time a lot less goes in it. multiball gets set up as a sub command because I know I will be making more, but for now it gets set as the default.

import Foundation
import ArgumentParser

struct SketchPadCLI: ParsableCommand {
     static let configuration = CommandConfiguration(
        abstract: "A Swift command-line tool to create 3D files from simple instructions",
        version: "0.0.1",
        subcommands: [
            multiball.self,
        ],
        defaultSubcommand: multiball.self)

    init() { }
}

SketchPadCLI.main()

multiball.swift

None of the logic for building the USD file lives in my UI code, even if it is a command line UI.

With ArgumentParser, I can create CLI tools with a --help feature really easily, which is why I use it.

extension SketchPadCLI {
    struct multiball:ParsableCommand {
        @Flag(name: [.customLong("save"), .customShort("s")], help: "Will save to file called \"multiball_$TIMESTAMP.usda\" instead of printing to stdout")
        var saveToFile = false
        
        @Option(name: [.customLong("output"), .customShort("o")], help: "Will save to custom path instead of printing to stdout")
        var customPath:String? = nil
        
        @Option(name: [.customLong("count"), .customShort("c")],
              help: "Number of spheres to generate in addition to the blue origin sphere. Default is 12")
        var count:Int = 12
        
        
        static var configuration =
        CommandConfiguration(abstract: "Generate a USDA file that references sphere_base.usda like previous examples. 12 + blue origin ball is the default count")
        
        func run() {
            let fileString = generateMultiBallUSDText(count:count)
            if saveToFile || customPath != nil {
                do {
                    guard let data:Data = fileString.data(using: .utf8) else {
                        print("Could not encode string to data")
                        return
                    }
                let path:String = customPath ?? "multiball_\(FileIO.timeStamp()).usda"
                    try FileIO.writeToFile(data: data, filePath: path)
                } catch {
                    print("Could not write data to file: \(error)")
                }
            } else {
                print(fileString)
            }
        }
        
    }
}

FileIO.swift

Since I tend to cross compile on Linux I have some helper FileIO functions. TDB if they will still be needed with the new Foundation. This isn’t the whole file, just what’s being used.

import Foundation

enum FileIO {
   
    static func timeStamp() -> String {
        let formatter = DateFormatter()
        formatter.dateFormat = "YYYYMMdd'T'HHmmss"
        return formatter.string(from: Date()) //<- TODO: confirm that is "now"
        
    }

    static func makeFileURL(filePath:String) -> URL {
        //TODO: For iOS??
        //let locationToWrite = URL.documentsDirectory.appendingPathComponent("testImage", conformingTo: .png)
        #if os(Linux)
        return URL(fileURLWithPath: filePath)
        #else
        if #available(macOS 13.0, *) {
            return URL(filePath: filePath)
        } else {
            // Fallback on earlier versions
             return URL(fileURLWithPath: filePath)
        }
        #endif
    }
    
    
    static func writeToFile(data:Data , filePath:String) throws {
        let url = makeFileURL(filePath: filePath)
        try data.write(to: url)
    }

    static func writeToFile(string:String, filePath:String? = nil) {
        let path = filePath ?? "text_\(timeStamp()).txt"
        do {
            guard let data:Data = string.data(using: .utf8) else {
                print("Could not encode string to data")
                return
            }
            try FileIO.writeToFile(data: data, filePath: path)
        } catch {
            print("Could not write data to file: \(error)")
        }
    }

}

Recreate multiball - Guts

The actual logic lives in the MultiBall.swift file in the library’s source directory.

This code will look very familiar from Part 6, but since it will be vaporized shortly to make way for a new approach, I’ll save it here for posterity.

import Foundation

public func generateMultiBallUSDText(count:Int) -> String {
     let minX = -4.0
     let maxX = 4.0
     let minY = minX
     let maxY = maxX
     let minZ = minX
     let maxZ = maxX
     let minRadius = 0.8
     let maxRadius = 2.0
    
     @StringBuilder func  makeMultiBall(count:Int) -> String {
         let builder = USDAFileBuilder()
         builder.generateHeader(defaultPrim:"blueSphere")
         builder.buildItem("blueSphere", "sphere_base", "sphere", 0, 0, 0, 1, 0, 0, 1)

         for i in (0...count-1) {
             builder.buildItem(
                 "sphere_\(i)",
                 "sphere_base",
                 "sphere",
                 Double.random(in: minX...maxX),
                 Double.random(in: minY...maxY),
                 Double.random(in: minZ...maxZ),
                 Double.random(in: minRadius...maxRadius),
                 Double.random(in: 0...1),
                 Double.random(in: 0...1),
                 Double.random(in: 0...1)
             )
         }
     }
    
    return makeMultiBall(count: count)
}

struct USDAFileBuilder {

    @StringBuilder func generateHeader(defaultPrim:String, metersPerUnit:Double = 1, upAxis:String = "Y", documentationNote:String? = nil) -> String {
        "#usda 1.0\n("
        "\tdefaultPrim = \"\(defaultPrim)\""
        "\tmetersPerUnit = \(metersPerUnit)"
        "\tupAxis = \"\(upAxis)\""
        if let documentationNote {
            "doc = \"\(documentationNote)\""
        }
        ")"
    }

    func translateString(_ xoffset:Double, _ yoffset:Double, _ zoffset:Double) -> String {
        return "\tdouble3 xformOp:translate = (\(xoffset), \(yoffset), \(zoffset))"
    }

    func opOrderStringTranslateOnly() -> String {
        "\tuniform token[] xformOpOrder = [\"xformOp:translate\"]"
    }
        
    func colorString(_ red:Double, _ green:Double, _ blue:Double) -> String {
        "\t\tcolor3f[] primvars:displayColor = [(\(red), \(green), \(blue))]"
    }
        
    func  radiusString(_ radius:Double) -> String {
        "\t\tdouble radius = \(radius)"
    }

    @StringBuilder func  buildItem(_ id:String, _ reference_file:String, _ geometry_name:String, _ xoffset:Double, _ yoffset:Double, _ zoffset:Double, _ radius:Double, _ red:Double, _ green:Double, _ blue:Double) -> String {
        """
        \nover "\(id)" (\n\tprepend references = @./\(reference_file).usd@\n)\n{
        """
        
        if xoffset != 0 || yoffset != 0 || zoffset != 0 {
            translateString(xoffset, yoffset, zoffset)
            opOrderStringTranslateOnly()
        }
                
        """
        \tover "\(geometry_name)"\n\t{
        """
        colorString(red, green, blue)
        radiusString(radius)
        "\t}"
        "}"
    } 
}

@resultBuilder
struct StringBuilder {
    static func buildBlock(_ parts: String...) -> String {
        parts.joined(separator: "\n")
    }

    static func buildOptional(_ component:String?) -> String {
        component ?? ""
    }

    static func buildEither(first component: String) -> String {
        return component
    }

    static func buildEither(second component: String) -> String {
        return component
    }

    static func buildArray(_ components: [String]) -> String {
        components.joined(separator: "\n")
    }
}

How to run?

Oh the options now! From the project directory all of the following will work.

No need to explicitly build. swift run does that automatically. For more options don’t forget to:

swift build --help
swift run --help