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 -
- Option 1: Some flavor of type erasing to keep an Array as the backing data structure
- an enum of allowed geometry shapes
- a Geometry protocol with an accompanying AnyGeometry existential/type eraser
- Option 2: Custom Collection type of some sort
- Option 3: A total reorg to be more like SwiftUI with a “Layer” type in the vein of SwiftUI’s View
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
- NotSwiftUI series: https://talk.objc.io/episodes/S01E225-view-protocols-and-shapes
- Using SwiftUI to make a server path builder: https://talk.objc.io/episodes/S01E343-swiftui-style-backend-library
- More on TupleView: https://forums.swift.org/t/swiftui-viewbuilder-result-is-a-tupleview-how-is-apple-using-it-and-able-to-avoid-turning-things-into-anyview/28181
- Example of the Tuple/ResultBuilder pattern: https://github.com/apple/swift-certificates/blob/8debe3f20df931a29d0e5834fd8101fb49feea42/Sources/X509/Verifier/AnyPolicy.swift#L41