Hello USD - Part 12: Lets make these FileBuilders cleaner
Currently both USDFileBuilder and X3DFileBuilder rely on MultiLineStringBuilder
, which newly ignores empty lines from the previous version of StringBuilder
shown in Part 8 I need to change these to use the StringNodeBuilder
instead.
@resultBuilder
struct MultiLineStringBuilder {
static func buildBlock(_ parts: String...) -> String {
buildArray(parts)
}
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.filter( { !$0.isEmpty } ).joined(separator: "\n")
}
}
Starting Off
First steps:
- Copy the code from yesterday into a new file in SketchPad.
- Clean out the extras
- Change the name of Nodeable -> StringNodeable
- rewrite
generateStringForStage
using theDocument
type.
//X3D
public func generateStringForStage(stage:Canvas3D) -> String {
let document = Document {
generateHeader()
sceneHeader()
for item in stage.content {
sphereBuilder(shape: item)
}
sceneFooter()
pageFooter()
}
return document.render(style: .indented)
}
//USD
public func generateStringForStage(stage:Canvas3D) -> String {
let document = Document {
generateHeader(defaultPrimID:stage.content[defaultPrimIndex].id)
for item in stage.content {
sphereBuilder(shape: item)
}
}
return document.render(style: .indented)
}
}
Worked as expected. first output
Next came the painstaking process of replacing every reference to @MultiLineStringBuilder
function returning a String with something that would return a StringNodeable
instead.
Refactoring the USDFileBuilder
Let’s start off by looking at what happened to sphereBuilder
@MultiLineBuilder func sphereBuilder(shape:Sphere) -> String {
"def Xform \"\(shape.id)\"\n{"
//"def Xform \"\(shape.shapeName)_\(shape.id)\"\n{"
if !shape.transformations.isEmpty {
transformString(shape:shape)
}
//"\tdef \(shape.shapeName) \"\(shape.shapeName.lowercased())_\(shape.id)\"\n\t{"
"\tdef \(shape.shapeName) \"\(shape.id.lowercased())\"\n\t{"
"\t\t\(extentString(shape: shape))"
if !shape.surfaces.isEmpty {
colorString(shape:shape)
}
"\t\t\(radiusString(shape.radius))"
"\t}"
"}"
}
Becomes:
func sphereBuilder(shape:Sphere) -> StringNodeable {
CurlyBraced(opening: "def Xform \"\(shape.id)\"") {
if !shape.transformations.isEmpty {
transformString(shape:shape)
}
CurlyBraced(opening: "def \(shape.shapeName) \"\(shape.id.lowercased())\"") {
"\(extentString(shape: shape))"
if !shape.surfaces.isEmpty {
colorString(shape:shape)
}
"\(radiusString(shape.radius))"
}
}
}
No more \t
, \n
, no more {
or }
manually written in to any of the strings with the help of a new struct CurlyBraced
.
struct CurlyBraced:StringNodeable {
let prefix: String
let suffix: String = "}"
var content: StringNode
init(opening:String, @StringNodeBuilder content: () -> [StringNode]) {
self.prefix = "\(opening) {"
self.content = .list(content())
}
var asStringNode: StringNode {
.container((prefix: prefix, content: content, suffix: suffix))
}
}
I could write custom struct
’s for each component of a USD file, but at this stage of the game, thats a lot of lines of very similar looking code for not a lot of payoff.
On the other hand, since .usd
files use both curly braces and parentheses, abstracting out a “Bracing” protocol made sense. I also added whitespace style choices: compact, semiCompact and expanded. USD files tend to use the expanded format with opening and closing braces on their own lines.
enum BraceSpacingStyle {
case compact, semiCompact, expanded
}
protocol Bracing:StringNodeable {
var braceOpener:String { get }
var braceCloser:String { get }
var precedingText:String? { get set }
var content: StringNode { get set }
var style:BraceSpacingStyle { get set }
var asStringNode: StringNode { get }
}
extension Bracing {
var asStringNode: StringNode {
switch style {
case .expanded:
guard let precedingText else {
fallthrough
}
return .list([
.content(precedingText),
.container((prefix: braceOpener, content: content, suffix: braceCloser))
])
case .semiCompact:
let start = precedingText != nil ? "\(precedingText!) \(braceOpener)" : "\(braceOpener)"
return .container((prefix: start, content: content, suffix: braceCloser))
case .compact:
let contentString = StringNode.stringify(node:content)
return .content("\(precedingText ?? "")\(braceOpener)\(contentString)\(braceCloser)")
}
}
}
struct CurlyBraced:Bracing {
let braceOpener:String = "{"
let braceCloser:String = "}"
var precedingText: String?
var style: BraceSpacingStyle
var content: StringNode
init(opening:String? = nil, style:BraceSpacingStyle = .semiCompact, @StringNodeBuilder content: () -> [StringNode]) {
self.precedingText = opening
self.content = .list(content())
self.style = style
}
}
struct Parens:Bracing {
let braceOpener:String = "("
let braceCloser:String = ")"
var precedingText: String?
var style: BraceSpacingStyle
var content: StringNode
init(opening:String? = nil, style:BraceSpacingStyle = .semiCompact, @StringNodeBuilder content: () -> [StringNode]) {
self.precedingText = opening
self.content = .list(content())
self.style = style
}
}
- TODO: see if I could somehow include the init in the protocol. Or yet another candidate for a macro?
- TODO: What if content is empty or only 1 item? Does it matter?
With all that implemented my generateHeader function can now look like:
func generateHeader(defaultPrimID:String,
fileType:String = "usda",
version:String = "1.0") -> StringNode {
var metaData = [
"defaultPrim":defaultPrimID.quoted(),
"metersPerUnit":"\(metersPerUnit)",
"upAxis":upAxis.quoted()
]
if let documentationNote {
metaData["documentationNote"] = documentationNote
}
return Parens(opening: "#\(fileType) \(version)",
style: .expanded,
content: { .list(dictionaryToEqualSigns(dict: metaData)) }
).asStringNode
}
func dictionaryToEqualSigns(dict:Dictionary<String,String>) ->
[StringNode] {
var tmp:[StringNode] = []
for (key, value) in dict {
tmp.append(.content("\(key) = \(value)"))
}
return tmp
}
Note the dictionary based metadata generator waiting to for a time when I might have other things to add. (It gets updated at the end of this post.)
Also a project level String
extension will help make adding escaped quotes easier.
extension String {
func brace(with e:String) -> String {
"\(e)\(self)\(e)"
}
func quoted() -> String{
brace(with:"\"")
}
}
To generate:
#usda 1.0
(
upAxis = "Y"
defaultPrim = "Sphere_53140"
metersPerUnit = 1
)
Refactoring the X3DFileBuilder
X3D
is XML
, which has tags, not braces. Tags, like the metadata in USD files, can make attributes from a varying number of key value pairs, so again I use a dictionary to drive the String
creation.
struct Tag:StringNodeable {
let name:String
let attributes:Dictionary<String, String>?
var prefix: String {
if let attributes {
return "<\(name)\(Self.attributesFromDictionary(attributes))>"
} else {
return "<\(name)>"
}
}
var suffix: String { "</\(name)>" }
var content: StringNode
init(_ name:String, attributes:Dictionary<String, String>? = nil, @StringNodeBuilder content: () -> [StringNode]) {
self.name = name
self.attributes = attributes
self.content = .list(content())
}
var asStringNode: StringNode {
.container((prefix: prefix, content: content, suffix: suffix))
}
}
extension Tag {
static func attributesFromDictionary(_ dict:Dictionary<String, String>) -> String {
var tmp:String = ""
for (key, value) in dict {
tmp.append(" \(key)=\(value.embrace(with: "'"))")
}
return tmp
}
}
That helper struct let me refactor thesphereBuilder
from:
@StringBuilder func sphereBuilder(shape:Sphere) -> String {
if !shape.transformations.isEmpty {
for transform in shape.transformations {
transformStart(transform:transform)
}
}
"<shape>"
"\t<appearance>"
if !shape.surfaces.isEmpty {
materialString(shape.surfaces)
}
"\t</appearance>"
"\t<sphere radius='\(shape.radius)'></sphere>"
"</shape>"
if !shape.transformations.isEmpty {
for _ in 0..<shape.transformations.count {
transformClose()
}
}
}
func transformStart(transform:Transformation) -> String {
switch transform {
case .translate(let v):
return "<transform translation='\(v.x) \(v.y) \(v.z)'>"
// default:
// fatalError()
}
}
func transformClose() -> String {
"</transform>"
}
To:
func sphereBuilder(shape:Sphere) -> StringNodeable {
var content = Tag("Shape") {
Tag("Appearance") {
if !shape.surfaces.isEmpty {
//function unchanged from previous code for now
//still needs updating at this point.
materialString(shape.surfaces)
}
}
"<Sphere radius='\(shape.radius)'></Sphere>"
}
if !shape.transformations.isEmpty {
//Last closest to the content.
let orderedTransforms = shape.transformations.reversed()
for item in orderedTransforms {
let attributes = transformAttribute(transform: item)
content = Tag("Transform", attributes:attributes) { content }
}
}
return content
}
func transformAttribute(transform:Transformation) -> Dictionary<String,String> {
switch transform {
case .translate(let v):
return ["translation":"\(v.x) \(v.y) \(v.z)"]
}
}
One last thing…
Added to USDBuilder
fileprivate extension Dictionary<String, String> {
func equalSigns() ->
[String] {
var tmp:[String] = []
for (key, value) in self {
tmp.append("\(key) = \(value)")
}
return tmp
}
}
Now allows me to write the return of USDBuilder’s generateHeader all the more cleanly.
return Parens(opening: "#\(fileType) \(version)",
style: .expanded,
content: { metaData.equalSigns() }
)
}
I gave the same treatment to few functions that were formatting dictionaries. I marked them as file private because different FileBuilders will have different needs.
Next Steps
One could spend a very long time writing custom file builders with type safe specialized tags, etc. That’s not the goal here because my source of truth for these will always be a strongly typed data structure. I’ve already in APItizer worked with turning structs
into Dictionaries if I end up needing that.
I’m pleased enough with the improved ergonomics for writing file builders that I can take a chip at my next task: .usda
file validation.
Before my files get too fancy, I’d like to figure out how to submit my USD files to usdchecker
automatically on creation so I get instant warning if something goes wrong. In an amazing world I’d be able to do that as part of XCTest
, but one step at a time.