diff --git a/requirements.txt b/requirements.txt
index ca0d4661db6078a4574e9ef07cf2ff0bd657ad17..ece88e046512ba0e6abb71c2ea6520b6e3e212bc 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,3 +11,4 @@ plotly
 kaleido
 circlify
 shapely
+dash
diff --git a/rocolib/__main__.py b/rocolib/__main__.py
index 5df484ccf782b001ef9a3789b642990adb2f0a82..882508ef02ed93b76c2b9bb53f1aa69b3f486751 100644
--- a/rocolib/__main__.py
+++ b/rocolib/__main__.py
@@ -5,11 +5,12 @@ import sys
 import logging
 import argparse
 from pprint import pprint
+from textwrap import shorten
 from rocolib.library import getComponent, getComponentTree
 from rocolib.api.composables.graph.Joint import FingerJoint
 
 
-def test(component, params, thickness, display=False, display3D=False):
+def test(component, params, thickness, outdir=None, display=False):
     f = getComponent(component)
     if params is not None:
         for p in params:
@@ -21,8 +22,18 @@ def test(component, params, thickness, display=False, display3D=False):
         t = thickness
         j = FingerJoint(thickness=t)
 
-    f.makeOutput("output/" + component, display=display, display3D=display3D, thickness=t, joint=j)
+    f.make()
 
+    if outdir:
+        filedir = f"{outdir}/{component}"
+        ret = f.makeOutput(outputs = True, filedir=filedir, thickness=t, joint=j, remake=False)
+
+    for composable, outs in ret.items():
+        print(f"Composable: {composable}")
+        pprint({k: shorten(str(v), 60) for k, v in outs.items()})
+
+    if display:
+        ret = f.visualize(outputs = True, thickness=t, joint=j, remake=False)
 
 if __name__ == '__main__':
     LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
@@ -33,6 +44,7 @@ if __name__ == '__main__':
     parser.add_argument("component", nargs='?',
                         help="Name of the component you'd like to test")
     parser.add_argument("--file", "-f", type=str, help="Load component and parameters from file")
+    parser.add_argument("--output", "-o", type=str, help="Output directory")
     parser.add_argument("--list", "-l", action='store_true',
                         help="List all known library components")
 
@@ -45,8 +57,7 @@ if __name__ == '__main__':
     parser.add_argument("-P", help="List component parameters (use multiple times for more detail)",
                         dest="param_list", action="append_const", const=1)
     parser.add_argument("-t", type=float, help="Thickness (i.e. making with wood)")
-    parser.add_argument("-d", action='store_true', help="Display 2D drawing")
-    parser.add_argument("-D", action='store_true', help="Display 3D drawing")
+    parser.add_argument("-d", action='store_true', help="Display visualizations")
     parser.add_argument("-p", nargs=2, action='append',
         metavar=("NAME", "VALUE"),
         help="Set component parameter NAME to value VALUE")
@@ -82,6 +93,14 @@ if __name__ == '__main__':
             config.update(dict(args.p))
         args.p = list(config.items())
 
+    if args.output:
+        if args.output == '-':
+            outdir = None
+        else:
+            outdir = args.output
+    else:
+        outdir = "output"
+
     if args.component:
         if args.interface_list:
             f = getComponent(args.component)
@@ -91,7 +110,7 @@ if __name__ == '__main__':
             else:
                 pprint(info)
 
-            if not args.p or args.t or args.d or args.D:
+            if not args.p or args.t or args.d:
                 exit(0)
         if args.param_list:
             f = getComponent(args.component)
@@ -105,9 +124,9 @@ if __name__ == '__main__':
             else:
                 pprint(info)
 
-            if not args.p or args.t or args.d or args.D:
+            if not args.p or args.t or args.d:
                 exit(0)
-        test(args.component, args.p, thickness=args.t, display=args.d, display3D=args.D)
+        test(args.component, args.p, thickness=args.t, outdir=outdir, display=args.d)
         acted = True
 
     if not acted:
diff --git a/rocolib/api/components/Component.py b/rocolib/api/components/Component.py
index 8e81033f833af106d8eb5308e3be028197e0c154..b5c2e4ba4e686714aad9726d126563fa3b16eded 100644
--- a/rocolib/api/components/Component.py
+++ b/rocolib/api/components/Component.py
@@ -7,12 +7,14 @@ import networkx as nx
 from circlify import circlify
 
 from rocolib import ROCOLIB_DIR
+from rocolib.api.composables.ComponentComposable import ComponentComposable
 from rocolib.api.Parameterized import Parameterized
 from rocolib.api.Function import YamlFunction
 from rocolib.utils.utils import prefix as prefixString
 from rocolib.utils.utils import tryImport
 from rocolib.utils.io import load_yaml
 from rocolib.utils.nx2go import GraphVisualization as gv
+from dash import Dash, html
 
 
 log = logging.getLogger(__name__)
@@ -35,21 +37,23 @@ def getSubcomponentObject(component, name=None):
 
 class Component(Parameterized):
     @classmethod
-    def test(cls, params=None, **kwargs):
+    def test(cls, params=None, outputs=(), filedir=None, **kwargs):
         c = cls()
         if params:
             for key, val in params.items():
                 c.setParameter(key, val)
-        filedir = kwargs.pop('filedir', None)
-        c.makeOutput(filedir, **kwargs)
+        c.makeOutput(outputs, filedir, **kwargs)
 
     def __init__(self, yamlFile=None):
         self._name = None
+        self.cName = type(self).__name__
         Parameterized.__init__(self)
         self.reset()
         yf = yamlFile
         if not yamlFile:
             yf = type(self).__name__ + ".yaml"
+        else:
+            self.cName = yf
         for fn in (yf, 
                    os.path.join(ROCOLIB_DIR, yf), 
                    os.path.join(ROCOLIB_LIBRARY, yf)):
@@ -60,6 +64,7 @@ class Component(Parameterized):
                 pass
             if yamlFile:
                 raise ValueError(f"No suitable yamlfile found for {yamlFile}")
+        self.composables['component'] = ComponentComposable(self)
         self.predefine()
         self.define()
 
@@ -380,8 +385,8 @@ class Component(Parameterized):
         ### Override to combine components' drawings to final drawing
         pass
 
-    def append(self, name, prefix, **kwargs):
-        component = self.getSubcomponent(name)
+    def append(self, prefix, **kwargs):
+        component = self.getSubcomponent(prefix)
 
         allPorts = set()
         for key in component.interfaces:
@@ -390,27 +395,40 @@ class Component(Parameterized):
           except TypeError:
             # interface is not iterable, i.e. a single port
             allPorts.add(component.getInterface(key))
-        for port in allPorts:
-          port.prefix(prefix)
 
         for (key, composable) in component.composables.items():
+            if key not in self.composables:
+                self.composables[key] = composable.new()
             self.composables[key].append(composable, prefix, **kwargs)
-
-    def attach(self, interface_1, interface_2, kwargs):
-        (fromName, fromPort) = interface_1
-        (toName, toPort) = interface_2
-        for (key, composable) in self.composables.items():
+            for port in allPorts:
+               port.update(component, composable, self.composables[key], prefix)
+
+    def attach(self, from_interface, to_interface, kwargs):
+        ifrom = self.getInterfaces(*from_interface)
+        ito = self.getInterfaces(*to_interface)
+
+        # Interfaces can contain multiple ports, so try each pair of ports
+        if not isinstance(ifrom, (list, tuple)):
+            ifrom = [ifrom]
+        if not isinstance(ito, (list, tuple)):
+            ito = [ito]
+        if len(ifrom) != len(ito):
+            raise AttributeError(f"Number of ports don't match connecting interface {from_interface} to {to_interface}")
+
+        for (fromPort, toPort) in zip(ifrom, ito):
             try:
-                composable.attachInterfaces(self.getInterfaces(fromName, fromPort),
-                                            self.getInterfaces(toName, toPort),
-                                            kwargs)
+                fromPort.attachTo(toPort, **kwargs)
+                toPort.attachFrom(fromPort, **kwargs)
+
+                for (key, composable) in self.composables.items():
+                    composable.attach(fromPort, toPort, **kwargs)
             except:
                 logstr  =  "Error in attach: \n"
-                logstr += f"  from ({fromName}, {fromPort}): "
-                logstr += self.getInterfaces(fromName, fromPort).toString()
+                logstr += f"  from {from_interface}: "
+                logstr += fromPort.toString()
                 logstr +=  "\n"
-                logstr += f"  to ({toName}, {toPort}): "
-                logstr += self.getInterfaces(toName, toPort).toString()
+                logstr += f"  to {to_interface}: "
+                logstr += toPort.toString()
                 log.error(logstr)
                 raise
 
@@ -478,10 +496,7 @@ class Component(Parameterized):
 
         try:
             obj.make(useDefaultParameters)
-            for (key, composable) in obj.composables.items():
-                if key not in self.composables:
-                    self.composables[key] = composable.new()
-            self.append(name, name, **kwargs)
+            self.append(name, **kwargs)
         except:
             log.error("Error in subclass %s, instance %s" % (classname, name))
             raise
@@ -496,17 +511,6 @@ class Component(Parameterized):
                         (toComponent, toPort),
                         kwargs)
 
-    def informComposables(self):
-      # Let composables know what components and interfaces exist
-      # TODO remove this when we have a better way of letting composables
-      # know about components that have no ports (ex Bluetooth module driver)
-      for (key, composable) in self.composables.items():
-        for (name, sc) in self.subcomponents.items():
-          composable.addComponent(sc['object'])
-        for (name, value) in self.interfaces.items():
-          if value is not None:
-            composable.addInterface(self.getInterface(name))
-
     # set useDefaultParameters = False to replace unset parameters with sympy variables
     def make(self, useDefaultParameters=True):
         if useDefaultParameters:
@@ -519,92 +523,52 @@ class Component(Parameterized):
             self.evalComponent(scName, useDefaultParameters)    # Merge composables from all subcomponents and tell them my components exist
             self.evalConnections(scName)   # Tell composables which interfaces are connected
 
-        self.informComposables()    # Tell composables that my interfaces exist
         self.assemble()
 
     ###
     # OUTPUT PHASE
     ###
 
-    def makeComponentMap(self, scd=None, full=None, idnum = 0):
-        if scd is None:
-            g = self.componentGraph()
-            full = nx.DiGraph()
-            full.add_node(0, name="", component=self.getName())
-            title = f"<b>{self.getName()}</b>"
-        else:
-            g = scd['graph']
-            title = f"<b>{scd['component']}</b><br>{scd['name']}"
-
-        data = dict(id=idnum, 
-                    datum=(g.graph['depth']+1)**2, 
-                    children=[dict(id=len(full), datum=0.5)])
-        full.add_node(len(full), title=title)
-
-        if g:
-            f = nx.convert_node_labels_to_integers(g, len(full))
-            full = nx.disjoint_union(full, f)
-            for sc, scd in f.nodes(data=True):
-                full, cd = self.makeComponentMap(scd, full, sc)
-                data["children"].append(cd)
-        return full, data
-
-    def drawComponentTree(self, basename, stub):
-        g, data = self.makeComponentMap()
-        circles = circlify([data])
-
-        tree = gv(g,
-            pos = {c.ex['id']: (c.x, c.y) for c in circles},
-            node_size = {c.ex['id']: c.r for c in circles},
-            node_text = lambda k, v : v.get("title", ""),
-            node_border_width = lambda k, v: "title" not in v and 2 or 0,
-            edge_width = 0,
-        )
-        fig = tree.create_figure(width=1024, height=768)
-
-        if basename:
-            fig.write_image(basename+stub)
-            ret = basename+stub
+    def visualize(self, outputs=True, **ka):
+        widgets = self.makeOutput(outputs, widgets=True, **ka)
+        app = Dash(__name__)
+        elts = [
+            html.H2('RoCo component visualizer'),
+            html.H1(f"Component: {self.getName()}"),
+        ]
+        for c, w in widgets.items():
+            elts.append(html.H3(f"Composable: {c}"))
+            for k, v in w.items():
+                elts.append(html.H4(f"Widget: {k}"))
+                elts.append(html.P(v['desc']))
+                elts.append(v['widget'])
+        app.layout = html.Div(elts)
+        app.run_server(debug=True)
+
+    def makeOutput(self, outputs=(), filedir=None, widgets=False, **ka):
+        if filedir:
+            log.info(f"Compiling robot designs to directory {filedir} ...")
+        elif widgets:
+            log.info(f"Generating visualization ...")
         else:
-            ret = fig.to_image(format="png")
-        return ret
-
-    def makeOutput(self, filedir=".", **kwargs):
-        log.info(f"Compiling robot designs to directory {filedir} ...")
-        def kw(arg, default=kwargs.get("default", True)):
-            return kwargs.get(arg, default)
+            log.info(f"Compiling robot design dictionary ...")
 
-        if kw("remake", True):
-            self.make(kw("useDefaultParameters", True))
+        if ka.pop("remake", True):
+            self.make(ka.pop("useDefaultParameters", True))
             log.debug(f"... done making {self.getName()}.")
 
         # XXX: Is this the right way to do it?
         import os
         try:
-            os.makedirs(filedir)
+            filedir and os.makedirs(filedir)
         except:
             pass
 
-        # Process composables in some ordering based on type
-        orderedTypes = ['electrical', 'ui', 'code'] # 'code' needs to know about pins chosen by 'electrical', and 'code' needs to know about IDs assigned by 'ui'
-        # First call makeOutput on the ones of a type whose order is specified
         rets = {}
-        for composableType in orderedTypes:
-            if composableType in self.composables:
-                kwargs["name"] = composableType
-                ss = self.composables[composableType].makeOutput(filedir, **kwargs)
-                rets[composableType] = ss
-        # Now call makeOutput on the ones whose type did not care about order
-        for (composableType, composable) in self.composables.items():
-            if composableType not in orderedTypes:
-                kwargs["name"] = composableType
-                ss = self.composables[composableType].makeOutput(filedir, **kwargs)
-                rets[composableType] = ss
-
-        if kw("tree"):
-            log.info("Generating hierarchy tree...")
-            rets["component"] = self.drawComponentTree(filedir, "/tree.png")
-            log.debug("done making tree.")
+        
+        for (name, composable) in self.composables.items():
+            ss = composable.makeOutput(name, outputs, filedir, widgets, **ka)
+            rets[name] = ss
 
         log.info("Happy roboting!")
         return rets
diff --git a/rocolib/api/components/FoldedComponent.py b/rocolib/api/components/FoldedComponent.py
index e41f3401c45eab3d1e4e229aedd67b146696951f..886382b57a4dabf967c3df99cb140a21dca30c92 100644
--- a/rocolib/api/components/FoldedComponent.py
+++ b/rocolib/api/components/FoldedComponent.py
@@ -33,7 +33,7 @@ class FoldedComponent(MechanicalComponent):
     self.setFaceInterface(interface, face)
 
   def setEdgeInterface(self, interface, edges, lengths):
-    self.setInterface(interface, EdgePort(self, edges, lengths))
+    self.setInterface(interface, EdgePort(self, self.getGraph(), edges, lengths))
 
   def setFaceInterface(self, interface, face):
     self.setInterface(interface, FacePort(self, self.getGraph(), face))
diff --git a/rocolib/api/composables/ComponentComposable.py b/rocolib/api/composables/ComponentComposable.py
new file mode 100644
index 0000000000000000000000000000000000000000..8f1523247bd933dc551a19b6e0107850f4d15e17
--- /dev/null
+++ b/rocolib/api/composables/ComponentComposable.py
@@ -0,0 +1,74 @@
+from rocolib.api.composables.Composable import Composable, output, widget
+import networkx as nx
+from circlify import circlify
+from rocolib.utils.nx2go import GraphVisualization as gv
+from dash import dcc
+
+
+class ComponentComposable(Composable):
+    def __init__(self, component):
+        self.c = component
+        self.net = nx.DiGraph()
+        self.net.graph['depth'] = 0
+
+    def append(self, c2, name, **kwargs):
+        sc = c2.c
+        scname = sc.getName()
+        scclass = sc.cName
+        scnet = c2.net
+        
+        self.net.add_node(scname, name=name, component=scclass, net=scnet)
+        self.net.graph['depth'] = max(self.net.graph['depth'], scnet.graph['depth']+1)
+
+    def attach(self, fromPort, toPort, **kwargs):
+        self.net.add_edge(fromPort.parent.getName(), toPort.parent.getName())
+
+    def _makeComponentMap(self, scd=None, full=None, idnum = 0):
+        if scd is None:
+            g = self.net
+            full = nx.DiGraph()
+            full.add_node(0, name="", component=self.c.getName())
+            title = f"<b>{self.c.getName()}</b>"
+        else:
+            try:
+                g = scd['net']
+            except KeyError:
+                print(scd, idnum)
+                raise
+            title = f"<b>{scd['component']}</b><br>{scd['name']}"
+
+        data = dict(id=idnum,
+                    datum=(g.graph['depth']+1)**2,
+                    children=[dict(id=len(full), datum=0.5)])
+        full.add_node(len(full), title=title)
+
+        if g:
+            f = nx.convert_node_labels_to_integers(g, len(full))
+            full = nx.disjoint_union(full, f)
+            for sc, scd in f.nodes(data=True):
+                full, cd = self._makeComponentMap(scd, full, sc)
+                data["children"].append(cd)
+        return full, data
+
+    def _drawComponentTree(self):
+        g, data = self._makeComponentMap()
+        circles = circlify([data])
+
+        tree = gv(g,
+            pos = {c.ex['id']: (c.x, c.y) for c in circles},
+            node_size = {c.ex['id']: c.r for c in circles},
+            node_text = lambda k, v : v.get("title", ""),
+            node_border_width = lambda k, v: "title" not in v and 2 or 0,
+            edge_width = 0,
+        )
+        fig = tree.create_figure(width=1024, height=768)
+        return fig
+
+    @output("map.png", "component topology map", binary=True)
+    def drawComponentMap(self, fp, **ka):
+        fig = self._drawComponentTree()
+        fig.write_image(fp, format="png")
+
+    @widget('displaymap', "Show component topology")
+    def displayMap(self, fp, **ka):
+        return dcc.Graph(figure = self._drawComponentTree())
diff --git a/rocolib/api/composables/Composable.py b/rocolib/api/composables/Composable.py
index eaccb69277cd6d4e0f49294e355c63c8ee719dbc..8b51fec41e414970542ae286d74fb4fb7953dbaf 100644
--- a/rocolib/api/composables/Composable.py
+++ b/rocolib/api/composables/Composable.py
@@ -1,30 +1,66 @@
+import logging
+from io import BytesIO, StringIO
+
+
+log = logging.getLogger(__name__)
+
+def output(stub, desc, binary=False, widget=False, **kwargs):
+    def wrap(f):
+        def inner(self, fp, **ka):
+            ka.update(kwargs)
+            return f(self, fp, **ka)
+        inner.output = dict(f=inner, desc=desc, stub=stub, binary=binary, widget=widget)
+        return inner
+    return wrap
+
+def widget(stub, desc, **kwargs):
+    return output(stub, desc, widget=True, **kwargs)
+
 class Composable:
-  def new(self):
-    return self.__class__()
-  def append(self, newComposable, newPrefix, **kwargs):
-    raise NotImplementedError
-  def addComponent(self, componentObj):
-    pass
-  def addInterface(self, newInterface):
-    pass
-
-  def attachInterfaces(self, interface1, interface2, kwargs):
-    # Interfaces can contain multiple ports, so try each pair of ports
-    if not isinstance(interface1, (list, tuple)):
-      interface1 = [interface1]
-    if not isinstance(interface2, (list, tuple)):
-      interface2 = [interface2]
-    if len(interface1) != len(interface2):
-      raise AttributeError("Number of ports in each interface don't match")
-
-    for (port1, port2) in zip(interface1, interface2):
-      self.attach(port1, port2, **kwargs)
-
-  def attach(self, fromPort, toPort, **kwargs):
-    fromPort.attachTo(toPort, self, **kwargs)
-    toPort.attachFrom(fromPort, self, **kwargs)
-
-
-
-  def makeOutput(self, filedir, **kwargs):
-    raise NotImplementedError
+    def new(self):
+        return self.__class__()
+    def append(self, newComposable, newPrefix, **kwargs):
+        pass
+    def attach(self, fromPort, toPort, **kwargs):
+        pass
+
+    @classmethod
+    def listOutputs(cls, widgets=False):
+        outputs = {}
+        for methodname in dir(cls):
+            method = getattr(cls, methodname)
+            if hasattr(method, 'output'):
+                out = method.output
+                keyword = out['stub'].split('.')[0]
+                if out['widget'] == widgets:
+                    outputs[keyword] = out
+        return outputs
+
+    def makeOutput(self, name, outputs=(), filedir=None, widgets=False, **ka):
+        basename = None
+        if filedir:
+            basename = filedir + f"/{name}-"
+
+        knownouts = self.listOutputs(widgets)
+
+        rets = {}
+        if outputs is True:
+            outputs = knownouts
+
+        for k in outputs:
+            if k in knownouts:
+                v = knownouts[k]
+                log.info("Generating %s ..." % v['desc'])
+                if basename and v['stub']:
+                    fn = basename+v['stub']
+                    fp = open(fn, v['binary'] and 'wb' or 'w')
+                    rets[k] = fn
+                else:
+                    fp = v['binary'] and BytesIO() or StringIO()
+                ret = v['f'](self, fp, **ka)
+                if widgets:
+                    rets[k] = dict(desc=v['desc'], widget=ret)
+                if k not in rets:
+                    rets[k] = fp.getvalue()
+                fp.close()
+        return rets
diff --git a/rocolib/api/composables/GraphComposable.py b/rocolib/api/composables/GraphComposable.py
index 16dd2614d4e4e2ebfa46e38118ed639ba28ce83d..d056063fea22613899c557266cff902a39fdb8a8 100644
--- a/rocolib/api/composables/GraphComposable.py
+++ b/rocolib/api/composables/GraphComposable.py
@@ -1,133 +1,83 @@
-import sys
-import logging
-from io import StringIO, BytesIO
-
 from rocolib.api.composables.graph.Graph import Graph as BaseGraph
 from rocolib.api.composables.graph.Drawing import Drawing
-from rocolib.api.composables.Composable import Composable
-from rocolib.utils.utils import decorateGraph
+from rocolib.api.composables.Composable import Composable, output, widget
 from rocolib.utils.tabs import BeamTabs, BeamTabDecoration, BeamSlotDecoration
-import rocolib.utils.numsym as np
-
+from rocolib.utils.display import meshfig
+from dash import dcc
 
-log = logging.getLogger(__name__)
 
 class DecorationComposable(Composable, BaseGraph):
-  def __init__(self):
-    BaseGraph.__init__(self)
-  def append(self, newComposable, newPrefix, **kwargs):
-    pass
-  def attach(self, fromInterface, toInterface, kwargs):
-    pass
-  def makeOutput(self, filedir, **kwargs):
-    pass
+    def __init__(self):
+        BaseGraph.__init__(self)
 
 class GraphComposable(Composable, BaseGraph):
-  def __init__(self):
-    BaseGraph.__init__(self)
-
-  def append(self, g2, prefix2, **kwargs):
-    if kwargs.get("mirror", False):
-        g2.mirror()
-    if kwargs.get("invert", False):
-        g2.invertEdges()
-    g2.prefix(prefix2)
-
-    if kwargs.get("root", False):
-        self.faces = g2.faces + self.faces
-        self.edges = g2.edges + self.edges
-    else:
-        self.faces.extend(g2.faces)
-        self.edges.extend(g2.edges)
-
-
-  def makeOutput(self, filedir, **kwargs):
-    if "displayOnly" in kwargs:
-      kwDefault = not kwargs["displayOnly"]
-      kwargs["display"] = kwargs["displayOnly"]
-    elif "default" in kwargs:
-      kwDefault = kwargs["default"]
-    else:
-      kwDefault = True
-
-    def kw(arg, default=kwDefault):
-      if arg in kwargs:
-        return kwargs[arg]
-      return default
-
-    self.tabify(kw("tabFace", BeamTabs), kw("tabDecoration", BeamTabDecoration),
-                kw("slotFace", None), kw("slotDecoration", BeamSlotDecoration), **kwargs)
-    if kw("joint", None):
-        self.jointify(**kwargs)
-    self.place(transform3D=kw("transform3D", None))
-
-    basename = None
-    if filedir:
-        basename = filedir + "/" + kw("name", "") + "-"
-
-    rets = {}
-    bufs = {}
-
-    def handle(keyword, text, fn, stub, **ka):
-        if kw(keyword):
-          log.info("Generating %s pattern..." % text)
-          sys.stdout.flush()
-          if basename and stub:
-            with open(basename+stub, 'w') as fp:
-              fn(fp, **ka)
-          else:
-            buf = StringIO()
-            fn(buf, **ka)
-            rets[keyword] = buf.getvalue()
-            bufs[keyword] = buf
-            
-          log.debug("Done generating %s pattern." % text)
-
-    d = Drawing()
-    if kw("display", False) or kw("unfolding") or kw("autofolding") or kw("silhouette") or kw("animate"):
-      d.fromGraph(self)
-      d.transform(relative=(0,0))
-
-    handle("unfolding", "Corel cut-and-fold", d.toSVG, "lasercutter.svg", mode="Corel")
-    handle("unfolding", "printer", d.toSVG, "lasercutter.svg", mode="print")
-    handle("animate", "OrigamiSimulator", d.toSVG, "anim.svg", mode="animate")
-    handle("silhouette", "Silhouette cut-and-fold", d.toDXF, "silhouette.dxf", mode="silhouette")
-    handle("autofolding", "autofolding", d.toDXF, "autofold-default.dxf", mode="autofold")
-    handle("autofolding", "  -- (graph)", d.toDXF, "autofold-graph.dxf")
-    handle("stl", "stl", self.to3D, "model.stl", format="stl", **kwargs)
-    handle("webots", "webots", self.to3D, "model.wbo", format="webots", **kwargs)
-
-
-    if kw("display3D", False) or kw("png"):
-      from rocolib.utils.display import display3D
-
-      if basename:
-        if kw("stl"):
-          try:
-            stl_handle = open(basename+"model.stl", 'rb')
-          except:
-            log.info("No Stl found")
-            return
+    def __init__(self):
+        BaseGraph.__init__(self)
+        self._d = None
+
+    @property
+    def d(self):
+        if self._d is None:
+            self._d = Drawing(self)
+            self._d.transform(relative=(0,0))
+        return self._d
+
+    def append(self, g2, prefix2, **kwargs):
+        if kwargs.get("mirror", False):
+            g2.mirror()
+        if kwargs.get("invert", False):
+            g2.invertEdges()
+        g2.prefix(prefix2)
+
+        if kwargs.get("root", False):
+            self.faces = g2.faces + self.faces
+            self.edges = g2.edges + self.edges
         else:
-          log.info("STL wasn't selected, making now...")
-          handle("png", "stl", self.to3D, None, format="stl")
-          stl_handle = bufs['png']
-          stl_handle.seek(0)
-        if kw("png"):
-          png_file = basename+"model.png"
-      else:
-        stl_handle = bufs['stl']
-        stl_handle.seek(0)
-        png_file = BytesIO()
+            self.faces.extend(g2.faces)
+            self.edges.extend(g2.edges)
 
-      with stl_handle as sh:
-        display3D(sh, png_file, kw("display3D", False))
+    def makeOutput(self, name, outputs, filedir=None, widgets=False, **ka):
+        self.tabify(ka.pop("tabFace", BeamTabs), ka.pop("tabDecoration", BeamTabDecoration),
+                    ka.pop("slotFace", None), ka.pop("slotDecoration", BeamSlotDecoration), **ka)
+        if ka.pop("joint", None):
+            self.jointify(**ka)
+        self.place(transform3D=ka.pop("transform3D", None))
 
-      if kw("png") and not basename:
-        rets["png"] = png_file.getvalue()
+        return Composable.makeOutput(self, name, outputs, filedir, widgets, **ka)
 
-    if kw("display", False):
-      from rocolib.utils.display import displayTkinter
-      displayTkinter(d)
-
-    return rets
+    def to2D(self, fp, **ka):
+        if ka.get("mode", "") in "Corel print animate".split():
+            return self.d.toSVG(fp, mode=ka.get("mode"))
+        else:
+            return self.d.toDXF(fp, mode=ka.get("mode", "silhouette"))
+
+    def to3D(self, fp, **ka):
+        if ka.get('format') == "stl":
+            self.fullmesh.save("roco-model", fh=fp)
+        elif ka.get('format') == "png":
+            meshfig(self.fullmesh, axes=False).write_image(fp)
+        elif ka.get('format') == 'webots':
+            from rocolib.utils.roco2sim.wbo_nodes import wboConverter
+            fp.write(wboConverter(self.meshes))
+
+    corelOut    = output("corel.svg", "Corel cut-and-fold pattern", mode="Corel")               (to2D)
+    printOut    = output("printer.svg", "cut pattern for printing", mode="print")               (to2D)
+    animOut     = output("animate.svg", "input for OrigamiSimulator", mode="animate")           (to2D)
+    cutOut      = output("silhouette.dxf", "Silhouette cut-and-fold pattern", mode="silhouette")(to2D)
+    autoOut     = output("autofold.dxf", "autofolding pattern", mode="autofold")                (to2D)
+
+    stlOut      = output("model.stl", "3D STL model", binary=True, format="stl")                (to3D)
+    pngOut      = output("render.png", "3D render", binary=True, format="png")                  (to3D)
+    wboOut      = output("webots.wbo", "Webots robot", format="webots")                         (to3D)
+
+    @widget('display3D', "Show 3D model")
+    def display3D(self, fp, **ka):
+        kwargs = {k:v for k,v in ka.items() if k in "color scale".split()}
+        return dcc.Graph(figure = meshfig(self.fullmesh, **kwargs))
+
+    def display2D(self):
+        if kw("display", False):
+            from rocolib.utils.display import displayTkinter
+            displayTkinter(d)
+
+        return rets
diff --git a/rocolib/api/composables/graph/Drawing.py b/rocolib/api/composables/graph/Drawing.py
index 44cdabe907632a6a550b84a0a61998c88f43a8fa..7c5e9ea068701609a3e94358305a3ddc2a3b8063 100644
--- a/rocolib/api/composables/graph/Drawing.py
+++ b/rocolib/api/composables/graph/Drawing.py
@@ -8,13 +8,15 @@ from rocolib.utils.utils import prefix as prefixString
 log = logging.getLogger(__name__)
 
 class Drawing:
-  def __init__(self):
+  def __init__(self, g=None):
     """
     Initializes an empty dictionary to contain Edge instances.
 
     Keys will be Edge labels as strings. Key values will be Edge instances.
     """
     self.edges = {}
+    if g:
+        self.fromGraph(g)
 
   def fromGraph(self, g):
     maxx = 0
@@ -143,18 +145,6 @@ class Drawing:
 
     return minx, miny, maxx, maxy
 
-  def renameedge(self, fromname, toname):
-    """
-    Renames an Edge instance's Key
-
-    @param fromname: string of the original Edge instance name
-    @param toname: string of the new Edge instance name
-    """
-    self.edges[toname] = self.edges.pop(fromname)
-    self.edges[toname].name = toname
-
-    return self
-
   def transform(self, scale=1, angle=0, origin=(0,0), relative=None):
     """
     Scales, rotates, and translates the Edge instances in Drawing
@@ -178,33 +168,6 @@ class Drawing:
 
     return self
 
-  def mirrorY(self):
-    """
-    Changes the coordinates of Edge instances in Drawing so that they are symmetric about the X axis.
-    @return: Drawing with the new Edge instances.
-    """
-    for e in list(self.edges.values()):
-      e.mirrorY()
-    return self
-
-  def mirrorX(self):
-    """
-    Changes the coordinates of Edge instances in Drawing so that they are symmetric about the Y axis.
-    @return: Drawing with the new Edge instances.
-    """
-    for e in list(self.edges.values()):
-      e.mirrorX()
-    return self
-
-  def flip(self):
-    """
-    Flips the directionality of Edge instances om Drawing around.
-    @return: Drawing with the new Edge instances.
-    """
-    for e in list(self.edges.values()):
-      e.flip()
-    return self
-
   def append(self, dwg, prefix = '', noGraph=False, **kwargs):
     for e in list(dwg.edges.items()):
       self.edges[prefixString(prefix, e[0])] = e[1].copy()
@@ -271,13 +234,6 @@ class Drawing:
         e[1].name = prefix + '.' + e[0]
         self.edges[prefix + '.' + e[0]] = e[1]
 
-  def times(self, n, fromedge, toedge, label, mode):
-    d = Drawing()
-    d.append(self, label+'0')
-    for i in range(1, n):
-      d.attach(label+repr(i-1)+'.'+toedge, self, fromedge, label+repr(i), mode)
-    return d
-
 class Face(Drawing):
   def __init__(self, pts, edgetype = None, origin = True):
     Drawing.__init__(self)
diff --git a/rocolib/api/composables/graph/Face.py b/rocolib/api/composables/graph/Face.py
index aaaad3650d54b5b5c2d61e34755b9b95503ca918..9be77bb764f586e3d41468dd1833095a0572f0d6 100644
--- a/rocolib/api/composables/graph/Face.py
+++ b/rocolib/api/composables/graph/Face.py
@@ -272,10 +272,10 @@ class Face(object):
       el = self.edgeLength(i)
       try:
           if el <= 0.01:
-            log.info(f'Skipping traversal of short edge, length = {el}')
+            log.debug(f'Skipping traversal of short edge, length = {el}')
             continue
       except TypeError:
-        log.info(f'Sympyicized variable detected - ignoring edge length check on edge {e.name}')
+        log.debug(f'Sympyicized variable detected - ignoring edge length check on edge {e.name}')
       
       da = e.faces[self]
 
diff --git a/rocolib/api/composables/graph/Graph.py b/rocolib/api/composables/graph/Graph.py
index 17f4dc40aa3bd9b5d2446d4a2d601cb3a63866ac..b8217b84a1009bf845d608c496c82d8650a5ef5c 100644
--- a/rocolib/api/composables/graph/Graph.py
+++ b/rocolib/api/composables/graph/Graph.py
@@ -1,49 +1,45 @@
 import logging
 
+from stl import mesh, Mode
 from sect.triangulation import Triangulation
 from ground.base import get_context
 
 from rocolib.api.composables.graph.HyperEdge import HyperEdge
 from rocolib.utils.utils import prefix as prefixString
-from rocolib.utils.roco2sim.Node import ShapeNode
 import rocolib.utils.numsym as np
-from rocolib.utils.roco2sim.format_3d import format_wrl
 
 
 log = logging.getLogger(__name__)
 
 def inflate(face, thickness=.1, edges=False):
-  dt = np.array([[0],[0],[thickness/2.],[0]])
-  nf = face-dt
-  pf = face+dt
+    dt = np.array([[0],[0],[thickness/2.],[0]])
+    nf = face-dt
+    pf = face+dt
 
-  faces = []
+    faces = []
 
-  if edges:
-    faces.append(np.transpose(np.array((pf[:,0], nf[:,0], pf[:,1]))))
-    faces.append(np.transpose(np.array((nf[:,0], nf[:,1], pf[:,1]))))
-  else:
-    faces.append(pf)          # top face
-    faces.append(nf[:,::-1])  # bottom face
+    if edges:
+        faces.append(np.transpose(np.array((pf[:,0], nf[:,0], pf[:,1]))))
+        faces.append(np.transpose(np.array((nf[:,0], nf[:,1], pf[:,1]))))
+    else:
+        faces.append(pf)          # top face
+        faces.append(nf[:,::-1])  # bottom face
 
-  return faces
+    return faces
 
 
 def _triangulate(faces, **kwargs):
     """Create triangulated faces in 3D space with thickness"""
     scale = .001  # roco units : mm ; STL units m
 
-    triangles_coord_index = []
-    triangles_facet_normal = []
-    facets_index = []
-    k = 0  # index initiated
+    tris = []
 
     thickness = kwargs.get("thickness", 0)
     nparr = lambda l: np.transpose([list(x) + [0, 1] for x in l])
 
     for f in faces:
         if f.area == 0:
-            log.info(f"Omitting face {f.name} with area {f.area} from solid model")
+            log.debug(f"Omitting face {f.name} with area {f.area} from solid model")
             continue # skip faces with 0 area
         r = f.transform3D
         facets_coord_index = []
@@ -51,7 +47,6 @@ def _triangulate(faces, **kwargs):
         facets = []
 
         def add_triangles(t, edges=False):
-            nonlocal k
             if thickness:
                 facets = inflate(t, thickness=thickness, edges=edges)
             elif edges:
@@ -60,10 +55,9 @@ def _triangulate(faces, **kwargs):
                 facets = [t]
 
             for x in facets:
-                facets_coord_index.extend(np.transpose(np.dot(r, x) * scale))
-                facets_normal.extend(np.transpose(np.dot(r, x) * scale))
-                facets_index.append([k, k + 1, k + 2])
-                k = k + 3
+                tri = np.transpose(np.dot(r, x)[0:3] * scale).tolist()
+                tris.append(tri)
+
 
         poly = f.polygon()
         triangles = ( x.vertices for x in Triangulation.constrained_delaunay(poly, context=get_context()).triangles() )
@@ -72,306 +66,271 @@ def _triangulate(faces, **kwargs):
             add_triangles(t)
         for t in map(nparr, poly.edges):
             add_triangles(t, edges=True)
-        triangles_coord_index.extend(facets_coord_index)
-        triangles_facet_normal.extend(facets_normal)
 
-    return format_wrl(triangles_coord_index, triangles_facet_normal, facets_index)
+    data = np.zeros(len(tris), dtype=mesh.Mesh.dtype)
+    solid = mesh.Mesh(data)
+    for i, tri in enumerate(tris):
+        for j in range(3):
+            solid.vectors[i][j] = tri[j]
+    return solid
 
 
 class Graph():
-  def __init__(self):
-    self.faces = []
-    self.facelists = []
-    self.edges = []
-    self.shapeNodes=None
-
-  def addFace(self, f, prefix=None, root=False, faceEdges=None, faceAngles=None, faceFlips=None):
-    if prefix:
-      f.prefix(prefix)
-    if f in self.faces:
-      raise ValueError("Face %s already in graph" % f.name)
-    if root:
-        self.faces.insert(0, f)
-    else:
-        self.faces.append(f)
-
-    if faceEdges is not None:
-      f.renameEdges(faceEdges, faceAngles, faceFlips, self.edges)
-      if prefix:
-        f.prefixEdges(prefix)
-
-    self.rebuildEdges()
-    return self
-
-  def attachFace(self, fromFace, toFace, prefix=None, transform=None):
-    self.addFace(toFace, prefix)
-    self.mergeFace(fromFace, toFace.name, transform)
-
-  def mergeFace(self, fromFaceName, toFaceName, transform=None):
-    fromFace = self.getFace(fromFaceName)
-    toFace = self.getFace(toFaceName)
-    if transform is None:
-        transform = np.eye(4)
-    fromFace.addFace(toFace, transform)
-    toFace.addFace(fromFace, np.inv(transform))
-
-  def attachEdge(self, fromEdge, newFace, newEdge, prefix=None, root=False, angle=0, edgeType=None, joints=None):
-    # XXX should set angle from a face, not absolute angle of the face
-    self.addFace(newFace, prefix, root)
-
-    if fromEdge is not None:
-      newEdge = prefixString(prefix, newEdge)
-      self.mergeEdge(fromEdge, newEdge, angle=angle, edgeType=edgeType, joints=joints)
-
-  def delFace(self, facename):
-    for (i, f) in enumerate(self.faces):
-      if f.name == facename:
-        f.disconnectAll()
-        self.faces.pop(i)
+    def __init__(self):
+        self.faces = []
+        self.facelists = []
+        self.edges = []
+        self._meshes = []
+        self._fullmesh = None
+
+    @property
+    def meshes(self):
+        if not self._meshes:
+            self.makeMeshes()
+        return self._meshes
+
+    @property
+    def fullmesh(self):
+        if not self._fullmesh:
+            self.makeMeshes()
+        return self._fullmesh
+
+    def addFace(self, f, prefix=None, root=False, faceEdges=None, faceAngles=None, faceFlips=None):
+        if prefix:
+            f.prefix(prefix)
+        if f in self.faces:
+            raise ValueError("Face %s already in graph" % f.name)
+        if root:
+            self.faces.insert(0, f)
+        else:
+            self.faces.append(f)
+
+        if faceEdges is not None:
+            f.renameEdges(faceEdges, faceAngles, faceFlips, self.edges)
+            if prefix:
+                f.prefixEdges(prefix)
+
         self.rebuildEdges()
         return self
 
-    return self
-
-  def getFace(self, name):
-    for f in self.faces:
-      if f.name == name:
-        return f
-    return None
-
-  def getEdge(self, name):
-    for e in self.edges:
-      if e.name == name:
-        return e
-    return None
-
-  def prefix(self, prefix):
-    for e in self.edges:
-      e.rename(prefixString(prefix, e.name))
-    for f in self.faces:
-      f.rename(prefixString(prefix, f.name))
-
-  def renameEdge(self, fromname, toname):
-    e = self.getEdge(fromname)
-    if e:
-      e.rename(toname)
-
-  def rebuildEdges(self):
-    self.edges = []
-    for f in self.faces:
-      for e in f.edges:
-        if e not in self.edges:
-          self.edges.append(e)
-
-  def invertEdges(self):
-    # swap mountain and valley folds
-    for e in self.edges:
-      for f in e.faces:
-        e.faces[f] = (-e.faces[f][0], e.faces[f][1])
-    for f in self.faces:
-      f.inverted = not f.inverted
-
-  def addTab(self, edge1, edge2, angle=0, width=10):
-    self.mergeEdge(edge1, edge2, angle=angle, tabWidth=width)
-
-  def mergeEdge(self, edge1, edge2, angle=0, tabWidth=None, edgeType=None, joints=None, swap=False):
-    e1 = self.getEdge(edge1)
-    e2 = self.getEdge(edge2)
-    if e1 is None or e2 is None:
-      logstr  = f"Edge not found trying to merge ({edge1}, {edge2}) in edges:\n  "
-      logstr += "\n  ".join([e.name for e in self.edges])
-      log.error(logstr)
-      raise AttributeError("Edge not found")
-    if swap:
-      e1, e2 = e2, e1
-
-    if len(e2.faces) > 1:
-      log.warning("Adding more than two faces to an edge, currently not well supported.")
-      e2.mergeWith(e1, angle=angle, flip=False, tabWidth=tabWidth)
-    else:
-      e2.mergeWith(e1, angle=angle, flip=True, tabWidth=tabWidth)
-    self.edges.remove(e1)
-    
-    e2.setType(edgeType)
-    if joints:
-        for joint in joints.joints:
-            e2.addJoint(joint)
-
-    return self
-
-  def splitEdge(self, edge):
-    old_edge = edge
-    old_edge_name = edge.name
-    new_edges_and_faces = []
-
-    for i, face in enumerate(list(old_edge.faces)):
-        length = old_edge.length
-        angle = old_edge.faces[face][0]
-        flip = old_edge.faces[face][1]
-
-        new_edge_name = old_edge_name + '.se' + str(i)
-        new_edge = HyperEdge(new_edge_name, length)
-        face.replaceEdge(old_edge, new_edge, angle, flip=False )
-        new_edges_and_faces.append((new_edge_name, face, length, angle, flip))
-
-    self.rebuildEdges()
-    return new_edges_and_faces
-
-  def jointify(self, **kwargs):
-    for e in self.edges:
-        if e.isNotFlat() and "joint" in kwargs:
-            e.setType("JOINT")
-            e.addJoint(kwargs["joint"])
-            #print "jointing ", e.name
-
-  def tabify(self, tabFace=None, tabDecoration=None, slotFace=None, slotDecoration=None, **kwargs):
-    for e in self.edges:
-      if e.isTab():
-        #print "tabbing ", e.name
-        for (edgename, face, length, angle, flip) in self.splitEdge(e):
-          if flip:
-            #print "-- tab on: ", edgename, face.name, angle
-            if tabDecoration is not None and ((abs(angle) > 179.5 and abs(angle) < 180.5) or tabFace is None):
-              tabDecoration(face, edgename, e.tabWidth, flip=True, **kwargs)
-            elif tabFace is not None:
-              tab = tabFace(length, e.tabWidth, **kwargs)
-              self.attachEdge(edgename, tab, tab.MAINEDGE, prefix=edgename, angle=0)
-          else:
-            #print "-- slot on: ", edgename, face.name
-            if slotFace is not None:
-              # XXX TODO: set angle appropriately
-              slot = slotFace(length, e.tabWidth, **kwargs)
-              self.attachEdge(edgename, slot, slot.MAINEDGE, prefix=edgename, angle=0)
-            if slotDecoration is not None:
-              slotDecoration(face, edgename, e.tabWidth, **kwargs)
-
-    #TODO: extend this to three+ edges
-    #component.addConnectors((conn, cname), new_edges[0], new_edges[1], depth, tabattachment=None, angle=0)
-
-  def flip(self):
-    raise NotImplementedError
-    for f in self.faces:
-      f.flip()
-
-  def transform(self, scale=1, angle=0, origin=(0,0)):
-    raise NotImplementedError
-
-  def dotransform(self, scale=1, angle=0, origin=(0,0)):
-    for f in self.faces:
-      f.transform(scale, angle, origin)
-
-  def mirror(self):
-    for f in self.faces:
-      f.mirror()
-
-  def mirrorY(self):
-    raise NotImplementedError
-    for f in self.faces:
-      f.transform( mirrorY())
-
-  def mirrorX(self):
-    raise NotImplementedError
-    for f in self.faces:
-      f.transform( mirrorX())
-
-  def toString(self):
-    print()
-    for f in self.faces:
-      print(f.name + repr(f.edges))
-
-  def graphObj(self):
-    g = {}
-    for f in self.faces:
-      g[f.name] = dict([(e and e.name or "", e) for e in f.edges])
-    return g
-
-  def showGraph(self):
-    import objgraph
-    objgraph.show_refs(self.graphObj(), max_depth = 2, filter = lambda x : isinstance(x, (dict, HyperEdge)))
-
-  def place(self, force=False, transform3D=None):
-    if force:
-        self.unplace()
-        self.facelists = []
+    def attachFace(self, fromFace, toFace, prefix=None, transform=None):
+        self.addFace(toFace, prefix)
+        self.mergeFace(fromFace, toFace.name, transform)
+
+    def mergeFace(self, fromFaceName, toFaceName, transform=None):
+        fromFace = self.getFace(fromFaceName)
+        toFace = self.getFace(toFaceName)
+        if transform is None:
+                transform = np.eye(4)
+        fromFace.addFace(toFace, transform)
+        toFace.addFace(fromFace, np.inv(transform))
+
+    def attachEdge(self, fromEdge, newFace, newEdge, prefix=None, root=False, angle=0, edgeType=None, joints=None):
+        # XXX should set angle from a face, not absolute angle of the face
+        self.addFace(newFace, prefix, root)
+
+        if fromEdge is not None:
+            newEdge = prefixString(prefix, newEdge)
+            self.mergeEdge(fromEdge, newEdge, angle=angle, edgeType=edgeType, joints=joints)
+
+    def delFace(self, facename):
+        for (i, f) in enumerate(self.faces):
+            if f.name == facename:
+                f.disconnectAll()
+                self.faces.pop(i)
+                self.rebuildEdges()
+                return self
 
-    if transform3D is None:
-        transform3D = np.eye(4)
+        return self
 
-    while True:
+    def getFace(self, name):
         for f in self.faces:
-            if f.transform2D is not None and f.transform3D is not None:
-                continue
-            else:
-                f.place(None, None, transform3D, self.facelists)
-                break
+            if f.name == name:
+                return f
+        return None
+
+    def getEdge(self, name):
+        for e in self.edges:
+            if e.name == name:
+                return e
+        return None
+
+    def prefix(self, prefix):
+        for e in self.edges:
+            e.rename(prefixString(prefix, e.name))
+        for f in self.faces:
+            f.rename(prefixString(prefix, f.name))
+
+    def renameEdge(self, fromname, toname):
+        e = self.getEdge(fromname)
+        if e:
+            e.rename(toname)
+
+    def rebuildEdges(self):
+        self.edges = []
+        for f in self.faces:
+            for e in f.edges:
+                if e not in self.edges:
+                    self.edges.append(e)
+
+    def invertEdges(self):
+        # swap mountain and valley folds
+        for e in self.edges:
+            for f in e.faces:
+                e.faces[f] = (-e.faces[f][0], e.faces[f][1])
+        for f in self.faces:
+            f.inverted = not f.inverted
+
+    def addTab(self, edge1, edge2, angle=0, width=10):
+        self.mergeEdge(edge1, edge2, angle=angle, tabWidth=width)
+
+    def mergeEdge(self, edge1, edge2, angle=0, tabWidth=None, edgeType=None, joints=None, swap=False):
+        e1 = self.getEdge(edge1)
+        e2 = self.getEdge(edge2)
+        if e1 is None or e2 is None:
+            logstr  = f"Edge not found trying to merge ({edge1}, {edge2}) in edges:\n  "
+            logstr += "\n  ".join([e.name for e in self.edges])
+            log.error(logstr)
+            raise AttributeError("Edge not found")
+        if swap:
+            e1, e2 = e2, e1
+
+        if len(e2.faces) > 1:
+            log.warning("Adding more than two faces to an edge, currently not well supported.")
+            e2.mergeWith(e1, angle=angle, flip=False, tabWidth=tabWidth)
         else:
-            break
-
-    #IPython.embed()
-    self.rebuildEdges()
-  
-  def unplace(self):
-  
-    for f in self.faces:
-        f.transform2D = None
-        f.transform3D = None
+            e2.mergeWith(e1, angle=angle, flip=True, tabWidth=tabWidth)
+        self.edges.remove(e1)
         
-    for e in self.edges:
-        e.pts2D = None
-        e.pts3D = None
-
-  def toShapeNodes(self, **kwargs):
-    """Create a shape node list"""
-    self.place()
-    self.shapeNodes = []
-    for i, faces in enumerate(self.facelists):
-      # Create 1 shape node per set of faces.
-      wrl = _triangulate(faces, **kwargs)
-      self.shapeNodes.append(ShapeNode(f"solid{i}", wrl, faces[0].transform3D))
-
-  def to3D(self, fp, **kwargs):
-    """stl or webots expects fp instance from handle. if None, not creating it"""
-    if self.shapeNodes is None:
-      self.toShapeNodes(**kwargs)
-
-    if kwargs.get('format') == 'stl':
-      from .stlwriter import ASCII_STL_Writer as STL_Writer
-      writer = STL_Writer(fp)
-      for sn in self.shapeNodes:
-        writer.add_faces(sn.getSTL().face)
-      writer.close()
-
-    if kwargs.get('format') == 'webots':
-        from rocolib.utils.roco2sim.wbo_nodes import wboConverter
-        fp.write(wboConverter(self.shapeNodes))
-
-
-  '''
-  @staticmethod
-  def joinAlongEdge((g1, prefix1, edge1), (g2, prefix2, edge2), merge=True, useOrigEdge=False, angle=0):
-    # TODO(mehtank): make sure that edges are congruent
-
-    g = Graph()
-    for f in g1.faces:
-      g.addFace(f.copy(prefix(prefix1, f.name)), faceEdges = [prefix(prefix1, e.name) for e in f.edges])
-
-    return g.attach(edge1, (g2, prefix2, edge2), merge=merge, useOrigEdge=useOrigEdge, angle=angle)
-
-  @staticmethod
-  def joinAlongFace((g1, prefix1, face1), (g2, prefix2, face2), toKeep=1):
-    # TODO(mehtank): make sure that faces are congruent
-    g = Graph()
-    for f in g1.faces:
-      g.addFace(f.copy(prefix1 + "." + f.name), faceEdges = [prefix1 + "." + e.name for e in f.edges])
-    for f in g2.faces:
-      g.addFace(f.copy(prefix2 + "." + f.name), faceEdges = [prefix2 + "." + e.name for e in f.edges])
-    f1 = g.getFace(prefix1 + "." + face1)
-    f2 = g.getFace(prefix2 + "." + face2)
-    for (e1, e2) in zip(f1.edges, f2.edges):
-      e1.mergeWith(e2)
-    if toKeep < 2:
-      g.delFace(f2.name)
-    if toKeep < 1:
-      g.delFace(f1.name)
-    return g
-  '''
+        e2.setType(edgeType)
+        if joints:
+            for joint in joints.joints:
+                e2.addJoint(joint)
+
+        return self
+
+    def splitEdge(self, edge):
+        old_edge = edge
+        old_edge_name = edge.name
+        new_edges_and_faces = []
+
+        for i, face in enumerate(list(old_edge.faces)):
+            length = old_edge.length
+            angle = old_edge.faces[face][0]
+            flip = old_edge.faces[face][1]
+
+            new_edge_name = old_edge_name + '.se' + str(i)
+            new_edge = HyperEdge(new_edge_name, length)
+            face.replaceEdge(old_edge, new_edge, angle, flip=False )
+            new_edges_and_faces.append((new_edge_name, face, length, angle, flip))
+
+        self.rebuildEdges()
+        return new_edges_and_faces
+
+    def jointify(self, **kwargs):
+        for e in self.edges:
+            if e.isNotFlat() and "joint" in kwargs:
+                e.setType("JOINT")
+                e.addJoint(kwargs["joint"])
+                #print "jointing ", e.name
+
+    def tabify(self, tabFace=None, tabDecoration=None, slotFace=None, slotDecoration=None, **kwargs):
+        for e in self.edges:
+            if e.isTab():
+                #print "tabbing ", e.name
+                for (edgename, face, length, angle, flip) in self.splitEdge(e):
+                    if flip:
+                        #print "-- tab on: ", edgename, face.name, angle
+                        if tabDecoration is not None and ((abs(angle) > 179.5 and abs(angle) < 180.5) or tabFace is None):
+                            tabDecoration(face, edgename, e.tabWidth, flip=True, **kwargs)
+                        elif tabFace is not None:
+                            tab = tabFace(length, e.tabWidth, **kwargs)
+                            self.attachEdge(edgename, tab, tab.MAINEDGE, prefix=edgename, angle=0)
+                    else:
+                        #print "-- slot on: ", edgename, face.name
+                        if slotFace is not None:
+                            # XXX TODO: set angle appropriately
+                            slot = slotFace(length, e.tabWidth, **kwargs)
+                            self.attachEdge(edgename, slot, slot.MAINEDGE, prefix=edgename, angle=0)
+                        if slotDecoration is not None:
+                            slotDecoration(face, edgename, e.tabWidth, **kwargs)
+
+        #TODO: extend this to three+ edges
+        #component.addConnectors((conn, cname), new_edges[0], new_edges[1], depth, tabattachment=None, angle=0)
+
+    def flip(self):
+        raise NotImplementedError
+        for f in self.faces:
+            f.flip()
+
+    def transform(self, scale=1, angle=0, origin=(0,0)):
+        raise NotImplementedError
+
+    def dotransform(self, scale=1, angle=0, origin=(0,0)):
+        for f in self.faces:
+            f.transform(scale, angle, origin)
+
+    def mirror(self):
+        for f in self.faces:
+            f.mirror()
+
+    def mirrorY(self):
+        raise NotImplementedError
+        for f in self.faces:
+            f.transform( mirrorY())
+
+    def mirrorX(self):
+        raise NotImplementedError
+        for f in self.faces:
+            f.transform( mirrorX())
+
+    def toString(self):
+        print()
+        for f in self.faces:
+            print(f.name + repr(f.edges))
+
+    def graphObj(self):
+        g = {}
+        for f in self.faces:
+            g[f.name] = dict([(e and e.name or "", e) for e in f.edges])
+        return g
+
+    def showGraph(self):
+        import objgraph
+        objgraph.show_refs(self.graphObj(), max_depth = 2, filter = lambda x : isinstance(x, (dict, HyperEdge)))
+
+    def place(self, force=False, transform3D=None):
+        if force:
+            self.unplace()
+            self.facelists = []
+
+        if transform3D is None:
+            transform3D = np.eye(4)
+
+        while True:
+            for f in self.faces:
+                if f.transform2D is not None and f.transform3D is not None:
+                    continue
+                else:
+                    f.place(None, None, transform3D, self.facelists)
+                    break
+            else:
+                break
+
+        #IPython.embed()
+        self.rebuildEdges()
+
+    def unplace(self):
+        for f in self.faces:
+            f.transform2D = None
+            f.transform3D = None
+        for e in self.edges:
+            e.pts2D = None
+            e.pts3D = None
+
+    def makeMeshes(self, **kwargs):
+        """Create a list of stl Mesh objects"""
+        self.place()
+        # Create 1 mesh per connected set of faces.
+        self._meshes = [_triangulate(faces, **kwargs) for faces in self.facelists]
+        self._fullmesh = mesh.Mesh(np.concatenate([m.data for m in self._meshes]))
diff --git a/rocolib/api/composables/graph/stlwriter.py b/rocolib/api/composables/graph/stlwriter.py
deleted file mode 100644
index 96b82b779bd8106e327c307fcb1478f1d29b5105..0000000000000000000000000000000000000000
--- a/rocolib/api/composables/graph/stlwriter.py
+++ /dev/null
@@ -1,110 +0,0 @@
-#!/usr/bin/env python
-#coding:utf-8
-# Purpose: Export 3D objects, build of faces with 3 or 4 vertices, as ASCII or Binary STL file.
-# License: MIT License
-
-import struct
-
-ASCII_FACET = """facet normal 0 0 0
-outer loop
-vertex {face[0][0]:.4f} {face[0][1]:.4f} {face[0][2]:.4f}
-vertex {face[1][0]:.4f} {face[1][1]:.4f} {face[1][2]:.4f}
-vertex {face[2][0]:.4f} {face[2][1]:.4f} {face[2][2]:.4f}
-endloop
-endfacet
-"""
-
-BINARY_HEADER ="80sI"
-BINARY_FACET = "12fH"
-
-class ASCII_STL_Writer:
-    """ Export 3D objects build of 3 or 4 vertices as ASCII STL file.
-    """
-    def __init__(self, stream):
-        self.fp = stream
-        self._write_header()
-
-    def _write_header(self):
-        self.fp.write("solid python\n")
-
-    def close(self):
-        self.fp.write("endsolid python\n")
-
-    def _write(self, face):
-        self.fp.write(ASCII_FACET.format(face=face))
-
-    def _split(self, face):
-        p1, p2, p3, p4 = face
-        return (p1, p2, p3), (p3, p4, p1)
-
-    def add_face(self, face):
-        """ Add one face with 3 or 4 vertices. """
-        pt1 = tuple(face[:3,0])
-        for i in range(2, len(face[1,:])):
-          pt2 = tuple(face[:3,i-1])
-          pt3 = tuple(face[:3,i])
-          self._write((pt1, pt2, pt3))
-
-    def add_faces(self, faces):
-        """ Add many faces. """
-        for face in faces:
-            self.add_face(face)
-
-class Binary_STL_Writer(ASCII_STL_Writer):
-    """ Export 3D objects build of 3 or 4 vertices as binary STL file.
-    """
-    def __init__(self, stream):
-        self.counter = 0
-        ASCII_STL_Writer.__init__(self, stream)
-
-    def close(self):
-        self._write_header()
-
-    def _write_header(self):
-        self.fp.seek(0)
-        self.fp.write(struct.pack(BINARY_HEADER, b'Python Binary STL Writer', self.counter))
-
-    def _write(self, face):
-        self.counter += 1
-        data = [
-            0., 0., 0.,
-            face[0][0], face[0][1], face[0][2],
-            face[1][0], face[1][1], face[1][2],
-            face[2][0], face[2][1], face[2][2],
-            0
-        ]
-        self.fp.write(struct.pack(BINARY_FACET, *data))
-
-
-def example():
-    def get_cube():
-        # cube corner points
-        s = 3.
-        p1 = (0, 0, 0)
-        p2 = (0, 0, s)
-        p3 = (0, s, 0)
-        p4 = (0, s, s)
-        p5 = (s, 0, 0)
-        p6 = (s, 0, s)
-        p7 = (s, s, 0)
-        p8 = (s, s, s)
-
-        # define the 6 cube faces
-        # faces just lists of 3 or 4 vertices
-        return [
-            [p1, p5, p7, p3],
-            [p1, p5, p6, p2],
-            [p5, p7, p8, p6],
-            [p7, p8, p4, p3],
-            [p1, p3, p4, p2],
-            [p2, p6, p8, p4],
-        ]
-
-    with open('cube.stl', 'wb') as fp:
-        writer = Binary_STL_Writer(fp)
-        writer.add_faces(get_cube())
-        writer.close()
-
-if __name__ == '__main__':
-    example()
-
diff --git a/rocolib/api/ports/DecorationPort.py b/rocolib/api/ports/DecorationPort.py
index aa3f2b236e3f3404f0fcffd2b35f79ea0ace21d2..1707f2d9a5150e1c8cf29744a0c5d82dfd68b434 100644
--- a/rocolib/api/ports/DecorationPort.py
+++ b/rocolib/api/ports/DecorationPort.py
@@ -17,10 +17,10 @@ class DecorationPort(Port):
   def canMate(self, otherPort):
     return (otherPort.getFaceName() is not None)
 
-  def attachFrom(self, fromPort, graph, **kwargs):
+  def attachFrom(self, fromPort, **kwargs):
     # If from face to decoration, we can decorate the face
     if isinstance(fromPort, FacePort):
-      face = graph.getFace(fromPort.getFaceName())
+      face = fromPort.getFace()
       deco = self.getDecoration()
       if face is not None:
         decorateGraph(face, decoration=deco, **kwargs)
@@ -37,8 +37,8 @@ class AnchorPort(FacePort):
   def canMate(self, otherPort):
     return False
 
-  def attachFrom(self, fromPort, graph, **kwargs):
+  def attachFrom(self, fromPort, **kwargs):
     if isinstance(fromPort, DecorationPort):
       deco = fromPort.getDecoration().faces[0]
       face = self.getFaceName()
-      graph.mergeFace(deco.joinedFaces[0][0].name, face, np.dot(self.getTransform(), deco.transform2D))
+      self.graph.mergeFace(deco.joinedFaces[0][0].name, face, np.dot(self.getTransform(), deco.transform2D))
diff --git a/rocolib/api/ports/EdgePort.py b/rocolib/api/ports/EdgePort.py
index 60c2c44a198b47b23ee1cc78fc1ebb33a8a5e0cd..b4babc965cb6aa94912d080e62d632e3808072e2 100644
--- a/rocolib/api/ports/EdgePort.py
+++ b/rocolib/api/ports/EdgePort.py
@@ -1,54 +1,56 @@
 from rocolib.api.ports import Port
 from rocolib.utils.utils import prefix as prefixString
 
+
 class EdgePort(Port):
-  def __init__(self, parent, edges, lengths):
-    Port.__init__(self, parent, {})
-    if isinstance(edges, str) or not hasattr(edges, "__iter__"):
-        edges = [edges]
-    if isinstance(lengths, str) or not hasattr(lengths, "__iter__"):
-        lengths = [lengths]
-    if len(edges) != len(lengths):
-        raise ValueError("Need one-to-one mapping of edges to lengths")
-    self.edges = edges
-    self.lengths = lengths
-
-  def getEdges(self):
-    return self.edges
-
-  def getParams(self):
-    return self.lengths
-
-  def prefix(self, prefix=""):
-    self.edges = [prefixString(prefix, e) for e in self.edges]
-
-  def canMate(self, otherPort):
-    try:
-      return len(self.getEdges()) == len(otherPort.getEdges())
-    except AttributeError:
-      return False
-
-  def toString(self):
-    return str(self.getEdges())
-
-
-  def attachTo(self, toPort, graph, **kwargs):
-    # if connecting from edge to edge, merge them in order
-    if isinstance(toPort, EdgePort):
-      label1 = self.getEdges()
-      label2 = toPort.getEdges()
-      # XXX associate ports with specific composables so this isn't necessary
-      for i in range(len(label1)):
-        if label1[i] not in (e.name for e in graph.edges):
-          return
-        if label2[i] not in (e.name for e in graph.edges):
-          return
-
-      for i in range(len(label1)):
-        newargs = {}
-        for key, value in kwargs.items():
-          if isinstance(value, (list, tuple)):
-            newargs[key] = value[i]
-          else:
-            newargs[key] = value
-        graph.mergeEdge(label1[i], label2[i], **newargs)
+    def __init__(self, parent, graph, edges, lengths):
+        Port.__init__(self, parent, {})
+        if isinstance(edges, str) or not hasattr(edges, "__iter__"):
+            edges = [edges]
+        if isinstance(lengths, str) or not hasattr(lengths, "__iter__"):
+            lengths = [lengths]
+        if len(edges) != len(lengths):
+            raise ValueError("Need one-to-one mapping of edges to lengths")
+        self.graph = graph
+        self.edges = edges
+        self.lengths = lengths
+
+    def getEdges(self):
+        return self.edges
+
+    def getParams(self):
+        return self.lengths
+
+    def update(self, newparent, oldcomposable, newcomposable, prefix):
+        self.setParent(newparent)
+        if oldcomposable == self.graph:
+            self.edges = [prefixString(prefix, e) for e in self.edges]
+            self.graph = newcomposable
+
+    def canMate(self, otherPort):
+        try:
+            return len(self.getEdges()) == len(otherPort.getEdges())
+        except AttributeError:
+            return False
+
+    def toString(self):
+        return str(self.getEdges())
+
+
+    def attachTo(self, toPort, **kwargs):
+        if isinstance(toPort, EdgePort):
+            label1 = self.getEdges()
+            label2 = toPort.getEdges()
+
+            for i in range(len(label1)):
+                newargs = {}
+                for key, value in kwargs.items():
+                    if isinstance(value, (list, tuple)):
+                        newargs[key] = value[i]
+                    else:
+                        newargs[key] = value
+                try:
+                    self.graph.mergeEdge(label1[i], label2[i], **newargs)
+                except AttributeError:
+                    # Edge not found, skipping
+                    pass
diff --git a/rocolib/api/ports/FacePort.py b/rocolib/api/ports/FacePort.py
index 3b3a162605a22763c9b63a5f931a7cf4ea380337..808c68c0937af140688ad4b319f008a39d526a57 100644
--- a/rocolib/api/ports/FacePort.py
+++ b/rocolib/api/ports/FacePort.py
@@ -7,8 +7,11 @@ class FacePort(Port):
     self.graph = graph
     self.face = face
 
-  def prefix(self, prefix=""):
-    self.face = prefixString(prefix, self.face)
+  def update(self, newparent, oldcomposable, newcomposable, prefix):
+    self.setParent(newparent)
+    if oldcomposable == self.graph:
+      self.face = prefixString(prefix, self.face)
+      self.graph = newcomposable
 
   def getFace(self):
     return self.graph.getFace(self.face)
@@ -31,9 +34,9 @@ class FacePort(Port):
     except:
       pass
 
-  def attachTo(self, toPort, graph, **kwargs):
+  def attachTo(self, toPort, **kwargs):
     # if connecting from face to face, merge them in order
     if isinstance(toPort, FacePort):
-      face1 = graph.getFace(self.getFaceName())
-      face2 = graph.getFace(toPort.getFaceName())
-      graph.mergeFace(face1.name, face2.name)
+      face1 = self.graph.getFace(self.getFaceName())
+      face2 = self.graph.getFace(toPort.getFaceName())
+      self.graph.mergeFace(face1.name, face2.name)
diff --git a/rocolib/api/ports/Port.py b/rocolib/api/ports/Port.py
index 838bd34af9025c7b0033d920935fe338208dfd48..d8660791fa6d6906bb7c4b3279c9edb10be0ee60 100644
--- a/rocolib/api/ports/Port.py
+++ b/rocolib/api/ports/Port.py
@@ -31,9 +31,9 @@ class Port(Parameterized):
     # Override to handle getting inherited to parent.interfaces[name]
     pass
 
-  def prefix(self, prefix=""):
-    # Override to handle prefixing
-    pass
+  def update(self, newparent, oldcomposable, newcomposable, prefix):
+    # Override to handle what happens when a composable gets appended to another with a prefix
+    self.setParent(newparent)
 
 
   def canMate(self, otherPort):
@@ -68,10 +68,10 @@ class Port(Parameterized):
   def toString(self):
     return str(self.parent) + '.' + self.getName()
 
-  def attachTo(self, toPort, composable, **kwargs):
+  def attachTo(self, toPort, **kwargs):
     pass
 
-  def attachFrom(self, fromPort, composable, **kwargs):
+  def attachFrom(self, fromPort, **kwargs):
     pass
 
 
diff --git a/rocolib/test/test_library.py b/rocolib/test/test_library.py
index 4fe21d84cd1dfcf370ce7aa291bc443537030f95..61b6379da60768fd5ff1237d8247e932566c08f0 100644
--- a/rocolib/test/test_library.py
+++ b/rocolib/test/test_library.py
@@ -15,7 +15,7 @@ def test_component_python(component):
 
 @pytest.mark.parametrize("component",yamlComponents)
 def test_component_yaml(component):
-    getComponent(component).makeOutput(default=False)
+    getComponent(component).makeOutput()
 
 if __name__ == "__main__":
     logging.basicConfig(level=logging.INFO)
diff --git a/rocolib/utils/display.py b/rocolib/utils/display.py
index 5cc74c8a4e84ddafc5b5a70463708e6ea6f6bafc..35b27834eed270d38048221640674f9fcc72fb2a 100644
--- a/rocolib/utils/display.py
+++ b/rocolib/utils/display.py
@@ -2,7 +2,6 @@ from tkinter import *
 import math
 import numpy
 import logging
-from stl import mesh
 import plotly.graph_objects as go
 
 from rocolib.api.composables.graph.Drawing import *
@@ -11,42 +10,35 @@ from rocolib.api.composables.graph.DrawingEdge import *
 
 log = logging.getLogger(__name__)
 
-### copied from https://chart-studio.plotly.com/~empet/15276/converting-a-stl-mesh-to-plotly-gomes/#/
-def stl2mesh3d(stl_mesh):
+### adapted from https://chart-studio.plotly.com/~empet/15276/converting-a-stl-mesh-to-plotly-gomes/#/
+def stl2mesh3d(stl_mesh, scale=1000):
+    # scale = 1000 back into roco units = mm
+
     # this function extracts the unique vertices and the lists I, J, K to define a Plotly mesh3d
     p, q, r = stl_mesh.vectors.shape #(p, 3, 3)
     # the array stl_mesh.vectors.reshape(p*q, r) can contain multiple copies of the same vertex;
     # extract unique vertices from all mesh triangles
     vertices, ixr = numpy.unique(stl_mesh.vectors.reshape(p*q, r), return_inverse=True, axis=0)
-    I = numpy.take(ixr, [3*k for k in range(p)])
-    J = numpy.take(ixr, [3*k+1 for k in range(p)])
-    K = numpy.take(ixr, [3*k+2 for k in range(p)])
-    return vertices, I, J, K
-
-def plotlyFigure(stlmesh, color):
-    vertices, I, J, K = stl2mesh3d(stlmesh)
-    x, y, z = vertices.T
+    i = numpy.take(ixr, [3*k for k in range(p)])
+    j = numpy.take(ixr, [3*k+1 for k in range(p)])
+    k = numpy.take(ixr, [3*k+2 for k in range(p)])
+    x, y, z = scale * vertices.T
+    return dict(x=x, y=y, z=z, i=i, j=j, k=k)
+
+def meshfig(stlmesh, scale=1000, color="#ccccff", axes=True):
     colorscale= [[0, color], [1, color]]
     mesh3D = go.Mesh3d(
-            x=x,
-            y=y,
-            z=z,
-            i=I,
-            j=J,
-            k=K,
+            **stl2mesh3d(stlmesh, scale),
             flatshading=True,
             colorscale=colorscale,
-            intensity=z,
             name='model',
             showscale=False)
     layout = go.Layout(
                 paper_bgcolor='rgba(0,0,0,0)',
                 plot_bgcolor='rgba(0,0,0,0)',
-                width=1024,
-                height=1024,
-                scene_xaxis_visible=False,
-                scene_yaxis_visible=False,
-                scene_zaxis_visible=False)
+                scene_xaxis_visible=axes,
+                scene_yaxis_visible=axes,
+                scene_zaxis_visible=axes)
 
     fig = go.Figure(data=[mesh3D], layout=layout)
     fig.update_layout(margin=dict(r=0, l=0, b=0, t=0),
@@ -64,24 +56,6 @@ def plotlyFigure(stlmesh, color):
 
     return fig
 
-def display3D(fh, ph=None, show=False, color="#ccccff"):
-    ### Modified from https://pypi.org/project/numpy-stl/ docs
-
-    # Load the STL mesh
-    stlmesh = mesh.Mesh.from_file(None, fh=fh)
-    # Back to units of mm
-    stlmesh.vectors *= 1000
-
-    fig = plotlyFigure(stlmesh, color)
-
-    if ph is not None:
-        fig.write_image(ph)
-    if show:
-        fig.update_layout(scene_xaxis_visible=True,
-                          scene_yaxis_visible=True,
-                          scene_zaxis_visible=True)
-        fig.show()
-
 class DisplayApp:
     def __init__(self, dwg, height = 500, width = 700, showFlats = True):
         self.root = Tk()
diff --git a/rocolib/utils/numsym.py b/rocolib/utils/numsym.py
index 42855a4a31921d07795e872ecc2257441e1aa251..920417cdcbf13d939c473f07882bb8f0421fdac5 100644
--- a/rocolib/utils/numsym.py
+++ b/rocolib/utils/numsym.py
@@ -100,6 +100,8 @@ known_fns = {
   "arctan2"   : ( numpy.arctan2     , sympy.atan2       ),
   "arccos"    : ( numpy.arccos      , sympy.acos        ),
   "array"     : ( numpy.array       , sympy.Matrix      ),
+  "zeros"     : ( numpy.zeros       , sympy.zeros       ),
+  "concatenate": ( numpy.concatenate, sympy.BlockMatrix ),
   "dot"       : ( numpy.dot         , sympy_dot         ),
   "norm"      : ( numpy.linalg.norm , sympy_norm        ),
   "inv"       : ( numpy.linalg.inv  , sympy_inv         ),
diff --git a/rocolib/utils/roco2sim/wbo_nodes.py b/rocolib/utils/roco2sim/wbo_nodes.py
index ebc6a02517add235cc39f85601a4ab19847c232f..17b99cbe2ea522980be83bd1cc8c1033a92d6651 100644
--- a/rocolib/utils/roco2sim/wbo_nodes.py
+++ b/rocolib/utils/roco2sim/wbo_nodes.py
@@ -1,3 +1,4 @@
+from stl import mesh
 from typing import List
 from rocolib.utils.roco2sim.Node import *
 
@@ -120,10 +121,28 @@ def wboHeader(version="R2021b"):
 """
 
 
-def wboConverter(sNodes: List[ShapeNode]):
-    robot = sNodes[0]
-    for sNode in sNodes[1:len(sNodes)]:
-        robot.children.append(wboSolid(sNode))
+def mesh2wrl(m: mesh.Mesh):
+    m.update_normals()
+    coords = set()
+    for v in m.vectors:
+        coords.update(map(tuple, v))
+    coords = list(coords)
+    vertices = []
+    for v in m.vectors:
+        vertices.append([coords.index(tuple(x)) for x in v])
+    coords = list(map(np.array, coords))
+    return wrl(coords, m.normals, vertices)
+
+
+def mesh2sNode(i, m: mesh.Mesh):
+    wrl = mesh2wrl(m)
+    return ShapeNode(f"solid{i}", wrl, np.eye(3))
+
+
+def wboConverter(meshes: List[mesh.Mesh]):
+    robot = mesh2sNode(0,meshes[0])
+    for i in range(1,len(meshes)):
+        robot.children.append(wboSolid(mesh2sNode(i, meshes[i])))
     node = wboHeader() + wboRobot(robot)
     return node