Hello USD - Part 9: Parlez vous MultiBall? DSL starts here

Wanna really never ship? Start writing a Domain Specific Language…

Honestly though, Swift enables doing that fairly easily with ResultBuilders, already in use in this project with StringBuilder. I’m assuming the reader has at least heard of ResultBuilders. SwiftUI uses them for View creation, for example. References provided in case they are totally new.

Today’s post documents the creation of CanvasBuilder. For this post it will be using the concrete type Sphere instead of its future TDB more abstract type because ResultBuilders are MUCH simpler with concrete types, although that has recently improved.

I used this code:

public struct MultiBallStage {
    public init(count:Int) {
        self.count = count
    }
    let count:Int
    let minTranslate = -4.0
    let maxTranslate = 4.0
     let minRadius = 0.8
     let maxRadius = 2.0

    public func buildStage() -> Canvas3D {
        Canvas3D {
            //blue origin sphere
            Sphere(radius: 1.0).color(red: 0, green: 0, blue: 1.0)
            //the multi in multiball
            for _ in 0..<count {
                Sphere(radius: Double.random(in: minRadius...maxRadius))
                .color(
                    red: Double.random(in: 0...1), 
                    green: Double.random(in: 0...1), 
                    blue: Double.random(in: 0...1)
                )
                .translateBy(Vector.random(range: minTranslate...maxTranslate))
            }
        }
    } 
}

To create this USDA file

13 spheres again, new colors and new layout

raw file

A difference to classic multiball? Its all one file. No references. That’s a feature for another day.

Setup Info

References

Relevant Repos:

Basics:

Excellent Example:

More:

TODO:

Non-String Custom Type Result Builder

It was kind of trick to find examples of ResultBuilders that weren’t concatenating Strings. String has a bit of recursion in that a String is both a single thing and a Sequence itself. A Sphere, on the other hand, can never be a collection of Spheres, so the String based examples didn’t show me everything I needed.

Running the following code in a Playground prints the number 14 to the console.

// The fundamental type
public struct Question {
    let question = "What is the meaning of life, the universe, everything?"
    let answer:Int = 42
}

// The distinct container type
public struct Exam {
    let content:[Question]
    public init(@TestBuilder content: () -> [Question]) {
        self.content = content()
    }
}

@resultBuilder
public enum TestBuilder {
    
    // Essential
    // Don't use (_ components: Question...) -> [Question] { components }
    public static func buildBlock(_ components: [Question]...) -> [Question] {
        components.flatMap { $0 }
    }
    
    // Allows for using both arrays and single items
    public static func buildExpression(_ expression: [Question]) -> [Question] {
        expression
    }
    
    public static func buildExpression(_ expression: Question) -> [Question] {
        [expression]
    }
    
    // Allows if's with no else's
    public static func buildOptional(_ component: [Question]?) -> [Question] {
        component ?? []
    }
    
    // Allows if-else statements
    public static func buildEither(first component: [Question]) -> [Question] {
        component
    }
    
    public static func buildEither(second component: [Question]) -> [Question] {
        component
    }
    
    // Allows for loops
    public static func buildArray(_ components: [[Question]]) -> [Question] {
        return components.flatMap { $0 }
    }
}

func run(count: Int) {
    let build = Exam {
        Question()
        Question()
        Question()
        for _ in 0...5 {
            Question()
        }
        if 5 == count {
            Question()
        } else {
            Question()
            Question()
        }
        [Question(), Question(), Question()]
    }
    print(build.content.count)
}

run(count: 3)

The only absolutely necessary function in th ResultBuilder is

public static func buildBlock(_ components: [Question]...) -> [Question] {
    components.flatMap { $0 }
}

Using (_ components: Question...) -> [Question] { components } works until any of the other functions are also implemented. Errors like Cannot pass array of type '[Sphere]' as variadic arguments of type 'Sphere' refer to this type miss-match that gets hidden in String based examples.

Lining the DSL up with USD

The nice thing about Multiball is that it’s all Spheres, so I that’s only one concrete type needed in my Canvas for now.

But what does that type look like and how does it work with USD Format output?

First off we have the protocol Geometry which mashes together Boundable, Surfaceble and Transformable, which roughly translate to UsdGeomBoundable, and I think Imagable and Xform? It should end up feeling like GPrim

Boundable types can return a Bounds3D modeled after mdlaxisalignedboundingbox and what USD files have written down as Extent

Transformable types are those things that can receive the common 3D linear transformations (translate, rotate, scale).

Surfacable for now means could one apply a material or a color to it. Hopefully one day a shader as well!

The functions that apply surfaces and transform geometries cannot be mutating, but instead must return something that the CanvasBuilder will accept like ViewModifiers in SwiftUI.

In this, our proto CanvasBuilder, that means Spheres.

public protocol Geometry:Transformable & Boundable & Surfaceable {
    var id:String { get }
    var shapeName:String { get } //This might become an enum? 
}
public protocol Boundable {
    var currentBounds:Bounds3D { get }
}

public struct Bounds3D {
    var minBounds:Vector
    var maxBounds:Vector
}
public enum Transformation {
    case translate(Vector)
}

public protocol Transformable {
    var transformations:[Transformation] { get set }
    mutating func translateBy(_ vector:Vector) -> Self
}
public extension Transformable {
    func translateBy(_ vector: Vector) -> Self {
        var copy = self
        copy.transformations.append(.translate(vector))
        return copy
    }
}
public enum Surface {
    case diffuseColor((r:Double, g:Double, b:Double))
    case emissiveColor((r:Double, g:Double, b:Double))
    case metallic(Double)
    case displayColor((r:Double, g:Double, b:Double)) // Only one that matters for now.
}

public protocol Surfaceable {
    var surfaces:[Surface] { get set }
    //func diffuseColor(red:Double, green:Double, blue:Double) -> Self
    //func emissiveColor(red:Double, green:Double, blue:Double) -> Self
    func color(red:Double, green:Double, blue:Double) -> Self
}
public extension Surfaceable {
    func color(red:Double, green:Double, blue:Double) -> Self {
        var copy = self
        copy.surfaces.append(.displayColor((red, green, blue)))
        return copy
    }
}

The current implementation of sphere has a lot of boiler plate… is this a possible future macro?


public struct Sphere:Geometry {
    static var shapeName = "Sphere"
    //UUID are too long
    public let id:String = IdString.make(prefix: Self.shapeName)
    let radius:Double
    
    public var shapeName: String {
        Self.shapeName
    }
    
    public init(radius: Double, transformations:[Transformation] = []) {
        self.radius = radius
        self.transformations = transformations
    }

    //Boundable
    public var currentBounds: Bounds3D {
        let minVect = Vector(x: -radius, y: -radius, z: -radius)
        let maxVect = Vector(x: radius, y: radius, z: radius)
        return Bounds3D(minBounds: minVect, maxBounds: maxVect)
    }

    //Transformable
    public var transformations:[Transformation] = []

    
    //Surfaceable
    public var surfaces:[Surface] = []
    
}

Like with regular USD, the layout of all the spheres in the scene has to go to a “renderer”. Unlike with USD, I don’t have to do any Graphics Programming, I just need to mash text together.

The New USDAFileBuilder takes in a canvas and it’s attendant spheres and some meta data and uses that data structure to create a USD file with a lot more flexibility than the USDAFileBuilder.


    import Foundation

    public struct USDAFileBuilder {
        var stage:Canvas3D
        var defaultPrimIndex:Int
        let metersPerUnit:Int
        let upAxis:String
        let documentationNote:String?
        
        public init(stage: Canvas3D, 
                    defaultPrimIndex: Int = 0, 
                    metersPerUnit: Int = 1, 
                    upAxis: String = "Y", 
                    docNote:String? = nil) {
            self.stage = stage
            self.defaultPrimIndex = defaultPrimIndex
            self.metersPerUnit = metersPerUnit
            self.upAxis = upAxis
            self.documentationNote = docNote
        }
        
        @StringBuilder func generateHeader() -> String {
            "#usda 1.0\n("
            "\tdefaultPrim = \"\(stage.content[defaultPrimIndex].id)\""
            "\tmetersPerUnit = \(metersPerUnit)"
            "\tupAxis = \"\(upAxis)\""
            if let documentationNote {
                "doc = \"\(documentationNote)\""
            }
            ")"
        }
            
        func colorString(_ red:Double, _ green:Double, _ blue:Double) -> String {
            "color3f[] primvars:displayColor = [(\(red), \(green), \(blue))]"
        }

        func colorString(shape:Geometry) -> String {
            //There has been a problem. 
            if shape.surfaces.count != 1 { fatalError() }
            let color = shape.surfaces[0]
            switch color {
                case .displayColor(let c):
                    return "\t\t\(colorString(c.r, c.g, c.b))"
                default:
                    fatalError()
            }
        }
            
        func  radiusString(_ radius:Double) -> String {
            "double radius = \(radius)"
        }

        func extentString(shape:Geometry) -> String {
            let minBounds = shape.currentBounds.minBounds
            let maxBounds = shape.currentBounds.maxBounds
            return "float3[] extent = [(\(minBounds.x), \(minBounds.y), \(minBounds.z)), (\(maxBounds.x), \(maxBounds.y), \(maxBounds.z))]"
        }

        //TODO: Right now, can do the one and only one transform
        func transformString(shape:Geometry) -> String {
            if shape.transformations.count != 1 { return "" }
            let translate = shape.transformations[0]
            switch translate {
                case .translate(let v):
                return """
                \t\tdouble3 xformOp:translate = (\(v.x), \(v.y), \(v.z))
                \t\tuniform token[] xformOpOrder = [\"xformOp:translate\"]
                """ 
            }

        }

        @StringBuilder 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))"
            //TODO: How to handle surfaces more generally
            if !shape.surfaces.isEmpty {
                colorString(shape:shape)
            }
            //This is what makes it a SPHERE builder.
            "\t\t\(radiusString(shape.radius))"
            "\t}"
            "}"
        }

        @StringBuilder public func generateStringFromStage() -> String {
            generateHeader()
            for item in stage.content {
                sphereBuilder(shape: item)
            }
        }
    }

Getting and Saving the Result

For now the CLI creates a MultiBallStage struct passing in the count and then requests the output of the generateStringFromStage() function, saving it to a file. Everything else in the CLI remains the same.

let fileBuilder = USDAFileBuilder(stage: MultiBallStage(count:count).buildStage())
let fileString:String = fileBuilder.generateStringFromStage()

Next time on Why No Test Flight…

It’s a mess, but I made a matching X3D FileBuilder as well.

raw file

Screenshot of QuickLook preview of USD file full of multi colored spheres that matches the embedded X3D file above. That the spheres are lower-poly meshes is of note.

raw file