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:

    //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
    }
}

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.