DLA.js

/** @module DLA */

import Defaults from './Defaults';
import Collisions from 'collisions';
import { toPath } from 'svg-points';
import { saveAs } from 'file-saver';

/** Structure for managing state and properties of all walkers, clusters, shapes, and the collision system. */
export default class DLA {
  /**
   * Create a new DLA object with reference to global P5 instance and any local sketch Settings
   * @param {object} p5 - Global p5.js instance passed from main sketch
   * @param {object} settings  - Object containing any override values passed from sketch to be merged with global Defaults
   */
  constructor(p5, settings) {
    this.p5 = p5;
    this.settings = Object.assign({}, Defaults, settings);

    // State flags
    this.paused = false;
    this.showWalkers = this.settings.ShowWalkers;
    this.showClusters = this.settings.ShowClusters;
    this.showShapes = this.settings.ShowShapes;
    this.useFrame = this.settings.UseFrame;
    this.renderMode = this.settings.RenderMode;

    // Number of active walkers
    this.numWalkers = 0;

    // Custom movement function for directed growth patterns
    this.customMovementFunction = undefined;

    // Outer edges of active sketch area (screen or confined "frame")
    this.edgeMargin = this.settings.EdgeMargin;
    this.edges = {};
    this.frame = {};

    if (typeof this.settings.FrameSize == 'number') {
      this.frame.left = window.innerWidth / 2 - this.settings.FrameSize / 2;
      this.frame.right = window.innerWidth / 2 + this.settings.FrameSize / 2;
      this.frame.top = window.innerHeight / 2 - this.settings.FrameSize / 2;
      this.frame.bottom = window.innerHeight / 2 + this.settings.FrameSize / 2;
    } else if (typeof this.settings.FrameSize == 'object') {
      this.frame.left = window.innerWidth / 2 - this.settings.FrameSize[0] / 2;
      this.frame.right = window.innerWidth / 2 + this.settings.FrameSize[0] / 2;
      this.frame.top = window.innerHeight / 2 - this.settings.FrameSize[1] / 2;
      this.frame.bottom = window.innerHeight / 2 + this.settings.FrameSize[1] / 2;
    }

    this.resetEdges();

    // Precalculate the largest possible distance of any particle to center for use in distance-based effects later
    this.maxDistance = this.p5.dist(this.edges.left, this.edges.top, window.innerWidth / 2, window.innerHeight / 2);

    // Collision system
    this.system = new Collisions();
    this.bodies = [];
    this.shapes = [];
    this.lines = [];
  }

  /** Run one "tick" of the simulation */
  iterate() {
    // Skip this iteration when the simulation is paused
    if (this.paused) {
      return;
    }

    // Replenish any walkers that stuck to the cluster(s) in the last iteration
    if (this.settings.ReplenishWalkers && this.numWalkers < this.settings.MaxWalkers) {
      this.createDefaultWalkers(this.settings.MaxWalkers - this.numWalkers, this.settings.ReplenishmentSource);
    }

    // Move all the walkers
    this.moveWalkers();

    // Update the collision system
    this.system.update();

    // Check for collisions and convert walkers to cluster particles as needed
    this.handleCollisions();

    // Remove any walkers that have been walking around for too long
    this.pruneWalkers();
  }


  /** Draw all objects based on current visibility flags and colors */
  draw() {
    if(this.settings.UseColors) {
      this.p5.background(this.getColorStringFromObject(this.settings.BackgroundColor));
    } else {
      this.p5.background(255);
    }

    // Draw all custom shapes
    if(this.showShapes) {
      for (let shape of this.shapes) {
        if(this.settings.UseColors) {
          this.p5.fill(this.getColorStringFromObject(this.settings.ShapeColor));
          this.p5.stroke(this.getColorStringFromObject(this.settings.ShapeColor));  
        } else {
          this.p5.noFill();
          this.p5.stroke(100);
        }
        
        this.p5.beginShape();

          for (let i = 0; i < shape._coords.length; i += 2) {
            this.p5.vertex(shape._coords[i], shape._coords[i + 1]);
          }

        this.p5.endShape();
      }
    }

    // Draw all walkers and clustered particles
    if(this.renderMode == 'Lines') {
      if(this.settings.UseColors) {
        this.p5.stroke(this.getColorStringFromObject(this.settings.LineColor));
      } else {
        this.p5.stroke(75);
      }

      if(this.lines.length > 0) {
        for(let line of this.lines) {
          this.p5.line(line.p1.x, line.p1.y, line.p2.x, line.p2.y);
        }
      }
    } else {
      for (let body of this.bodies) {
        // Points
        if (body._point) {
          this.p5.noFill();

          if (body.stuck && this.showClusters) {
            this.p5.noStroke();

            if(this.settings.UseColors) {
              this.p5.fill(this.getColorStringFromObject(this.settings.ClusterColor));
            } else {
              this.p5.fill(200);
            }

            this.p5.ellipse(body.x, body.y, 5);

          } else if (!body.stuck && this.showWalkers) {
            if(this.settings.UseColors) {
              this.p5.stroke(this.getColorStringFromObject(this.settings.WalkerColor));
            } else {
              this.p5.stroke(0)
            }
          } else {
            this.p5.noStroke();
          }

          this.p5.point(body.x, body.y);

        // Circles
        } else if (body._circle) {
          if(this.settings.UseStroke) {
            if(this.settings.UseColors) {
              this.p5.stroke(this.getColorStringFromObject(this.settings.BackgroundColor));
            } else {
              this.p5.stroke(255);
            }
          } else {
            this.p5.noStroke();
          }

          if (body.stuck && this.showClusters) {
            if(this.settings.UseColors) {
              this.p5.fill(this.getColorStringFromObject(this.settings.ClusterColor));
            } else {
              this.p5.fill(120);
            }
          } else if (!body.stuck && this.showWalkers) {
            if(this.settings.UseColors) {
              this.p5.fill(this.getColorStringFromObject(this.settings.WalkerColor));
            } else {
              this.p5.fill(230);
            }
          } else {
            this.p5.noFill();
          }

          this.p5.ellipse(body.x, body.y, body.radius * 2);

        // Polygons
        } else if (body._polygon) {
          if(this.settings.UseStroke) {
            if(this.settings.UseColors) {
              this.p5.stroke(this.getColorStringFromObject(this.settings.BackgroundColor));
            } else {
              this.p5.stroke(255);
            }
          } else {
            this.p5.noStroke();
          }

          if (body.stuck && this.showClusters) {
            if(this.settings.UseColors) {
              this.p5.fill(this.getColorStringFromObject(this.settings.ClusterColor));
            } else {
              this.p5.fill(120);
            }
          } else if (!body.stuck && this.showWalkers) {
            if(this.settings.UseColors) {
              this.p5.fill(this.getColorStringFromObject(this.settings.WalkerColor));
            } else {
              this.p5.fill(230);
            }
          } else {
            this.p5.noFill();
          }

          this.p5.beginShape();

            for (let i = 0; i < body._coords.length - 1; i += 2) {
              this.p5.vertex(body._coords[i], body._coords[i + 1]);
            }

          this.p5.endShape();
        }
      }
    }

    // Draw a square around the active area, if set
    if (this.useFrame) {
      this.drawFrame();
    }
  }

  /** Draw a rectangle to represent the frame (bounding box), if active */
  drawFrame() {
    this.p5.noFill();

    if(this.settings.UseColors) {
      this.p5.stroke(this.getColorStringFromObject(this.settings.FrameColor));
    } else {
      this.p5.stroke(0);
    }

    if (typeof this.settings.FrameSize == 'number') {
      this.p5.rect(
        window.innerWidth / 2 - this.settings.FrameSize / 2 - 1,
        window.innerHeight / 2 - this.settings.FrameSize / 2 - 1,
        this.settings.FrameSize + 2,
        this.settings.FrameSize + 2
      );
    } else if (typeof this.settings.FrameSize == 'object') {
      this.p5.rect(
        window.innerWidth / 2 - this.settings.FrameSize[0] / 2 - 1,
        window.innerHeight / 2 - this.settings.FrameSize[1] / 2 - 1,
        this.settings.FrameSize[0] + 2,
        this.settings.FrameSize[1] + 2
      )
    }
  }

  /** Recalculate the positions of the four edges of the simulation based on whether the frame is in use or not. */
  resetEdges() {
    this.edges.left = this.useFrame ? this.frame.left : 0;
    this.edges.right = this.useFrame ? this.frame.right : window.innerWidth;
    this.edges.top = this.useFrame ? this.frame.top : 0;
    this.edges.bottom = this.useFrame ? this.frame.bottom : window.innerHeight;
  }

  
  /** Apply Brownian motion and bias forces to all walkers to make them move a little bit. */
  moveWalkers() {
    if (this.bodies.length > 0) {
      for (let body of this.bodies) {
        if (!body.stuck) {
          // Start with a randomized movement (Brownian motion)
          let deltaX = this.p5.random(-1, 1),
            deltaY = this.p5.random(-1, 1),
            deltas;

          // Add in per-walker bias, if enabled
          if(this.settings.UsePerWalkerBias && body.hasOwnProperty('BiasTowards')) {
            deltas = this.getDeltasTowards(body.x, body.y, body.BiasTowards.x, body.BiasTowards.y);
            deltaX += deltas.x;
            deltaY += deltas.y;

          // Otherwise add in uniform bias to all walkers (if defined)
          } else {

            // Add in a bias towards a specific direction, if set
            switch (this.settings.BiasTowards) {
              case 'Top':
                deltaY -= this.settings.BiasForce;
                break;

              case 'Bottom':
                deltaY += this.settings.BiasForce;
                break;

              case 'Left':
                deltaX -= this.settings.BiasForce;
                break;

              case 'Right':
                deltaX += this.settings.BiasForce;
                break;

              case 'Center':
                deltas = this.getDeltasTowards(body.x, body.y, window.innerWidth / 2, window.innerHeight / 2);
                deltaX += deltas.x;
                deltaY += deltas.y;
                break;

              case 'Edges':
                deltas = this.getDeltasTowards(body.x, body.y, window.innerWidth / 2, window.innerHeight / 2);
                deltaX -= deltas.x;
                deltaY -= deltas.y;
                break;

              case 'Equator':
                if (body.y < window.innerHeight / 2) {
                  deltaY += this.settings.BiasForce;
                } else {
                  deltaY -= this.settings.BiasForce;
                }

                break;

              case 'Meridian':
                if (body.x < window.innerWidth / 2) {
                  deltaX += this.settings.BiasForce;
                } else {
                  deltaX -= this.settings.BiasForce;
                }

                break;

            }
          }

          // Apply custom movement function, if it has bee provided
          if(typeof this.customMovementFunction != undefined && this.customMovementFunction instanceof Function) {
            let deltas = this.customMovementFunction(body);
            deltaX += deltas.dx;
            deltaY += deltas.dy;
          }

          // Ensure only whole numbers for single-pixel particles so they are always "on lattice"
          if (body._point) {
            deltaX = Math.round(deltaX);
            deltaY = Math.round(deltaY);
          }

          // Apply deltas to walker
          body.x += deltaX;
          body.y += deltaY;

          // Increment age of the walker
          body.age++;
        }
      }
    }
  }

  /**
   * Calculates movement deltas for a given walker in order to move it towards a given point in space.
   * @param {number} bodyX - X coordinate of walker to move
   * @param {number} bodyY  - Y coordinate of walker to move
   * @param {number} targetX  - X coordinate of target we want to move the walker towards
   * @param {number} targetY  - YY coordinate of target we want to move the walker towards
   * @returns {Object} Object with properties x and y representing directional forces to apply to walker
   */
  getDeltasTowards(bodyX, bodyY, targetX, targetY) {
    let angle = Math.atan2(targetY - bodyY, targetX - bodyX);

    return {
      x: Math.cos(angle) * this.settings.BiasForce,
      y: Math.sin(angle) * this.settings.BiasForce
    }
  }

  /** Look for collisions between walkers and clustered elements, converting walkers to clustered particles as needed. */
  handleCollisions() {
    // Look for collisions between walkers and custom shapes
    for (let shape of this.shapes) {
      const potentials = shape.potentials();

      for (let secondBody of potentials) {
        if (shape.collides(secondBody)) {
          secondBody.stuck = true;
          this.numWalkers--;
        }
      }
    }

    // Look for collisions between walkers and clustered particles
    for (let body of this.bodies) {
      // Cut down on duplicate computations by only looking for collisions on walkers
      if (body.stuck) {
        continue;
      }

      // Look for broadphase collisions
      const potentials = body.potentials();

      for (let secondBody of potentials) {

        // Points should be checked for adjacency to a stuck particle 
        if (body._point) {
          if (secondBody.stuck) {
            body.stuck = true;
            this.numWalkers--;
          }

        // Circles and polygons should be checked for collision (overlap) with potentials
        } else {
          if (secondBody.stuck && body.collides(secondBody)) {
            body.stuck = true;
            this.numWalkers--;

            if(this.settings.CaptureLines) {
              this.lines.push({
                p1: { x: body.x, y: body.y },
                p2: { x: secondBody.x, y: secondBody.y }
              });
            }
          }
        }
      }
    }
  }

  /** Remove any walkers that are no longer "useful" in an effort to make the simulation more efficient. */
  pruneWalkers() {
    // Remove any walkers that have been wandering around for too long
    if(this.settings.PruneOldWalkers || this.settings.PruneDistantWalkers) {
      for(let [index, body] of this.bodies.entries()) {
        if(
          !body.stuck && 
          (
            (this.settings.PruneOldWalkers && body.age > this.settings.MaxAge) ||
            (this.settings.PruneDistantWalkers && this.p5.dist(body.x, body.y, body.originalX, body.originalY) > this.settings.MaxWanderDistance)
          )
        ) {
          body.remove();
          this.bodies.splice(index, 1);
          this.numWalkers--;
        }
      }
    }
  }


  /**
   * Creates a new body (walker or clustered particle) using the provided parameters in the collision system and stores it in a private array for manipulation later.
   * @param {object} params - Object of particle parameters such as X/Y coordinates, type, shape, and rotation
   */
  createParticle(params) {
    if (typeof params == 'undefined' || typeof params != 'object') {
      return;
    }

    let body;

    if (params.hasOwnProperty('type')) {
      switch (params.type) {
        case 'Point':
          body = this.system.createPoint(Math.round(params.x), Math.round(params.y));
          body._point = true;
          break;

        case 'Circle':
        default:
          body = this.system.createCircle(params.x, params.y, params.diameter / 2);
          body._circle = true;
          break;

        case 'Polygon':
          body = this.system.createPolygon(params.x, params.y, params.polygon, params.hasOwnProperty('rotation') ? this.p5.radians(params.rotation) : 0);
          body._polygon = true;
          break;
      }
    } else {
      const diameter = params.hasOwnProperty('diameter') ? params.diameter : this.settings.CircleDiameter;
      body = this.system.createCircle(params.x, params.y, diameter / 2);
      body._circle = true;
    }

    body.stuck = params.hasOwnProperty('stuck') ? params.stuck : false;
    body.age = 0;

    if(params.hasOwnProperty('BiasTowards')) {
      body.BiasTowards = params.BiasTowards;
    }

    body.originalX = body.x;
    body.originalY = body.y;

    this.bodies.push(body);
  }

  /**
   * Wrapper for createParticle() that increments internal count of walkers.
   * @param {Object} params - Object of particle parameters such as X/Y coordinates, type, shape, and rotation
   */
  createWalker(params) {
    this.createParticle(params);
    this.numWalkers++;
  }

  /**
   * Create a set of walkers in a specific area in the simulation (center, edges, randomly, etc).
   * @param {number} count - Number of walkers to create
   * @param {string} source - Location where walkers should be created.
   */
  createDefaultWalkers(count = this.settings.MaxWalkers, source = this.settings.WalkerSource) {
    for (let i = 0; i < count; i++) {
      let params = {};

      switch (source) {
        // Edges = spawn walkers at screen edges
        case 'Edges':
          let edge = Math.round(this.p5.random(1, 4));

          switch (edge) {
            case 1: // top
              params.x = this.p5.random(this.edges.left + this.edgeMargin, this.edges.right - this.edgeMargin);
              params.y = this.p5.random(this.edges.top, this.edges.top + this.edgeMargin);
              break;

            case 3: // bottom
              params.x = this.p5.random(this.edges.left + this.edgeMargin, this.edges.right - this.edgeMargin);
              params.y = this.p5.random(this.edges.bottom - this.edgeMargin, this.edges.bottom);
              break;

            case 4: // left
              params.x = this.p5.random(this.edges.left, this.edges.left + this.edgeMargin);
              params.y = this.p5.random(this.edges.top, this.edges.bottom);
              break;

            case 2: // right
              params.x = this.p5.random(this.edges.right - this.edgeMargin, this.edges.right);
              params.y = this.p5.random(this.edges.top, this.edges.bottom);
              break;
          }

          break;

        // Circle = spawn walkers in a circle around the center of the screen
        case 'Circle':
          let radius = this.p5.random(5, 200 / 2 - 20),
            angle = this.p5.random(360),
            center = this.settings.hasOwnProperty('CircleCenter') ? this.settings.CircleCenter : {x: window.innerWidth / 2, y: window.innerHeight / 2};

          params.x = center.x + radius * Math.cos(angle * Math.PI / 180);
          params.y = center.y + radius * Math.sin(angle * Math.PI / 180);
          break;

        // Random = spawn walkers randomly throughout the entire screen
        case 'Random':
          params.x = this.p5.random(this.edges.left, this.edges.right);
          params.y = this.p5.random(this.edges.top, this.edges.bottom);
          break;

        // Center = spawn all walkers at screen center
        case 'Center':
          params.x = window.innerWidth / 2;
          params.y = window.innerHeight / 2;
          break;

        // Offscreen = spawn all walkers outside of the screen edges
        case 'Offscreen':
          params.x = this.p5.random(this.edges.left - 200, this.edges.right + 200);
          params.y = this.p5.random(this.edges.top - 200, this.edges.bottom + 200);

          if(
            (params.x > this.edges.left && params.x < this.edges.right) &&
            (params.y > this.edges.top && params.y < this.edges.bottom)
          ) {
            continue;
          }
          
          break;
      }

      // Vary diameter based on distance, if enabled
      if (this.settings.VaryDiameterByDistance) {
        let dist = this.p5.dist(params.x, params.y, window.innerWidth / 2, window.innerHeight / 2);
        params.diameter = this.p5.map(dist, 0, this.maxDistance, this.settings.CircleDiameterRange[0], this.settings.CircleDiameterRange[1]);
      }

      // Create a walker with the coordinates
      this.createWalker(params);
    }
  }

  /**
   * Create a set of clustered particles with the provided pattern.
   * @param {string} clusterType - Pattern to create all clustered particles with. Can be Point, Ring, Random, or Wall
   */
  createDefaultClusters(clusterType = this.settings.InitialClusterType) {
    let paramsList = [];

    switch (clusterType) {
      // Single particle in center of screen
      case 'Point':
        paramsList.push({
          x: window.innerWidth / 2,
          y: window.innerHeight / 2,
          diameter: this.settings.CircleDiameter
        });

        break;

      // Series of particles evenly spaced in a circle around center of screen
      case 'Ring':
        let radius = 100,
          numParticles = 20;

        for (let i = 0; i < numParticles; i++) {
          paramsList.push({
            x: window.innerWidth / 2 + radius * Math.cos((360 / numParticles) * i * Math.PI / 180),
            y: window.innerHeight / 2 + radius * Math.sin((360 / numParticles) * i * Math.PI / 180),
            diameter: this.settings.CircleDiameter
          });
        }

        break;

      // Individual particles randomly distributed across entire screen
      case 'Random':
        for (let i = 0; i < 40; i++) {
          paramsList.push({
            x: this.p5.random(this.edges.left, this.edges.right),
            y: this.p5.random(this.edges.top, this.edges.bottom),
            diameter: this.settings.CircleDiameter
          });
        }

        break;

      // Line of particles along an edge of the screen or frame
      case 'Wall':
        switch (this.settings.BiasTowards) {
          case 'Top':
            paramsList = this.createHorizontalClusterWall(this.edges.top);
            break;

          case 'Bottom':
            paramsList = this.createHorizontalClusterWall(this.edges.bottom);
            break;

          case 'Left':
            paramsList = this.createVerticalClusterWall(this.edges.left);
            break;

          case 'Right':
            paramsList = this.createVerticalClusterWall(this.edges.right);
            break;

          case 'Edges':
            paramsList = paramsList.concat(this.createHorizontalClusterWall(this.edges.top));
            paramsList = paramsList.concat(this.createHorizontalClusterWall(this.edges.bottom));
            paramsList = paramsList.concat(this.createVerticalClusterWall(this.edges.left));
            paramsList = paramsList.concat(this.createVerticalClusterWall(this.edges.right));
            break;

          case 'Equator':
            paramsList = paramsList.concat(this.createHorizontalClusterWall(window.innerHeight / 2));
            break;

          case 'Meridian':
            paramsList = paramsList.concat(this.createVerticalClusterWall(window.innerWidth / 2));
            break;
        }

        break;
    }

    this.createClusterFromParams(paramsList);
  }

  /**
   * Create a horizontal line of clustered particles at a given Y coordinate
   * @param {number} yPos - vertical coordinate where line of particles is created
   * @returns {Object} Object containing X and Y coordinates of all clustered particles in line
   */
  createHorizontalClusterWall(yPos) {
    let coords = [],
      width = this.useFrame ? this.edges.right - this.edges.left : window.innerWidth;

    for (let i = 0; i <= width / this.settings.CircleDiameter; i++) {
      coords.push({
        x: this.edges.left + i * this.settings.CircleDiameter,
        y: yPos,
        diameter: this.settings.CircleDiameter
      });
    }

    return coords;
  }

  /**
   * Create a vertical line of clustered particles at a given X coordinate
   * @param {number} xPos - horizontal coordinate where line of particles is created 
   * @return {Object} Object containing the X and Y coordinates of all clustered particles in line
   */
  createVerticalClusterWall(xPos) {
    let coords = [],
      height = this.useFrame ? this.edges.bottom - this.edges.top : window.innerHeight;

    for (let i = 0; i <= height / this.settings.CircleDiameter; i++) {
      coords.push({
        x: xPos,
        y: this.edges.top + i * this.settings.CircleDiameter,
        diameter: this.settings.CircleDiameter
      });
    }

    return coords;
  }

  /**
   * Create a set of clustered particles from an array of individual particle parameters
   * @param {Array} paramsList - Array of objects containing particle parameters.  
   */
  createClusterFromParams(paramsList) {
    if (paramsList.length > 0) {
      for (let params of paramsList) {
        params.stuck = true;
        this.createParticle(params);
      }
    }
  }

  /**
   * Create shapes in the internal collision detection system from a set of paths
   * @param {Array} paths - Array of objects defining polygons (technically polylines) with starting X and Y coordinates and a list of points
   */
  createShapesFromPaths(paths) {
    if (!paths.hasOwnProperty('points') && paths.length == 0) {
      console.error('Unable to create shapes. Paths must have an array of points [[x,y],...]');
      return;
    }

    for (let path of paths) {
      // Create a single polygon if the shape is marked as "solid"
      if (path.solid) {
        let shape = this.system.createPolygon(path.x, path.y, path.points);
        shape.solid = path.solid;
        shape.closed = path.closed;
        this.shapes.push(shape);

      // Create a series of separate line segments if shape is not "solid", per https://github.com/Sinova/Collisions#anchor-lines
      } else {
        for (let i = 1; i < path.points.length; i++) {
          let line = this.system.createPolygon(path.x, path.y, [[path.points[i - 1][0], path.points[i - 1][1]], [path.points[i][0], path.points[i][1]]]);
          line.solid = false;
          line.closed = false;
          this.shapes.push(line);
        }
      }
    }
  }


  //==============
  //  Removers
  //==============
  /** Remove all walkers, clustered particles, shapes, and lines from the system */
  removeAll() {
    for (let body of this.bodies) {
      this.system.remove(body);
    }

    for (let shape of this.shapes) {
      this.system.remove(shape);
    }

    this.bodies = [];
    this.shapes = [];
    this.lines = [];
    this.numWalkers = 0;
  }


  //==============
  //  Togglers
  //==============
  /** Toggle between paused or unpaused state */
  togglePause() {
    this.paused = !this.paused;
  }

  /** Toggle the visibility of walkers */
  toggleShowWalkers() {
    this.showWalkers = !this.showWalkers;
  }

  /** Toggle the visibility of clustered particles */
  toggleShowClusters() {
    this.showClusters = !this.showClusters;
  }

  /** Toggle the visibility of shapes */
  toggleShowShapes() {
    this.showShapes = !this.showShapes;
  }

  /** Toggle the use of a custom-defined frame (bounding box) */
  toggleUseFrame() {
    this.useFrame = !this.useFrame;
    this.resetEdges();
  }

  /** Toggle the line-based rendering mode */
  toggleLineRenderingMode() {
    if(this.renderMode != 'Lines') {
      if(this.settings.CaptureLines) {
        this.renderMode = 'Lines';
      } else {
        console.error('Line rendering mode only allowed when CaptureLines is set.');
      }
    } else {
      this.renderMode = 'Shapes';
    }
  }


  //===================
  //  Pause/unpause
  //===================
  /** Pause the simulation */
  pause() {
    this.paused = true;
  }

  /** Unpause the simulation */
  unpause() {
    this.paused = false;
  }


  //======================
  //  Utility functions
  //======================
  /**
   * Create an HSL-formatted string that plays well with p5.js from an object with appropriate properties
   * @param {object} colorObject - Object with the properties h, s, and b (all numbers) 
   * @returns {string} - String in the format of hsl({h}, {s}, {b})
   */
  getColorStringFromObject(colorObject) {
    return 'hsla(' +
      colorObject.h + ', ' +
      colorObject.s + '%, ' +
      colorObject.b + '%, ' +
      colorObject.a + ')';
  }


  //============
  //  Export
  //============
  /** Constructs an SVG node with paths based on current rendering mode of the simulation, then initiates a download on the user's machine of the generated file */
  export() {
    // Set up <svg> element
    let svg = document.createElement('svg');
    svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
    svg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
    svg.setAttribute('width', window.innerWidth);
    svg.setAttribute('height', window.innerHeight);
    svg.setAttribute('viewBox', '0 0 ' + window.innerWidth + ' ' + window.innerHeight);

    // Export all bodies based on the current rendering mode
    switch(this.renderMode) {
      case 'Shapes':
      default:
        for(let body of this.bodies) {
          if(!body.stuck && !this.showWalkers) {
            continue;
          }

          if(body._circle) {
            svg.appendChild( this.createCircleElFromBody(body) );
          } else {  
            svg.appendChild( this.createPathElFromPoints( this.getPointsFromCoords(body._coords) ) );
          }
        }

        break;

      case 'Lines':
        if(this.lines.length > 0) {
          for(let line of this.lines) {
            let points = [];
            
            points.push({
              x: line.p1.x, 
              y: line.p1.y
            });

            points.push({
              x: line.p2.x,
              y: line.p2.y
            });

            svg.appendChild( this.createPathElFromPoints(points) );
          }
        }

        break;
    }

    // Export all custom imported shapes as paths
    if(this.shapes.length > 0) {
      for(let shape of this.shapes) {
        svg.appendChild( this.createPathElFromPoints( this.getPointsFromCoords(shape._coords) ) );
      }
    }
    
    // Force download of .svg file based on https://jsfiddle.net/ch77e7yh/1
    let svgDocType = document.implementation.createDocumentType('svg', "-//W3C//DTD SVG 1.1//EN", "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd");
    let svgDoc = document.implementation.createDocument('http://www.w3.org/2000/svg', 'svg', svgDocType);
    svgDoc.replaceChild(svg, svgDoc.documentElement);
    let svgData = (new XMLSerializer()).serializeToString(svgDoc);

    let blob = new Blob([svgData.replace(/></g, '>\n\r<')]);
    saveAs(blob, 'dla-' + Date.now() + '.svg');
  }

  /**
   * Convert a flat array of coords ([x1, y1, x2, y2, ...]), used internally by collisions package into an array of objects for easier traversing
   * @param {array} coords 
   */
  getPointsFromCoords(coords) {
    let points = [];

    for (let i = 0; i < coords.length - 1; i += 2) {
      points.push({
        x: coords[i], 
        y: coords[i + 1]
      });
    }

    return points;
  }

  /**
   * Create a <path> element with a "d" attribute containing provided points
   * @param {array} points 
   * @returns {node} SVG <path> element with "d" attribute containing provided points
   */
  createPathElFromPoints(points) {
    let pointsString = '';

    for(let [index, point] of points.entries()) {
      pointsString += point.x + ',' + point.y;

      if(index < points.length - 1) {
        pointsString += ' ';
      }
    }

    let d = toPath({
      type: 'polyline',
      points: pointsString
    });

    let pathEl = document.createElement('path');
    pathEl.setAttribute('d', d);
    pathEl.setAttribute('style', 'fill: none; stroke: black; stroke-width: 1');

    return pathEl;
  }

  /**
   * Create an SVG <circle> element with attributes `cx`, `cy`, and `r` extracted from provided body
   * @param {object} body 
   * @returns {node} SVG <circle> element with `cx`, `cy`, and `r` attributes from body
   */
  createCircleElFromBody(body) {
    let circleEl = document.createElement('circle');
    circleEl.setAttribute('cx', body.x);
    circleEl.setAttribute('cy', body.y);
    circleEl.setAttribute('r', body.radius);
    return circleEl;
  }

}