Hello USD - Part 16: Swift just sipping OpenUSD through... a pipe!
This article is part of a series.
I’ve mostly been working with OpenUSD via the command line, so I decided to take initial pass at interfacing with the OpenUSD library in Swift trough a Swift launched shell.
I don’t need much. I’d like to:
Related post: Part 7: Where my error messages at????
I’m going to write the library package and a cli executable package separately a lá the SwiftPNG and clipng pair to keep the cli code far away from the package I’ll eventually bring into SketchPad.
New Repos: USDTestingCLI | USDServiceProvider
The first chunk of the post covers setting up that CLI/Package pair and adding a shell call (Process()
). The OpenUSD part starts at “Really getting started: usdcat”
End of Post Tagged Commits:
- https://github.com/carlynorama/USDTestingCLI/releases/tag/v0.0.0_proof_of_concept
- https://github.com/carlynorama/USDServiceProvider/releases/tag/v0.0.0_proof_of_concept
Make the project scaffolding
Step 1: Executable Package Test
I had XCode open so I didn’t use the command line, I just File > New > Package
’d from the menu to create USDTestingCLI
and made the following changes
Update USDTestingCLI.swift
@main
public struct USDTestingCLI {
public static func main() {
print("Hello world!")
}
}
Update Package.swift
// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "USDTestingCLI",
products: [
.executable(
name: "myusdtests",
targets: ["USDTestingCLI"]),
],
dependencies: [ ],
targets: [
.executableTarget(
name: "USDTestingCLI",
dependencies: []),
//None yet.
// .testTarget(
// name: "USDTestingCLITests",
// dependencies: ["USDTestingCLI"]),
]
)
Hit the Run
button (cmd-R) and “Hello world!” showed up in the console. Done.
Step 2: Make Library Package
Again just made another default package from File > New > Package
. This time making no changes, so the main file in Sources stays like:
USDServiceProvider.swift
No Changes.
public struct USDServiceProvider {
public private(set) var text = "Hello, World!"
public init() {
}
}
Step 3: Link the two
USDTestingCLI - USDTestingCLI.swift
Update the main file in the CLI Package to:
import Foundation
import ArgumentParser
import USDServiceProvider
@main
public struct USDTestingCLI:ParsableCommand {
public static let configuration = CommandConfiguration(
abstract: "A Swift command-line tool to create 3D files from simple instructions",
version: "0.0.1",
subcommands: [
test.self,
],
defaultSubcommand: test.self)
public init() {}
struct test:ParsableCommand {
func run() throws {
print(USDServiceProvider().text)
}
}
}
USDTestingCLI - Package.swift
Add a local dependency.
// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "USDTestingCLI",
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.executable(
name: "myusdtests",
targets: ["USDTestingCLI"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.2"),
.package(path: "../USDServiceProvider"),
],
targets: [
.executableTarget(
name: "USDTestingCLI",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "USDServiceProvider", package: "USDServiceProvider"),
]),
.testTarget(
name: "USDTestingCLITests",
dependencies: ["USDTestingCLI"]),
]
)
Again, running via XCode (cmd-R) should work to see “Hello, World!” in the console.
Step 4: Add shell commands
USDServiceProvider - USDServiceProvider.swift
Next I’m going to pull from previous work
Useful links for understanding this code:
- https://stackoverflow.com/questions/26971240/how-do-i-run-a-terminal-command-in-a-swift-script-e-g-xcodebuild
- https://rderik.com/blog/using-swift-for-scripting/
- https://developer.apple.com/documentation/foundation/process
- https://www.hackingwithswift.com/example-code/system/how-to-run-an-external-program-using-process
I added the following to the USDServiceProvider
struct:
public func echo(_ input:String) -> String {
if input.isEmpty {
return "[crickets chirping]"
} else {
let message = try? shell("echo \(input)")
return (message != nil) ? message! : "nothing to say"
}
}
@discardableResult // Add to suppress warnings when you don't want/need a result
func shell(_ command: String) throws -> String {
let task = Process()
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = pipe
task.arguments = ["-c", command]
task.standardInput = nil
task.executableURL = URL(fileURLWithPath: "/bin/bash") //<-- what shell
try task.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)!
return output
}
public func whatsInMyBin() {
let testPath = "/usr/bin"
let ls = Process()
//https://manned.org/env.1
ls.executableURL = URL(fileURLWithPath: "/usr/bin/env")
ls.arguments = ["ls", "-al", testPath]
do{
try ls.run()
} catch {
print(error)
}
}
And changed the CLI test struct to:
struct test:ParsableCommand {
func run() throws {
print(USDServiceProvider().whatsInMyBin())
print(USDServiceProvider().echo("can you hear me?"))
}
}
In a shell program (because the output is long):
cd ~/Developer/GitHub/USDTestingCLI & swift run myusdtests
I get a very very very long list of commands I could potentially use in my program!
Really getting started: usdcat
usdcat
is a freestanding binary with no python dependencies. usdchecker
, on the other hand, is a python script with a shebang on the top. While it is “clickable”, it is not contained. Getting usdcat
to run on a known valid .usda
file will be much easier to implement than usdchecker
. The goal:
usdcat -o $OUTPUT_NAME --flatten $INPUT_ROOT_FILE
Since I know the location of the OpenUSD build I want to use, just to see if it works, I can change USDServiceProvider
to:
public struct USDServiceProvider {
public private(set) var pathToBin:String
public init(_ pathToUSD:String? = nil) {
pathToBin = "/Users/USERNAME/opd/USD_nousdview_0722/bin"
}
public func usdcatHelp() -> String {
let message = try? Self.shell("\(pathToBin)/usdcat -h")
return (message != nil) ? message! : "nothing to say"
}
//NOTE: Changed to static
@discardableResult // Add to suppress warnings when you don't want/need a result
static func shell(_ command: String) throws -> String {
let task = Process()
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = pipe
task.arguments = ["-c", command]
task.standardInput = nil
task.executableURL = URL(fileURLWithPath: "/bin/bash") //<-- what shell
try task.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)!
return output
}
}
I can call it!
But can I use it?
public func makeUSDC() {
let inputFile = "/Users/USERNAME/Developer/GitHub/USDTestingCLI/compress_me.usda"
let outputFile = "/Users/USERNAME/Developer/GitHub/USDTestingCLI/compressed.usdc"
print(try? shell("pwd"))
let message = try? shell("\(pathToBin)/usdcat -o \(outputFile) --flatten \(inputFile)")
print(message)
}
Success!!!
Note the call to pwd
there. When using the XCode Run / Cmd-R button the print out reads:
Optional("/Users/USERNAME/Library/Developer/Xcode/DerivedData/USDTestingCLI-dabxknpcmjbcpiexpxknncyhvmiy/Build/Products/Debug\n")
But swift run myusdtests
from a shell within the CLI’s directory prints out the more reasonable:
Optional("/Users/USERNAME/Developer/GitHub/USDTestingCLI\n")
Just something to keep in mind when setting the input and output file locations. XCode runs a CLI in a quarantined area, like an App, not in the pwd
of the project. Using full paths made it easier to run this quick test.
Using The Python for usdchecker
usdchecker
does not work with the default flavor of python installed on MacOS (3.11), although that appears to be fixable (see aside).
ASIDE: boost problem w/ 3.11 (https://github.com/boostorg/python/pull/385), according to comment in OpenUSD build script can set a different version of boost via a build arg? --build-args boost,cxxflags=
IF the caller’s python environment could be dependably be the same as the build’s python environment, simply adding the following to the shell function would be fine:
var environment = ProcessInfo.processInfo.environment
environment["PYTHONPATH"] = "\(pathToPython)"
task.environment = environment
print(task.environment ?? "")
I don’t want to have to remember to only ever call my script from the perfect environment. My code can remember that for me. I already have script I can work from documented in a SETUP file.
USDService provider gets a new extension
extension USDServiceProvider {
func environmentWrap(_ newCommand:String, python:PythonEnvironment) -> String {
"""
\(python.setString)
export PATH=$PATH:\(pathToBaseDir)/bin;
export PYTHONPATH=$PYTHONPATH:\(pathToBaseDir)/lib/python
\(newCommand)
"""
}
public enum PythonEnvironment {
case defaultSystem
case pyenv(String)
case systemInstallMacOS(String)
case customPath(String)
var setString:String {
switch self {
case .defaultSystem:
return ""
case .pyenv(let v):
return setPythonWithPyEnv(version: v)
case .systemInstallMacOS(let v):
return setPythonSystemMacOS(version: v)
case .customPath(let p):
return prependPath(customString: p)
}
}
func setPythonWithPyEnv(version:String) -> String {
"""
export PYENV_ROOT="$HOME/.pyenv"
command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
export PYENV_VERSION=\(version)
"""
}
func setPythonSystemMacOS(version:String) -> String {
"""
PATH="/Library/Frameworks/Python.framework/Versions/\(version)/bin:${PATH}"
export PATH
"""
}
func prependPath(customString:String) -> String {
"""
PATH="\(customString):${PATH}"
export PATH
"""
}
}
}
A new initializer
public init(pathToUSDBuild:String, pythonEnv:PythonEnvironment) {
self.pathToBaseDir = pathToUSDBuild
self.pythonEnv = pythonEnv
}
and an update to the shell command
task.standardError = pipe
//BELOW: NEW WRAPPER FOR TASK ARG
task.arguments = ["-c", environmentWrap(command, python: pythonEnv)]
task.standardInput = nil
The CLI gets a new command
struct checkncrate:ParsableCommand {
@Argument(help: "The input file") var inputFile: String
@Argument(help: "The output file") var outputFile: String?
func run() throws {
//TODO: fragile. if cli sticks around, improve.
let outputFilePath = outputFile ?? inputFile.replacingOccurrences(of: ".usda", with: ".usdc")
let usdSP = USDServiceProvider(pathToUSDBuild: USDBuild, pythonEnv: .pyenv("3.10"))
print("hello")
let result = usdSP.check(inputFile)
print(result)
usdSP.makeCrate(from: inputFile, outputFile: outputFilePath)
}
}
cd ~/Developer/GitHub/USDTestingCLI & swift run myusdtests compress_me.usda
results in…
FAILURE!!! I mean SUCCESS!!! I mean getting info about a failure IS success!!
WHEW!
Still a bit of a way to go before USDServiceProvider actually provides the services I want for SketchPad, but a nice start.
Wait Wait… one more thing.
A OpenUSD build isn’t confirmed working until the hello-world script is run…
Added to USDTestingCLI.swift
struct helloworld:ParsableCommand {
@Argument(help: "The output file") var outputFile: String?
func run() throws {
//TODO: also fragile. if cli sticks around, improve.
let outputFilePath = outputFile ?? "~/Documents/hello_world.usda"
let usdSP = USDServiceProvider(pathToUSDBuild: USDBuild, pythonEnv: pythonEnv)
usdSP.saveHelloWorld(to: outputFilePath)
}
}
Added to USDServiceProvider.swift
// Works from Terminal other shell program, not so much XCode b/c of pyenv
// TODO: Try on computer with system python USD Build
public func saveHelloWorld(to outputLocation:String) {
let current = URL(string: #file)
let dir = current!.deletingLastPathComponent()
let message = try? shell("python3 \(dir)/python_scripts/hello_world.py \(outputLocation)")
print("message:\(message ?? "no message")")
}
NEW: Sources/USDServiceProvider/python_scripts/hello_world.py
from pxr import Usd, UsdGeom
import sys
def main(output):
stage = Usd.Stage.CreateNew(output)
xformPrim = UsdGeom.Xform.Define(stage, '/hello')
spherePrim = UsdGeom.Sphere.Define(stage, '/hello/world')
stage.GetRootLayer().Save()
if __name__ == "__main__":
main(sys.argv[1])
Hello Sphere!!