Hello USD - Part 21: What if I refactored SketchPad to work like SwiftUI?

This article is part of a series.

I would like to be able to put items other than Spheres in my SketchPad generated OpenUSD files.

In order to do that I need to refactor Canvas3D so its existential type isn’t [Sphere].

Arrays must contain items all of the same type in Swift, so a Canvas3D like [Sphere, Cube, Cone, Cube, Sphere] won’t just come for free.

Some possible approaches -

Since I’d already done my own data-type enum for StringNode I thought I’d look into Option 3. The first parts of the SwiftUI Layout Explained series and SwiftUI-Style backend Library from Swift Talk made that possible.

Complete code as gist: https://gist.github.com/carlynorama/3e6765d4a87aaaf3fe2f69abb14764ca

Branch-Type, Leaf-Type

This method has an interesting way of knowing when a leaf is a leaf by creating two separate but interwoven protocols.

First create the base protocol which looks pretty recursive and nonsensical:

public protocol Layer {
    associatedtype Content:Layer
    var content: Content { get }
}

Make a thing, whose only rule is that it has another thing that also is a thing. With zero explanation as to what makes a thing other than that. None. Shenanigans. This will be the public type everyone uses.

Next make ANOTHER protocol which will be the leaf type.

//Indicate a leaf by conforming it to renderable.
protocol RenderableLayer {
    var id:String { get }
    func render()
    typealias Content = Never
}

extension RenderableLayer {
    func render() {
        print("\(self.id)")
    }
}

It does NOT conform to the Layer type, but items that conform to it should be able to get Layer conformance for free. Therefore Never needs to be a Layer, too.

public extension Layer where Content == Never {
    var content: Never { fatalError("This should never be called.") }
}

extension Never: Layer {
    public var id:String { "Never" }
    public typealias Content = Never
}

Walking the Tree

When walking the tree, how will we know if we’re at a leaf or not? The _render function on Layer will check if the current node conforms to the leaf protocol, and if so, get the leaf’s render function, which exits the loop. Otherwise, the current layer will dive down into its content’s ._render, which must exist, because it must have content that also conforms to Layer. If implementing this for real the current location in the tree or other context information could be passed through via parameters in the render functions, i.e. render(context:SomeContextType)

public extension Layer {
    func _render()  {
        if let leaf = self as? RenderableLayer {
            leaf.render()
        } else {
            content._render()
        }
    }
}

Building a Layer

Let’s say my goal is something like:

let insert = Assembly {
    Triangle()
    Triangle()
    Triangle()
}

let test = Assembly {
    Circle()
    Square()
    insert
    Circle()
    Circle()
}

test._render()

to resulting in a printed list like:

Circle
Square
Triangle
Triangle
Triangle
Circle
Circle

SwiftUI itself uses the new easier resultBuilder to do something like:

@resultBuilder
public enum LayerBuilder {

    public static func buildPartialBlock<L: Layer>(first: L) -> some Layer {
        first
    }
    
    public static func buildPartialBlock<L0: Layer, L1: Layer>(accumulated: L0, next: L1) -> some Layer {
        Tuple2Layer(first: accumulated, second: next)
    }
}

struct Tuple2Layer<First:Layer, Second:Layer>: Layer, RenderableLayer {
    var id:String { "Tuple" }
    var first: First
    var second: Second
    
    init(first:First, second:Second) {
        self.first = first
        self.second = second
    }
    
    func render() {
        first._render()
        second._render()
    }
}

A TupleView really exists in SwiftUI. It’s an aggregating type for exactly this situation.

An Assembly can use the LayerBuilder for an initializer:

struct Assembly<Content:Layer>:Layer {
    var content: Content
    
    public init(@LayerBuilder content: () -> Content) {
        self.content = content()
    }
}

And the geometries can all conform to Layer & RenderableLayer:

protocol Geometry: Layer & RenderableLayer {}

struct Circle:Geometry {
    var id:String { "Circle" }
}

struct Square:Geometry {
    var id:String { "Square" }
}

struct Triangle:Geometry {
    var id:String { "Triangle" }
}

And the goal code prints out the goal result.

If we print the test Assembly, we can see that the resulting memory bristles with Tuple2Layers.

print(test)
//results in 
Assembly<Tuple2Layer<Tuple2Layer<Tuple2Layer<Tuple2Layer<Circle, Square>, 
Assembly<Tuple2Layer<Tuple2Layer<Triangle, Triangle>, Triangle>>>, Circle>, 
Circle>>(content: __lldb_expr_55.Tuple2Layer<__lldb_expr_55.
Tuple2Layer<__lldb_expr_55.Tuple2Layer<__lldb_expr_55.
Tuple2Layer<__lldb_expr_55.Circle, __lldb_expr_55.Square>, __lldb_expr_55.
Assembly<__lldb_expr_55.Tuple2Layer<__lldb_expr_55.
Tuple2Layer<__lldb_expr_55.Triangle, __lldb_expr_55.Triangle>, 
__lldb_expr_55.Triangle>>>, __lldb_expr_55.Circle>, __lldb_expr_55.Circle>
(first: __lldb_expr_55.Tuple2Layer<__lldb_expr_55.Tuple2Layer<__lldb_expr_55.
Tuple2Layer<__lldb_expr_55.Circle, __lldb_expr_55.Square>, __lldb_expr_55.
Assembly<__lldb_expr_55.Tuple2Layer<__lldb_expr_55.
Tuple2Layer<__lldb_expr_55.Triangle, __lldb_expr_55.Triangle>, 
__lldb_expr_55.Triangle>>>, __lldb_expr_55.Circle>(first: __lldb_expr_55.
Tuple2Layer<__lldb_expr_55.Tuple2Layer<__lldb_expr_55.Circle, __lldb_expr_55.
Square>, __lldb_expr_55.Assembly<__lldb_expr_55.Tuple2Layer<__lldb_expr_55.
Tuple2Layer<__lldb_expr_55.Triangle, __lldb_expr_55.Triangle>, 
__lldb_expr_55.Triangle>>>(first: __lldb_expr_55.Tuple2Layer<__lldb_expr_55.
Circle, __lldb_expr_55.Square>(first: __lldb_expr_55.Circle(), second: 
__lldb_expr_55.Square()), second: __lldb_expr_55.Assembly<__lldb_expr_55.
Tuple2Layer<__lldb_expr_55.Tuple2Layer<__lldb_expr_55.Triangle, 
__lldb_expr_55.Triangle>, __lldb_expr_55.Triangle>>(content: __lldb_expr_55.
Tuple2Layer<__lldb_expr_55.Tuple2Layer<__lldb_expr_55.Triangle, 
__lldb_expr_55.Triangle>, __lldb_expr_55.Triangle>(first: __lldb_expr_55.
Tuple2Layer<__lldb_expr_55.Triangle, __lldb_expr_55.Triangle>(first: 
__lldb_expr_55.Triangle(), second: __lldb_expr_55.Triangle()), second: 
__lldb_expr_55.Triangle()))), second: __lldb_expr_55.Circle()), second: 
__lldb_expr_55.Circle()))

I’m not convinced, aesthetically, I like this as the resting storage for my Stage. It does not …feel… composed. It feels smashed.

Next Steps

Right now I have FileBuilders (USD or X3D) that can take in a “Stage”, munch through the data to spit out the needed text files. This model feels more like I’d have to create a Builder-Context Class to give to the render function which will pass it (by reference?) through the tree collecting each nodes contribution?

Also, the advantage of this Option 3 approach over a hard coded list of allowed geometries in an enum is that more kinds can be added than I can think of via extensions. But how to handle the render information not being available for also-addable Builder type?

So plenty more staring off into space and pushing pieces of code around until it feels right to do.

References

This article is part of a series.