Mar 29, 2020

Real-Time 3D with Python Part V: Ground and Roads

In the introduction I explained the idea: Using Python to program old skool demo style real-time vector graphics. In Part I I set up a simple 3D wireframe object, rotated it, and drew it on the screen, and in Part II added surfaces and perspective. Part III was about calculating object surface visibility and shading. Part IV set up an XML reader to define the objects and other properties using a common file format (XML), and added the code needed to work with multiple objects. In this part, I add two special features: the ground and roads.

The program code and XML files for all parts can be downloaded from github. Note that the full code is not found below - to run this, use github code.

Going Somewhere


The cityscape in the previous part is floating in a void of black, empty space. To make it look more realistic, we need to show the ground and add some roads. I will cover the roads first. In the original demo (see introduction) the roads seem to stretch to infinity, and that is the effect I am after here, too. For infinity, "far enough" will do just fine; I have defined the coordinates so that the roads go to ±48,000 when the city in general sits within ±1,000. 

There are two problems with using the road objects: (1) they very often have nodes (corner points) both behind and in front of the viewer, causing an issue with the perspective (scaling using the Z coordinate), and (2) sorting objects using their midpoint Z coordinate does not guarantee the correct drawing order, as the roads are so long. Hence, I am adding two new attributes to the Vectorobject class:

        self.isFlat = 0
        self.prio = 0                                       # priority order when drawn. Highest prio will be drawn first

The isFlat attribute is set to 1 when the object is created if it has only one surface. Then, before flattening from 3D to 2D and drawing it, the surface is cropped in 3D to the positive side of objMinZ (which is set to 100) in Z coordinates:

        if self.isFlat == 1:
            # for flat objects, build a list of transNodes for the surface by first cropping the necessary surface sides to minZ
            for surface in self.surfaces:
                surface.setVisible(1) # set all surfaces to "visible" 
                flat_nodes = np.zeros((0, 3))
                for node_num in range(len(surface.nodes)):
                    node = self.rotatedNodes[surface.nodes[node_num], 0:3] # current node XYZ coordinates
                    prev_node = self.rotatedNodes[surface.nodes[node_num - 1], 0:3] # previous node XYZ coordinates
                    diff_node = node - prev_node # surface side vector
                    # if both Z coordinates behind the viewer: do not draw at all, do not add a transNode
                    if (node[2] < objMinZ and prev_node[2] >= objMinZ) or (node[2] >= objMinZ and prev_node[2] < objMinZ):
                        # line crosses objMinZ, so add a "crop point". Start from previous node and add difference stopping to objMinZ
                        flat_nodes = np.vstack((flat_nodes, prev_node + diff_node * ((objMinZ - prev_node[2]) / diff_node[2])))
                    if node[2] >= objMinZ:
                        # add current node, if it is visible
                        flat_nodes = np.vstack((flat_nodes, node))
                # apply perspective using Z coordinates and add midScreen to center on screen to get to transNodes
                self.transNodes = (-flat_nodes[:, 0:2] * zScale) / (flat_nodes[:, 2:3]) + midScreen

After cropping, applying perspective can be done the usual way by dividing X and Y with the Z coordinate.

The prio attribute is also set when creating the objects and can be defined in the XML file for each object. It is used is a simple way so that the higher prio objects will be drawn before anything with a lower prio, and sorting for the drawing order happens within each prio class separately. By giving the roads a higher priority than for the buildings, for instance, will make sure the roads are drawn first and the buildings after them. Otherwise the roads might appear to overlap the buildings.

If you look at the original demo, the roads should nicely vanish into the horizon, but for now they are just drawn in one constant color. This will be fixed in Part VIII.

Ground Control


The ground will be blue and nicely faded to black in the horizon. However, it will not be fixed; the fading will depend on the height of the viewer, and in the Python code I want to preserve the ability to rotate in true 3D, unlike the original purely vertical axis rotation. For this purpose I created a special ground object. There is not much special about the object itself - it is a flat square at ground level, just providing the information on rotation - but it is handled by custom code.

First, using the ground object rotated nodes, I want to find out a 3D square on that surface that has Z coordinates at either groundZ or -groundZ; this will make the fading work as intended with distance.

    def groundData(self, midScreen, zScale, objMinZ, groundZ, groundShades, groundShadeNr):
        """ 
        Calculate ground data for on a ground object.
        Assumes the ground object "covers the ground" reasonably and isFlat = 1, and the perimeter is concave.
        Ground settings are defined in VectorViewer.
        Returns an array of shape(groundShadeNr + 1, 4) where each row has X,Y of left edge and X.Y of right edge starting from most distant
        """
        # find the most distant node
        maxZ = max(self.rotatedNodes[:, 2])
        for nodenum in range(len(self.nodes)):
            if self.rotatedNodes[nodenum, 2] == maxZ:
                node = self.rotatedNodes[nodenum, :]
                break
        prev_node = self.rotatedNodes[nodenum - 1, :]
        if nodenum == len(self.nodes) - 1:
            next_node = self.rotatedNodes[0, :]
        else:
            next_node = self.rotatedNodes[nodenum + 1, :]
            
        # get a straight line where Z (ie, distance from viewer) is constant. Start with the mid of farthest of the two lines.
        # then find the point with matching Z coordinate on the other line.
        # special cases: next_node or prev_node as far as node.
        if node[2] == prev_node[2]:
            mid1_node = node
            mid2_node = prev_node
        else:
            if node[2] == next_node[2]:
                mid1_node = node
                mid2_node = next_node
            else:
                if next_node[2] > prev_node[2]:
                    mid1_node = (next_node + node) / 2
                    mid2_node = node + (prev_node - node) * (mid1_node[2] - node[2]) / (prev_node[2] - node[2])
                else:
                    mid1_node = (prev_node + node) / 2
                    mid2_node = node + (next_node - node) * (mid1_node[2] - node[2]) / (next_node[2] - node[2])
        if mid1_node[1] < mid2_node[1]:
            # make sure mid1_node X < mid2_node X
            mid1_node, mid2_node = mid2_node, mid1_node
        # adjust Z
        mid1_node = mid1_node * groundZ / mid1_node[2]
        mid2_node = mid2_node * groundZ / mid2_node[2]
        # finalize a square around object position
        mid2_node_back = self.position[0:3] + (self.position[0:3] - mid1_node) # from front left (mid1) to back right (mid2_back)
        mid1_node_back = self.position[0:3] + (self.position[0:3] - mid2_node) # from front right (mid2) to back left (mid1_back)

Then, having this 3D square, I will generate groundShadeNr (defined in VectorViewer class) slices of ground, the most distant being first. When drawing these in preset colors the fading effect will be accomplished. I am using 16 shades - on the Amiga 15 was the maximum - the more you use, the better the quality, and the slower the drawing. The nodes needed for the slices  (groundShadeNr + 1 nodes) are also cropped to screen X coordinates, as the ground is very large and usually overlaps the screen by a lot.

       # then generate arrays with necessary node data and transNode data
        left_nodes = np.zeros((groundShadeNr + 1, 3), dtype=float)
        right_nodes = np.zeros((groundShadeNr + 1, 3), dtype=float)
        # multipliers will span ground component span between groundZ/2 (furthest) and objMinZ
        mult = (mid1_node[2] / 2 - objMinZ) / ((mid1_node[2] - mid1_node_back[2]) / 2)
        # the most distant component (at groundZ). Most distant component will be very large (half of total)
        left_nodes[0,:] = mid1_node  
        right_nodes[0,:] = mid2_node                

        # other components from groundZ/2 to objMinZ
        for i in range(groundShadeNr):
            mult_i =  mult * math.sqrt((i+1) / groundShadeNr)
            left_nodes[i+1,:] = (mid1_node * (1.0 - mult_i) + mid1_node_back * mult_i) / 2
            right_nodes[i+1,:] = (mid2_node * (1.0 - mult_i) + mid2_node_back * mult_i) / 2         
        left_transNodes = (-left_nodes[:, 0:2] * zScale) / (left_nodes[:, 2:3]) + midScreen
        right_transNodes = (-right_nodes[:, 0:2] * zScale) / (right_nodes[:, 2:3]) + midScreen
        
        # crop these nodes to screen X edges
        diff_transNodes = right_transNodes - left_transNodes
        mult_nodes = right_transNodes[:, 0] / diff_transNodes[:, 0] 
        left_transNodes = right_transNodes - np.multiply(np.transpose(np.vstack((mult_nodes, mult_nodes))), diff_transNodes)
        diff_transNodes = right_transNodes - left_transNodes
        mult_nodes = (midScreen[0] * 2) / diff_transNodes[:,0]
        right_transNodes = left_transNodes + np.multiply(np.transpose(np.vstack((mult_nodes, mult_nodes))), diff_transNodes)

        # the first component is "the top of the sky".
        if left_transNodes[0,1] < left_transNodes[1,1]:
            # "normal ground", add a node to the top of the screen
            if left_transNodes[0,1] < 0:
                # if ground already covers the whole screen, use the top node
                left_skynode = left_transNodes[0,:]
            else:
                left_skynode = np.array([0, 0])
        else:
            # inverted ground ie. going upside down, add a node to the bottom of the screen
            if left_transNodes[0,1] > midScreen[1] * 2:
                # if ground already covers the whole screen, use the top node
                left_skynode = left_transNodes[0,:]
            else:
                left_skynode = np.array([0, midScreen[1] * 2])
        if right_transNodes[0,1] < right_transNodes[1,1]:
            # "normal ground", add a node to the top of the screen
            if right_transNodes[0,1] < 0:
                # if ground already covers the whole screen, use the top node
                right_skynode = right_transNodes[0,:]
            else:
                right_skynode = np.array([midScreen[0] * 2, 0])
        else:
            # inverted ground ie. going upside down, add a node to the bottom of the screen
            if right_transNodes[0,1] > midScreen[1] * 2:
                # if ground already covers the whole screen, use the top node
                right_skynode = right_transNodes[0,:]
            else:
                right_skynode = midScreen * 2               
        # add the first component and build an array of all the transnodes
        transNodes = np.vstack((np.hstack((left_skynode, right_skynode)), np.hstack((left_transNodes, right_transNodes))))

Since the ground is the first object to be drawn - it has the highest prio - and usually covers about half of the screen, I am also adding an extra component; see "the top of the sky" in the above code. By adding the part of the screen not covered by the ground, I can skip clearing the screen altogether. In effect I am only clearing the part that will not be overwritten by the ground anyway, in an effort to save some time.

Size Does Matter


With the new, big objects ground and roads, they will certainly be stretching outside of our screen. I already used cropping with the Z coordinate above, and now I am adding code to crop also X and Y transNodes i.e. 2D screen coordinates to fit the screen, so that we will not try to draw anything outside of the viewing area. The code below goes through a list of polygon nodes and makes the necessary deletions and modifications. It also converts the list of nodes to integer values. 

    def cropEdges(self, node_list, cropX = True, cropY = True):
        # crop to screen size. "Auto crop" does not seem to work if points very far outside.
        # takes list of nodes (X,Y) in drawing order as input.
        # returns list of nodes (X,Y) cropped to screen edges.
        # crop both X, Y, if cropX and cropY = True; X: i=0, Y: i=1
        if len(node_list) > 2:
            for i in range(2):
                if (i == 0 and cropX == True) or (i == 1 and cropY == True):
                    crop_nodes = [] # empty list
                    prev_node = node_list[-1]
                    for node in node_list:
                        diff_node = node - prev_node # surface side vector
                        # start cropping from prev_node direction, as order must stay the same
                        if node[i] >= 0 and prev_node[i] < 0:
                            # line crosses 0, so add a "crop point". Start from previous node and add difference stopping to 0
                            crop_nodes.append(prev_node + diff_node * ((0 - prev_node[i]) / diff_node[i]))
                        if node[i] <= self.midScreen[i] * 2 and prev_node[i] > self.midScreen[i] * 2:
                            # line crosses screen maximum, so add a "crop point". Start from previous node and add difference stopping to midScreen[i] * 2
                            crop_nodes.append(prev_node + diff_node * ((self.midScreen[i] * 2 - prev_node[i]) / diff_node[i]))
                        # then crop current node
                        if node[i] < 0 and prev_node[i] >= 0:
                            # line crosses 0, so add a "crop point". Start from previous node and add difference stopping to 0
                            crop_nodes.append(prev_node + diff_node * ((0 - prev_node[i]) / diff_node[i]))
                        if node[i] > self.midScreen[i] * 2 and prev_node[i] <= self.midScreen[i] * 2:
                            # line crosses screen maximum, so add a "crop point". Start from previous node and add difference stopping to midScreen[i] * 2
                            crop_nodes.append(prev_node + diff_node * ((self.midScreen[i] * 2 - prev_node[i]) / diff_node[i]))         
                        # always add current node, if it is on screen
                        if node[i] >= 0 and node[i] <= self.midScreen[i] * 2:
                            crop_nodes.append(node)
                        prev_node = node
                    # for next i, copy results. Quit loop if no nodes to look at
                    node_list = crop_nodes
                    if len(node_list) < 3:
                        break               
            # convert to integers
            node_list = [(int(x[0] + 0.5), int(x[1] + 0.5)) for x in node_list]
        return node_list

Now the end result looks like this:

To Blit or not to Blit?


On the Amiga, the Blitter co-processor is capable of drawing lines between the given coordinates. In addition, it has a special mode that only sets one bit (i.e. draws one pixel) per horizontal line. This comes in handy when combined with the Blitter fill operation, which will go through each horizontal line in an area bit by bit, and each time it encounters a bit that is set, it will turn area fill mode on if it was off, and off if it was on. So, by drawing the edges of a square using the special mode, it possible to fill that square with the fill operation.

Remember that the Amiga uses bitplane graphics so that one can choose the depth - the number of bitplanes - of the screen freely between one and five (special modes have six) and the number of colors available is two to the power of that, so with five bitplanes one gets 2^5 = 32 colors. If I needed a rotating cube, for example, like in Part III, I would only need three colors as that's the maximum number of surfaces drawn simultaneously. Obviously, I also need one color for the background. These four colors can be defined with two bitplanes. For any one pixel, the first bitplane defines the least significant bit of the color used, and the second bitplane the next bit. So, if both bitplanes are cleared, I would have color 00 (binary) = 0 (decimal) - this is the background. If I only draw a filled surface on the first bitplane, it will have color 01 = 1, and if only on the second bitplane, color 10 = 2. If both bitplanes are filled, the color is 11 = 3.

How to actually draw this on the Amiga then? To be efficient, the best way is not to draw the surfaces one by one, like the Python code and it's pygame.draw.polygon do. It's more efficient to first figure out for each edge of the cube if it needs to be drawn or not. For our cube, let's say there are three surfaces (1, 2 and 3) to be drawn, and we have defined that they will have colors 1, 2 and 3, respectively. The colors needed (in binary) will then be 01, 10, and 11. Furthermore, both surfaces 1 and 2 will have one edge in common with surface 3. In practice, then, I need to fill the area of surfaces 1 and 3 on bitplane 1, and the area of surfaces 2 and 3 on bitplane 2. So, instead of drawing and filling each surface with four operations (surface 1 on biplane 1, s. 2 on 2, s. 3 on 1, and s. 3 on 2) it will be much more efficient to draw the common boundaries of surfaces 1 and 3 on bitplane 1 and then fill it in one blitter fill operation, and then the common boundaries of surfaces 2 and 3 on bitplane 2 and then fill that in another blitter fill operation. In addition, the fill operation needs a "virgin" bitplane to work properly; trying to draw and fill a surface next to another already drawn and filled would mess up the second fill operation. Figuring out which edges need to be drawn in which colors is actually very simple by using an XOR logical operation on the edge colors with each surface drawn.

With multiple objects on top of each other, it is necessary to have separate bitplanes for drawing the next object, and then copy that to the main bitplanes on top of the other objects, again using the blitter.

Next: Part VI: Shadows

Mar 22, 2020

Real-Time 3D with Python Part IV: Cityscape

In the introduction I explained the idea: Using Python to program old skool demo style real-time vector graphics. In Part I I set up a simple 3D wireframe object, rotated it, and drew it on the screen, and in Part II added surfaces and perspective. Part III was about calculating object surface visibility and shading. This part sets up an XML reader to define the objects and other properties using a common file format (XML), and adds the code needed to work with multiple objects.

The program code and XML files for all parts can be downloaded from github. Note that the full code is not found below - to run this, use github code.

It's a World Out There


Previously, I only had a single object shown in the middle of the screen. Now I am going to add many objects, so that they all have their own coordinates. In addition, they can have their own rotation defined, so I may need multiple "angle sets". What I will end up with all the changes is this:


To get there, let's add two new classes, VectorAngles and VectorPosition:

class VectorAngles:

    """
    Angles for rotating vector objects. For efficiency, one set of angles can be used for many objects.
    Angles are defined for axes X (horizontal), Y (vertical), Z ("distance") in degrees (360).
    
    @author: kalle
    """
    def __init__(self):
        self.angles = np.array([0.0, 0.0, 0.0])
        self.angleScale = (2.0 * np.pi) / 360.0 # to scale degrees.
        self.angName = ""
        self.rotationMatrix = np.zeros((3,3))
        self.rotateAngles = np.array([0.0, 0.0, 0.0])
        self.rotate = np.array([0.0, 1.0, 0.0])
    
    def setAngles(self, angles):
        # Set rotation angles to fixed values. 
        self.angles = angles
     
    def setRotateAngles(self):
        self.rotateAngles += self.rotate
        for i in range(3):
            if self.rotateAngles[i] >= 360: self.rotateAngles[i] -= 360
            if self.rotateAngles[i] < 0: self.rotateAngles[i] += 360

    def setRotationMatrix(self):
        # Set matrix for rotation using angles.
        
        (sx, sy, sz) = np.sin((self.angles + self.rotateAngles) * self.angleScale)
        (cx, cy, cz) = np.cos((self.angles + self.rotateAngles) * self.angleScale)
 
        # build a matrix for X, Y, Z rotation (in that order, see Wikipedia: Euler angles) including position shift. 
        # add a column of zeros for later position use
        self.rotationMatrix = np.array([[cy * cz               , -cy * sz              , sy      ],
                                        [cx * sz + cz * sx * sy, cx * cz - sx * sy * sz, -cy * sx],
                                        [sx * sz - cx * cz * sy, cz * sx + cx * sy * sz, cx * cy ]])
    
class VectorPosition:

    """
    A vector object defining the positions of other objects in its nodes (see VectorObject).
    
    @author: kalle
    """
    def __init__(self):
        self.position = np.array([0.0, 0.0, 0.0, 1.0])
        self.angles = VectorAngles()
        self.nodes = np.zeros((0, 4))                   # nodes will have unrotated X,Y,Z coordinates plus a column of ones for position handling
        self.rotatedNodes = np.zeros((0, 3))            # rotatedNodes will have X,Y,Z coordinates
        self.objects = []                               # connects each node to a respective VectorObject
        self.objName = ""
        
    def addNodes(self, node_array):
        # add nodes (all at once); add a column of ones for using position in transform
        self.nodes = np.hstack((node_array, np.ones((len(node_array), 1))))
        self.rotatedNodes = node_array # initialize with nodes
        
    def addObjects(self, object_list):
        self.objects = object_list

    def rotate(self):
        # apply a rotation defined by a given rotation matrix.
        matrix = np.vstack((self.angles.rotationMatrix, np.zeros((1, 3))))
        # apply rotation and position matrix to nodes
        self.rotatedNodes = np.dot(self.nodes, matrix) + self.position[0:3]

As you may note, I have just moved the code in VectorAngles from VectorObject, where it still was in Part III. Now that we have multiple objects, most of them share their rotation (angles), so it is better to process that part just once per rotation set.

VectorPosition is new but very simple. The nodes hold the original coordinates of each object, and rotatedNodes hold the coordinates after rotation according to angles. In addition, there's a list objects to maintain the connection between the coordinates and the respective object. In a way, VectorPosition is very similar to a VectorObject, but it does not have surfaces. Instead, each of its nodes just represents the position of a VectorObject.

Now, when rotating the objects in VectorViewer, rotation is done in two steps: first the positions above are rotated and copied into the actual objects, which are then rotated in the second step:

    def rotate(self):
        """ 
        Rotate all objects. First calculate rotation matrix.
        Then apply the relevant rotation matrix with object position to each VectorObject.
        """
                
        # calculate rotation matrices for all angle sets
        for VectorAngles in self.VectorAnglesList:
            VectorAngles.setRotateAngles()
            VectorAngles.setRotationMatrix()
        
        # rotate object positions, copy those to objects.
        self.VectorPos.rotate() 
        for (node_num, VectorObj) in self.VectorPos.objects:
            VectorObj.setPosition(self.VectorPos.rotatedNodes[node_num, :])

        # rotate and flatten (transform) objects
        for VectorObj in self.VectorObjs:
            VectorObj.updateVisiblePos(self.objMinZ) # test for object position Z
            if VectorObj.visible == 1:
                VectorObj.rotate() # rotates objects in 3D
                VectorObj.updateVisibleNodes(self.objMinZ) # test for object minimum Z
                if VectorObj.visible == 1:
                    VectorObj.transform(self.midScreen, self.zScale) # flattens to 2D, crops X,Y
                    VectorObj.updateVisibleTrans(self.midScreen) # test for outside of screen

Note how first all possible "angle sets" are processed so that the rotation matrices are ready for use. Also, when rotating the objects, I am updating a visible property; now that we have many objects moving around, it may be possible to stop processing some of them, if we know they will not be visible anyway. First, I am testing for object position Z coordinate. In my world, the viewer is sitting at the origo, so anything with a negative Z coordinate is behind her. I have actually defined an objMinZ property to not draw anything which is too close. So, if the object is indeed too close, judging by its position, it will not be shown and thus also rotating it will be unnecessary.

A second test, for the objects surviving the first, is to check is the object is on screen at all. This is done by checking the minimum and maximum transNodes i.e. X and Y coordinates against the screen boundaries. (For the actual code for these simple checks, see the github files.) 

City Planning


To set up the objects, I created an XML file. In the code, I imported some new modules to do that:

import os
from operator import itemgetter
import copy
import xml.etree.ElementTree as et

And then, before launching the VectorViewer, the part below reads the XML and adds the objects. (It's a lengthy part so this is just the beginning.) If you run the github code, you will need to change the data directory to point to the right place. Handling XML is very easy; the ElementTree.parse builds a structure which can then be iterated or searched for the data as required. Note that the code I built contains almost no error checking whatsoever for missing or incorrect data.

if __name__ == '__main__':
    """ 
    Prepare screen, read objects etc. from file.
    """

    # set data directory
    os.chdir("D:\kalle\Documents\Python")

    # set screen size
    # first check available full screen modes
    pygame.display.init()
    # disp_modes = pygame.display.list_modes(0, pygame.FULLSCREEN | pygame.DOUBLEBUF | pygame.HWSURFACE)
    # disp_size = disp_modes[4] # selecting display size from available list. Assuming the 5th element is nice...
    disp_size = (1280, 800)

    vv = VectorViewer(disp_size[0], disp_size[1])

    # read data file defining angles, movements and objects
    vecdata = et.parse("vectordata cityscape.xml")

    root = vecdata.getroot()
    
    for angles in root.iter('vectorangles'):
        ang = VectorAngles()
        ang.angName = angles.get('name')
        ang.angles[0] = float(angles.findtext("angleX", default="0"))
        ang.angles[1] = float(angles.findtext("angleY", default="0"))
        ang.angles[2] = float(angles.findtext("angleZ", default="0"))
        vv.addVectorAnglesList(ang)
   
    for vecobjs in root.iter('vectorobject'):
        vobj = VectorObject()
        vobj.objName = vecobjs.get('name')
            
    ...

The XML code is also rather lengthy and below is the beginning, defining a lightsource, one set of angles, and one vector object.

<?xml version="1.0"?>
<vectordata>
 <lightsource>
  <!-- set lightsource position for shading. If any object has the "lightsource" property, these will be overridden. -->
  <lightposition>
   <lightpositionX>600</lightpositionX>
   <lightpositionY>200</lightpositionY>
   <lightpositionZ>800</lightpositionZ>
  </lightposition>
 </lightsource>
 <vectorangleslist>
  <!-- set up angle sets for rotations. Values given are starting values. -->
  <vectorangles name="viewer">
   <angleX>0</angleX>
   <angleY>90</angleY>
   <angleZ>0</angleZ>
  </vectorangles>
 </vectorangleslist>
 <vectorobjectslist>
  <!-- set up vectorobjects. Each object must have a position, a reference to angles used, a list of nodes, and a list of surfaces. -->
  <!-- minshade (must be between 0 and 1) determines "shade multiplier" when surface is at 90 degree angle to light source. Use 1.0 to keep full color value ie. no shading. -->
  <!-- use "copyfrom" property (e.g. <vectorobject name="cube2" copyfrom="cube1">) to copy objects; properties like position and colors can then be re-defined, and initangles specified. -->
  <!-- object can have default values for surface color, edgewidth, showback (e.g. "defcolor"). These can be overridden for each surface. --> 
  <vectorobject name="house1_1">
   <!-- house1: a small house with gabled roof -->
   <position>
    <positionX>-120</positionX>
    <positionY>0</positionY>
    <positionZ>200</positionZ>
   </position>
   <anglesref>viewer</anglesref>
   <minshade>0.2</minshade>
   <defcolor>
    <defcolorR>255</defcolorR>
    <defcolorG>255</defcolorG>
    <defcolorB>255</defcolorB>
   </defcolor>
   <defedgewidth>0</defedgewidth>
   <defshowback>0</defshowback>
   <nodelist numnodes="10">
    <node ID="0">
     <nodeX>50</nodeX>
      <nodeY>0</nodeY>
      <nodeZ>-100</nodeZ>
    </node>
    <node ID="1">
     <nodeX>50</nodeX>
      <nodeY>0</nodeY>
      <nodeZ>100</nodeZ>
    </node>
    <node ID="2">
     <nodeX>-50</nodeX>
      <nodeY>0</nodeY>
      <nodeZ>100</nodeZ>
    </node>
    <node ID="3">
     <nodeX>-50</nodeX>
      <nodeY>0</nodeY>
      <nodeZ>-100</nodeZ>
    </node>
    <node ID="4">
     <nodeX>50</nodeX>
      <nodeY>100</nodeY>
      <nodeZ>-100</nodeZ>
    </node>
    <node ID="5">
     <nodeX>50</nodeX>
      <nodeY>100</nodeY>
      <nodeZ>100</nodeZ>
    </node>
    <node ID="6">
     <nodeX>-50</nodeX>
      <nodeY>100</nodeY>
      <nodeZ>100</nodeZ>
    </node>
    <node ID="7">
     <nodeX>-50</nodeX>
      <nodeY>100</nodeY>
      <nodeZ>-100</nodeZ>
    </node>
    <node ID="8">
     <nodeX>0</nodeX>
      <nodeY>125</nodeY>
      <nodeZ>-75</nodeZ>
    </node>
    <node ID="9">
     <nodeX>0</nodeX>
      <nodeY>125</nodeY>
      <nodeZ>75</nodeZ>
    </node>
   </nodelist>
   <surfacelist>
    <surface ID="0">
     <nodelist>
      <node order="0" refID="0"/>
      <node order="1" refID="1"/>
      <node order="2" refID="5"/>
      <node order="3" refID="4"/>
     </nodelist>
    </surface>
    <surface ID="0">
     <nodelist>
      <node order="0" refID="1"/>
      <node order="1" refID="2"/>
      <node order="2" refID="6"/>
      <node order="3" refID="5"/>
     </nodelist>
    </surface>
    <surface ID="0">
     <nodelist>
      <node order="0" refID="2"/>
      <node order="1" refID="3"/>
      <node order="2" refID="7"/>
      <node order="3" refID="6"/>
     </nodelist>
    </surface>
    <surface ID="0">
     <nodelist>
      <node order="0" refID="3"/>
      <node order="1" refID="0"/>
      <node order="2" refID="4"/>
      <node order="3" refID="7"/>
     </nodelist>
    </surface>
    <surface ID="0">
     <nodelist>
      <node order="0" refID="4"/>
      <node order="1" refID="5"/>
      <node order="2" refID="9"/>
      <node order="3" refID="8"/>
     </nodelist>
    </surface>
    <surface ID="0">
     <nodelist>
      <node order="0" refID="5"/>
      <node order="1" refID="6"/>
      <node order="2" refID="9"/>
     </nodelist>
    </surface>
    <surface ID="0">
     <nodelist>
      <node order="0" refID="6"/>
      <node order="1" refID="7"/>
      <node order="2" refID="8"/>
      <node order="3" refID="9"/>
     </nodelist>
    </surface>
    <surface ID="0">
     <nodelist>
      <node order="0" refID="7"/>
      <node order="1" refID="4"/>
      <node order="2" refID="8"/>
     </nodelist>
    </surface>
   </surfacelist>
  </vectorobject>

The object above is a simple house with a gabled roof. We need ten nodes; four for the corners on the ground, four for the corners at the top of the walls, and two for the gabled roof. Furthermore, there are eight surfaces; four for the walls, and four for the roof. The color of each surface can be defined individually, but I have only defined a "defcolor", which is then the default.

The position of the object is its position in the world, and its nodes are defined relative to that being the center of the object. The "anglesref" links the object to an angle set of the same name for rotation.

There is also a copying functionality implemented. By setting e.g.
  <vectorobject name="house2_2" copyfrom="house2_1">
   <position>
    <positionX>-700</positionX>
    <positionY>0</positionY>
    <positionZ>-100</positionZ>
   </position>
  </vectorobject>

the object house2_2 will be a copy of house2_1, but its position will be set separately. It is also possible to override some other properties like surface colors, or to rotate the copied object in place before applying it. This makes re-using object data easier. 


Efficient Programming


There must be tons of books, university courses etc. on how to achieve efficiency in programming. I have not read or attended any of them. Back in the 1980's I taught myself programming first in BASIC (I think Beginners' All-purpose Symbolic Instruction Code says it all) by reading instruction manuals and magazines, and by trial and error; then later Assembly by the same method and the Amiga Hardware Reference Manual and Amiga Machine Language (still available at Amazon!).

To me, there must be at least three definitions of efficiency in programming:
  1. how efficiently the program code runs - how quickly it gets the job done.
  2. how efficient it is to program - how long a time it takes for the programmer to create the program for the specified purpose.
  3. how efficient it is to document and maintain the program - how to keep the program running and add changes when requirements change etc.
Arguably, Assembly language excels in (1) but is miserable in (2) and (3), especially if the programmer is a 1980's self-taught demo coder. (Note that while we demo coders seldom did so, there were libraries etc. we could have used even then and in Assembly.) Python, for example, is probably the opposite - not very efficient code in comparison, but way ahead in programmability and maintainability.