Repository: coldtype/coldtype (language: python)

Issue:
`.reversePens()` treats glyph ordering differently, when using multiline in Glyphwise
As soon, as the Glyphwise utilizes a `\n` for multiline, the `reversePens()` reorders the entire words instead of the single glyphs, as it does when there is only a  one-line-string.

This might be desired or a bug. If it’s desired, how can one achieve the glyph reordering in a multiline Glyphwise string?

compare:

```py
# A
@aframe((1080, 1080), bg=0)
def reverse_as_expected(f):
    return P(
            Glyphwise("COLD", lambda g:
            Style(Font.ColdObvi(), 300, wght=1, tu=-100))
            .align(f.a.r)
            .understroke()
            .reversePens()
            )

# B
@aframe((1080, 1080), bg=0)
def reverse_not_as_expected(f):
    return P(
            Glyphwise("COLD\nTYPE", lambda g:
            Style(Font.ColdObvi(), 300, wght=1, tu=-100))
            .align(f.a.r)
            .understroke()
            .reversePens()
            )
```

A
<img width="538" alt="Screenshot 2022-06-12 at 12 18 50" src="https://user-images.githubusercontent.com/5824595/173228656-e3ee2c0e-1b0a-4c30-9111-6cf47bcd8b9b.png">

B
<img width="538" alt="Screenshot 2022-06-12 at 12 19 02" src="https://user-images.githubusercontent.com/5824595/173228653-3d13277c-2a7d-4bd8-8d9d-5e07f7b0d36e.png">


PS: Is there a difference between `reversePens()` and `reverse()`?


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index b61b0d42..268eedcd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ venv
 venv2
 venv37
 venv-arm
+venv_x64
 test/visuals/artifacts/*
 test/visuals/*.blend1
 test/visuals/test_media
diff --git a/coldtype/blender/__init__.py b/coldtype/blender/__init__.py
index 765dd068..05552f12 100644
--- a/coldtype/blender/__init__.py
+++ b/coldtype/blender/__init__.py
@@ -6,7 +6,7 @@
 
 from coldtype.geometry.rect import Rect
 from coldtype.runon.path import P
-from coldtype.pens.blenderpen import BlenderPen, BPH
+from coldtype.pens.blenderpen import BlenderPen, BlenderPenCube, BPH
 from coldtype.color import hsl
 
 from coldtype.time import Frame, Timeline, Timeable
@@ -16,7 +16,7 @@
 from coldtype.blender.render import blend_source
 from coldtype.time.sequence import ClipTrack, Clip, Sequence
 
-from coldtype.blender.fluent import BpyWorld, BpyObj, BpyCollection
+from coldtype.blender.fluent import BpyWorld, BpyObj, BpyCollection, BpyGroup, BpyMaterial
 
 from typing import Callable
 
@@ -82,7 +82,7 @@ def __init__(self, file, duration=None, **kwargs):
 
 def b3d(callback:Callable[[BlenderPen], BlenderPen],
     collection="Coldtype",
-    plane=False,
+    primitive=None,
     dn=False,
     cyclic=True,
     material=None,
@@ -122,7 +122,7 @@ def annotate(pen:P):
             tag_prefix=(tag_prefix or prev.get("tag_prefix")),
             dn=dn,
             cyclic=cyclic,
-            plane=plane,
+            primitive=primitive,
             zero=zero,
             #reposition=c,
             upright=upright))
@@ -199,8 +199,14 @@ def walker(p:P, pos, data):
                 denovo = bdata.get("dn", dn)
                 cyclic = bdata.get("cyclic", True)
 
-                if bdata.get("plane"):
-                    bp = p.cast(BlenderPen).draw(coll, plane=True, material=material, dn=True)
+                primitive = bdata.get("primitive")
+
+                if primitive is not None:
+                    _class = BlenderPen
+                    if primitive == "cube":
+                        _class = BlenderPenCube
+                    
+                    bp = p.cast(_class).draw(coll, primitive=primitive, material=material, dn=True)
                 else:
                     bp = p.cast(BlenderPen).draw(coll, dn=denovo, material=material, cyclic=cyclic)
                 
@@ -244,9 +250,14 @@ class b3d_runnable(runnable):
     def __init__(self,
         solo=False,
         cond=None,
-        once=True
+        once=True,
+        delay=False,
+        playback=None,
         ):
         self.once = once
+        self.delay = delay
+        self.playback = playback
+
         if cond is not None:
             super().__init__(solo=solo, cond=lambda: cond and bool(bpy))
         else:
diff --git a/coldtype/blender/fluent.py b/coldtype/blender/fluent.py
index f4e5afab..a8d45206 100644
--- a/coldtype/blender/fluent.py
+++ b/coldtype/blender/fluent.py
@@ -1,7 +1,15 @@
+import math, time
+
+from pathlib import Path
+from typing import Callable
+from contextlib import contextmanager
+
+from coldtype.runon.path import P
+from coldtype.runon.runon import Runon
 from coldtype.time.timeline import Timeline
 from coldtype.geometry import Rect
-import math
-from contextlib import contextmanager
+from coldtype.color import Gradient, normalize_color
+from coldtype.renderable.animation import animation
 
 try:
     import bpy
@@ -16,6 +24,23 @@
 class _Chainable():
     def noop(self):
         return self
+    
+    def op(self, fn):
+        fn(self)
+        return self
+    
+    def data(self, key=None, default=None, **kwargs):
+        if not hasattr(self, "_data"):
+            self._data = {}
+
+        if key is None and len(kwargs) > 0:
+            for k, v in kwargs.items():
+                self._data[k] = v
+            return self
+        elif key is not None:
+            return self._data.get(key, default)
+        else:
+            return self
 
 class BpyWorld(_Chainable):
     def __init__(self, scene="Scene"):
@@ -30,33 +55,33 @@ def deselect_all(self):
     
     deselectAll = deselect_all
     
-    def delete_previous(self, collection="Coldtype", orphans=True):
+    def delete_previous(self, collection="Coldtype", keep=[], materials=True, curves=True, meshes=True, objects=True):
         self.deselect_all()
 
-        BpyCollection.Find(collection).delete_hierarchy()
-        if orphans:
-            self.deleteOrphans()
+        BpyCollection.Find(collection, create=False).delete_hierarchy()
+        self.deleteOrphans(keep=keep, materials=materials, curves=curves, meshes=meshes, objects=objects)
         return self
     
     deletePrevious = delete_previous
 
-    def deleteOrphans(self):
+    def deleteOrphans(self, keep=[], **kwargs):
         from bpy import data as D
         
         props = ["curves", "meshes", "materials", "objects"]
         for x in range(2):
             for c in D.collections:
-                if "RigidBodyWorld" in c.name or c.users == 0:
+                if ("RigidBodyWorld" in c.name or c.users == 0) and c.name not in keep:
                     bpy.data.collections.remove(c)
 
             for p in props:
-                for block in getattr(D, p):
-                    if block.users == 0:
-                        getattr(D, p).remove(block)
+                if kwargs.get(p):
+                    for block in getattr(D, p):
+                        if block.users == 0 and block.name not in keep:
+                            getattr(D, p).remove(block)
         
         return self
     
-    def timeline(self, t:Timeline, resetFrame=None):
+    def timeline(self, t:Timeline, resetFrame=None, output=None, version=None):
         self.scene.frame_start = 0
         self.scene.frame_end = t.duration-1
 
@@ -69,6 +94,17 @@ def timeline(self, t:Timeline, resetFrame=None):
         
         if resetFrame is not None:
             self.scene.frame_set(resetFrame)
+        
+        if output:
+            output = Path(output)
+            if output.is_file():
+                folder = output.stem
+                if version:
+                    folder = f"{output.stem}_{version}"
+                
+                output = output.parent / "renders" / folder / f"{folder}_"
+            
+            self.scene.render.filepath = str(output)
 
         return self
     
@@ -96,8 +132,8 @@ def rigidbody(self, speed=1, frame_end=250):
         try:
             bpy.ops.rigidbody.world_remove()
             yield
-        except RuntimeError:
-            print("! Failed to reset rigidbody !")
+        except RuntimeError as e:
+            print("! Failed to reset rigidbody !", e)
             yield
         
         if self.scene:
@@ -125,23 +161,51 @@ def insertKeyframe(self, frame, path, value=None):
 
 class BpyCollection(_Chainable):
     @staticmethod
-    def Find(tag, create=True):
+    def Find(tag, create=True, parent=None):
+        if "/" in tag:
+            if tag.startswith("/"):
+                parentTag = "Coldtype"
+                tag = tag[1:]
+            else:
+                parentTag, tag = tag.split("/")
+            parent = BpyCollection.Find(parentTag, create=create)
+
         bc = BpyCollection()
-        try:
-            bc.c = bpy.data.collections[tag]
-        except KeyError:
+        c = None
+
+        if tag not in bpy.data.collections:
             if create:
-                bc.c = bpy.data.collections.new(tag)
-            else:
-                bc.c = None
+                c = bpy.data.collections.new(tag)
+                if parent:
+                    if isinstance(parent, BpyCollection):
+                        parent = parent.c
+                    parent.children.link(c)
+                else:
+                    bpy.context.scene.collection.children.link(c)
+        
+        c = bpy.data.collections.get(tag)
+        bc.c = c
         return bc
 
+        # try:
+        #     bc.c = bpy.data.collections[tag]
+        # except KeyError:
+        #     if create:
+        #         print("CREATING", tag)
+        #         bc.c = bpy.data.collections.new(tag)
+        #     else:
+        #         bc.c = None
+        # return bc
+
     def delete_hierarchy(self):
         if not self.c: return
 
         bpy.context.view_layer.objects.active = None
         for obj in self.c.objects:
-            BpyObj.Find(obj.name).select()
+            try:
+                BpyObj.Find(obj.name).select()
+            except Exception as e:
+                print("deleteHierarchy failed to delete object:", obj.name, e)
         bpy.ops.object.delete()
         bpy.data.collections.remove(self.c)
         return None
@@ -149,6 +213,131 @@ def delete_hierarchy(self):
     deleteHierarchy = delete_hierarchy
 
 
+class BpyMaterial():
+    def __init__(self, material):
+        self.m = material
+    
+    def Find(tag, create=True, use_nodes=True):
+        if not isinstance(tag, str):
+            return BpyMaterial(tag)
+
+        try:
+            m = bpy.data.materials[tag]
+            return BpyMaterial(m)
+        except KeyError:
+            if create:
+                m = bpy.data.materials.new(tag)
+                m.use_nodes = use_nodes
+                return BpyMaterial(m)
+        
+        return None
+
+    def bsdf(self):
+        return self.m.node_tree.nodes["Principled BSDF"]
+    
+    def setColorValue(self, value, color):
+        value[0] = color.r
+        value[1] = color.g
+        value[2] = color.b
+        value[3] = 1
+        if color.a != 1:
+            self.transmission(1)
+
+    def f(self, color):
+        if isinstance(color, Gradient):
+            return self.f(color.stops[0][0])
+        else:
+            color = normalize_color(color)
+
+        bsdf = self.bsdf()
+        if bsdf:
+            dv = bsdf.inputs[0].default_value
+            self.setColorValue(dv, color)
+        
+        return self
+    
+    fill = f
+    
+    def specular(self, amount=0.5):
+        self.bsdf().inputs[7].default_value = amount
+        return self
+    
+    def metallic(self, amount=1):
+        self.bsdf().inputs[6].default_value = amount
+        return self
+    
+    def roughness(self, amount=0.5):
+        self.bsdf().inputs[9].default_value = amount
+        return self
+    
+    def transmission(self, amount=1):
+        self.bsdf().inputs[17].default_value = amount
+        return self
+
+    def emission(self, color, strength=1):
+        self.setColorValue(self.bsdf().inputs[19].default_value, normalize_color(color))
+        self.bsdf().inputs[20].default_value = strength
+        return self
+    
+    def animation(self, anim:animation, start=0):
+        src = anim.pass_path(start)
+        tl = anim.timeline
+        return self.image(src, timeline=tl)
+    
+    def image(self, src=None, opacity=1, rect=None, pattern=True, alpha=True, timeline:Timeline=None):
+        bsdf = self.bsdf()
+        
+        if "Image Texture" in self.m.node_tree.nodes:
+            tex = self.m.node_tree.nodes["Image Texture"]
+        else:
+            tex = self.m.node_tree.nodes.new("ShaderNodeTexImage")
+            self.m.node_tree.links.new(bsdf.inputs["Base Color"], tex.outputs["Color"])
+            if alpha:
+                self.m.node_tree.links.new(bsdf.inputs["Alpha"], tex.outputs["Alpha"])
+            
+            bx, by = bsdf.location
+            tex.location = (bx - 320, by)
+        
+        found = False
+
+        for img in bpy.data.images:
+            if src.name in img.name:
+                found = True
+                img.reload()
+
+        if not found or not tex.image:
+            tex.image = bpy.data.images.load(str(src))
+        
+        if timeline is not None:
+            tex.image.source = "SEQUENCE"
+            tex.image_user.frame_duration = timeline.duration
+            tex.image_user.frame_start = 0
+            tex.image_user.frame_offset = -1
+            tex.image_user.use_cyclic = True
+            tex.image_user.use_auto_refresh = True
+
+        return self
+
+
+class BpyGroup(Runon):
+    def yields_wrapped(self):
+        return False
+    
+    @staticmethod
+    def Curves(pens:P, prefix=None, collection=None, cyclic=True, fill=True, th=0, tv=0):
+        curves = BpyGroup()
+
+        def walker(p:P, pos, data):
+            if pos == 0:
+                name = None
+                if prefix:
+                    name = prefix + "_" + ".".join([str(s) for s in data["idx"]])
+                curves.append(BpyObj.Curve(name=name, collection=collection).draw(p, cyclic=cyclic, fill=fill, th=th, tv=tv))
+        
+        pens.walk(walker)
+        return curves
+
+
 class BpyObj(_Chainable):
     def __init__(self, dat=None) -> None:
         self.eo = None
@@ -169,15 +358,63 @@ def Norm(obj_or_tag):
         else:
             return BpyObj.Find(obj_or_tag)
     
+    @staticmethod
+    def Primitive(name=None, collection="Coldtype") -> "BpyObj":
+        if collection is None:
+            collection = "Coldtype"
+
+        created = bpy.context.object
+        bobj = BpyObj()
+        bobj.obj = created
+        if collection:
+            bobj.collect(collection)
+        created.select_set(False)
+        if name is not None:
+            bobj.obj.name = name
+        return bobj
+    
+    @staticmethod
+    def Empty(name=None, collection=None) -> "BpyObj":
+        bpy.ops.object.empty_add(type="PLAIN_AXES")
+        return BpyObj.Primitive(name, collection)
+
+    @staticmethod
+    def Cube(name=None, collection=None) -> "BpyObj":
+        bpy.ops.mesh.primitive_cube_add()
+        return BpyObj.Primitive(name, collection)
+
+    @staticmethod
+    def Plane(name=None, collection=None) -> "BpyObj":
+        bpy.ops.mesh.primitive_plane_add()
+        return BpyObj.Primitive(name, collection)
+    
+    @staticmethod
+    def Curve(name=None, collection=None) -> "BpyObj":
+        bpy.ops.curve.primitive_bezier_curve_add()
+        bo = BpyObj.Primitive(name, collection)
+        return bo
+    
+    @staticmethod
+    def UVSphere(name=None, collection=None) -> "BpyObj":
+        bpy.ops.mesh.primitive_uv_sphere_add()
+        return BpyObj.Primitive(name, collection)
+    
+    @staticmethod
+    def Monkey(name=None, collection=None) -> "BpyObj":
+        bpy.ops.mesh.primitive_monkey_add()
+        return BpyObj.Primitive(name, collection)
+    
     def select(self, selected=True):
         self.obj.select_set(selected)
         return self
     
-    def collect(self, collectionTag, create=True):
-        for c in self.obj.users_collection:
-            c.objects.unlink(self.obj)
-        bc = BpyCollection.Find(collectionTag, create=create)
+    def collect(self, collectionTag, create=True, unlink=True, parent=None):
+        bc = BpyCollection.Find(collectionTag, create=create, parent=parent)
         bc.c.objects.link(self.obj)
+        if unlink:
+            for c in self.obj.users_collection:
+                if c != bc.c:
+                    c.objects.unlink(self.obj)
         return self
     
     @contextmanager
@@ -281,6 +518,11 @@ def select_and_delete(self, select_mode, selector):
         return self
     
     selectAndDelete = select_and_delete
+
+    def separateByLooseParts(self):
+        with self.all_vertices_selected():
+            bpy.ops.mesh.separate(type="LOOSE")
+        return self
     
     def parent(self, parent_tag, hide=False):
         with self.obj_selection_sequence(parent_tag) as o:
@@ -309,25 +551,11 @@ def addEmptyOrigin(self, collection="Coldtype"):
         bpy.ops.object.empty_add(type="PLAIN_AXES")
         bc = bpy.context.object
         bc.name = self.obj.name + "_EmptyOrigin"
-        print("EMPTY ORIGIN", bc.name)
         self.eo = bc
         BpyObj.Find(bc.name).collect(collection)
         return self
     
     # Geometry Methods
-
-    def rotate(self, x=None, y=None, z=None):
-        if isinstance(x, Euler):
-            self.obj.rotation_euler = x
-            return self
-        
-        if x is not None:
-            self.obj.rotation_euler[0] = math.radians(x)
-        if y is not None:
-            self.obj.rotation_euler[1] = math.radians(y)
-        if z is not None:
-            self.obj.rotation_euler[2] = math.radians(z)
-        return self
     
     def apply_transform(self,
         location=True,
@@ -345,6 +573,9 @@ def apply_transform(self,
     
     applyTransform = apply_transform
 
+    def applyScale(self):
+        return self.applyTransform(location=False, rotation=False, scale=True, properties=False)
+
     def apply_modifier(self, name):
         with self.obj_selected():        
             #bpy.ops.object.modifier_set_active(modifier=name)
@@ -353,6 +584,28 @@ def apply_modifier(self, name):
         return self
     
     applyModifier = apply_modifier
+
+    def applyAllModifiers(self):
+        with self.obj_selected():
+            for mod in self.obj.modifiers:
+                bpy.ops.object.modifier_apply(modifier=mod.name)
+                self.obj.to_mesh(preserve_all_data_layers=True)
+        return self
+
+#region Basic transformations
+
+    def rotate(self, x=None, y=None, z=None):
+        if isinstance(x, Euler):
+            self.obj.rotation_euler = x
+            return self
+        
+        if x is not None:
+            self.obj.rotation_euler[0] = math.radians(x)
+        if y is not None:
+            self.obj.rotation_euler[1] = math.radians(y)
+        if z is not None:
+            self.obj.rotation_euler[2] = math.radians(z)
+        return self
     
     def origin_to_geometry(self):
         with self.obj_selected():
@@ -406,7 +659,36 @@ def locate_relative(self, x=None, y=None, z=None):
     
     locateRelative = locate_relative
 
-    # Convenience Methods
+    def scale(self, x=None, y=None, z=None):
+        if x is not None:
+            self.obj.scale[0] = x
+        if y is not None:
+            self.obj.scale[1] = y
+        if z is not None:
+            self.obj.scale[2] = z
+        return self
+
+#endregion Basic transformations
+
+#region Materials
+
+    def material(self, tag, modFn:Callable[[BpyMaterial], BpyMaterial]=None, clear=False):
+        if clear:
+            self.obj.data.materials.clear()
+
+        bm = BpyMaterial.Find(tag, create=True, use_nodes=True)
+
+        if bm.m.name not in self.obj.data.materials:
+            self.obj.data.materials.append(bm.m)
+        
+        if bm and modFn:
+            modFn(bm)
+
+        return self
+
+#endregion Materials
+
+#region Convenience Methods
 
     def rigidbody(self,
         mode="active",
@@ -442,6 +724,10 @@ def rigidbody(self,
         
         return self
     
+#endregion Convenience Methods
+    
+#region Modifiers
+    
     def solidify(self, thickness=1):
         with self.obj_selected():
             bpy.ops.object.modifier_add(type="SOLIDIFY")
@@ -459,6 +745,35 @@ def remesh(self, octree_depth=7, smooth=False):
             m.use_smooth_shade = smooth
         return self
     
+    def array(self, count=2, relative=(1, 0, 0), constant=(0.1, 0, 0)):
+        with self.obj_selected():
+            bpy.ops.object.modifier_add(type='ARRAY')
+            m = self.obj.modifiers[-1]
+            m.count = count
+            
+            if relative:
+                m.use_relative_offset = True
+                m.relative_offset_displace[0] = relative[0]
+                m.relative_offset_displace[1] = relative[1]
+                m.relative_offset_displace[2] = relative[2]
+            else:
+                m.use_relative_offset = False
+
+            if constant:
+                m.use_constant_offset = True
+                m.constant_offset_displace[0] = constant[0]
+                m.constant_offset_displace[1] = constant[1]
+                m.constant_offset_displace[2] = constant[2]
+            else:
+                m.use_constant_offset = False
+        return self
+    
+    def arrayX(self, count=2, relative=1, constant=0.1):
+        return self.array(count=count, relative=(relative, 0, 0), constant=(constant, 0, 0))
+    
+    def arrayY(self, count=2, relative=-1, constant=-0.1):
+        return self.array(count=count, relative=(0, relative, 0), constant=(0, constant, 0))
+    
     def remove_doubles(self, threshold=0.01):
         with self.all_vertices_selected():
             bpy.ops.mesh.remove_doubles(threshold=threshold)
@@ -571,4 +886,109 @@ def decimate_planar(self):
             m.decimate_type = "DISSOLVE"
         return self
     
-    decimatePlanar = decimate_planar
\ No newline at end of file
+    decimatePlanar = decimate_planar
+
+#endregion Modifiers
+
+#region Curve functions
+
+    def draw(self, path:P, cyclic=True, fill=True, th=0, tv=0) -> "BpyObj":
+        if len(path) > 0:
+            path = path.pen()
+        
+        path = path.removeOverlap()#.centerZero()
+        amb = path.ambit(th=th, tv=tv)
+        origin = amb.x + amb.w/2, amb.y + amb.h/2
+
+        path = path.q2c()
+
+        #czOffset = path.data("centerZeroOffset")
+
+        splines = []
+        spline = None
+        value = []
+
+        for mv, pts in path._val.value:
+            if mv == "moveTo":
+                p = pts[0]
+                if spline and len(spline) > 0 and spline not in splines:
+                    splines.append(spline)
+                spline = []
+                value.append([p])
+                spline.append(["BEZIER", "start", [p, p, p]])
+
+            elif mv == "lineTo":
+                p = pts[0]
+                value.append([p])
+                spline.append(["BEZIER", "curve", [p, p, p]])
+
+            elif mv == "curveTo":
+                p1, p2, p3 = pts
+                spline[-1][-1][-1] = p1
+                value.append([p1, p2, p3])
+                spline.append(["BEZIER", "curve", [p2, p3, p3]])
+
+            # elif mv == "qCurveTo":
+            #     p1, p2 = pts
+            #     start = value[-1][-1]
+            #     q1, q2, q3 = raise_quadratic(start, (p1[0], p1[1]), (p2[0], p2[1]))
+            #     spline[-1][-1][-1] = q1
+            #     value.append([q1, q2, q3])
+            #     spline.append(["BEZIER", "curve", [q2, q3, q3]])
+
+            elif mv == "closePath":
+                if spline and len(spline) > 0 and spline not in splines:
+                    splines.append(spline)
+                    spline = None
+                spline = None
+        
+            else:
+                raise Exception("blender curve unhandled curve type", mv)
+            
+            if spline and len(spline) > 0 and spline not in splines:
+                splines.append(spline)
+
+        bez = self.obj.data
+
+        def zvec(pt, z=0):
+            x, y = pt
+            return Vector((x, y, z))
+
+        for spline in reversed(bez.splines): # clear existing splines
+            bez.splines.remove(spline)
+
+        for spline_data in splines:
+            bez.splines.new('BEZIER')
+            spline = bez.splines[-1]
+            spline.use_cyclic_u = cyclic
+            for i, (t, style, pts) in enumerate(spline_data):
+                l, c, r = pts
+                if i > 0:
+                    spline.bezier_points.add(1)
+                pt = spline.bezier_points[-1]
+                pt.co = zvec(c)
+                pt.handle_left = zvec(l)
+                pt.handle_right = zvec(r)
+        
+        if fill:
+            bez.dimensions = "2D"
+            bez.fill_mode = "BOTH"
+
+        self.setOrigin(*origin, 0)
+
+        return self
+
+    def extrude(self, amount=0.1) -> "BpyObj":
+        self.obj.data.extrude = amount
+        return self
+    
+    def bevel(self, depth=0.02) -> "BpyObj":
+        self.obj.data.bevel_depth = depth
+        return self
+    
+    def convertToMesh(self) -> "BpyObj":
+        with self.obj_selected():
+            bpy.ops.object.convert(target="MESH")
+        return self
+
+#endregion
\ No newline at end of file
diff --git a/coldtype/blender/watch.py b/coldtype/blender/watch.py
index f7af0734..7d231b1a 100644
--- a/coldtype/blender/watch.py
+++ b/coldtype/blender/watch.py
@@ -152,23 +152,41 @@ class ColdtypeWatchingOperator(bpy.types.Operator):
     current_frame = -1
     persisted = None
 
+    _delayed_runnables = []
+    _should_start_playing = False
+
     def render_current_frame(self, statics=False):
+        delayed_runnables = []
         out = []
         animation_found = False
         frame = self.current_frame
         prerendered = bpy.app.driver_namespace.get("_coldtype_prerendered", False)
+        playback = None
 
         def display_image(r, result):
             lp_path = render_as_image(r, result)
             display_image_in_blender(lp_path)
+        
+        if statics:
+            if bpy.context.screen.is_animation_playing:
+                bpy.ops.screen.animation_play() # stop it
 
         for r in self.candidates:
             if isinstance(r, b3d_runnable):
+                if statics and r.playback is not None:
+                    playback = r.playback
+                
                 if r.once:
                     if statics:
-                        r.run()
+                        if r.delay:
+                            delayed_runnables.append(r)
+                        else:
+                            r.run()
                 else:
-                    r.run()
+                    if r.delay:
+                        delayed_runnables.append(r)
+                    else:
+                        r.run()
             else:
                 out.append(r)
 
@@ -195,10 +213,14 @@ def run_passes():
                     else:
                         animation_found = True
                         result = run_passes()
-                        if r.renderer == "b3d":
-                            walk_to_b3d(result, renderable=r)
-                        else:
-                            raise Exception("r.renderer not supported", r.renderer)
+                        if result:
+                            if r.renderer == "b3d":
+                                walk_to_b3d(result, renderable=r)
+                            else:
+                                raise Exception("r.renderer not supported", r.renderer)
+                        
+                        if r.reset_to_zero:
+                            bpy.data.scenes[0].frame_set(0)
                         
                         if prerendered:
                             display_image(r, ps[0].output_path)
@@ -221,6 +243,18 @@ def run_passes():
             if hasattr(o, "post_run") and o.post_run:
                 o.post_run()
         
+        if statics:
+            self._delayed_runnables = delayed_runnables
+
+            # for dr in self._delayed_runnables:
+            #     dr.run()
+            
+            # self._delayed_runnables = []
+
+            if playback is not None:
+                if playback > 0:
+                    bpy.ops.screen.animation_play()
+        
         return animation_found
 
     def reimport(self, arg, inputs):
@@ -256,6 +290,17 @@ def _frame_update_handler(scene):
 
     def modal(self, context, event):
         if event.type == 'TIMER':
+            
+            for dr in self._delayed_runnables:
+                dr.run()
+            
+            self._delayed_runnables = []
+
+            # if self._should_start_playing:
+            #     if not bpy.context.screen.is_animation_playing:
+            #         bpy.ops.screen.animation_play()
+            #     self._should_start_playing = False
+
             if bpy.app.driver_namespace.get("_coldtype_needs_rerender", False):
                 bpy.app.driver_namespace["_coldtype_needs_rerender"] = False
                 self.render_current_frame(statics=False)
@@ -279,6 +324,9 @@ def modal(self, context, event):
                     bpy.ops.screen.frame_offset(delta=int(arg))
                 elif cmd == "refresh_sequencer":
                     bpy.ops.sequencer.refresh_all()
+                    for k, v in bpy.data.images.items():
+                        print("RELOAD", k, v)
+                        v.reload()
                 elif cmd == "refresh_sequencer_and_image":
                     bpy.ops.sequencer.refresh_all()
                     for k, v in bpy.data.images.items():
diff --git a/coldtype/demo/viewseq.py b/coldtype/demo/viewseq.py
new file mode 100644
index 00000000..a52f80b6
--- /dev/null
+++ b/coldtype/demo/viewseq.py
@@ -0,0 +1,29 @@
+from coldtype import *
+from coldtype.tool import parse_inputs
+from coldtype.img.skiaimage import SkiaImage
+
+args = parse_inputs(__inputs__, dict(
+    path=[None, str, "Must provide path to image sequence"],
+    fps=[30, float],
+    dirsort=["x.name", str],
+    ))
+
+dirsort = eval(f"lambda x: {args['dirsort']}")
+
+def find_pngs(_root):
+    return sorted(list(_root.glob("*.png")), key=lambda p: p.stem.split("_")[-1])
+
+root = Path(args["path"]).expanduser().absolute()
+images = find_pngs(root)
+
+if len(images) == 0:
+    for dir in sorted(filter(lambda x: not x.name.startswith("."), root.iterdir()), key=dirsort):
+        if not dir.name.startswith("."):
+            images.extend(find_pngs(dir))
+
+sk1 = SkiaImage(images[0])
+
+@animation(sk1.bounds(), tl=Timeline(len(images), fps=args["fps"]))
+def viewseq(f):
+    ski = SkiaImage(images[f.i])
+    return (P(f.a.r).img(ski, f.a.r))
\ No newline at end of file
diff --git a/coldtype/pens/blenderpen.py b/coldtype/pens/blenderpen.py
index e02c25ad..9d80b8f2 100644
--- a/coldtype/pens/blenderpen.py
+++ b/coldtype/pens/blenderpen.py
@@ -94,12 +94,15 @@ def Primitive(_type, coll, name, dn=False, container=None, material="ColdtypeDef
             created = True
             if _type == "Bezier":
                 bpy.ops.curve.primitive_bezier_curve_add()
-            elif _type == "Plane":
+            elif _type == "plane":
                 bpy.ops.mesh.primitive_plane_add()
+            elif _type == "cube":
+                bpy.ops.mesh.primitive_cube_add()
             bc = bpy.context.object
             bc.name = name
             bc.data.name = name
             #name = bc.name
+            
             if _type == "Bezier":
                 if cyclic:
                     bc.data.dimensions = "2D"
@@ -107,7 +110,7 @@ def Primitive(_type, coll, name, dn=False, container=None, material="ColdtypeDef
                     bc.data.extrude = 0.1
                 else:
                     bc.data.dimensions = "3D"
-            elif _type == "Plane":
+            elif _type == "plane":
                 if container:
                     bc.scale[0] = container.w/2
                     bc.scale[1] = container.h/2
@@ -115,6 +118,16 @@ def Primitive(_type, coll, name, dn=False, container=None, material="ColdtypeDef
                     bc.location[1] = container.y + container.h/2
                     bpy.ops.object.origin_set(type="ORIGIN_CURSOR")
                     bpy.ops.object.transform_apply()
+            elif _type == "cube":
+                if container:
+                    bc.scale[0] = container.w/2
+                    bc.scale[1] = container.h/2
+                    bc.scale[2] = 0.1
+
+                    bc.location[0] = container.x + container.w/2
+                    bc.location[1] = container.y + container.h/2
+                    bpy.ops.object.origin_set(type="ORIGIN_CURSOR")
+                    bpy.ops.object.transform_apply()
             
             if material:
                 if material == "auto":
@@ -407,24 +420,14 @@ def center_origin(self):
         self.locate_relative(x=+c.x/100, y=+c.y/100)
         return self
     
-    def draw(self, collection, style=None, scale=0.01, cyclic=True, dn=False, plane=False, material="auto"):
+    def draw(self, collection, style=None, scale=0.01, cyclic=True, dn=False, primitive=None, material="auto"):
         self.material = material
 
-        if plane:
-            self.obj, self.created = BPH.Primitive("Plane", collection, self.tag, dn=dn, material=material, container=self.dat.ambit().scale(scale))
+        if primitive is not None:
+            self.obj, self.created = BPH.Primitive(primitive, collection, self.tag, dn=dn, material=material, container=self.dat.ambit().scale(scale))
         else:
             self.obj, self.created = BPH.Primitive("Bezier", collection, self.tag, dn=dn, material=material, cyclic=cyclic)
 
-            if material and material != "auto":
-                try:
-                    mat = bpy.data.materials[material]
-                except KeyError:
-                    mat = bpy.data.materials.new(material)
-                    mat.use_nodes = True
-                    
-                self.obj.data.materials.clear()
-                self.obj.data.materials.append(mat)
-
             if cyclic:
                 self.obj.data.fill_mode = "BOTH"
             self.origin_to_cursor()
@@ -438,8 +441,20 @@ def draw(self, collection, style=None, scale=0.01, cyclic=True, dn=False, plane=
                 self.draw_on_bezier_curve(self.obj.data, cyclic=cyclic)
             except:
                 pass
+        
+        if material and material != "auto":
+            try:
+                mat = bpy.data.materials[material]
+            except KeyError:
+                mat = bpy.data.materials.new(material)
+                mat.use_nodes = True
+                
+            self.obj.data.materials.clear()
+            self.obj.data.materials.append(mat)
+        
         for attrs, attr in self.findStyledAttrs(style):
             self.applyDATAttribute(attrs, attr)
+
         return self
 
     def draw_on_bezier_curve(self, bez, cyclic=True):
@@ -460,4 +475,12 @@ def draw_on_bezier_curve(self, bez, cyclic=True):
                 pt.handle_right = BPH.Vector(r)
 
     def noop(self, *args, **kwargs):
+        return self
+
+class BlenderPenCube(BlenderPen):
+    def extrude(self, amount=0.1, apply=False):
+        with self.obj_selected():
+            self.obj.scale[2] = amount*10
+            if apply:
+                bpy.ops.object.transform_apply()
         return self
\ No newline at end of file
diff --git a/coldtype/renderer/__init__.py b/coldtype/renderer/__init__.py
index 78fca121..61090b05 100644
--- a/coldtype/renderer/__init__.py
+++ b/coldtype/renderer/__init__.py
@@ -507,6 +507,7 @@ def check_watches(render):
                 check_watches(render)
                 
                 passes = render.passes(trigger, self.state, indices)
+
                 render.last_passes = passes
                 
                 for rp in passes:
@@ -642,14 +643,18 @@ def render(self, trigger, indices=[], ditto_last=False) -> Tuple[int, int]:
         
         if not self.args.is_subprocess and render_count > 0:
             for render in renders:
-                result = render.package()
+                if hasattr(render, "package"):
+                    result = render.package()
+                else:
+                    result = None
+                
                 if result:
                     self.previews_waiting.append([render, result, None])
                 else:
                     self.action_waiting = Action.PreviewStoryboard
                     self.action_waiting_reason = "unclear"
 
-            self.winmans.did_render(render_count, ditto_last)
+            self.winmans.did_render(render_count, ditto_last, renders)
             
         did_render_fn = self.buildrelease_fn("didRender")
         if did_render_fn:
@@ -1195,6 +1200,7 @@ def shortcut_to_action(self, shortcut):
             for _ in range(0, len(adjs())):
                 self.actions_queued.append(KeyboardShortcut.RenderAll)
                 self.actions_queued.append(KeyboardShortcut.LoadNextInDirectory)
+                self.actions_queued.append(Action.PreviewStoryboardReload)
             self.actions_queued.append(KeyboardShortcut.Release)
             return Action.PreviewStoryboardReload
         elif shortcut in [
@@ -1327,8 +1333,9 @@ def turn_over(self):
             for k, v in self.debounced_actions.items():
                 if v:
                     if (now - v) > self.source_reader.config.debounce_time:
-                        self.action_waiting = Action.PreviewStoryboardReload
-                        self.action_waiting_reason = "debouncing"
+                        #self.action_waiting = Action.PreviewStoryboardReload
+                        self.actions_queued.append(Action.PreviewStoryboardReload)
+                        #self.action_waiting_reason = "debouncing"
                         self.debounced_actions[k] = None
 
         if self.action_waiting:
diff --git a/coldtype/renderer/reader.py b/coldtype/renderer/reader.py
index 73f4d1bd..93d0607d 100644
--- a/coldtype/renderer/reader.py
+++ b/coldtype/renderer/reader.py
@@ -332,6 +332,8 @@ def Demo(name):
             return root / "demo/midi.py"
         elif name == "vf":
             return root / "demo/vf.py"
+        elif name == "viewseq":
+            return root / "demo/viewseq.py"
         elif name == "pj":
             return root / "renderer/picklejar.py"
         return name
@@ -396,8 +398,7 @@ def read_configs(self, args, filepath):
                         "__as_config__": True,
                         "__sibling__": partial(sibling, p),
                     })
-                    #self.midi_mapping = py_config.get("MIDI", self.midi_mapping)
-                    #self.hotkey_mapping = py_config.get("HOTKEYS", self.hotkey_mapping)
+                    
                     self.config = ColdtypeConfig(py_config, self.config if self.config else None, args)
 
                     for f in self.config.font_dirs:
@@ -407,8 +408,8 @@ def read_configs(self, args, filepath):
                     if isinstance(e, ColdtypeCeaseConfigException):
                         pass
                     else:
-                        #print("Failed to load config", p)
-                        #print("Exception:", e)
+                        print("Failed to load config", p)
+                        print("Exception:", e)
                         pass
         
         if len(files) == 0 or not self.config:
diff --git a/coldtype/renderer/winman/__init__.py b/coldtype/renderer/winman/__init__.py
index ba97d0a1..6b4dfbca 100644
--- a/coldtype/renderer/winman/__init__.py
+++ b/coldtype/renderer/winman/__init__.py
@@ -105,9 +105,9 @@ def did_reload_animation(self, rs):
         if self.audio:
             self.audio.reload_with_animation(rs)
     
-    def did_render(self, count, ditto_last):
+    def did_render(self, count, ditto_last, renders):
         if self.b3d:
-            self.b3d.did_render(count, ditto_last)
+            self.b3d.did_render(count, ditto_last, renders)
     
     def found_blend_files(self, blend_files):
         if len(blend_files) > 0:
@@ -165,9 +165,9 @@ def toggle_playback(self):
     def frame_offset(self, offset):
         self.renderer.state.frame_offset += offset
 
-        if not self.glsk:
-            if self.b3d:
-                self.b3d.frame_offset(offset)
+        # if not self.glsk:
+        #     if self.b3d:
+        #         self.b3d.frame_offset(offset)
     
     def should_close(self):
         return any([wm.should_close() for wm in self.map()])
diff --git a/coldtype/renderer/winman/blender.py b/coldtype/renderer/winman/blender.py
index 56269fd6..a9652179 100644
--- a/coldtype/renderer/winman/blender.py
+++ b/coldtype/renderer/winman/blender.py
@@ -29,7 +29,7 @@ def write_command(self, cmd, arg, kwargs=[]):
         except FileNotFoundError:
             pass
 
-    def did_render(self, count, ditto_last):
+    def did_render(self, count, ditto_last, renders):
         if ditto_last:
             self.write_command("refresh_sequencer_and_image", count)
         else:
diff --git a/coldtype/renderer/winman/midi.py b/coldtype/renderer/winman/midi.py
index 8e1a66a3..e02b9727 100644
--- a/coldtype/renderer/winman/midi.py
+++ b/coldtype/renderer/winman/midi.py
@@ -60,11 +60,27 @@ def monitor(self, playing):
                     cn = msg.getControllerNumber()
                     cv = msg.getControllerValue()
                     cc = msg.getChannel()
-                    shortcut = self.config.midi[device].get("controller", {}).get(cn)
-                    if shortcut:
-                        if cv in shortcut:
-                            print("shortcut!", shortcut, cv)
-                            self.on_shortcut(shortcut.get(cv), cn)
+                    
+                    mapping = self.config.midi[device].get("controller", {})
+                    shortcutA = mapping.get(cn)
+                    shortcutB = mapping.get((cn, cc))
+                    
+                    if shortcutA:
+                        if cv in shortcutA:
+                            print("shortcut!", shortcutA, cv)
+                            self.on_shortcut(shortcutA.get(cv), cn)
+                    
+                    if shortcutB:
+                        if callable(shortcutB):
+                            res = shortcutB(cv, self.state)
+                            if res is not None:
+                                print("shortcut!", res)
+                                self.on_shortcut(res, None)
+                        else:
+                            if cv in shortcutB:
+                                print("shortcut!", shortcutB, cv)
+                                self.on_shortcut(shortcutB.get(cv), cn)
+                    
                     else:
                         controllers["_".join([device, str(cc), str(cn)])] = cv
                 msg = mi.getMessage(0)
diff --git a/coldtype/runon/_path.py b/coldtype/runon/_path.py
index cd4b0f57..14bd158b 100644
--- a/coldtype/runon/_path.py
+++ b/coldtype/runon/_path.py
@@ -138,9 +138,9 @@ def pens(self):
 
     # multi-use overrides
     
-    def reverse(self, recursive=False):
+    def reverse(self, recursive=False, winding=True):
         """Reverse elements; if pen value present, reverse the winding direction of the pen."""
-        if self.val_present():
+        if winding and self.val_present():
             if self.unended():
                 self.closePath()
             dp = RecordingPen()
@@ -149,7 +149,7 @@ def reverse(self, recursive=False):
             self._val.value = dp.value
             return self
 
-        return super().reverse(recursive=recursive)
+        return super().reverse(recursive=recursive, winding=winding)
     
     def index(self, idx, fn=None):
         if not self.val_present():
diff --git a/coldtype/runon/mixins/LayoutMixin.py b/coldtype/runon/mixins/LayoutMixin.py
index cc3a6b5d..989d2f2c 100644
--- a/coldtype/runon/mixins/LayoutMixin.py
+++ b/coldtype/runon/mixins/LayoutMixin.py
@@ -263,6 +263,13 @@ def zero(self, th=0, tv=0):
         self.translate(-x, -y)
         return self
     
+    def centerZero(self, th=0, tv=0):
+        x, y, w, h = self.ambit(th=th, tv=tv)
+        nx, ny = -x-w/2, -y-h/2
+        return (self
+            .t(-x-w/2, -y-h/2)
+            .data(centerZeroOffset=(nx, ny)))
+    
     def centerPoint(self, rect, pt, interp=1, th=1, tv=0, **kwargs):
         if "i" in kwargs:
             interp = kwargs["i"]
diff --git a/coldtype/runon/path.py b/coldtype/runon/path.py
index abd6103e..a3d208ed 100644
--- a/coldtype/runon/path.py
+++ b/coldtype/runon/path.py
@@ -193,9 +193,9 @@ def pens(self):
 
     # multi-use overrides
     
-    def reverse(self, recursive=False):
+    def reverse(self, recursive=False, winding=True):
         """Reverse elements; if pen value present, reverse the winding direction of the pen."""
-        if self.val_present():
+        if winding and self.val_present():
             if self.unended():
                 self.closePath()
             dp = RecordingPen()
@@ -204,7 +204,7 @@ def reverse(self, recursive=False):
             self._val.value = dp.value
             return self
 
-        return super().reverse(recursive=recursive)
+        return super().reverse(recursive=recursive, winding=winding)
     
     def index(self, idx, fn=None):
         if not self.val_present():
@@ -1144,6 +1144,15 @@ def zero(self, th=0, tv=0) -> "P":
         return self
     
 
+    def centerZero(self, th=0, tv=0):
+
+        x, y, w, h = self.ambit(th=th, tv=tv)
+        nx, ny = -x-w/2, -y-h/2
+        return (self
+            .t(-x-w/2, -y-h/2)
+            .data(centerZeroOffset=(nx, ny)))
+    
+
     def centerPoint(self, rect, pt, interp=1, th=1, tv=0, **kwargs) -> "P":
 
         if "i" in kwargs:
diff --git a/coldtype/runon/runon.py b/coldtype/runon/runon.py
index b24685e7..96357aa4 100644
--- a/coldtype/runon/runon.py
+++ b/coldtype/runon/runon.py
@@ -14,13 +14,6 @@
 def _arg_count(fn):
     return len(signature(fn).parameters)
 
-def _call_idx_fn(fn, idx, arg):
-    ac = _arg_count(fn)
-    if ac == 1:
-        return fn(arg)
-    else:
-        return fn(idx, arg)
-
 
 RunonEnumerable = namedtuple("RunonEnumerable", ["i", "el", "e", "len", "k", "parent"])
 
@@ -74,6 +67,10 @@ def post_init(self):
         """subclass hook"""
         pass
 
+    def yields_wrapped(self):
+        """subclass hook"""
+        return True
+
     # Value operations
 
     def update(self, val):
@@ -89,6 +86,17 @@ def v(self):
 
     # Array Operations
 
+    def _call_idx_fn(self, fn, idx, arg:"Runon"):
+        if not self.yields_wrapped():
+            if arg.val_present():
+                arg = arg.v
+
+        ac = _arg_count(fn)
+        if ac == 1:
+            return fn(arg)
+        else:
+            return fn(idx, arg)
+
     def _norm_element(self, el):
         if el is None:
             return None
@@ -343,7 +351,7 @@ def parent(self):
 
     def map(self, fn):
         for idx, p in enumerate(self._els):
-            res = _call_idx_fn(fn, idx, p)
+            res = self._call_idx_fn(fn, idx, p)
             if res:
                 self._els[idx] = res
         return self
@@ -351,7 +359,7 @@ def map(self, fn):
     def filter(self, fn):
         to_delete = []
         for idx, p in enumerate(self._els):
-            res = _call_idx_fn(fn, idx, p)
+            res = self._call_idx_fn(fn, idx, p)
             if res == False:
                 to_delete.append(idx)
         to_delete = sorted(to_delete, reverse=True)
@@ -370,7 +378,7 @@ def walker(el, pos, _):
             nonlocal idx
             if pos != 0: return
             
-            res = _call_idx_fn(fn, idx, el)
+            res = self._call_idx_fn(fn, idx, el)
             idx += 1
             return res
         
@@ -382,7 +390,7 @@ def walker(el, pos, data):
             nonlocal idx
             
             if pos == 0:
-                res = _call_idx_fn(fn, idx, el)
+                res = self._call_idx_fn(fn, idx, el)
                 if not res:
                     el.data(_walk_delete=True)
                 idx += 1
@@ -487,12 +495,12 @@ def sum(self):
             out.append(el._val)
         return out
     
-    def reverse(self, recursive=False):
+    def reverse(self, recursive=False, winding=True):
         """in-place element reversal"""
         self._els = list(reversed(self._els))
         if recursive:
             for el in self._els:
-                el.reverse(recursive=True)
+                el.reverse(recursive=True, winding=winding)
         return self
     
     def shuffle(self, seed=0):
@@ -555,7 +563,7 @@ def index(self, idx, fn=None):
             p = self[idx]
 
         if fn:
-            res = _call_idx_fn(fn, lidx, p)
+            res = self._call_idx_fn(fn, lidx, p)
             if res is not None:
                 parent[lidx] = res
         else:
@@ -614,7 +622,7 @@ def finder(p, pos, _):
         
         if fn:
             for idx, match in narrowed:
-                _call_idx_fn(fn, idx, match)
+                self._call_idx_fn(fn, idx, match)
 
         if fn:
             return self
@@ -653,7 +661,7 @@ def data(self, key=None, default=None, **kwargs):
         if key is None and len(kwargs) > 0:
             for k, v in kwargs.items():
                 if callable(v):
-                    v = _call_idx_fn(v, k, self)
+                    v = self._call_idx_fn(v, k, self)
                 self._data[k] = v
             return self
         elif key is not None:
diff --git a/coldtype/text/composer.py b/coldtype/text/composer.py
index 581063a6..108cd962 100644
--- a/coldtype/text/composer.py
+++ b/coldtype/text/composer.py
@@ -199,7 +199,7 @@ def StSt(text,
     strip=True,
     multiline=False,
     #xa="mdx",
-    **kwargs):
+    **kwargs) -> P:
 
     if not isinstance(text, str):
         text = "\n".join(text)
@@ -244,7 +244,7 @@ def StSt(text,
 GlyphwiseGlyph = namedtuple("GlyphwiseGlyph", ["i", "c", "e", "l", "li"])
 
 
-def Glyphwise(st, styler, start=0, line=0):
+def Glyphwise(st, styler, start=0, line=0, multiline=False):
     # TODO possible to have an implementation
     # aware of a non-1-to-1 mapping of characters
     # to glyphs? seems very difficult if not impossible,
@@ -286,7 +286,7 @@ def run_styler(g):
 
     try:
         lines = st.split("\n")
-        if len(lines) > 1:
+        if len(lines) > 1 or multiline:
             gs = []
             start = 0
             for lidx, l in enumerate(lines):
diff --git a/coldtype/text/reader.py b/coldtype/text/reader.py
index 7fb1a401..4f768332 100644
--- a/coldtype/text/reader.py
+++ b/coldtype/text/reader.py
@@ -209,7 +209,8 @@ def Find(regex, regex_dir=None, index=0):
         found = Font.List(regex, regex_dir)
         try:
             return Font.Cacheable(found[index])
-        except:
+        except Exception as e:
+            print(">", e)
             raise FontNotFoundException(regex)
     
     def RegisterDir(dir):
diff --git a/coldtype/tool.py b/coldtype/tool.py
index 674b6b16..f726e425 100644
--- a/coldtype/tool.py
+++ b/coldtype/tool.py
@@ -11,9 +11,12 @@ def parse_inputs(inputs, defaults):
 
     parsed = {}
     if not isinstance(inputs, dict):
-        for input in inputs:
-            k, v = input.split("=")
-            parsed[k] = v
+        for idx, input in enumerate(inputs):
+            if "=" in input:
+                k, v = input.split("=")
+                parsed[k] = v
+            else:
+                parsed[list(defaults.keys())[idx]] = input
     else:
         parsed = {**inputs}
 
diff --git a/examples/animations/808.py b/examples/animations/808.py
index dc9a95c3..d0d48fa1 100644
--- a/examples/animations/808.py
+++ b/examples/animations/808.py
@@ -61,10 +61,6 @@ def drummachine(f):
     # Now with the `style` variable settled, we can construct a multi-line text lockup with the `StSt` class, by passing in a rectangle to hold the lockup, then the text itself, and the style object we created above.
 
     # We can also set the leading of the multi-line setting here, by specifying `leading=` as a function of the kick signal.
-
-    # pens = (StSt("COLD\nTYPE", style,
-    #     leading=10+kick.ease()*50))
-    #     .align(f.a.r))
     
     pens = (StSt("COLD\nTYPE", style,
         leading=kick.adsr(ar["KD"], r=(10, 50)))
@@ -164,6 +160,6 @@ def drummachine(f):
         P(f.a.r).f(0),
         ghz_logo.f(hsl(0.9, 0.55, 0.5)),
         (pens.fssw(1, 0, 15, 1)
-            .reversePens()
+            .reverse()
             .translate(0, 100)
             .ch(phototype(f.a.r, cut=190, cutw=8)))])
\ No newline at end of file
diff --git a/examples/animations/adsr_ascii.py b/examples/animations/adsr_ascii.py
new file mode 100644
index 00000000..efaed206
--- /dev/null
+++ b/examples/animations/adsr_ascii.py
@@ -0,0 +1,15 @@
+from coldtype import *
+
+at = AsciiTimeline(1, 30, """
+                           <
+T           T           T
+""")
+
+@animation((1080, 1080), timeline=at, bg=0)
+def choreography(f):
+    v = at.ki("T").adsr([8, 110])
+
+    return (StSt("T", Font.MutatorSans(), 500
+        , wght=v)
+        .align(f.a.r)
+        .f(1))
\ No newline at end of file
diff --git a/examples/animations/alphabet.py b/examples/animations/alphabet.py
index fc4454c1..6dc6a65a 100644
--- a/examples/animations/alphabet.py
+++ b/examples/animations/alphabet.py
@@ -1,6 +1,6 @@
 from coldtype.test import *
 
-tl = Timeline(26, fps=14)
+tl = Timeline(26, fps=18)
 
 @animation(rect=(1080, 1080), timeline=tl, bg=0)
 def render(f):
diff --git a/examples/animations/alternate_glyphs.py b/examples/animations/alternate_glyphs.py
new file mode 100644
index 00000000..785cc6b1
--- /dev/null
+++ b/examples/animations/alternate_glyphs.py
@@ -0,0 +1,14 @@
+from coldtype import *
+
+at = AsciiTimeline(3, 30, """
+                            <
+    [ss06      ]
+           [ss07        ]
+""")
+
+@animation((1400, 540), timeline=at, bg=1, render_bg=1)
+def scratch(f):
+    return (StSt("alter-\nnates", "SFCompactItalic", 200
+        , ss06=at.ki("ss06").on()
+        , ss07=at.ki("ss07").on())
+        .align(f.a.r))
diff --git a/examples/animations/ascii_pixels.py b/examples/animations/ascii_pixels.py
index 81884348..d3ec4df0 100644
--- a/examples/animations/ascii_pixels.py
+++ b/examples/animations/ascii_pixels.py
@@ -13,6 +13,6 @@ def pixellation(f):
         .scale(0.5, 1)
         .align(f.a.r)
         .map(lambda i, p: p
-            | precompose(f.a.r
+            .ch(precompose(f.a.r
                 , scale=at.ki(i).adsr([5, 25], ["qei", "l"]
-                    , r=(0.25, 0.02)))))
+                    , r=(0.25, 0.02))))))
diff --git a/examples/animations/ascii_simple.py b/examples/animations/ascii_simple.py
index f23128b9..abc658c9 100644
--- a/examples/animations/ascii_simple.py
+++ b/examples/animations/ascii_simple.py
@@ -12,7 +12,9 @@
 @animation((1080, 540), timeline=at)
 def ascii(f):
     return (Glyphwise("AAA", lambda g:
-        Style(Font.MutatorSans(), 250,
+        Style(Font.MutatorSans(), 120,
             wdth=at.ki(g.i).io(8),
             wght=at.ki("wght").io(10)))
-        .align(f.a.r, tv=1)) 
+        .align(f.a.r, tv=1)
+        .mapv(lambda i, p: p
+            .scale(at.ki(i).io(8, r=(1, 0.5)))))
diff --git a/examples/animations/glyphwise.py b/examples/animations/glyphwise.py
index c393fcd0..1b7d62d6 100644
--- a/examples/animations/glyphwise.py
+++ b/examples/animations/glyphwise.py
@@ -14,11 +14,11 @@ def styler(g):
                 ro=1,
                 tv=1)]
 
-    return (Glyphwise("COLD\nTYPE", styler)
+    # should also work w/ a \n in between COLD & TYPE
+    return (Glyphwise("COLDTYPE", styler, multiline=1)
         .xalign(f.a.r, th=0)
         .lead(30)
         .align(f.a.r, th=0)
-        .collapse()
-        .rp()
+        .reverse(recursive=True)
         .fssw(1, 0, 7, 1)
         .ch(phototype(f.a.r, 3, 190, cutw=15)))
\ No newline at end of file
diff --git a/examples/animations/pseudomorph.py b/examples/animations/pseudomorph.py
new file mode 100644
index 00000000..7a3e1104
--- /dev/null
+++ b/examples/animations/pseudomorph.py
@@ -0,0 +1,14 @@
+from coldtype import *
+
+r = Rect(1440, 540)
+
+@animation(r, tl=70)
+def pseudomorph(f):
+    return (StSt("HELLO" if f.e() < 0.5 else "WORLD"
+        , Font.MutatorSans()
+        , f.e(rng=(300, 100))
+        , wght=f.e()
+        , wdth=f.e()
+        , rotate=f.e(rng=(0, 360))
+        )
+        .align(f.a.r, th=0))
\ No newline at end of file
diff --git a/examples/animations/retails/casual.py b/examples/animations/retails/casual.py
new file mode 100644
index 00000000..1dd3589c
--- /dev/null
+++ b/examples/animations/retails/casual.py
@@ -0,0 +1,42 @@
+from coldtype import *
+
+at = AsciiTimeline(6, 30, """
+                                              <
+[0   ][1   ][2     ][3  ][4         ]
+  [0w                                  ]
+        [1w                            ]
+               [2w                     ]
+                      [3w              ]
+                              [4w      ]
+                                      [z    ]
+                                      [o   ]
+""")
+
+@animation((1080, 1080), timeline=at, bg=1, render_bg=1)
+def casual1(f):
+    letter = at.ki(list("012345"))
+    li, lidx = letter.e(find=1)
+    
+    z = at.ki("z")
+    o = at.ki("o")
+
+    return (Glyphwise("Dicey", lambda g:
+        Style("Casual", 100
+            , opsz=o.e("eeo", 1, r=(1, 0))
+                if o.on()
+                    else at.ki(g.i).e("eeio", 1, r=(1, 0))
+            , wght=z.e("eeo", 0, r=(1, 0))
+                if z.on()
+                    else at.ki(f"{g.i}w").io([8, 8], "eeo")))
+        .align(f.a.r)
+        .centerPoint(f.a.r, (lidx, "thtvC"), i=li)
+        .cond(True,
+            lambda p: p
+                .scale(letter.io([15, 5], r=(1,10)), pt=(lidx, "tvC"))
+                .scale(z.io([10, 15], ["eeo", "eei"], r=(1, 5))),
+            lambda p: p
+                .scale(letter.e(r=(1,10)), pt=(lidx, "tvC"))
+                .scale(z.e("eeio", r=(1, 5))))
+        .f(0))
+
+release = casual1.export("h264", loops=3)
\ No newline at end of file
diff --git a/examples/animations/retails/flyin.py b/examples/animations/retails/flyin.py
new file mode 100644
index 00000000..6f11626a
--- /dev/null
+++ b/examples/animations/retails/flyin.py
@@ -0,0 +1,25 @@
+from coldtype import *
+
+at = AsciiTimeline(2, 30, """
+                                        <
+    a        a          b        b
+        [left                ]
+""")
+
+@animation((1080, 260), tl=at)
+def scratch(f):
+    a = (StSt("NICE", "ObviouslyV", 250)
+        .align(f.a.r.inset(50), "W"))
+
+    b = (StSt("NICE", "ObviouslyV", 250, slnt=1)
+        .align(f.a.r.inset(50), "E"))
+
+    left = at.ki("left").on()
+    out = (P().enumerate(a, lambda x: x.el
+        .copy()
+        .interpolate(
+            at.kf(keyframes=dict(a=0, b=1)
+                , offset=x.i*2 if left else (len(a)-x.i)*2),
+            b[x.i].copy())))
+    
+    return P(a, b, out)
\ No newline at end of file
diff --git a/examples/blender/bump.py b/examples/blender/bump.py
new file mode 100644
index 00000000..25dc5e19
--- /dev/null
+++ b/examples/blender/bump.py
@@ -0,0 +1,29 @@
+from coldtype import *
+from coldtype.fx.skia import phototype
+
+r = Rect(1080, 540)
+font = Font.ColdObvi()
+
+def shapes(r):
+    return (StSt("COLD", font, 250)
+        .align(r))
+
+@renderable(r)
+def graphic(r):
+    return P(r).f(0) + (shapes(r).f(1))
+
+from coldtype.blender import *
+
+@b3d_runnable()
+def setup(blw:BpyWorld):
+    (blw.deletePrevious(materials=False)
+        .timeline(Timeline(50, 12)
+            , output=__FILE__
+            , version=1))
+
+    (BpyObj.Plane("ImagePlane")
+        .scale(2, 1)
+        .material("asdf_image", lambda m: m
+            .image(graphic.pass_path())))
+
+#bpy.data.materials["asdf_image"].node_tree.nodes["Mapping.001"].inputs[1].default_value[0]
\ No newline at end of file
diff --git a/examples/blender/direct_objects.py b/examples/blender/direct_objects.py
new file mode 100644
index 00000000..edca9375
--- /dev/null
+++ b/examples/blender/direct_objects.py
@@ -0,0 +1,76 @@
+from coldtype import *
+from coldtype.blender import *
+
+@b3d_runnable(playback=0)
+def setup(blw:BpyWorld):
+    (blw.deletePrevious("Coldtype"
+            , materials=True)
+        .deletePrevious("Cubes"
+            , materials=True)
+        .timeline(Timeline(120)
+            , resetFrame=0
+            , output=__FILE__
+            , version=1))
+    
+    with blw.rigidbody(2.1, 120):
+        (BpyObj.Empty("Empty1"))
+        
+        (BpyObj.Curve("ObviGlyph")
+            .draw(StSt("TYPE", Font.ColdObvi(), 5)
+                .centerZero())
+            .extrude(0.2)
+            .locate(z=7.5)
+            .rotate(x=90)
+            .convertToMesh()
+            .rigidbody("active", bounce=0.5)
+            .material("coldtype_material"))
+        
+        (BpyGroup.Curves(
+            StSt("COLD", Font.MuSan(), 3
+                , wght=1, wdth=1)
+                .centerZero()
+            , collection="/Glyphs")
+            .map(lambda bp: bp
+                .parent("Empty1")
+                .extrude(0.25)
+                .locate(z=15)
+                .convertToMesh()
+                .rigidbody("active", bounce=0.5)
+                .material("coldtype_material")))
+        
+        BpyObj.Find("Empty1").locate(z=-2)
+
+        (BpyMaterial.Find("monkey_material")
+            .f(hsl(0.3, 1))
+            .roughness(1)
+            .specular(0))
+
+        (BpyObj.Monkey()
+            .locate(z=11)
+            .rotate(z=45)
+            .scale(1,1,1)
+            .rigidbody("active", bounce=0.3)
+            .material("monkey_material")
+            .subsurface()
+            .shadeSmooth())
+        
+        (BpyMaterial.Find("monkey_material")
+            .f(hsl(0.6, 1)))
+        
+        (BpyObj.UVSphere("Cube1", collection="Cubes")
+            .scale(2, 2, 2)
+            .locate(z=20)
+            .rigidbody("active", bounce=0.3)
+            .material("cube_material", lambda m: m
+                .f(hsl(0.85, 1))
+                .transmission(1))
+            .subsurface()
+            .shadeSmooth())
+        
+        (BpyObj.Plane()
+            .scale(x=30, y=30)
+            .applyScale()
+            .rigidbody("passive", bounce=0.5)
+            .material("plane_material", lambda m: m
+                .f(hsl(0.17, 0.8, 0.5))
+                .specular(0)))
\ No newline at end of file
diff --git a/examples/blender/displace.py b/examples/blender/displace.py
index 24893828..c8133a0f 100644
--- a/examples/blender/displace.py
+++ b/examples/blender/displace.py
@@ -1,28 +1,57 @@
 from coldtype import *
 from coldtype.blender import *
 
+@b3d_runnable()
+def setup(bw:BpyWorld):
+    (bw.deletePrevious(materials=False))
+
+# @b3d_renderable(center=(0, 1), upright=1, reset_to_zero=1)
+# def bg(r):
+#     return (P(r)
+#         .tag("bg")
+#         .ch(b3d(lambda p: p
+#             .extrude(0.1)
+#             , material="bg_mat")))
+
+big_c = "A"
+
 @b3d_renderable(center=(0, 1), upright=1)
 def displace(r):
-    return (StSt("ABC", Font.MutatorSans(), 350
-        , wdth=0, wght=1)
-        .align(r)
-        .pen()
+    return (
+        #StSt(big_c, "Streco-Stencil-Superfat.otf", 850, wdth=1, wght=1)
+        #.align(r)
+        #.pen()
+        P().rect(r.inset(200))
+        #.outline(50)
         .tag("glyph")
-        .ch(b3d(lambda p: p.extrude(1)
+        .ch(b3d(lambda p: p
+            .extrude(1)
             .convertToMesh()
-            .remesh(7)
+            .remesh(6)
             .applyModifier("Remesh")
             .makeVertexGroup(lambda p: p.co[2] > 0, name="front")
+            .addEmptyOrigin()
             .displace(
-                strength=0.85,
-                midlevel=1,
+                strength=3.35,
+                midlevel=0,
                 texture="Texture",
-                coords_object="Empty",
+                coords_object="glyph_EmptyOrigin",
                 direction="Z",
                 vertex_group="front")
             .subsurface()
-            .smooth(factor=5, repeat=2, x=0, y=0, z=1)
+            .smooth(factor=6, repeat=2, x=0, y=0, z=1)
             .shadeSmooth()
-            , dn=1
-            , material="sponge"
-            , upright=1)))
\ No newline at end of file
+            , material="sponge")))
+
+@b3d_animation(tl=30, name=f"animator_{big_c}")
+def animator(f):
+    # (BpyObj.Find("bg")
+    #     .locate(y=f.e("seo", 1, r=(1, -2.95))))
+    if bpy:
+        (BpyObj.Find("glyph_EmptyOrigin")
+            .locate(x=ord(big_c)+f.e("l", 0, r=(0, 2)))
+            .rotate(x=f.e("l", 0, r=(0, 5))))
+
+@b3d_runnable(delay=True)
+def post_setup(bw:BpyWorld):
+    bw.scene.frame_set(20)
\ No newline at end of file
diff --git a/examples/blender/img.py b/examples/blender/img.py
index e55e0aa1..b5285785 100644
--- a/examples/blender/img.py
+++ b/examples/blender/img.py
@@ -20,4 +20,4 @@
 @b3d_renderable()
 def show_img(r):
     return (P(r).f(-1).img(img_path, r)
-        | b3d(lambda p: p, material="auto", plane=True))
\ No newline at end of file
+        | b3d(lambda p: p, material="auto", primitive="plane"))
\ No newline at end of file
diff --git a/examples/blender/letters.py b/examples/blender/letters.py
index eebf8a0a..1ecba873 100644
--- a/examples/blender/letters.py
+++ b/examples/blender/letters.py
@@ -9,6 +9,7 @@ def letters(f):
         .translate(0, 200)
         .f(1)
         .pen()
+        .align(f.a.r)
         .ch(b3d(lambda bp: bp
             .extrude(0.01)
             .rotate(45))))
diff --git a/examples/blender/physics_upright.py b/examples/blender/physics_upright.py
index 63f2396a..7b467b4b 100644
--- a/examples/blender/physics_upright.py
+++ b/examples/blender/physics_upright.py
@@ -17,13 +17,14 @@
 
 @b3d_runnable()
 def setup(bw:BpyWorld):
-    with (bw.deselect_all()
+    (bw.deselect_all()
         .delete_previous()
-        .timeline(Timeline(250))
-        .render_settings(128)
-        .rigidbody(speed=1.5, frame_end=1000)):
-            (BpyObj.Find("Plane")
-                .rigidbody("passive", friction=1, bounce=0))
+        .timeline(Timeline(250), resetFrame=0)
+        .render_settings(128))
+
+    with bw.rigidbody(speed=1.5, frame_end=1000):
+        (BpyObj.Find("Plane")
+            .rigidbody("passive", friction=1, bounce=0))
 
 r = Rect(1080, 1080)
 lockup = (StSt(txt, Font.Find("PlincBubbleGum33.otf"), 400)
@@ -33,6 +34,8 @@ def setup(bw:BpyWorld):
     .deblank()
     .collapse())
 
+# probably better as a single operation that duplicates each letter & then parents a lossless curve to a low-poly mesh
+
 @b3d_renderable(center=(0, 1), upright=1)
 def physics_upright(r):
     return (lockup.copy()
diff --git a/examples/blender/reprojection.py b/examples/blender/reprojection.py
new file mode 100644
index 00000000..95edb60b
--- /dev/null
+++ b/examples/blender/reprojection.py
@@ -0,0 +1,28 @@
+from coldtype import *
+from coldtype.blender import *
+
+# to fully re-cache Rendered sequence in blender, try switching to Material Preview and then back to Rendered
+
+@animation((540, 540), timeline=30, bg=0)
+def varfont_animation(f):
+    return (P(
+        Glyphwise("CDEL", lambda g:
+            Style(Font.ColdObvi(), 250
+                , wdth=f.adj(-g.i*40).e("seio")))
+            .align(f.a.r, th=0)
+            .f(1),
+        StSt(str(f.i)
+            , Font.RecursiveMono(), 50)
+            .align(f.a.r.inset(50), th=0, y="S")
+            .f(1)))
+
+@b3d_runnable()
+def setup(blw:BpyWorld):
+    (blw.deletePrevious(materials=False))
+
+    (BpyObj.Plane("Projection")
+        .scale(2, 2)
+        .material("projection_material", lambda m: m
+            .f(0)
+            .specular(0)
+            .animation(varfont_animation)))
\ No newline at end of file
diff --git a/examples/blender/simple_single.py b/examples/blender/simple_single.py
new file mode 100644
index 00000000..3efa340b
--- /dev/null
+++ b/examples/blender/simple_single.py
@@ -0,0 +1,12 @@
+from coldtype import *
+from coldtype.blender import *
+
+@b3d_animation(tl=60)
+def scratch(f):
+    return (P()
+        .roundedRect(f.a.r.inset(350), 130)
+        .rotate(f.e("ceio", 1, rng=(-75, 75)))
+        .f(hsl(0.65))
+        .tag("single_shape")
+        .ch(b3d(lambda p: p
+            .extrude(1.5))))
diff --git a/examples/blender/varfont.py b/examples/blender/varfont.py
index 154da2ed..0736e4d6 100644
--- a/examples/blender/varfont.py
+++ b/examples/blender/varfont.py
@@ -3,17 +3,17 @@
 
 # TODO tag the b3d lambda value as BlenderPen
 
-@b3d_runnable()
-def prerun(bw):
-    bw.deletePrevious()
+# @b3d_runnable()
+# def prerun(bw):
+#     bw.deletePrevious()
 
 @b3d_animation(timeline=60, denoise=0)
 def varfont(f):
-    return (StSt("C", Font.ColdtypeObviously(),
+    return (StSt("T", Font.ColdtypeObviously(),
         fontSize=f.e("seio", 1, rng=(300, 1500)),
         wdth=f.e("seio", 1, rng=(1, 0)))
         .align(f.a.r)
-        .pen()
         .f(1)
-        .ch(b3d(lambda bp: bp
-            .extrude(f.e("seio", 1, rng=(0.5, 5.75))))))
\ No newline at end of file
+        .mapv(lambda p: p            
+            .ch(b3d(lambda bp: bp
+                .extrude(f.e("seio", 1, rng=(0.5, 5.75)))))))
\ No newline at end of file
diff --git a/examples/midictrl/toggle.py b/examples/midictrl/toggle.py
new file mode 100644
index 00000000..7d3d4b55
--- /dev/null
+++ b/examples/midictrl/toggle.py
@@ -0,0 +1,13 @@
+from coldtype import *
+from coldtype.midi.controllers import LaunchControlXL
+
+@animation((1080, 1080), bg=1, rstate=1)
+def render(f, rstate):
+    nxl = LaunchControlXL(rstate.midi)
+    toggle_state = nxl(10, 0) > 0.5
+    
+    return (StSt(
+        "ON" if toggle_state else "OFF"
+        , font=Font.MutatorSans()
+        , fontSize=500 if toggle_state else 100)
+        .align(f.a.r))
\ No newline at end of file
diff --git a/setup.py b/setup.py
index f9156381..92248ca4 100644
--- a/setup.py
+++ b/setup.py
@@ -75,6 +75,7 @@
             "skia-python>=86.0",
             "skia-pathops", # can this be taken from skia-python?
             "ufo2ft",
+            #"ufoLib2",
             "numpy",
         ],
         "experimental": [


Test patch:
diff --git a/test/test_glyphwise.py b/test/test_glyphwise.py
index c4301f47..15546126 100644
--- a/test/test_glyphwise.py
+++ b/test/test_glyphwise.py
@@ -195,12 +195,32 @@ def styler(g):
             return Style(Font.MutatorSans(), 120,
                 meta=dict(idx=g.i))
         
-        gw = (Glyphwise("AB\nCD\nEF", styler)
-            .collapse())
-        
+        gw = (Glyphwise("AB\nCD\nEF", styler))
+
+        self.assertEqual(len(gw), 3)
+        gw.collapse()
+        self.assertEqual(len(gw), 6)
+
         for idx, g in enumerate(gw):
             self.assertEqual(g.data("idx"), idx)
     
+        gw2 = (Glyphwise("ABC", styler, multiline=1))
+        
+        self.assertEqual(len(gw2), 1)
+        gw2.collapse()
+        self.assertEqual(len(gw2), 3)
+
+        for idx, g in enumerate(gw2):
+            self.assertEqual(g.data("idx"), idx)
+        
+        gw3 = (Glyphwise("ABC", styler, multiline=0))
+        self.assertEqual(len(gw3), 3)
+        gw3.collapse()
+        self.assertEqual(len(gw3), 3)
+
+        for idx, g in enumerate(gw3):
+            self.assertEqual(g.data("idx"), idx)
+    
     def test_no_reverse(self):
         def styler(g):
             return Style(Font.MutatorSans(), 120, r=1)