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:
Tomorrow will focus more on using ResultBuilder to help make creating a generative USD easier.
ArgumentParser References
- https://swiftpackageindex.com/apple/swift-argument-parser/1.2.2/documentation/argumentparser/gettingstarted
- https://www.swift.org/blog/argument-parser/
- https://www.youtube.com/watch?v=pQt71tLmiac
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.
swift run
(multiball is the default command, with default options set)swift run sketchpad multiball -sc 4
swift run sketchpad multiball --help
swift run sketchpad multiball -o testFile.usd -c 32
No need to explicitly build. swift run
does that automatically. For more options don’t forget to:
swift build --help
swift run --help