Repository: threedworld-mit/tdw (language: python)

Issue:
Robotics documentation
Overview of agents: https://github.com/threedworld-mit/tdw/blob/0a7d52373eeb4e697bd08e414dfb30e61da136d7/Documentation/lessons/agents/overview.md

- Read this and then click the link to the robotics tutorial
- Regarding the tutorials listed at the bottom (navigation and multi-agent simulation): You can ignore them for now. I don't think that they're easy to find right now and I'll probably move them to their own section in the README.


Discussion / hints:

Fix patch:
diff --git a/Documentation/composite_objects/composite_objects.md b/Documentation/composite_objects/composite_objects.md
deleted file mode 100644
index 80ebd8195..000000000
--- a/Documentation/composite_objects/composite_objects.md
+++ /dev/null
@@ -1,34 +0,0 @@
-# Composite Objects
-
-Composite Objects are any models that have _sub-objects_ that can be referenced to in the TDW API.
-
-Each sub-object may also be a _mechanism_ (a TDW-specific idiom). Currently, the following mechanism have been implemented:
-
-| Mechanism | Behavior                                                     | Example                |
-| --------- | ------------------------------------------------------------ | ---------------------- |
-| `hinge`   | Swings freely on a pivot point around an axis.               | A box with a lid       |
-| `motor`   | Can rotate on a pivot point around an axis by applying a target velocity and a force magnitude. | A helicopter propeller |
-| `spring`  | Can rotate on a pivot point around an axis by applying a target position. The motion will appear "spring-like" | Jack-in-the-box        |
-| `light`   | Can be turned on or off.                                     | A lightbulb            |
-| `none`    | (No mechanism)                                               | A chest of drawers     |
-
-### Output Data
-
-Each sub-object has a Rigidbody, segmentation color, etc. All of this can be retrieved from output data like any other object (e.g. via `send_transforms` or `send_rigidbodies`). The difference is that each sub-objects ID is defined by Unity at runtime rather than assigned by the user in a controller.
-
-To receive a map of sub-objects in a composite object, send the [`send_composite_objects`](../api/command_api#send_composite_objects) command to receive [`CompositeObjects`](../api/output_data.md#CompositeObjects) data.
-
-### Command API
-
-Certain commands can only be sent to certain types of sub-objects (e.g. `set_motor` only works with motors). For a list of all sub-object commands, see the [Command API documentation](../api/command_api.md) under "SubObjectCommand".
-
-### Example Controllers
-
-There are two [example controllers](../python/example_controllers.md) for composite objects:
-
-- `composite_object.py` (Tests output data and sub-object commands)
-- `open_box.py` (Basic demo of a Sticky Mitten Avatar opening a box with a lid)
-
-### Creating Composite Objects
-
-Read [Creating Composite Objects](creating_composite_objects.md).
\ No newline at end of file
diff --git a/Documentation/getting_started.md b/Documentation/getting_started.md
deleted file mode 100644
index e941831fd..000000000
--- a/Documentation/getting_started.md
+++ /dev/null
@@ -1,325 +0,0 @@
-# Getting Started With TDW
-
-## Overview 
-
-**ThreeDWorld (TDW)** is a platform for interactive multi-modal physical simulation. With TDW, users can simulate high-fidelity sensory data and physical interactions between mobile agents and objects in a wide variety of rich 3D environments.
-
-TDW is a general-purpose tool that allows the user to communicate and manipulate a 3D environment. As such, there's no single "correct" procedure for using TDW. This guide will show you how to start using TDW and how to explore the available options.
-
-## What you need to know
-
-- Some familiarity with running programs from the command line.
-- Beginner Python3 skills.
-
-## Requirements
-
-- Windows, OS X, or Linux
-- Python 3.6+
-- A GPU, the faster the better. It is possible to run TDW without a GPU but you will lose some speed and photorealism.
-- For [audio/video recording](misc_frontend/video.md), you will need an audio driver.
-- [Flex](misc_frontend/flex.md) has complicated requirements; please refer to the linked document.
-
-#### Remote server requirements
-
-* **The build requires X.** 
-* We have tested the build in Ubuntu 16 and 18.
-* Up-to-date NVIDIA drivers.
-* The server must have 8 or less GPUs. (If there are more, you can temporarily disable them by editing xorg.conf)
-
-## Installation
-
-#### 1. Install the `tdw` Python module
-
-| Windows                   | OS X and Linux        |
-| ------------------------- | --------------------- |
-| **`pip3 install tdw --user`** | **`sudo pip3 install tdw`** |
-
-***
-
-#### 2. Run a minimal test
-
-##### 2a. On a personal computer:
-
-1. Create this Python script and save it as `my_controller.py`:
-
-```python
-from tdw.controller import Controller
-
-c = Controller()
-print("Everything is OK!")
-c.communicate({"$type": "terminate"})
-```
-
-2. Run the controller:
-
-| Windows                  | OS X                       | Linux                      |
-| ------------------------ | -------------------------- | -------------------------- |
-| `py -3 my_controller.py` | `python3 my_controller.py` | `python3 my_controller.py` |
-
-When you launch run this script, the `Controller` will download the **build**, the binary executable application that runs the simulation environment and then launch the build. The controller will also check to see if your version of TDW is the most recent. For more information on what happens when you start a controller, read [this](misc_frontend/releases.md#Updates).
-
-##### 2b. On a remote Linux server:
-
-These instructions assume that the build and controller are on the same machine.
-
-1. Set up a virtual display.
-
-```bash
-sudo service lightdm stop
-sudo killall Xorg
-sudo nvidia-xconfig -a --use-display-device=None --virtual=256x256
-sudo /usr/bin/X :0&
-```
-
-Not all of these commands will be applicable to every server. The `:0` is the display number, which might vary between servers.
-
-2. Download [the latest build of TDW](https://github.com/threedworld-mit/tdw/releases/latest/) and extract the zip file.
-
-3. Create this Python script and save it as `my_controller.py`:
-
-```python
-from tdw.controller import Controller
-
-c = Controller(launch_build=False, port=1071) # This is the default port.
-print("Everything is OK!")
-c.communicate({"$type": "terminate"})
-```
-
-4. Run the controller:
-
-```bash
-python3 my_controller.py
-```
-
-5. In another terminal window, navigate to the directory with the build:
-
-```bash
-cd path/to/the/build/TDW
-```
-
-Replace `path/to/the/build` with the actual path.
-
-6. Launch the build with the `DISPLAY` parameter. This should match the display number you set in step 1.
-
-```bash
-DISPLAY=:0.0 ./TDW.x86_64 -port=1071
-```
-
-If the build and the controller are on different machines, you can add `-address=controller_address` (replace `controller_address` with the address of the machine running the controller). 
-
-##### 2c. From a Docker container:
-
-Read [this](https://github.com/threedworld-mit/tdw/blob/v1.6.1/Documentation/Docker/docker.md). We recommend first trying TDW on a personal computer to familiarize yourself with the basic setup.
-
-***
-
-### 3. Download this repo and run a test
-
-This repo contains all of the example controllers and documentation for TDW, as well as the source code for the `tdw` Python module. You can download the repo either as a zip file or by forking the repo.
-
-In this test, TDW creates a basic simulation in which objects are added into the 3D environment. Images are routed to the controller and saved to the local disk: `tdw/Python/example_controllers/dist/`
-
-3. `cd tdw/Python/example_controllers`
-4. `python3 getting_started.py`
-
-## Core concepts
-
-![network](images/network.png)
-
-TDW has the following core components:
-
-1. **The build** is the 3D environment application. It is available as a [compiled executable](https://github.com/threedworld-mit/tdw/releases/latest).
-2. **The controller** is an external Python script created by the user that communicates with the build.
-3. **The S3 server** is a remote server. It contains the  binary files of each model, material, etc. that can be added to the build at runtime. These binary files are called [asset bundles](https://docs.unity3d.com/Manual/AssetBundlesIntro.html).
-4. **The records databases** are a set of local .json metadata files with **records** corresponding to each asset bundle.
-5. A [**librarian**](python/librarian/librarian.md) is a Python wrapper class to easily query metadata in a records database file.
-
-### The simulation pattern
-
-1. The controller communicates with the build by sending a list of  [**commands**](api/command_api_guide.md).
-2. The build receives the list of serialized commands, deserializes them, and executes them.
-3. The build advances 1 _physics frame_ (simulation step).
-4. The build returns [**output data**](api/output_data.md) to the controller.
-
-#### Output Data
-
-Output data is always sent as a list, with the last element of the list being the frame number:
-
-```
-[data, data, data, frame]
-```
-
-If the build has no data to send, it will send `[frame]`.
-
-## The build
-
-### Terminology
-
-- The **Unity Engine** is the underlying architecture of the build. Unity is powerful and solves a lot of the hardest tasks automatically, such as calculating lighting and shadows. However, Unity is closed-source; a significant portion of the engine is "black-box".
-- A **scene** is a blank slate that can be populated with meshes, scripts, etc. Scenes are intrinsic to Unity.
-- An **environment** is a TDW idiom for a space within a scene that can be populated with objects. The default proc-gen room created by the [`create_exterior_walls`](api/command_api.md#create_exterior_walls) command has 1 environment. Scenes loaded from asset bundles may have additional environments, which represent open spaces.
-- An **avatar** is an object in TDW that can be "piloted" by TDW and return data (usually images).
-- An [**asset bundle**](https://docs.unity3d.com/Manual/AssetBundlesIntro.html) is a Unity Engine binary file for loading assets into the executable at runtime. To query which asset bundles are included in TDW, use a [librarian](python/librarian/librarian.md).
-
-### Command-line arguments
-
-The build accepts several command-line arguments. Command-line arguments should always be formatted `-flag=argument`:
-
-`./TDW.exe -port=1204`
-
-| Flag                  | Description                             | Default   |
-| --------------------- | --------------------------------------- | --------- |
-| `-address`      | Address of the controller                       | localhost |
-| `-port` | Network port for sending and receiving messages | 1071   |
-| `-screenWidth`        | Width of the screen                     | 256       |
-| `-screenHeight`       | Height of the screen                    | 256       |
-| `-gpuDevice`          | GPU device number used for CUDA (Flex)  | 0         |
-| `-force-glcore42` | Force the build to run on OpenGL 4.2 (see [Debug TDW](misc_frontend/debug_tdw.md)).<br>This is one of many [Unity arguments](https://docs.unity3d.com/Manual/CommandLineArguments.html), all of which can be used for a TDW build. | N/A |
-
-### Running the build on a remote server
-
-- See **Requirements** for system requirements.
-- Make sure that xorg is set up correctly such that there is a virtual display that you can launch the build on.
-- Use the `run_build.sh` script located in `tdw/bash/` or the [Docker container](Docker/docker.md).
-
-## The controller
-
-All controllers are sub-classes of the `Controller` class. Controllers send and receive data via the `communicate` function:
-
-```python
-from tdw.controller import Controller
-
-c = Controller()
-
-# resp will be a list of one element: [frame]
-resp = c.communicate({"$type": "load_scene", 
-                      "scene_name": "ProcGenScene"})
-```
-
-In many example controllers, you will see a function `self.start()`. This is a wrapper function for the `load_scene` command.
-
-***
-
-_The full version of the following example can be found [here](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/getting_started.py)._
-
-Commands can be sent in lists of arbitrary length, allowing for arbitrarily complex instructions per frame. The user must explicitly request output data:
-
-```python
-from pathlib import Path
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.librarian import ModelLibrarian
-from tdw.output_data import OutputData, Bounds, Images
-
-lib = ModelLibrarian("models_core.json")
-# Get the record for the table.
-table_record = lib.get_record("small_table_green_marble")
-
-c = Controller()
-
-table_id = 0
-
-# 1. Load the scene.
-# 2. Create an empty room (using a wrapper function)
-# 3. Add the table.
-# 4. Request Bounds data.
-resp = c.communicate([{"$type": "load_scene",
-                       "scene_name": "ProcGenScene"},
-                      TDWUtils.create_empty_room(12, 12),
-                      c.get_add_object(model_name=table_record.name,
-                                       object_id=table_id,
-                                       position={"x": 0, "y": 0, "z": 0},
-                                       rotation={"x": 0, "y": 0, "z": 0}),
-                      {"$type": "send_bounds",
-                       "ids": [table_id],
-                       "frequency": "once"}])
-```
-
-The `resp` object is a list of byte arrays that can be deserialized into output data:
-
-```python
-# Get the top of the table.
-top_y = 0
-for r in resp[:-1]:
-    r_id = OutputData.get_data_type_id(r)
-    # Find the bounds data.
-    if r_id == "boun":
-        b = Bounds(r)
-        # We only requested the table, so it is object 0:
-        _, top_y, _ = b.get_top(0)        
-```
-
-The variable `top_y` can be used to place an object on the table:
-
-```python
-box_record = lib.get_record("puzzle_box_composite")
-box_id = 1
-c.communicate(c.get_add_object(model_name=box_record.name,
-                               object_id=box_id,
-                               position={"x": 0, "y": top_y, "z": 0},
-                               rotation={"x": 0, "y": 0, "z": 0}))
-```
-
-Then an **avatar** can be added to the scene. In this case, the avatar is just a camera. The avatar can then send an image. This image is a numpy array that can be either saved to disk or fed directly into a ML system:
-
-```python
-avatar_id = "a"
-resp = c.communicate([{"$type": "create_avatar",
-                       "type": "A_Img_Caps_Kinematic",
-                       "avatar_id": avatar_id},
-                      {"$type": "teleport_avatar_to",
-                       "position": {"x": 1, "y": 2.5, "z": 2},
-                       "avatar_id": avatar_id},
-                      {"$type": "look_at",
-                       "avatar_id": avatar_id,
-                       "object_id": box_id},
-                      {"$type": "set_pass_masks",
-                       "avatar_id": avatar_id,
-                       "pass_masks": ["_img"]},
-                      {"$type": "send_images",
-                       "frequency": "once",
-                       "avatar_id": avatar_id}])
-
-# Get the image.
-for r in resp[:-1]:
-    r_id = OutputData.get_data_type_id(r)
-    # Find the image data.
-    if r_id == "imag":
-        img = Images(r)
-        
-        # Usually, you'll want to use one of these functions, but not both of them:
-        
-        # Use this to save a .jpg
-        TDWUtils.save_images(img, filename="test_img") 
-        
-        print(f"Image saved to: {Path('dist/test_img.jpg').resolve()}")
-        
-        # Use this to convert the image to a PIL image, which can be processed by a ML system at runtime.
-        # The index is 0 because we know that there is only one pass ("_img").
-        pil_img = TDWUtils.get_pil_image(img, index=0)
-```
-
-To terminate the build once your simulation is done:
-
-```python
-c.communicate({"$type": "terminate"})
-```
-
-Put together, the example code will create this image:
-
-![](images/box_on_desk.png)
-
-## Restarting a simulation
-
-To restart a simulation, **always kill the controller _and_ the build.** Never recycle a build or controller instance!
-
-Kill the build by sending `{"$type": "terminate"}`:
-
-```python
-c = Controller(launch_build=True) # Launch the build.
-c.communicate({"$type": "terminate"}) # Terminate the build.
-```
-
-## Further documentation
-
-**TDW is EXTENSIVELY documented.** For a full list of documents and topics covered, see the [README](../README.md).
diff --git a/Documentation/images/robots/magnebot_back.jpg b/Documentation/images/robots/magnebot_back.jpg
deleted file mode 100644
index d411f179f..000000000
Binary files a/Documentation/images/robots/magnebot_back.jpg and /dev/null differ
diff --git a/Documentation/images/robots/magnebot_front.jpg b/Documentation/images/robots/magnebot_front.jpg
deleted file mode 100644
index cbccf4b50..000000000
Binary files a/Documentation/images/robots/magnebot_front.jpg and /dev/null differ
diff --git a/Documentation/lessons/3d_models/custom_models.md b/Documentation/lessons/3d_models/custom_models.md
new file mode 100644
index 000000000..47a76bebe
--- /dev/null
+++ b/Documentation/lessons/3d_models/custom_models.md
@@ -0,0 +1,278 @@
+##### 3D Model Libraries
+
+# Add your own models to TDW
+
+It is possible to add any 3D model to TDW. However, the underlying Unity engine can't directly import 3D model files at runtime. They must be converted into asset bundles (the binary format that all other TDW objects are stored as). A separate set of applications must also generate physics colliders for the model.
+
+## Requirements
+
+- Windows 10, OS X, or Linux
+- (Windows only) [Visual C++ 2012 Redistributable](https://www.microsoft.com/en-au/download/confirmation.aspx?id=30679)
+- The `tdw` module
+- Python 3.6+
+- Unity Hub
+- Unity Editor 2020.3 (installed via Unity Hub)
+  - Build options must enabled for Windows, OS X, and Linux (these can  be set when installing Unity).
+- A .fbx or .obj+.mtl model
+
+## The `AssetBundleCreator`
+
+The [`AssetBundleCreator`](../../python/asset_bundle_creator.py) Python class will convert an .fbx or .obj file into an asset bundle and generate physics colliders. Depending on the complexity of the base mesh, this can be a lengthy process, especially when generating physics colliders.
+
+```python
+from tdw.asset_bundle_creator import AssetBundleCreator
+
+a = AssetBundleCreator()
+model_path = "test.fbx" # Change this to the actual path.
+asset_bundle_paths, record_path = a.create_asset_bundle(model_path=model_path, cleanup=True)
+```
+
+`asset_bundle_paths` is a list of [Paths](https://docs.python.org/3/library/pathlib.html) to the generated asset bundles for Windows, OS X and Linux. `record_path` is the path to a json file containing the [model metadata](../../python/librarian/model_librarian.md).
+
+The `cleanup` parameter determines whether the `AssetBundleCreator` will remove intermediate files after creating the asset bundles.
+
+There are optional parameters for `wnid` and `wcategory` which will set the semantic category for the object. You can usually guess what the category should be by comparing it to existing categories. For example, if you have a `chair.fbx` file, this how you'd get the the `wnid` and `wcategory`:
+
+```python
+from tdw.asset_bundle_creator import AssetBundleCreator
+from tdw.librarian import ModelLibrarian
+
+model_path = "chair.fbx"
+wnid = -1
+wcategory = "chair"
+
+librarian = ModelLibrarian("models_full.json")
+wnids_and_wcategories = librarian.get_model_wnids_and_wcategories()
+for w in wnids_and_wcategories:
+    if wnids_and_wcategories[w] == "chair":
+        # Convert to an integer. In this case, "n03001627" to 3001627.
+        wnid = int(w[1:])
+        break
+a = AssetBundleCreator()
+asset_bundle_paths, record_path = a.create_asset_bundle(model_path=model_path, 
+                                                        cleanup=True,
+                                                        wnid=wnid,
+                                                        wcategory=wcategory)
+```
+
+ There is one other optional parameter, `scale`, which should usually be set to 1 (the default value). Setting it to another value will scale the model by this factor whenever it is instantiated in TDW.
+
+### Intermediate API calls
+
+It's possible to manually perform any of the operations involved in creating an asset bundle.
+
+Sometimes, it's useful to convert a 3D model to a [prefab](https://docs.unity3d.com/Manual/Prefabs.html), edit the prefab in Unity Editor (for example, to adjust the color or positions of the colliders), and then convert the prefab to an asset bundle. In that case, you'd first do this:
+
+```python
+from pathlib import Path
+from tdw.asset_bundle_creator import AssetBundleCreator
+
+model_name = "chair"
+model_path = Path(model_name + ".fbx")
+a = AssetBundleCreator()
+obj_path, is_new = a.fbx_to_obj(model_path)
+wrl_path = a.obj_to_wrl(model_path)
+obj_colliders_path = a.wrl_to_obj(wrl_path, model_name)
+copied_file_paths = a.move_files_to_unity_project(obj_colliders_path, model_path)
+prefab_path, report_path = a.create_prefab(f"{model_name}_colliders.obj", model_name, model_path.suffix)
+
+# Save the prefab path for later.
+Path("prefab_path.txt").write_text(str(prefab_path.resolve()))
+```
+
+Then adjust the prefab in Unity Editor.
+
+Then do this:
+
+```python
+from pathlib import Path
+from tdw.asset_bundle_creator import AssetBundleCreator
+
+wcategory = "chair"
+wnid = 3001627
+prefab_path = Path(Path("prefab_path.txt").read_text())
+model_name = "chair"
+model_path = Path(model_name + ".fbx")
+a = AssetBundleCreator()
+asset_bundle_paths = a.prefab_to_asset_bundle(prefab_path=prefab_path, model_name=model_name)
+urls = a.get_local_urls(asset_bundle_paths=asset_bundle_paths)
+record_path = a.create_record(model_name=model_name, scale=1, wnid=wnid, wcategory=wcategory, urls=urls)
+```
+
+## Add a custom model to a TDW simulation
+
+You can load a model saved on a local machine with the [`add_object` command](../../api/command_api.md#add_object) just like a model from one of TDW's model library.
+
+There are two ways to do this. First, you can just manually set the URL of the asset bundle. Be aware that you need to select the asset bundle for your operating system and you need to add `file:///` to the start of the URL.
+
+Suppose the directory structure looks like this:
+
+```
+/home
+....tdw_asset_bundles/
+........chair/
+............record.json
+............StandaloneWindows64/
+................chair
+............StandaloneOSX/
+................chair
+............StandaloneLinux64/
+................chair
+```
+
+`StandaloneWindows64`, `StandaloneOSX`, and `StandaloneLinux64` are descriptors generated by Unity Engine. TDW includes a convenient way to convert from these to "system" descriptions:
+
+```python
+from platform import system
+from tdw.backend.platforms import SYSTEM_TO_UNITY
+
+print(SYSTEM_TO_UNITY[system()])
+```
+
+So we can find the correct asset bundle like this:
+
+```python
+from pathlib import Path
+from platform import system
+from tdw.backend.platforms import SYSTEM_TO_UNITY
+
+model_name = "chair"
+path = Path.home().joinpath("tdw_asset_bundles/" + model_name + "/" + SYSTEM_TO_UNITY[system()] + model_name)
+path = "file:///" + str(path.resolve())
+```
+
+And then we can create an object instance of the model with the `add_object` command:
+
+```python
+from pathlib import Path
+from platform import system
+from tdw.backend.platforms import SYSTEM_TO_UNITY
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+model_name = "chair"
+path = Path.home().joinpath("tdw_asset_bundles/" + model_name + "/" + SYSTEM_TO_UNITY[system()] + model_name)
+path = "file:///" + str(path.resolve())
+
+c = Controller()
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               {"$type": "add_object",
+                "name": model_name,
+                "url": path,
+                "scale_factor": 1,
+                "position": {"x": 0, "y": 0, "z": 0},
+                "rotation": {"x": 0, "y": 0, "z": 0},
+                "category": "chair",
+                "id": c.get_unique_id()}])
+```
+
+Or, you can use the record data generated by the `AssetBundleCreator`. `ModelRecord.get_url()` returns the URL for your operating system:
+
+```python
+from pathlib import Path
+from json import loads
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.librarian import ModelRecord
+
+model_name = "chair"
+model_record = ModelRecord(loads(Path.home().joinpath("tdw_asset_bundles/chair/record.json")))
+c = Controller()
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               {"$type": "add_object",
+                "name": model_name,
+                "url": model_record.get_url(),
+                "scale_factor": model_record.scale_factor,
+                "position": {"x": 0, "y": 0, "z": 0},
+                "rotation": {"x": 0, "y": 0, "z": 0},
+                "category": model_record.wcategory,
+                "id": c.get_unique_id()}])
+```
+
+##  Create a custom model library
+
+If you want to create many asset bundles, it's usually convenient to create your own `ModelLibrarian` and store it as a local json file. This `ModelLibrarian` can contain any model records including models from other model libraries.
+
+First, create the librarian json file by calling `ModelLibrarian.create_library()`:
+
+```python
+from tdw.librarian import ModelLibrarian
+
+ModelLibrarian.create_library(description="Custom model librarian",
+                              path="models_custom.json")
+```
+
+Then, load the librarian and add your model:
+
+```python
+from pathlib import Path
+from json import loads
+from tdw.librarian import ModelLibrarian, ModelRecord
+
+# Convert from a relative to absolute path to load the librarian.
+librarian = ModelLibrarian(library=str(Path("models_custom.json").resolve()))
+
+record = ModelRecord(loads(Path.home().joinpath("tdw_asset_bundles/chair/record.json")))
+
+librarian.add_or_update_record(record=record, overwrite=False, write=True)
+```
+
+You can add a record from another model library in nearly the same way:
+
+```python
+from pathlib import Path
+from tdw.librarian import ModelLibrarian
+
+custom_librarian = ModelLibrarian(library=str(Path("models_custom.json").resolve()))
+core_librarian = ModelLibrarian("models_core.json")
+record = core_librarian.get_record("rh10")
+custom_librarian.add_or_update_record(record=record, overwrite=False, write=True)
+```
+
+## Creating Many Asset Bundles
+
+You could convert many models to asset bundles by creating a loop that repeatedly calls `AssetBundleCreator.create_asset_bundles()`. **This is not a good idea.** Repeatedly calling Unity from a Python script is actually very slow. (It also appears to slow down over many consecutive calls).
+
+Instead, consider using `create_many_asset_bundles(library_path)` for large numbers of models. This function will walk through `asset_bundle_creator/Assets/Resources/models/` searching for .obj and .fbx models. It will convert these models to asset bundles.
+
+## Export .fbx files from Blender 2.8
+
+Blender can be fussy when exporting to Unity. If you export a .obj, you shouldn't have any problems. If you export a .fbx, there may be problems with the model's scale, as well as the collider's scale.
+
+To set correct .fbx model scaling in Blender:
+
+1. Set the scale of your model to 0.5
+
+![](images/custom_models/0_set_scale.png)
+
+2. Making sure that the model is still selected, apply the scale: **ctrl+a+s**
+3. Making sure that the model is still selected, export the .fbx file. Make sure you're exporting only the selected object, and set the scaling to "FBX Units Scale".
+
+<img src="images/custom_models/1_export.png" style="zoom:50%;" />
+
+ ## Troubleshooting
+
+- Make sure that Unity Editor and Visual Studio are _closed_ when running `AssetBundleCreator`.
+- Make sure that you have installed the Editor _via Unity Hub_; the Python script assumes this when trying to find the editor .exe
+- If `AssetBundleCreator` is crashing with errors, please let us know; copy+paste the error when you do.
+
+***
+
+**Next: [Add ShapeNet models to TDW](shapenet.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [local_object.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/3d_models/local_object.py) Create a local asset bundle and load it into TDW.
+
+Python API:
+
+- [`ModelLibrarian`](../../librarian/model_librarian.md)
+- [`AssetBundleCreator`](../../python/asset_bundle_creator.md)
+
+Command API:
+
+- [`add_object`](../../api/command_api.md#add_object)
diff --git a/Documentation/lessons/3d_models/free_models.md b/Documentation/lessons/3d_models/free_models.md
new file mode 100644
index 000000000..c744a865d
--- /dev/null
+++ b/Documentation/lessons/3d_models/free_models.md
@@ -0,0 +1,82 @@
+##### 3D Model Libraries
+
+# Free models
+
+"Free" models are models that don't require special access privlieges to use in TDW. They exist on an S3 server and have public URLs.
+
+The "core" model library is the largest free model library in TDW and the most commonly used. There are two other free model libraries:
+
+## `models_special.json`
+
+`models_special.json` includes special-case models for particular use-cases. It also includes some primitives:
+
+```python
+from tdw.librarian import ModelLibrarian
+
+librarian = ModelLibrarian("models_special.json")
+for record in librarian.records:
+    print(record.name)
+```
+
+Output:
+
+```
+cloth_1meter_square
+cloth_square
+fluid_receptacle1m_round
+fluid_receptacle1x1
+fluid_receptacle1x2
+new_ramp
+prim_capsule
+prim_cone
+prim_cube
+prim_cyl
+prim_sphere
+ramp_scene_max
+ramp_with_platform
+```
+
+It's possible that you'll want to use the `prim_` models if you need to add primitive shapes to a simulation. The `cloth_` and `fluid_` models are used in certain [Flex simulations](TODO). 
+
+## `models_flex.json`
+
+`models_flex.json` contains primitives that have been optimized for [Flex simulations](TODO).
+
+```python
+from tdw.librarian import ModelLibrarian
+
+librarian = ModelLibrarian("models_flex.json")
+for record in librarian.records:
+    print(record.name)
+```
+
+Output:
+
+```
+bowl
+cone
+cube
+cylinder
+dumbbell
+octahedron
+pentagon
+pipe
+platonic
+pyramid
+sphere
+torus
+triangular_prism
+```
+
+***
+
+**Next: [Non-free models](non_free_models.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Python API:
+
+- [`ModelLibrarian`](../../librarian/model_librarian.md)
+
diff --git a/Documentation/images/blender_export/0_set_scale.png b/Documentation/lessons/3d_models/images/custom_models/0_set_scale.png
similarity index 100%
rename from Documentation/images/blender_export/0_set_scale.png
rename to Documentation/lessons/3d_models/images/custom_models/0_set_scale.png
diff --git a/Documentation/images/blender_export/1_export.png b/Documentation/lessons/3d_models/images/custom_models/1_export.png
similarity index 100%
rename from Documentation/images/blender_export/1_export.png
rename to Documentation/lessons/3d_models/images/custom_models/1_export.png
diff --git a/Documentation/images/models_full/screen1.jpg b/Documentation/lessons/3d_models/images/non_free_models/screen1.jpg
similarity index 100%
rename from Documentation/images/models_full/screen1.jpg
rename to Documentation/lessons/3d_models/images/non_free_models/screen1.jpg
diff --git a/Documentation/images/models_full/screen2.jpg b/Documentation/lessons/3d_models/images/non_free_models/screen2.jpg
similarity index 100%
rename from Documentation/images/models_full/screen2.jpg
rename to Documentation/lessons/3d_models/images/non_free_models/screen2.jpg
diff --git a/Documentation/images/models_full/screen3.jpg b/Documentation/lessons/3d_models/images/non_free_models/screen3.jpg
similarity index 100%
rename from Documentation/images/models_full/screen3.jpg
rename to Documentation/lessons/3d_models/images/non_free_models/screen3.jpg
diff --git a/Documentation/images/models_full/screen3b.jpg b/Documentation/lessons/3d_models/images/non_free_models/screen3b.jpg
similarity index 100%
rename from Documentation/images/models_full/screen3b.jpg
rename to Documentation/lessons/3d_models/images/non_free_models/screen3b.jpg
diff --git a/Documentation/images/models_full/screen4.jpg b/Documentation/lessons/3d_models/images/non_free_models/screen4.jpg
similarity index 100%
rename from Documentation/images/models_full/screen4.jpg
rename to Documentation/lessons/3d_models/images/non_free_models/screen4.jpg
diff --git a/Documentation/misc_frontend/models_full.md b/Documentation/lessons/3d_models/non_free_models.md
similarity index 56%
rename from Documentation/misc_frontend/models_full.md
rename to Documentation/lessons/3d_models/non_free_models.md
index 03d6f7aae..d04aab9f4 100644
--- a/Documentation/misc_frontend/models_full.md
+++ b/Documentation/lessons/3d_models/non_free_models.md
@@ -1,92 +1,144 @@
-# Non-free models
-
-In addition to the 200 or so free models in `models_core.json` and `models_special.json`, TDW has a non-free library of models: `models_full.json`, which contains an additional 2000 models. These models are near-photoreal and are tagged with semantic categories, just like the models of `models_core.json`. There are far more models and categories in `models_full.json` than `models_core.json`.
-
-For the sake of convenience, the full library's [metadata file](../python/librarian/model_librarian.md) contains all of the records for `models_core.json`.
-
-## How to access non-free models
-
-### Requirements
-
-- You must be affiliated with MIT, IBM, or Stanford. (This will change in the future.)
-- Amazon S3 access keys
-
-### How to obtain Amazon S3 access keys
-
-If you haven't done so already, [email Jeremy Schwartz](mailto:jeremyes@mit.edu) to request access keys for the full model library. You will receive an email with the title “Welcome to ThreeDWorld(TDW)”. This email will contain login instructions and your username for the TDW Amazon AWS console, where you will generate your access credentials. _You will also receive a second email_, containing the temporary password associated with that username. Note this email will be an encrypted email, and will require entering an access key to open which will be sent to you separately via Slack DM. 
-
-You must change your password immediately upon login, per the instructions below.  Also, you will not have permissions to perform any actions in the Console; you will only be able to generate your access credentials as described below.
-
-After clicking on the link, you will be taken to the Amazon AWS Console page for the TDW account. Click on the down arrow marked in red below:
-
-![](../images/models_full/screen1.jpg)
-
-This will display the drop-down menu for your login. Click on “My Security Credentials”:
-
-![](../images/models_full/screen2.jpg)
-
-You will go to the credentials management page, where you will first change the temporary password you logged in with by clicking in the “Change password” button:
-
-![](../images/models_full/screen3.jpg)
-
-Next, you will generate your access key, which is in the form of a public / private key pair; click on the “Create access key” button as shown below. 
-
-![](../images/models_full/screen3b.jpg)
-
-This is your _only_ opportunity to save the information for both keys, so you must either download the CSV using the button highlighted below (recommended), or else show the secret access key and make a note of both keys.
-
-![](../images/models_full/screen4.jpg)
-
-### How to set up your credentials file
-
-1. Create a new file here:
-
-```
-<home>/.aws/credentials
-```
-
-Where `<home>` is your home directory. Note that `credentials` doesn't have a file extension.
-
-2. Add this to the file:
-
-```
-[tdw]
-aws_access_key_id=<access key id>
-aws_secret_access_key=<secret acccess key>
-```
-
-Replace `<access key id>` with your access key ID, and `<secret access key>` with your secret access key.
-
-### How to validate your credentials and add a config file
-
-You need a config file in addition to your credentials file:
-
-```
-<home>/.aws/config
-```
-
-You can create this config file in one of two ways.
-
-#### 1. Copy-paste the following:
-
-```
-[default]
-region = us-east-1
-output = json
-```
-
-#### 2. Run TDW's own validation
-
-With Python3:
-
-```python
-from tdw.tdw_utils import TDWUtils
-
-TDWUtils.validate_amazon_s3()
-```
-
-This will validate that everything is set up correctly and generate a config file for you.
-
-#### 3. (Optional) Create images of all of the models
-
-You can run [`screenshotter.py --type models_full`](models_full.md) to generate images of every model in the library. Be aware that it will take at least an hour to create every image.
\ No newline at end of file
+##### 3D Model Libraries
+
+# Non-free models
+
+TDW includes a non-free model library: `models_full.json`. This library has *far* more models than the free `models_core.json` and far more categories. Like `models_core.json`, they are photorealistic.
+
+```python
+from tdw.librarian import ModelLibrarian
+
+for librarian_filename in ["models_core.json", "models_full.json"]:
+    librarian = ModelLibrarian(librarian_filename)
+    categories = librarian.get_model_wnids_and_wcategories()
+    print(librarian_filename)
+    print(f"Number of models: {len(librarian.records)}")
+    print(f"Number of semantic categories: {len(categories)}")
+    print("")
+```
+
+Output:
+
+```
+models_core.json
+Number of models: 376
+Number of semantic categories: 89
+
+models_full.json
+Number of models: 2399
+Number of semantic categories: 239
+```
+
+For the sake of convenience, `models_full.json` includes all of the models in `models_core.json`:
+
+```python
+from tdw.librarian import ModelLibrarian
+
+core = ModelLibrarian("models_core.json")
+full = ModelLibrarian("models_full.json")
+for core_record in core.records:
+    full_record = full.get_record(core_record.name)
+    assert full_record is not None
+```
+
+## How to access non-free models
+
+**`models_full.json` is non-free and can't be downloaded without special access privlieges.**
+
+### Requirements
+
+- You must be affiliated with MIT, IBM, or Stanford. (This will change in the future.)
+- Amazon S3 access keys
+
+### 1. Obtain Amazon S3 access keys
+
+If you haven't done so already, [email Jeremy Schwartz](mailto:jeremyes@mit.edu) to request access keys for the full model library. You will receive an email with the title “Welcome to ThreeDWorld(TDW)”. This email will contain login instructions and your username for the TDW Amazon AWS console, where you will generate your access credentials. _You will also receive a second email_, containing the temporary password associated with that username. Note this email will be an encrypted email, and will require entering an access key to open which will be sent to you separately via Slack DM. 
+
+You must change your password immediately upon login, per the instructions below.  Also, you will not have permissions to perform any actions in the Console; you will only be able to generate your access credentials as described below.
+
+After clicking on the link, you will be taken to the Amazon AWS Console page for the TDW account. Click on the down arrow marked in red below:
+
+![](images/non_free_models/screen1.jpg)
+
+This will display the drop-down menu for your login. Click on “My Security Credentials”:
+
+![](images/non_free_models/screen2.jpg)
+
+You will go to the credentials management page, where you will first change the temporary password you logged in with by clicking in the “Change password” button:
+
+![](images/non_free_models/screen3.jpg)
+
+Next, you will generate your access key, which is in the form of a public / private key pair; click on the “Create access key” button as shown below. 
+
+![](images/non_free_models/screen3b.jpg)
+
+This is your _only_ opportunity to save the information for both keys, so you must either download the CSV using the button highlighted below (recommended), or else show the secret access key and make a note of both keys.
+
+![](images/non_free_models/screen4.jpg)
+
+### 2. Set up your credentials file
+
+1. Create a new file here:
+
+```
+<home>/.aws/credentials
+```
+
+Where `<home>` is your home directory. Note that `credentials` doesn't have a file extension.
+
+2. Add this to the file:
+
+```
+[tdw]
+aws_access_key_id=<access key id>
+aws_secret_access_key=<secret acccess key>
+```
+
+Replace `<access key id>` with your access key ID, and `<secret access key>` with your secret access key.
+
+### 3. Validate your credentials and add a config file
+
+You need a config file in addition to your credentials file:
+
+```
+<home>/.aws/config
+```
+
+You can create this config file in one of two ways.
+
+#### Option A: Copy-paste the following:
+
+```
+[default]
+region = us-east-1
+output = json
+```
+
+#### Option B: Run TDW's own validation
+
+With Python3:
+
+```python
+from tdw.tdw_utils import TDWUtils
+
+TDWUtils.validate_amazon_s3()
+```
+
+This will validate that everything is set up correctly and generate a config file for you.
+
+#### 3. (Optional) Create images of all of the models
+
+You can run [`screenshotter.py --type models_full`](../core_concepts/objects.md) to generate images of every model in the library. Be aware that it will take at least an hour to create every image.
+
+***
+
+**Next: [Add your own models to TDW](custom_models.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Python API:
+
+- [`ModelLibrarian`](../../librarian/model_librarian.md)
+- [`TDWUtils.validate_amazon_s3()](../../python/tdw_utils.md)
+
diff --git a/Documentation/lessons/3d_models/overview.md b/Documentation/lessons/3d_models/overview.md
new file mode 100644
index 000000000..ac31c2f0e
--- /dev/null
+++ b/Documentation/lessons/3d_models/overview.md
@@ -0,0 +1,66 @@
+##### 3D Model Libraries
+
+# Overview
+
+In most of the tutorials and documentation for TDW, as well as most example controllers and high-level APIs, the controller only uses the *default* [models](../core_concepts/objects.md) from the "core" [model library](../../librarian/model_librarian.md). These are free models that anyone can use in TDW.
+
+This:
+
+```python
+from tdw.librarian import ModelLibrarian
+
+librarian = ModelLibrarian()
+for record in librarian.records:
+    print(record.name)
+```
+
+...is the same as this:
+
+```python
+from tdw.librarian import ModelLibrarian
+
+librarian = ModelLibrarian("models_core.json")
+for record in librarian.records:
+    print(record.name)
+```
+
+However, there are other model libraries in TDW. Some have free models and others have non-free models that require an access key.
+
+| Model library         | Description                                       | Free |
+| --------------------- | ------------------------------------------------- | ---- |
+| `models_core.json`    | Approximately 400 models.                         | Yes  |
+| `models_full.json`    | Approximately 2400 models.                        | No   |
+| `models_special.json` | Primitives and special-purpose models.            | Yes  |
+| `models_flex.json`    | Primitives optimized for NVIDIA Flex simulations. | Yes  |
+
+You can also import models into TDW and define custom model libraries.
+
+This tutorial will cover the other model libraries in TDW and how to add your own models to TDW.
+
+## How to add objects from non-default model libraries into a scene
+
+Set the `library` parameter of `c.get_add_object()`:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               c.get_add_object(model_name="cube",
+                                library="models_flex.json",
+                                position={"x": 0, "y": 0, "z": 0,},
+                                object_id=c.get_unique_id())])
+```
+
+***
+
+**Next: [Free models](free_models.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Python API:
+
+- [`ModelLibrarian`](../../librarian/model_librarian.md)
\ No newline at end of file
diff --git a/Documentation/lessons/3d_models/shapenet.md b/Documentation/lessons/3d_models/shapenet.md
new file mode 100644
index 000000000..d0741e9d6
--- /dev/null
+++ b/Documentation/lessons/3d_models/shapenet.md
@@ -0,0 +1,111 @@
+##### 3D Model Libraries
+
+# Add ShapeNet models to TDW
+
+You can add [ShapeNet](https://www.shapenet.org/) models to TDW by converting them to asset bundles.
+
+ShapeNet is a huge repository of semantically tagged obj files available to users that has several advantages and disadvantages compared to TDW's models.
+
+**Advantages:**
+
+- There are *far* more ShapeNet models and semantic categories than TDW's model libraries.
+- ShapeNet is free (as opposed to [TDW's largest model library](non_free_models.md), which is non-free).
+
+**Disadvantages:**
+
+- ShapeNet models are inferior to TDW's models.
+  - Most of them are not photorealistic.
+  - Some of them are corrupted or have features that make them unsuitable for TDW such as being a 2D quad. 
+  - Many of them have different "up" directions.
+  - ShapeNet models were often created at different canonical scales; for example, two ShapeNet that are ostensibly 1 cubic meter may be totally different sizes when added  to TDW.
+- ShapeNet models are not hosted by default as TDW asset bundles. This is because of the reasons listed above (it would be extremely time-consuming for us to vet all of ShapeNet) and for potential licensing reasons.
+
+That said, you can generate your own ShapeNet asset bundles and host them locally.
+
+## Requirements
+
+1. You must download and extract the relevant ShapeNet zip files into the following directory structure:
+
+  - ShapeNet Core v2: Just unzip and you're all set.
+  - ShapeNet SEM: Download models, textures, and metadata.csv. They must all be in the same directory:
+
+```
+<root>
+....models/
+....textures/
+....metadata.csv
+```
+
+2. Clone the `tdw` repo.
+3. See [the requirements for using the `AssetBundleCreator`](custom_models.md).
+
+## Usage
+
+1. `cd tdw/Python/shapenet`
+2. `python3 shapenet.py [ARGUMENTS]`
+
+| Argument             | Type | Description                                                  | Default                  |
+| -------------------- | ---- | ------------------------------------------------------------ | ------------------------ |
+| `--src`              | str  | The absolute path to the root source directory.              | `"D:/ShapeNetCore.v2"`   |
+| `--dest`             | str  | The absolute path to the root destination directory.         | `"D:/tdw_shapenet_core"` |
+| `--set`              | str  | Which ShapeNet set this is.<br>_Choices:_ `"core"` or `"sem"` | `"core"`                 |
+| `--batch_size`       | int  | The number of models per batch.                              | 1000                     |
+| `--vhacd_resolution` | int  | Higher value=better-fitting colliders and slower build process. | 8000000                  |
+| `--first_batch_only` | N/A  | Output only the first batch. Useful for testing purposes.    | N/A                      |
+
+## What the script will do
+
+1. Create a [model librarian file](../../python/librarian/model_librarian.md) of all of the models. (Some of the metadata fields such as `bounds` will require Unity further down the creation pipeline.)
+2. (ShapeNet SEM only): Move all images in the `textures/` folder to `models/`
+3. Move _batches_ of .obj models, .mtl files, etc. to the asset_bundle_creator project. Default batch size is 1000.
+4. Create collider .objs for each model .obj (unless one already exists).
+5. Create prefabs.
+6. Create asset bundles from the prefabs.
+7. Update the database file with accurate metadata.
+8. Move the asset bundles to a destination directory.
+9. Remove all asset files (models, images, etc.) used to create the asset bundles.
+
+For ShapeNet Core v2, every time a model is added to the _batch_, the Python script will make a few changes to the files. These changes are harmless, unless you're using these models for unrelated research.
+
+## Time required
+
+**This is a very slow process. It can take multiple weeks to process all of the models.**
+
+To speed up the process, set `--vhacd_resolution` to a low value (e.g. 1000). This will make the physics colliders fit the model poorly, but will vastly speed up the overall asset bundle creation process. _Even then, expect the whole process to require several days._
+
+**The process can be paused, restarted, etc.** Once you've generated the initial library file, the script will scan for existing asset bundles and won't try to re-create them
+
+## How to use the models in TDW
+
+Once you've created the asset bundles, you can add them to TDW like any other asset bundles:
+
+```python
+from tdw.librarian import ModelLibrarian
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+shapenet_library_path = "path/to/shapenet/records.json"
+
+c = Controller()
+c.model_librarian = ModelLibrarian(library=shapenet_library_path)
+c.communicate([TDWUtils.create_empty_room(12, 12,),
+               c.get_add_object(model_name=c.model_librarian.records[0].name,
+                                object_id=c.get_unique_id())])
+```
+
+***
+
+**This is the last document in the "Non-Default 3D models" tutorial.**
+
+[Return to the README](../../../README.md)
+
+***
+
+ShapeNet convertor script:
+
+- [`shapenet.py`](https://github.com/threedworld-mit/tdw/blob/master/Python/shapenet/shapenet.py)
+
+Python API:
+
+- [`ModelLibrarian`](../../librarian/model_librarian.md)
+- [`AssetBundleCreator`](../../python/asset_bundle_creator.md)
diff --git a/Documentation/images/robots/reach_high.gif b/Documentation/lessons/agents/images/reach_high.gif
similarity index 100%
rename from Documentation/images/robots/reach_high.gif
rename to Documentation/lessons/agents/images/reach_high.gif
diff --git a/Documentation/lessons/agents/images/ur5.gif b/Documentation/lessons/agents/images/ur5.gif
new file mode 100644
index 000000000..c1f542e19
Binary files /dev/null and b/Documentation/lessons/agents/images/ur5.gif differ
diff --git a/Documentation/lessons/agents/overview.md b/Documentation/lessons/agents/overview.md
new file mode 100644
index 000000000..da9e7b5c7
--- /dev/null
+++ b/Documentation/lessons/agents/overview.md
@@ -0,0 +1,74 @@
+##### Agents
+
+# Overview
+
+*Most agent behavior in TDW is handled via the PhysX physics engine. If you haven't done so already, we strongly recommend you read the [physics tutorial](../physx/overview.md).*
+
+Unlike most simulation platforms, TDW has a very loose definition for "agent". Some examples of what *might* be, but doesn't *have* to be, an agent:
+
+- [Avatars](../core_concepts/avatars.md) have image sensors but typically are not embodied.
+- [Objects](../core_concepts.md) don't have image sensors but [it is possible to directly apply forces to objects](../physx/forces.md) and thereby make them act as if they are embodied agents.
+- [Robots](../robots/overview.md) can act as agents but don't have image sensors by default.
+
+TDW includes higher-level add-ons to effectively "create" agents from lower-level functionality. The [`Robot` add-on](../../python/add_ons/robot.md), for example, is a robotics wrapper class that has been designed assuming that the user wants to use robots as agents.
+
+The following tables lists the agent tutorials, but should not be taken as the be-all-end-all of what is possible for agent behavior in TDW. Each agent has been optimized to achieve realism in certain ways but not in other ways.
+
+### [Robot](../robots/overview.md)
+
+[Robots](../robots/overview.md) are physically realistic and driven by motor drives. You can set joint targets, apply forces to drives, and so on. Robots can interact with objects in the scene.
+
+**Trade-offs:** TDW doesn't include built-in motion planning or high-level action spaces for its robots (though it is possible for you to implement this yourself).
+
+![](images/ur5.gif)
+
+### [Magnebot](https://github.com/alters-mit/magnebot)
+
+[Magnebots](https://github.com/alters-mit/magnebot) are robot-like agents. In many respects, Magnebots behave exactly as robots; their joints are motorized robot joints.
+
+The Magnebot API is a high-level action space API with built-in motion planning.   
+
+ For example, to add a Magnebot to a scene and move the Magnebot forward by 2 meters:
+
+```python
+from magnebot import MagnebotController
+
+c = MagnebotController()
+c.init_scene()
+c.move_by(2)
+```
+
+**Trade-offs:** The Magnebot doesn't exist in real life. It uses a grasp system that, while *physically responsive* to the environment, isn't possible in real life.
+
+![](images/reach_high.gif)
+
+### Human
+
+Human users can directly control TDW. A human can move an agent using keyboard controls. A human can also directly control an embodied virtual reality agent.
+
+**Trade-offs:** The VR agent can't walk around large spaces without instantaneous teleport actions and doesn't have colliders except on its hands.
+
+### Replicant
+
+Replicants are human-like, or "humanoid" agents. They are embodied and physically response to the environment. They have far more sophisticated motion planning than robots or Magnebots and can have specialized animation routines for specific actions.
+
+**Trade-offs:** Unlike robots or Magnebots, replicant joints aren't motorized. If a replicant tries to pick up a large object, it might act *as if* the object is too heavy to lift but the physics engine alone won't determine the outcome of the action.
+
+### Embodied avatar
+
+Embodied avatars are simple geometric shapes with image sensors. They can be useful for prototyping.
+
+**Trade-offs:** Embodied avatars are simple. They can't interact with objects except by running into them.
+
+***
+
+## Additional tutorials for special topics
+
+| Tutorial                | Summary                                                      |
+| ----------------------- | ------------------------------------------------------------ |
+| Navigation              | Built-in navigation aids such as pathfinding and occupancy maps. |
+| Multi-agent simulations | How to enable and organize multi-agent simulations.          |
+
+***
+
+[Return to the README](../../../README.md)
\ No newline at end of file
diff --git a/Documentation/lessons/camera/follow.md b/Documentation/lessons/camera/follow.md
new file mode 100644
index 000000000..de382041a
--- /dev/null
+++ b/Documentation/lessons/camera/follow.md
@@ -0,0 +1,98 @@
+##### Camera Controls
+
+# Follow an object
+
+An avatar can "follow" an object via the [`follow_object` command](../../api/command_api.md#follow_object), in which case it will translate to a position offset from the object's current position and optionally match the object's rotation. `follow_object` must be sent *per frame* in order for the position and rotation to continuously update:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+
+object_id = c.get_unique_id()
+avatar_position = {"x": 2, "y": 1.6, "z": -0.6}
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name="iron_box",
+                             library="models_core.json",
+                             position={"x": 1, "y": 0, "z": -0.5},
+                             object_id=object_id)]
+commands.extend(TDWUtils.create_avatar(position=avatar_position,
+                                       avatar_id="a",
+                                       look_at={"x": 1, "y": 0, "z": -0.5}))
+# Apply a force to the object.
+commands.append({"$type": "apply_force_to_object",
+                "id": object_id,
+                "force": {"x": -15, "y": 2, "z": 3}})
+
+c.communicate(commands)
+
+# Per frame, look at the object and follow its position.
+for i in range(100):
+    c.communicate([{"$type": "follow_object",
+                    "object_id": object_id,
+                    "position": avatar_position,
+                    "rotation": False,
+                    "avatar_id": "a"},
+                   {"$type": "look_at",
+                    "object_id": object_id,
+                    "avatar_id": "a"}])
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/follow.gif)
+
+A [`ThirdPersonCamera`](../../python/add_ons/third_person_camera.md) can also follow an object by setting the `follow_object` parameter of the constructor. You can optionally set `follow_object_rotation` to match the rotation of the object. The `ThirdPersonCamera` will automatically send `follow_object` commands per frame. To reset the target, set `self.follow_target` and `self.follow_rotate`:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+c = Controller()
+
+object_id = c.get_unique_id()
+# Create a third-person camera that will follow the object.
+cam = ThirdPersonCamera(avatar_id="a",
+                        position={"x": 2, "y": 1.6, "z": -0.6},
+                        follow_object=object_id,
+                        follow_rotate=False,
+                        look_at=object_id)
+c.add_ons.append(cam)
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               c.get_add_object(model_name="iron_box",
+                                library="models_core.json",
+                                position={"x": 1, "y": 0, "z": -0.5},
+                                object_id=object_id),
+               {"$type": "apply_force_to_object",
+                "id": object_id,
+                "force": {"x": -15, "y": 2, "z": 3}}])
+for i in range(100):
+    c.communicate([])
+# Stop following the object.
+cam.follow_object = None
+c.communicate({"$type": "terminate"})
+```
+
+
+***
+
+**This is the last document in the "Camera Controls" tutorial.**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [follow.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/camera_controls/follow.py) Tell a camera to follow an object.
+
+Python API:
+
+- [`ThirdPersonCamera`](../../python/add_ons/third_person_camera.md)
+
+Command API:
+
+- [`follow_object`](../../api/command_api.md#follow_object)
diff --git a/Documentation/lessons/camera/images/follow.gif b/Documentation/lessons/camera/images/follow.gif
new file mode 100644
index 000000000..b3aec54d2
Binary files /dev/null and b/Documentation/lessons/camera/images/follow.gif differ
diff --git a/Documentation/lessons/camera/overview.md b/Documentation/lessons/camera/overview.md
new file mode 100644
index 000000000..c20a4ebe7
--- /dev/null
+++ b/Documentation/lessons/camera/overview.md
@@ -0,0 +1,13 @@
+##### Camera Controls
+
+# Overview
+
+This tutorial covers basic camera controls in TDW. It is *not* comprehensive. There are many camera commands in TDW. Some are used infrequently. Others are used frequently but are best covered in other tutorials. This tutorial covers how to move and rotate a third person camera.
+
+For a full list of avatar camera (sensor container) commands, [read the Command API](../../api/command_api.md). 
+
+***
+
+**Next: [Move a camera](position.md)**
+
+[Return to the README](../../../README.md)
\ No newline at end of file
diff --git a/Documentation/lessons/camera/position.md b/Documentation/lessons/camera/position.md
new file mode 100644
index 000000000..4ca5b81df
--- /dev/null
+++ b/Documentation/lessons/camera/position.md
@@ -0,0 +1,63 @@
+##### Camera Controls
+
+# Move a camera
+
+Translate the avatar (camera)'s position with the [`teleport_avatar_to` command](../../api/command_api.md#teleport_avatar_to):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+commands = [TDWUtils.create_empty_room(12, 12)]
+x = 0
+y = 1.6
+z = 0
+commands.extend(TDWUtils.create_avatar(position={"x": x, "y": y, "z": z},
+                                       avatar_id="a"))
+c.communicate(commands)
+for i in range(30):
+    z += 0.05
+    c.communicate({"$type": "teleport_avatar_to",
+                   "avatar_id": "a",
+                   "position": {"x": x, "y": y, "z": z}})
+c.communicate({"$type": "terminate"})
+```
+
+Or, with a `ThirdPersonCamera`, call `teleport(position)`:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+c = Controller()
+x = 0
+y = 1.6
+z = 0
+cam = ThirdPersonCamera(position={"x": x, "y": y, "z": z})
+c.add_ons.append(cam)
+c.communicate(TDWUtils.create_empty_room(12, 12))
+for i in range(30):
+    z += 0.05
+    cam.teleport(position={"x": x, "y": y, "z": z})
+    c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+***
+
+**Next: [Rotate a camera](rotation.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Command API:
+
+- [`teleport_avatar_to`](../../api/command_api.md#teleport_avatar_to)
+
+Python API:
+
+- [`ThirdPersonCamera`](../../python/add_ons/third_person_camera.md)
+
diff --git a/Documentation/lessons/camera/rotation.md b/Documentation/lessons/camera/rotation.md
new file mode 100644
index 000000000..051d70c9b
--- /dev/null
+++ b/Documentation/lessons/camera/rotation.md
@@ -0,0 +1,245 @@
+##### Camera Controls
+
+# Rotate a camera
+
+## Rotating the avatar vs. rotating the sensor container
+
+The non-embodied avatar object is structured like this:
+
+```
+Avatar
+....SensorContainer (camera)
+```
+
+This is a holdover from earlier versions of TDW that emphasized using embodied avatars. For this reason, the Command API includes commands to rotate the avatar and, separately, to rotate the sensor container. The avatar rotation commands can be considered more or less deprecated; you should translate the avatar with `teleport_avatar_to` and rotate the camera with sensor container commands.
+
+## Rotate the sensor container (camera)
+
+### Rotate the camera by an angle and axis
+
+Option 1: Send the [`rotate_sensor_container_by` command:](../../api/command_api.md#rotate_sensor_container_by) 
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+
+object_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name="iron_box",
+                             library="models_core.json",
+                             position={"x": 1, "y": 0, "z": -0.5},
+                             object_id=object_id)]
+commands.extend(TDWUtils.create_avatar(position={"x": 2, "y": 1.6, "z": -0.6},
+                                       look_at={"x": 1, "y": 0, "z": -0.5},
+                                       avatar_id="a"))
+c.communicate(commands)
+c.communicate({"$type": "rotate_sensor_container_by",
+               "axis": "pitch",
+               "angle": -10,
+               "avatar_id": "a"})
+c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+Option 2: With a [`ThirdPersonCamera`](../../python/add_ons/third_person_camera.md), call the `rotate()` function (which internally creates a `rotate_sensor_container_by` command):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+c = Controller()
+
+object_id = c.get_unique_id()
+cam = ThirdPersonCamera(position={"x": 2, "y": 1.6, "z": -0.6},
+                        look_at=object_id)
+c.add_ons.append(cam)
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               c.get_add_object(model_name="iron_box",
+                                library="models_core.json",
+                                position={"x": 1, "y": 0, "z": -0.5},
+                                object_id=object_id)])
+# Stop following the target object.
+cam.look_at_target = None
+cam.rotate({"x": -10, "y": 0, "z":0})
+c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+### Rotate the camera to a quaternion
+
+Send the [`rotate_sensor_container_to` command](../../api/command_api.md#rotate_sensor_container_to) or, with a `ThirdPersonCamera`, call `rotate` but supply an (x, y, z, w) dictionary instead of an (x, y, z) dictionary.
+
+Quaternions are difficult to use, but they're the only reliable way to repeatedly rotate an object; all other rotation commands such as `rotate_sensor_container_by` convert parameters specified by the user into quaternions.
+
+It is often use to rotate the camera to a quaternion if you want to store the camera's rotation and apply it later. To get the current quaternion of the camera, send [`send_image_sensors`](../../api/command_api.md#send_image_sensors) which will return [`ImageSensors` output data](../../api/output_data.md#ImageSensors):
+
+```python
+from typing import List
+import random
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, ImageSensors
+
+
+class RotateSensorContainerToExample(Controller):
+    def get_random_rotation(self) -> List[dict]:
+        commands = []
+        for axis in ["pitch", "yaw", "roll"]:
+            commands.append({"$type": "rotate_sensor_container_by",
+                             "axis": axis,
+                             "angle": random.uniform(-15, 15),
+                             "avatar_id": "a"})
+        return commands
+
+    def run(self):
+        commands = [TDWUtils.create_empty_room(12, 12)]
+        commands.extend(TDWUtils.create_avatar(position={"x": 2, "y": 1.6, "z": -0.6},
+                                               avatar_id="a"))
+        # Set a random initial rotation.
+        commands.extend(self.get_random_rotation())
+        # Request image sensors data.
+        commands.append({"$type": "send_image_sensors",
+                         "frequency": "once"})
+        resp = self.communicate(commands)
+        initial_rotation = (0, 0, 0, 0)
+        for i in range(len(resp) - 1):
+            r_id = OutputData.get_data_type_id(resp[i])
+            if r_id == "imse":
+                imse = ImageSensors(resp[i])
+                if imse.get_avatar_id() == "a":
+                    initial_rotation = imse.get_sensor_rotation(0)
+        # Set a random rotation.
+        self.communicate(self.get_random_rotation())
+        # Reset to the initial rotation.
+        self.communicate({"$type": "rotate_sensor_container_to",
+                          "rotation": TDWUtils.array_to_vector4(initial_rotation), 
+                          "avatar_id": "a"})
+        self.communicate({"$type": "terminate"})
+        
+if __name__ == "__main__":
+    c = RotateSensorContainerToExample()
+    c.run()
+```
+
+### Reset the camera rotation
+
+Reset the camera rotation to its neutral position with the [`reset_sensor_container_rotation`](../../api/command_api.md#reset_sensor_container_rotation):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+commands = [TDWUtils.create_empty_room(12, 12)]
+commands.extend(TDWUtils.create_avatar(position={"x": 2, "y": 1.6, "z": -0.6},
+                                       avatar_id="a"))
+c.communicate(commands)
+c.communicate({"$type": "rotate_sensor_container_by",
+               "axis": "pitch",
+               "angle": 70,
+               "avatar_id": "a"})
+c.communicate([{"$type": "reset_sensor_container_rotation",
+                "avatar_id": "a"}])
+c.communicate({"$type": "terminate"})
+```
+
+## Rotate the camera to look at something
+
+Send [`look_at`](../../api/command_api.md#look_at) to rotate the camera such that a target object is at the center of the image (the viewport). Set the `"use_centroid"` to True to look at the center of the object rather than its bottom-center pivot:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+
+object_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name="iron_box",
+                             library="models_core.json",
+                             position={"x": 1, "y": 0, "z": -0.5},
+                             object_id=object_id)]
+commands.extend(TDWUtils.create_avatar(position={"x": 2, "y": 1.6, "z": -0.6},
+                                       avatar_id="a"))
+commands.append({"$type": "look_at",
+                 "object_id": object_id,
+                 "use_centroid": True,
+                 "avatar_id": "a"})
+c.communicate(commands)
+c.communicate({"$type": "terminate"})
+```
+
+Send [`look_at_position`](../../api/command_api.md#look_at_position) to rotate the camera such that a target position  is at the center of the image (the viewport). This is automatically called whenever we set the `look_at` parameter of `TDWUtils.create_avatar()`. This example creates an avatar and points the camera at a position without using the wrapper function:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+
+object_id = c.get_unique_id()
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               {"$type": "create_avatar",
+                "type": "A_Img_Caps_Kinematic",
+                "id": "a"},
+               {"$type": "look_at_position",
+                "avatar_id": "a",
+                "position": {"x": 0, "y": 10, "z": 0}}])
+c.communicate({"$type": "terminate"})
+```
+
+The `ThirdPersonCamera` wraps both of these commands (`look_at` and `look_at_position` in a single field: `look_at_position`:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+c = Controller()
+
+object_id = c.get_unique_id()
+cam = ThirdPersonCamera(avatar_id="a", position={"x": 2, "y": 1.6, "z": -0.6})
+c.add_ons.append(cam)
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               c.get_add_object(model_name="iron_box",
+                                library="models_core.json",
+                                position={"x": 1, "y": 0, "z": -0.5},
+                                object_id=object_id)])
+# Look at the object.
+cam.look_at_target = object_id
+c.communicate([])
+# Look at a position.
+cam.look_at_target = {"x": -2, "y": 0.5, "z": 0}
+c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+***
+
+**Next: [Follow an object](follow.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Command API:
+
+- [`rotate_sensor_container_by`](../../api/command_api.md#rotate_sensor_container_by)
+- [`rotate_sensor_container_to`](../../api/command_api.md#rotate_sensor_container_to)
+- [`send_image_sensors`](../../api/command_api.md#send_image_sensors)
+- [`reset_sensor_container_rotation`](../../api/command_api.md#reset_sensor_container_rotation) 
+- [`look_at`](../../api/command_api.md#look_at)
+- [`look_at_position`](../../api/command_api.md#look_at_position)
+
+Output Data API:
+
+- [`ImageSensors`](../../api/output_data.md#ImageSensors)
+
+Python API:
+
+- [`ThirdPersonCamera`](../../python/add_ons/third_person_camera.md)
+- [`TDWUtils.array_to_vector4(arr)`](../../python/tdw_utils.md) Convert an array to a vector4 dictionary (in this case, a quaternion).
+
diff --git a/Documentation/lessons/core_concepts/add_ons.md b/Documentation/lessons/core_concepts/add_ons.md
new file mode 100644
index 000000000..221cc9549
--- /dev/null
+++ b/Documentation/lessons/core_concepts/add_ons.md
@@ -0,0 +1,163 @@
+##### Core Concepts
+
+# Add-ons and the `ThirdPersonCamera`
+
+**Add-ons** in TDW are objects that can be added to a controller and act as wrappers for the Command API. They will inject commands at every `communicate()` call. You can attach an add-on to the controller by adding it to the `c.add_ons` list.
+
+The purpose of add-ons is to simplify repetitious or complex behavior in TDW and to standardize common API calls. The most important thing to know about add-ons is that there is nothing that add-on can do that can't be done with low-level commands.
+
+The [`ThirdPersonCamera`](../../python/add_ons/third_person_camera.md) add-on will add a third-person camera avatar to the scene. It can do everything covered [in the previous document](avatars.md) but with simplified controls. 
+
+To start, we'll define the camera and append it to the `c.add_ons` list:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+c = Controller()
+
+# Create the third-person camera.
+camera = ThirdPersonCamera(avatar_id="a",
+                           position={"x": -1, "y": 5.7, "z": -3.8},
+                           rotation={"x": 26, "y": 0, "z": 0})
+# Append the third-person camera add-on.
+c.add_ons.append(camera)
+```
+
+At this point the controller still hasn't sent any commands  to add the camera. Add-on commands are always sent when `c.communicate()` is called:
+
+```python
+from time import sleep
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+c = Controller()
+
+# Create the third-person camera.
+camera = ThirdPersonCamera(avatar_id="a",
+                           position={"x": -1, "y": 5.7, "z": -3.8},
+                           rotation={"x": 26, "y": 0, "z": 0})
+# Append the third-person camera.
+c.add_ons.append(camera)
+c.communicate([TDWUtils.create_empty_room(12, 12)])
+sleep(2)
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/third_person_camera.png)
+
+## Moving the `ThirdPersonCamera`
+
+The third-person camera can be moved via `camera.teleport(position)` and rotated via `camera.rotate(rotation)`:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+c = Controller()
+
+camera = ThirdPersonCamera(avatar_id="a",
+                           position={"x": -1, "y": 5.7, "z": -3.8},
+                           rotation={"x": 26, "y": 0, "z": 0})
+c.add_ons.append(camera)
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               {"$type": "set_target_framerate",
+                "framerate": 30}])
+for i in range(20):
+    # Raise the camera up by 0.1 meters.
+    camera.teleport(position={"x": 0, "y": 0.1, "z": 0},
+                    absolute=False)
+    # Rotate around the yaw axis by 2 degrees.
+    camera.rotate(rotation={"x": 0, "y": 2, "z": 0})
+    c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/avatar_move.gif)
+
+Note that we're iterating by calling `c.communicate([])`. This sends an empty list of commands (plus any commands injected by the `camera` add-on). 
+
+## Add-ons order of execution
+
+Add-on commands are always sent in the order that they appear in `c.add_ons`. Add-on commands are always sent after the commands explicitly listed in `communicate(commands)`.
+
+To demonstrate this, we'll add a second add-on, [`Debug`](../../python/add_ons/debug.md). This add-on will log all commands sent to a build and optionally play them back.
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.debug import Debug
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+c = Controller()
+
+# Create the third-person camera.
+camera = ThirdPersonCamera(avatar_id="a",
+                           position={"x": -1, "y": 5.7, "z": -3.8},
+                           rotation={"x": 26, "y": 0, "z": 0})
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("add_ons/log.txt")
+print(f"Log will be saved to: {path}")
+# Add a debug add-on.
+debug = Debug(record=True, path=path)
+
+# Append the third-person camera and debug add-ons.
+c.add_ons.extend([debug, camera])
+
+c.communicate(TDWUtils.create_empty_room(12, 12))
+c.communicate({"$type": "terminate"})
+
+# Show the commands from the first logged frame.
+for command in debug.playback[0]:
+    print(command["$type"])
+```
+
+Output:
+
+```
+create_exterior_walls
+send_log_messages
+create_avatar
+set_pass_masks
+set_render_order
+set_anti_aliasing
+teleport_avatar_to
+rotate_sensor_container_by
+rotate_sensor_container_by
+rotate_sensor_container_by
+```
+
+- [`create_exterior_walls`](../../api/command_api.md#create_exterior_walls) is first in the list because  we called `c.communicate(TDWUtils.create_empty_room(12, 12))` (which sends a `create_extertior_walls` command).
+- [`send_log_messages`](../../api/command_api.md#send_log_messages) was added by the `Debug` add-on. Because the list of add-ons is `[debug, camera]`, this command is sent before `camera`'s commands.
+- The ret of the commands are injected into the list by the `ThirdPersonCamera`.
+
+***
+
+**Next: [Objects](objects.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [third_person_camera.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/core_concepts/third_person_camera.py) Example usage of the `ThirdPersonCamera`.
+
+Python API:
+
+- [`AddOn`](../../python/add_ons/add_on.md) (abstract base class for all add-ons)
+- [`ThirdPersonCamera`](../../python/add_ons/third_person_camera.md) 
+- [`Debug`](../../python/add_ons/debug.md) 
+
+Command API:
+
+- [`create_exterior_walls`](../../api/command_api.md#create_exterior_walls)
+- [`send_log_messages`](../../api/command_api.md#send_log_messages)
+
diff --git a/Documentation/lessons/core_concepts/avatars.md b/Documentation/lessons/core_concepts/avatars.md
new file mode 100644
index 000000000..e70afdabc
--- /dev/null
+++ b/Documentation/lessons/core_concepts/avatars.md
@@ -0,0 +1,159 @@
+##### Core Concepts
+
+# Avatars and cameras
+
+As mentioned [earlier](design_philosophy.md), TDW doesn't impose metaphors regarding what an agent is or whether there needs to be one at all. **Avatars** are a type of agent in TDW but not the only one.
+
+All avatars have a camera. The most commonly used avatar is a non-embodied camera; you can think of this avatar as being more or less equivalent to a third-person camera.
+
+To add the third-person camera avatar to the scene:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               {"$type": "create_avatar",
+                "type": "A_Img_Caps_Kinematic",
+                "id": "a"}])
+```
+
+ ...which will render this image in the build application's window:
+
+![](images/avatar.png)
+
+The avatar is currently at position (0, 0, 0) i.e. floor level--the top half of the image is above the floor and the bottom half is below the floor. To move the avatar to a better location, send `teleport_avatar_to`:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               {"$type": "create_avatar",
+                "type": "A_Img_Caps_Kinematic",
+                "id": "a"},
+               {"$type": "teleport_avatar_to",
+                "avatar_id": "a",
+                "position": {"x": -1, "y": 0.6, "z": 2.4}}])
+```
+
+...which will render this image:
+
+![](images/avatar_y.png)
+
+This is a *little* better (the camera is now above floor level) but still not great. Let's rotate the avatar's **sensor container** (its camera) for a better view of the scene:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               {"$type": "create_avatar",
+                "type": "A_Img_Caps_Kinematic",
+                "id": "a"},
+               {"$type": "teleport_avatar_to",
+                "avatar_id": "a",
+                "position": {"x": -1, "y": 5.7, "z": -3.8}},
+               {"$type": "rotate_sensor_container_by",
+                "axis": "pitch",
+                "angle": 26,
+                "avatar_id": "a"},
+               {"$type": "rotate_sensor_container_by",
+                "axis": "yaw",
+                "angle": -15,
+                "avatar_id": "a"}])
+```
+
+...which will render this image:
+
+![](images/avatar_rot.png)
+
+## `TDWUtils.create_avatar()`
+
+`TDWUtils` includes a helpful wrapper function for creating avatars: [`create_avatar()`](https://github.com/threedworld-mit/tdw/blob/master/Documentation/python/tdw_utils.md#create_avataravatar_typea_img_caps_kinematic-avatar_ida-positionnone-look_atnone---listdict):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+commands = [TDWUtils.create_empty_room(12, 12)]
+commands.extend(TDWUtils.create_avatar(avatar_type="A_Img_Caps_Kinematic",
+                                       position={"x": -1, "y": 5.7, "z": -3.8},
+                                       look_at={"x": 0, "y": 1, "z": 0}))
+c.communicate(commands)
+```
+
+...which will render this image:
+
+![](images/avatar_tdwutils.png)
+
+## Moving and rotating the avatar
+
+To move and rotate the avatar, simply teleport and rotate it by small increments per frame:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+"""
+Move the avatar and rotate the camera.
+Note that in this example, the `avatar_id` parameters are missing. That's because they all default to "a".
+"""
+
+c = Controller()
+y = 5.7
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               {"$type": "create_avatar",
+                "type": "A_Img_Caps_Kinematic",
+                "id": "a"},
+               {"$type": "teleport_avatar_to",
+                "position": {"x": -1, "y": y, "z": -3.8}},
+               {"$type": "rotate_sensor_container_by",
+                "axis": "pitch",
+                "angle": 26},
+               {"$type": "set_target_framerate",
+                "framerate": 30}])
+for i in range(20):
+    y += 0.1
+    c.communicate([{"$type": "rotate_sensor_container_by",
+                    "axis": "yaw",
+                    "angle": 2},
+                   {"$type": "teleport_avatar_to",
+                    "position": {"x": -1, "y": y, "z": -3.8}}])
+c.communicate({"$type": "terminate"})
+```
+
+...which moves the avatar like this:
+
+![](images/avatar_move.gif)
+
+## Embodied avatars
+
+**There are several embodied avatars in TDW; however, we don't recommend you use them.** Avatars are one of the oldest components of TDW and they've been gradually superseded. [There are many non-avatar embodied agents in TDW](TODO), all of which are more sophisticated than the embodied avatars. For more information regarding embodied avatars, read the API documentation for [`create_avatar`](../../api/command_api.md#create_avatar).
+
+***
+
+**Next: [Add-ons and the `ThirdPersonCamera`](add_ons.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [move_avatar.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/core_concepts/move_avatar.py) Move an avatar and rotate its camera.
+
+Python API:
+
+- [`TDWUtils.create_avatar(avatar_type, position, look_at)`](../../python/tdw_utils.md)
+
+Command API:
+
+- [`create_avatar`](../../api/command_api.md#create_avatar)
+- [`teleport_avatar_to`](../../api/command_api.md#teleport_avatar_to)
+- [`rotate_sensor_container_by`](../../api/command_api.md#rotate_sensor_container_by)
+- [`set_target_framerate`](../../api/command_api.md#set_target_framerate)
diff --git a/Documentation/lessons/core_concepts/commands.md b/Documentation/lessons/core_concepts/commands.md
new file mode 100644
index 000000000..cfbc1d78d
--- /dev/null
+++ b/Documentation/lessons/core_concepts/commands.md
@@ -0,0 +1,76 @@
+##### Core Concepts
+
+# Commands
+
+**Commands** are instructions that are sent by the controller to the build per frame.
+
+In a controller, a command is a dictionary like this:
+
+```python
+{"$type": "set_screen_size", "width": 1024, "height": 1024}
+```
+
+Where the value of `"$type"`  defines the  type of command (in this case, `"set_screen_size"`).
+
+To send this command, you must call the controller's `communicate(commands)` function:
+
+```python
+from tdw.controller import Controller
+
+c = Controller() 
+c.communicate({"$type": "set_screen_size", "width": 1024, "height": 1024})
+c.communicate({"$type": "terminate"})
+```
+
+## Sending list of commands
+
+You can send arbitrarily long lists of commands  to the build on the same simulation step. These commands will execute in order from the first command in the list to the last. This allows for highly complex behavior in TDW such as adding multiple objects at the same time or moving multiple robot joints on the same frame:
+
+```python
+from tdw.controller import Controller
+
+c = Controller()
+# On the same frame, set the screen size and disable physics.
+c.communicate([{"$type": "set_screen_size",
+                "width": 1024,
+                "height": 1024},
+               {"$type": "simulate_physics",
+                "value": False}])
+c.communicate({"$type": "terminate"})
+```
+
+## Parameters with default values
+
+Many commands have parameters with default values. In these cases, it isn't necessary to include the parameter in the dictionary.
+
+ For example:
+
+ `{"$type": "create_avatar", "type": "A_Img_Caps"}` 
+
+...is equivalent to
+
+ `{"$type": "create_avatar", "type": "A_Img_Caps", "id": "a"}` 
+
+...because the default parameter of `"id"` is `"a"`. 
+
+To learn which parameters have default values, read the [Command API documentation](../../api/command_api.md).
+
+## API
+
+TDW includes hundreds of commands. **[Read the Command API documentation here.](../../api/command_api.md)**
+
+## If you need more commands...
+
+...don't hesitate to reach out by posting a GitHub issue! The Command API is designed to be easily improved and extended and we'll usually be able to add the functionality you need.
+
+***
+
+See also: 
+
+- [Command API](../../api/command_api.md)
+
+***
+
+**Next: [Design philosophy of TDW](design_philosophy.md)**
+
+[Return to the README](../../../README.md)
diff --git a/Documentation/lessons/core_concepts/controller.md b/Documentation/lessons/core_concepts/controller.md
new file mode 100644
index 000000000..51cc38c0d
--- /dev/null
+++ b/Documentation/lessons/core_concepts/controller.md
@@ -0,0 +1,78 @@
+##### Core Concepts
+
+# The Controller
+
+*Note: We recommend you try the code examples in this document on a personal computer, not a remote server. [The next Core Concepts document](launch_build.md) explains how to run TDW on a remote server in more detail.*
+
+A controller is the Python object that communicates with the simulation application (the build). Typically, users are responsible for writing their own controllers, though the documentation for TDW includes numerous examples.
+
+![](images/network.png)
+
+You've already seen a minimal controller script example:
+
+```python
+from tdw.controller import Controller
+
+c = Controller()
+c.communicate({"$type": "terminate"})
+```
+
+This will launch the build and immediately kill the process.
+
+In many cases, you'll want to add your own functions to a controller. To do this, create a child class of `Controller`:
+
+```python
+from tdw.controller import Controller
+
+class MyController(Controller):
+    pass
+
+
+if __name__ == "__main__":
+    c = MyController()
+    c.communicate({"$type": "terminate"})
+```
+
+This example will print all of the commands sent to the build:
+
+```python
+from typing import Union, List
+from tdw.controller import Controller
+
+class MyController(Controller):
+    def communicate(self, commands: Union[dict, List[dict]]) -> list:
+        print(commands)
+        return super().communicate(commands=commands)
+ 
+
+if __name__ == "__main__":
+    c = MyController()
+    c.communicate({"$type": "terminate"})
+```
+
+## Frames, Commands, and Output Data
+
+- The controller sends **commands** to the build per `communicate()` call. In the above examples, `{"$type": "terminate"}` is a command.
+- Every time the controller calls `communicate()`, the simulation advances 1 **frame**. In nearly all cases, a frame is equivalent in TDW to both a visual camera render and a physics step (the exception is the `step_physics` command, which is covered in a later tutorial). If the controller *doesn't* call `communicate()`, the simulation pauses until it does. 
+- The build receives **output data** from the build per communicate call.
+
+
+
+![](images/network.png)
+
+***
+
+**Next: [Auto-launching the TDW build](launch_build.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Python API:
+
+- [`Controller`](../../python/controller.md)  (Base class for all TDW controllers)
+
+Command API:
+
+- [`terminate`](../../api/command_api.md#terminate)
+- [`step_physics`](../../api/command_api.md#step_physics)
\ No newline at end of file
diff --git a/Documentation/lessons/core_concepts/design_philosophy.md b/Documentation/lessons/core_concepts/design_philosophy.md
new file mode 100644
index 000000000..82dbe9567
--- /dev/null
+++ b/Documentation/lessons/core_concepts/design_philosophy.md
@@ -0,0 +1,41 @@
+##### Core Concepts
+
+# Design philosophy of TDW
+
+## 1. Low-level atomic API
+
+The API is designed to be as low-level as possible. Each API call (a "command" in TDW) is typically designed to do exactly one thing. It is possible to send multiple commands at once to achieve complex behavior. Likewise, TDW doesn't return [output data](output_data.md) by default; it must be explicitly requested.
+
+There are several key advantages to this style of API:
+
+- Researchers have the ability to control their simulation at a fine-grained level.
+- It's relatively easy for the TDW development team to add to or modify a single API call without affecting any others.
+- It's possible to develop multiple high-level APIs that share common code.
+
+## 2. Wrapper functions, add-ons, and high-level APIs
+
+TDW includes many **wrapper functions** that simplify the usage of commonly used commands. There are also **add-ons** which typically do something on every frame, such as log messages or save images (add-ons will be covered later in the Core Concepts guide).
+
+The TDW team has also developed several **high-level APIs** that constitute more general frameworks for certain types of experiments such as dataset generation; these APIs usually exist in separate repos.
+
+When possible, this documentation will first show you the underlying TDW commands and then the higher-level functions or classes that do the same thing. The most important thing to know is that all of these higher-level calls ultimately return low-level TDW commands; there is nothing in TDW that exists outside of the Command API.
+
+## 3. No imposed metaphors
+
+Commands don't impose any metaphors. It isn't even strictly necessary to have any agents or environments, nor are there limits to how many agents there are, what sort of agents there are, etc. Some examples:
+
+- It's possible to create a physics simulation without any agents or images.
+- It's possible to create a physics simulation with a robot agent.
+- It's possible to create a non-physics scene with a non-embodied third person camera.
+
+***
+
+**Next: [Scenes](scenes.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+See also: 
+
+- [Command API](../../api/command_api.md)
\ No newline at end of file
diff --git a/Documentation/lessons/core_concepts/images.md b/Documentation/lessons/core_concepts/images.md
new file mode 100644
index 000000000..b58888bab
--- /dev/null
+++ b/Documentation/lessons/core_concepts/images.md
@@ -0,0 +1,188 @@
+##### Core Concepts
+
+# Images
+
+TDW makes a distinction between images rendered to the build's application window (render frames) and encoded image output data. By default, avatar cameras generate render frames but don't actually return output data; this is because serializing image output data is one of the slowest processes in TDW. Only request image data when you really need it!
+
+Image data in TDW is divided into **capture passes**. By far the most commonly used capture pass is `_img`, which is a render of what the camera is currently viewing. Other capture passes will return more specialized data.
+
+To capture an `_img` pass, we'll first create a scene, add an object, and add an avatar. 
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+object_id = c.get_unique_id()
+
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name="iron_box",
+                             position={"x": 0, "y": 0, "z": 0},
+                             object_id=object_id)]
+commands.extend(TDWUtils.create_avatar(position={"x": 2, "y": 1.6, "z": -0.6},
+                                       avatar_id="a",
+                                       look_at={"x": 0, "y": 0, "z": 0}))
+```
+
+Now we need to add two more commands:
+
+- [`set_pass_masks`](../../api/command_api.md#set_pass_masks) to enable the `_img` pass.
+- [`send_images`](../../api/command_api.md#send_images) to request image data per frame.
+
+We'll send all of these commands and receive a response:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+object_id = c.get_unique_id()
+
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name="iron_box",
+                             position={"x": 0, "y": 0, "z": 0},
+                             object_id=object_id)]
+commands.extend(TDWUtils.create_avatar(position={"x": 2, "y": 1.6, "z": -0.6},
+                                       avatar_id="a",
+                                       look_at={"x": 0, "y": 0, "z": 0}))
+commands.extend([{"$type": "set_pass_masks",
+                  "pass_masks": ["_img"],
+                  "avatar_id": "a"},
+                 {"$type": "send_images",
+                  "frequency": "always",
+                  "ids": ["a"]}])
+
+resp = c.communicate(commands)
+```
+
+We can then read the [`Images`](../../api/output_data.md#Images) output data, which contains metadata about the image plus the image itself. We can then choose what to do with the image: If we want to analyze it at runtime, we could convert it to a [PIL image](https://pillow.readthedocs.io/en/stable/reference/Image.html). Or, we could immediately save it to disk. The following example does both options:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, Images
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+"""
+Create a scene, add an object, and save the image.
+"""
+
+c = Controller()
+object_id = c.get_unique_id()
+
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name="iron_box",
+                             position={"x": 0, "y": 0, "z": 0},
+                             object_id=object_id)]
+commands.extend(TDWUtils.create_avatar(position={"x": 2, "y": 1.6, "z": -0.6},
+                                       avatar_id="a",
+                                       look_at={"x": 0, "y": 0, "z": 0}))
+commands.extend([{"$type": "set_pass_masks",
+                  "pass_masks": ["_img"],
+                  "avatar_id": "a"},
+                 {"$type": "send_images",
+                  "frequency": "always",
+                  "ids": ["a"]}])
+
+resp = c.communicate(commands)
+output_directory = str(EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("send_images").resolve())
+print(f"Images will be saved to: {output_directory}")
+
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    # Get Images output data.
+    if r_id == "imag":
+        images = Images(resp[i])
+        # Determine which avatar captured the image.
+        if images.get_avatar_id() == "a":
+            # Iterate throught each capture pass.
+            for j in range(images.get_num_passes()):
+                # This is the _img pass.
+                if images.get_pass_mask(j) == "_img":
+                    image_arr = images.get_image(j)
+                    # Get a PIL image.
+                    pil_image = TDWUtils.get_pil_image(images=images, index=j)
+            # Save the image.
+            TDWUtils.save_images(images=images, filename="0", output_directory=output_directory)
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/img_pass_box.png)
+
+## The `ImageCapture` add-on
+
+You can add use an [`ImageCapture`](../../python/add_ons/image_capture.md) to save images per frame. Note that in this example, we don't need to manually initialize the camera, enable image capture, or parse output data; all of that is handled automatically by the `ImageCapture` add-on:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+c = Controller()
+object_id = c.get_unique_id()
+cam = ThirdPersonCamera(position={"x": 2, "y": 1.6, "z": -0.6},
+                        look_at=object_id,
+                        avatar_id="a")
+c.add_ons.append(cam)
+output_directory = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("send_images_2")
+# Add the ImageCapture add-on.
+cap = ImageCapture(path=output_directory, avatar_ids=["a"], pass_masks=["_img", "_id"])
+c.add_ons.append(cap)
+
+# This will create the scene and the object.
+# Then, the ThirdPersonCamera add-on will create an avatar.
+# Then, the ImageCapture add-on will save an image to disk.
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(model_name="iron_box",
+                                       position={"x": 1, "y": 0, "z": -0.5},
+                                       object_id=object_id)])
+c.communicate({"$type": "terminate"})
+```
+
+## Other capture passes
+
+The `set_pass_masks` command can enable multiple **image passes**. So far, we've only reviewed the `_img` pass but other passes such as the `_id` segmentation color pass are possible. [Read this for more information.](../../api/command_api.md#set_pass_masks)
+
+## jpg vs. png encoding
+
+The `_img` capture pass is standard rendering of the scene. It can be encoded as a lossless png or a lossy jpg. Generally, jpg encoding is significantly faster than png encoding and good enough for most use cases. To enable jpg encoding, send [`{"$type": "set_img_pass_encoding", "value": False}`](../../api/command_api.md#set_img_pass_encoding) or set `png=False` in the `ImageCapture` constructor. 
+
+Larger jpg images will look better than smaller images, as will larger images that are later resized to be smaller.
+
+| `_img` pass (png)        | `_img` pass (jpg)        |
+| ------------------------ | ------------------------ |
+| ![](images/img_0000.png) | ![](images/img_0000.jpg) |
+
+***
+
+**This is the last document in the "Core Concepts" section. We recommend you next read our guide on [troubleshooting and good coding practices in TDW](TODO).**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [send_images.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/core_concepts/send_images.py) Capture an image and save it to disk.
+- [image_capture.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/core_concepts/image_capture.py) Example implementation of the ImageCapture add-on.
+- [panorama.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/core_concepts/panorama.py) Capture a series of images around a model to form a 360-degree panorama.
+
+Python API:
+
+- [`ImageCapture`](../../python/add_ons/image_capture.md)  (add-on that saves images every frame)
+- [`TDWUtils.save_images(images, filename, output_directory)`](../../python/tdw_utils.md)  (Save all capture passes)
+- [`TDWUtils.get_pil_image(images, index)`](../../python/tdw_utils.md)  (Convert a capture pass to a PIL image)
+
+Command API:
+
+- [`set_pass_masks`](../../api/command_api.md#set_pass_masks)
+- [`send_images`](../../api/command_api.md#send_images)
+- [`set_img_pass_encoding`](../../api/command_api.md#set_img_pass_encoding)
+
+Output Data API:
+
+- [`Images`](../../api/output_data.md#Images) 
diff --git a/Documentation/lessons/core_concepts/images/add_object.png b/Documentation/lessons/core_concepts/images/add_object.png
new file mode 100644
index 000000000..a4c2eada2
Binary files /dev/null and b/Documentation/lessons/core_concepts/images/add_object.png differ
diff --git a/Documentation/lessons/core_concepts/images/avatar.png b/Documentation/lessons/core_concepts/images/avatar.png
new file mode 100644
index 000000000..cf6884d60
Binary files /dev/null and b/Documentation/lessons/core_concepts/images/avatar.png differ
diff --git a/Documentation/lessons/core_concepts/images/avatar_move.gif b/Documentation/lessons/core_concepts/images/avatar_move.gif
new file mode 100644
index 000000000..b4ec451b1
Binary files /dev/null and b/Documentation/lessons/core_concepts/images/avatar_move.gif differ
diff --git a/Documentation/lessons/core_concepts/images/avatar_rot.png b/Documentation/lessons/core_concepts/images/avatar_rot.png
new file mode 100644
index 000000000..2f717bbe7
Binary files /dev/null and b/Documentation/lessons/core_concepts/images/avatar_rot.png differ
diff --git a/Documentation/lessons/core_concepts/images/avatar_tdwutils.png b/Documentation/lessons/core_concepts/images/avatar_tdwutils.png
new file mode 100644
index 000000000..39f50d4b1
Binary files /dev/null and b/Documentation/lessons/core_concepts/images/avatar_tdwutils.png differ
diff --git a/Documentation/lessons/core_concepts/images/avatar_y.png b/Documentation/lessons/core_concepts/images/avatar_y.png
new file mode 100644
index 000000000..d809e71c4
Binary files /dev/null and b/Documentation/lessons/core_concepts/images/avatar_y.png differ
diff --git a/Documentation/lessons/core_concepts/images/empty_room.png b/Documentation/lessons/core_concepts/images/empty_room.png
new file mode 100644
index 000000000..58b5f24c2
Binary files /dev/null and b/Documentation/lessons/core_concepts/images/empty_room.png differ
diff --git a/Documentation/lessons/core_concepts/images/img_0000.jpg b/Documentation/lessons/core_concepts/images/img_0000.jpg
new file mode 100644
index 000000000..96f790ea4
Binary files /dev/null and b/Documentation/lessons/core_concepts/images/img_0000.jpg differ
diff --git a/Documentation/lessons/core_concepts/images/img_0000.png b/Documentation/lessons/core_concepts/images/img_0000.png
new file mode 100644
index 000000000..30ce2b0db
Binary files /dev/null and b/Documentation/lessons/core_concepts/images/img_0000.png differ
diff --git a/Documentation/lessons/core_concepts/images/img_pass_box.png b/Documentation/lessons/core_concepts/images/img_pass_box.png
new file mode 100644
index 000000000..782421bca
Binary files /dev/null and b/Documentation/lessons/core_concepts/images/img_pass_box.png differ
diff --git a/Documentation/lessons/core_concepts/images/network.png b/Documentation/lessons/core_concepts/images/network.png
new file mode 100644
index 000000000..ef4383dd7
Binary files /dev/null and b/Documentation/lessons/core_concepts/images/network.png differ
diff --git a/Documentation/lessons/core_concepts/images/network.xml b/Documentation/lessons/core_concepts/images/network.xml
new file mode 100644
index 000000000..ed692b7b6
--- /dev/null
+++ b/Documentation/lessons/core_concepts/images/network.xml
@@ -0,0 +1,1 @@
+<mxfile modified="2021-08-05T13:21:51.650Z" host="app.diagrams.net" agent="5.0 (Windows)" etag="pe8KcqgMRze-TzI5RA7L" version="14.9.2" type="device"><diagram id="Lc5a7oOPgw0o8sccFI__" name="Page-1">1VfLctowFP0alsn4bbIMkKSLPpihnTx2whK2GtnyyDLY+frK9jW2MBBoaSdlwfgeXVnSPecewciexsWDQGn0hWPCRpaBi5E9G1nWeOyr7wooG8C13QYIBcUNZHbAgr4RAA1Ac4pJpiVKzpmkqQ4GPElIIDUMCcE3etqKM33VFIVkACwCxIboI8UyAtQxjG7gE6FhBEuPXRiIUZsMQBYhzDc9yL4b2VPBuWye4mJKWFW7ti7NvPsDo9uNCZLIUybMv77cI3rFpj/c7554Kh0xe7wCMjJZtgcmWJ0fQi5kxEOeIHbXoZMO/cx5qtJMBf4kUpZAH8olV1AkYwajpKDyST0b1y5Ez1UEz7OiH5RtkEhRwiTfaoHn/mg3sY60mXMiaEwkEQAGuVgTDPvJpOCvW0Krl68oY1POuKirYBMTu8TfZvZGbjzfRp4aGdYfKMl4LgJypOitjpEIiTySZzV5FSO9BYDdB8LV8USpEgRhSNK1rlgEwg+3eTD1VghU9hJSThOZ9d48rwCVAC1seSBg6GDL2NHZTr7j3RzLVw/NDtqod5QOqrV7ho5bu1gjlkMdpjyOUYKzgcIlKaQuT0Ey+oaWdUKlFSiJynYnI3emEMRomCggUGRXkpqsiZBU2cQtDMQU47o7GFoSNkHBayh4nuCeclb1Z69yjvVmtRIp9pkd7FjzE00YMOvKuDZsG+aeJ5aO3TaFr1aZUq3O6GU4HFCozqnajzFV8V0S6+Ju+3kTUUkWKarbbqMuIp3f85t7pVZuXXCP05oHqDlIQdsE26ZwIN70LpX2poj694lxmCGNgHOr7f+G82seerFrwDz5GuhfAuY7l8C7Fq80MMbOPhWMraXt/ZnFWydavPkhLN7ZsXjbO27xu1fCTv5fsvihP3zLZZpX+5ohif5bn/cv5fPK5i3/o7u8NWBxmVP1o/ySBn9Ca1/a4J1/aPAq7P42NLx0/73su18=</diagram></mxfile>
\ No newline at end of file
diff --git a/Documentation/lessons/core_concepts/images/perlin_noise.png b/Documentation/lessons/core_concepts/images/perlin_noise.png
new file mode 100644
index 000000000..d8cdfd48e
Binary files /dev/null and b/Documentation/lessons/core_concepts/images/perlin_noise.png differ
diff --git a/Documentation/lessons/core_concepts/images/red_box.png b/Documentation/lessons/core_concepts/images/red_box.png
new file mode 100644
index 000000000..a63d7cd45
Binary files /dev/null and b/Documentation/lessons/core_concepts/images/red_box.png differ
diff --git a/Documentation/lessons/core_concepts/images/tdw_room.png b/Documentation/lessons/core_concepts/images/tdw_room.png
new file mode 100644
index 000000000..3137cb0e3
Binary files /dev/null and b/Documentation/lessons/core_concepts/images/tdw_room.png differ
diff --git a/Documentation/lessons/core_concepts/images/third_person_camera.png b/Documentation/lessons/core_concepts/images/third_person_camera.png
new file mode 100644
index 000000000..1a7bd0b0e
Binary files /dev/null and b/Documentation/lessons/core_concepts/images/third_person_camera.png differ
diff --git a/Documentation/lessons/core_concepts/launch_build.md b/Documentation/lessons/core_concepts/launch_build.md
new file mode 100644
index 000000000..1af6461a3
--- /dev/null
+++ b/Documentation/lessons/core_concepts/launch_build.md
@@ -0,0 +1,41 @@
+##### Setup
+
+# Auto-launching the TDW build
+
+By default, a TDW controller will automatically launch a build when it runs. **However, the build won't work if auto-launched on a Linux server.** This is because it's too difficult to automatically predict the display settings of a server.
+
+This will work if the build is running on a personal computer:
+
+```python
+from tdw.controller import Controller
+
+c = Controller()
+c.communicate({"$type": "terminate"})
+```
+
+On a Linux server, you must set the `launch_build` parameter of the Controller constructor to `False`:
+
+```python
+from tdw.controller import Controller
+
+c = Controller(launch_build=False)
+c.communicate({"$type": "terminate"})
+```
+
+Then, download, extract, and run the [latest version of the build](https://github.com/threedworld-mit/tdw/releases/latest/).
+
+All of the code snippets in the TDW documentation are set up to automatically launch the build. If you want to try running them on a Linux server, just remember to add `launch_build=False` to the controller constructor.
+
+## Desktop computers
+
+You can set `launch_build=False` on a personal computer as well (in which case you'll need to [download the build manually](https://github.com/threedworld-mit/tdw/releases/latest/)).
+
+**If you are using OS X, you'll need to run the shellscript located in the same directory as TDW.app before running TDW for the first time.** This is handled automatically if `launch_build=True`.
+
+If you've already launched the build automatically via `Controller()`, the location of the build is always `~/tdw_build` where `~` is your home directory.
+
+***
+
+**Next: [Commands](commands.md)**
+
+[Return to the README](../../../README.md)
\ No newline at end of file
diff --git a/Documentation/lessons/core_concepts/objects.md b/Documentation/lessons/core_concepts/objects.md
new file mode 100644
index 000000000..36134acb2
--- /dev/null
+++ b/Documentation/lessons/core_concepts/objects.md
@@ -0,0 +1,261 @@
+##### Core Concepts
+
+# Objects
+
+**Objects** in TDW are 3D models that can be placed in the scene by the controller. By default, they will also respond to TDW's rendering and physics engine (though this can be toggled off if needed). A **model** is a non-instanced object; in TDW, the controller downloads a model and instantiates it as an object.
+
+Each object has an **object ID**, an integer that is assigned by the controller and is used by subsequent commands that reference objects.
+
+To add an object to the scene, send the [`add_object`](../../api/command_api.md#add_object) command or use the `Controller.get_add_object()` wrapper function:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+c = Controller()
+
+# Generate a unique object ID.
+object_id = c.get_unique_id()
+# Add a camera and look at the object (we haven't added the object yet but this will execute after adding the object).
+cam = ThirdPersonCamera(position={"x": 2, "y": 1.6, "z": -0.6},
+                        look_at=object_id)
+c.add_ons.append(cam)
+# Create the scene and add the object.
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               c.get_add_object(model_name="iron_box",
+                                library="models_core.json",
+                                position={"x": 1, "y": 0, "z": -0.5},
+                                object_id=object_id)])
+```
+
+Result:
+
+![](images/add_object.png)
+
+The above example is equivalent to this example, which doesn't use the `get_add_object()` wrapper function:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+c = Controller()
+
+# Generate a unique object ID.
+object_id = c.get_unique_id()
+# Add a camera and look at the object (we haven't added the object yet but this will execute after adding the object).
+cam = ThirdPersonCamera(position={"x": 2, "y": 1.6, "z": -0.6},
+                        look_at=object_id)
+c.add_ons.append(cam)
+# Create the scene and add the object.
+# Change the URL to your operating system: windows, osx, or linux.
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               {"$type": "add_object",
+                "name": "iron_box",
+                "url": "https://tdw-public.s3.amazonaws.com/models/windows/2018-2019.1/iron_box",
+                "scale_factor": 1.0,
+                "position": {"x": 1, "y": 0, "z": -0.5},
+                "rotation": {"x": 0, "y": 0, "z": 0},
+                "category": "box",
+                "id": object_id}])
+```
+
+## Object positions
+
+Every object's pivot is at its **bottom-center**. To place an object on the ground, set the `y` coordinate of its position to 0.
+
+## Objects and memory management
+
+Like scenes, objects are stored as **asset bundles**. All of TDW's object asset bundles are stored on a remote S3 server. At runtime, the asset bundles are downloaded into memory. When the build process ends, the asset bundles are removed from memory.
+
+It might take some time to download and load any particular object into TDW, but only the first time the object is added to the scene; subsequent copies of the object will load into the scene nearly instantaneously.
+
+In this example, multiple instances of the same sofa model will be added to the scene. The first takes a couple of seconds to download; the rest are loaded in much faster:
+
+```python
+from time import time
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+c.communicate(TDWUtils.create_empty_room(12, 12))
+# Add a bunch of sofas.
+y = 0
+for i in range(10):
+    object_id = c.get_unique_id()
+    t0 = time()
+    c.communicate(c.get_add_object("arflex_hollywood_sofa",
+                                   object_id=object_id,
+                                   position={"x": 0, "y": y, "z": 0}))
+    y += 10
+    print(time() - t0)
+c.communicate({"$type": "terminate"})
+```
+
+## Model metadata records and the `ModelLibrarian`
+
+The `add_object` command can  be cumbersome to use because of all of its parameters. To that end, TDW stores metadata records for each of its models in a `ModelLibrarian`(../../python/librarian/model_librarian.md). To fetch the record of `iron_box` (the object in the previous example), create a `ModelLibrarian` and call `get_record()`:
+
+```python
+from tdw.librarian import ModelLibrarian
+
+librarian = ModelLibrarian()
+record = librarian.get_record("iron_box")
+
+# iron_box
+print(record.name)
+# Prints the URL for your operating system.
+# For example, if you're using Linux, this will print record.urls["Linux"]
+print(record.get_url())
+
+# Prints the object's semantic category. In this case: box
+print(record.wcategory)
+```
+
+Every object in the model librarian has been semantically tagged by category: the `wcategory` and `wnid` parameters are the object's WordNet category and ID values.
+
+Records are stored in a list: `librarian.records`. You can iterate through the list:
+
+```python
+from tdw.librarian import ModelLibrarian
+
+librarian = ModelLibrarian()
+for record in librarian.records:
+    if record.wcategory == "box":
+        print(record.name)
+```
+
+There are many other useful parameters in a model record ([read this for more information](../../python/librarian/model_librarian.md)) but one worth nothing right away is `do_not_use`. Most of TDW's models were purchased from a third party and sometimes they have problems. If we've flagged a model as `do_not_use`, don't use it! We will fix the model in the near future.
+
+```python
+from tdw.librarian import ModelLibrarian
+
+librarian = ModelLibrarian()
+record = librarian.get_record("iron_box")
+
+for record in librarian.records:
+    if record.do_not_use:
+        print(record.name)
+```
+
+## Other model libraries
+
+ The default model librarian is the "core" model library. The objects in this library are publicly available and free:
+
+```python
+from tdw.librarian import ModelLibrarian
+
+# These are the same.
+lib_0 = ModelLibrarian()
+lib_1 = ModelLibrarian("models_core.json")
+```
+
+There are other model libraries as well; it is possible to use your own models in TDW and to generate your own metadata libraries for those models. See [Model Libraries in the README](../../../README.md).
+
+## Cached model librarians
+
+Every time `Controller.get_add_object()` is called, it will evaluate the optional `library` parameter (the default value is `"models_core.json"`). The controller will then load and cache the library if it hasn't done so already. This is beneficial for performance reasons but it can be also be useful at runtime for checking record data. Cached model librarians are stored in a dictionary: `Controller.MODEL_LIBRARIANS`:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+print(Controller.MODEL_LIBRARIANS)
+
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               c.get_add_object(model_name="rh10",
+                                object_id=0,
+                                library="models_core.json")])
+print(Controller.MODEL_LIBRARIANS)
+c.communicate({"$type": "terminate"})
+```
+
+Output:
+
+```
+{}
+{'models_core.json': <tdw.librarian.ModelLibrarian object at 0x000002191EA98F48>}
+```
+
+## How to get images of every model in the library
+
+The TDW repo includes [a controller that will create images of every object in the model library](https://github.com/threedworld-mit/tdw/blob/master/Python/screenshotter.py):
+
+1. `cd path/to/tdw/Python` (replace `path/to` with the actual path)
+2. `python3 screenshotter.py`
+3. [Launch the build manually](launch_build.md)
+
+Images will be saved to `~/TDWImages` (where `~` is your home directory).
+
+You can review the images with the [TDW Model Visualizer](https://github.com/threedworld-mit/tdw_visualizers).
+
+## Object commands
+
+There are [many object commands in TDW](../../api/command_api.md) (see "Object Command"). Each of these commands refers to the object using its integer ID. This example will create an `iron_box` object and then change its color to red:
+
+```python
+from time import sleep
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+"""
+Add a box and make it red.
+"""
+
+c = Controller()
+
+# Generate a unique object ID.
+object_id = c.get_unique_id()
+# Add a camera and look at the object (we haven't added the object yet but this will execute after adding the object).
+cam = ThirdPersonCamera(position={"x": 2, "y": 1.6, "z": -0.6},
+                        look_at=object_id)
+c.add_ons.append(cam)
+# Create the scene, add the object, and make the object red.
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               c.get_add_object(model_name="iron_box",
+                                library="models_core.json",
+                                position={"x": 1, "y": 0, "z": -0.5},
+                                object_id=object_id),
+               {"$type": "set_color",
+                "color": {"r": 1.0, "g": 0, "b": 0, "a": 1.0},
+                "id": object_id}])
+sleep(2)
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/red_box.png)
+
+## Scenes and objects
+
+Some streamed scenes include objects such as benches, tables, etc. These objects are *static*--they won't move or response to any of the object-related commands in the API. The basic distinction between scenes and objects is that scenes are treated as background objects while objects can be dynamically created and manipulated by a controller.
+
+***
+
+**Next: [Output data](output_data.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [red_box.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/core_concepts/red_box.py) Add a box and make it red.
+
+Python API:
+
+- [`Controller.get_unique_id()` and `Controller.get_add_object(model_name, object_id, position, rotation, library)`](../../python/controller.md)
+- [`ModelLibrarian`](../../python/librarian/model_librarian.md)
+
+Command API:
+
+- [`add_object`](../../api/command_api.md#add_object)
+- [`set_color`](../../api/command_api.md#set_color)
+
+Utility applications:
+
+- [TDW Model Visualizer](https://github.com/threedworld-mit/tdw_visualizers)
diff --git a/Documentation/lessons/core_concepts/output_data.md b/Documentation/lessons/core_concepts/output_data.md
new file mode 100644
index 000000000..5afa011cb
--- /dev/null
+++ b/Documentation/lessons/core_concepts/output_data.md
@@ -0,0 +1,247 @@
+##### Core Concepts
+
+# Output data
+
+So far we've covered how to send **commands** to the build. This document covers how to receive **output data** from the build.
+
+`Controller.communicate()` always returns a list of byte arrays sent by the build at the end of the frame. By default, this list has only one element: the frame number.
+
+```python
+from tdw.controller import Controller
+
+c = Controller()
+resp = c.communicate({"$type": "do_nothing"})
+
+print(resp)  # [b'\x00\x00\x00\x02']
+print(c.get_frame(resp[0]))  # 2
+
+c.communicate({"$type": "terminate"})
+```
+
+The controller must explicitly request each type of output data from the build. This way, the controller can run efficiently because it isn't forced to receive data it doesn't need.
+
+Unlike commands, which are serialized JSON dictionaries that get converted to C# classes in the build, output data is always serialized Flatbuffer byte arrays. The main difference is that output isn't a dictionary and can't be read as such. The output data byte arrays are deserialized into Python classes.
+
+Each output data byte array has a four-byte string identifier. Use this identifier to determine how to deserialize the output data.
+
+In this example, the controller creates a simple scene, requests [`SceneRegions` output data](../../api/output_data.md#SceneRegions) via the [`send_scene_regions` command](../../api/command_api.md#send_scene_regions), and prints the bounds of each region in the scene (a region is a rectangular space in a scene; in interior scenes, they usually correspond to rooms).
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, SceneRegions
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+c = Controller()
+
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      {"$type": "send_scene_regions"}])
+for i in range(len(resp) - 1):
+    # Get the output data ID.
+    r_id = OutputData.get_data_type_id(resp[i])
+    # This is scene regions output data.
+    if r_id == "sreg":
+        scene_regions = SceneRegions(resp[i])
+        # Print the bounds.
+        for j in range(scene_regions.get_num()):
+            print(j, scene_regions.get_bounds(j))
+c.communicate({"$type": "terminate"})
+```
+
+Note that each "region" isn't a separate object or stored in a convenient dictionary. Flatbuffer doesn't support nested serialization.
+
+Also, note that we don't actually need an avatar in the scene to get `SceneRegions` output data. The only data that requires an avatar in the scene is data actually captured by the avatar (such as `Images`).
+
+## Per-frame output data
+
+Suppose we want to drop an object, record its position as it falls, and end the simulation when it stops moving.
+
+We already know how to set up the scene. Note that the object's `y` value is high above the floor level:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+object_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name="iron_box",
+                             position={"x": 0, "y": 6, "z": 0},
+                             rotation={"x": 25, "y": 38, "z": -10},
+                             object_id=object_id)]
+```
+
+To record the position of the object per-frame, send the [`send_transforms` command](../../api/command_api.md#send_transforms). The `"frequency"` parameter can be set to `"once"` (receive data only on this frame), `"always"` (receive data per frame), and `"never"` (stop receiving data). The `"ids"` parameter is optional. If you don't include it, you'll receive data for *all* objects in the scene.
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+object_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name="iron_box",
+                             position={"x": 0, "y": 6, "z": 0},
+                             rotation={"x": 25, "y": 38, "z": -10},
+                             object_id=object_id),
+            {"$type": "send_transforms",
+             "frequency": "always",
+             "ids": [object_id]}]
+```
+
+To determine if the object has stopped moving, we'll also need to send  [`send_rigidbodies`](../../api/command_api.md#send_rigidbodies):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+object_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name="iron_box",
+                             position={"x": 0, "y": 6, "z": 0},
+                             rotation={"x": 25, "y": 38, "z": -10},
+                             object_id=object_id),
+            {"$type": "send_transforms",
+             "frequency": "always",
+             "ids": [object_id]},
+            {"$type": "send_rigidbodies",
+             "frequency": "always",
+             "ids": [object_id]}]
+```
+
+Now we will run the simulation in a loop. Per frame, the controller will receive [`Transforms`](../../api/output_data.md#Transforms), which it will used to log the position of the object, and  [`Rigidbodies`](../../api/output_data.md#Rigidbodies), which it will use to determine if the object has stopped moving:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, Transforms, Rigidbodies
+
+c = Controller()
+object_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name="iron_box",
+                             position={"x": 0, "y": 6, "z": 0},
+                             rotation={"x": 25, "y": 38, "z": -10},
+                             object_id=object_id),
+            {"$type": "send_transforms",
+             "frequency": "always",
+             "ids": [object_id]},
+            {"$type": "send_rigidbodies",
+             "frequency": "always",
+             "ids": [object_id]}]
+
+# Send the commands.
+resp = c.communicate(commands)
+
+# The position of the object per frame.
+positions = []
+# If True, the object has stopped moving.
+sleeping = False
+
+# Wait for the object to stop moving.
+while not sleeping:
+    for i in range(len(resp) - 1):
+        # Get the output data ID.
+        r_id = OutputData.get_data_type_id(resp[i])
+        # This is transforms output data.
+        if r_id == "tran":
+            transforms = Transforms(resp[i])
+            for j in range(transforms.get_num()):
+                if transforms.get_id(j) == object_id:
+                    # Log the position.
+                    positions.append(transforms.get_position(j))
+        elif r_id == "rigi":
+            rigidbodies = Rigidbodies(resp[i])
+            for j in range(rigidbodies.get_num()):
+                if rigidbodies.get_id(j) == object_id:
+                    # Check if the object is sleeping.
+                    sleeping = rigidbodies.get_sleeping(j)
+    # Advance another frame and continue the loop.
+    if not sleeping:
+        resp = c.communicate([])
+print(positions)
+c.communicate({"$type": "terminate"})
+```
+
+## The `ObjectManager` add-on
+
+TDW includes an [`ObjectManager` add-on](../../python/add_ons/object_manager.md) that reorganizes static and per-frame object output data. The trade-off is that `ObjectManager` is less flexible than managing this data yourself; it can only return object data for *every* object in the scene and can only do so *always* or *never*. Note that it can optionally include [`Bounds`](../../api/output_data.md#Bounds) output data.
+
+This example does nearly the same thing as the previous example:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.object_manager import ObjectManager
+
+c = Controller()
+om = ObjectManager(transforms=True, rigidbodies=True, bounds=False)
+c.add_ons.append(om)
+
+# Send the commands.
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(model_name="iron_box",
+                                       position={"x": 0, "y": 6, "z": 0},
+                                       rotation={"x": 25, "y": 38, "z": -10},
+                                       object_id=c.get_unique_id())])
+
+# Print the name and category of each object.
+for object_id in om.objects_static:
+    print(object_id, om.objects_static[object_id].name, om.objects_static[object_id].category)
+
+# Run the simulation until all objects stop moving.
+positions = dict()
+sleeping = False
+while not sleeping:
+    sleeping = True
+    for object_id in om.rigidbodies:
+        if not om.rigidbodies[object_id].sleeping:
+            sleeping = False
+    # Remember the position
+    for object_id in om.transforms:
+        if object_id not in positions:
+            positions[object_id] = list()
+        positions[object_id].append(om.transforms[object_id].position)
+    # Advance once frame.
+    c.communicate([])
+print(positions)
+c.communicate({"$type": "terminate"})
+```
+
+## Other output data
+
+This document covers only a fraction of the possible output data available in TDW. The next document will explain image output data.
+
+**[Read the Output Data API documentation here.](../../api/output_data.md)**
+
+***
+
+**Next: [Images](images.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [object_output_data.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/core_concepts/object_output_data.py) Receive object output data.
+- [object_manager.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/core_concepts/object_manager.py) Example implementation of an `ObjectManager`.
+
+Python API:
+
+- [`ObjectManager`](../../python/add_ons/object_manager.md)
+
+Command API:
+
+- [`send_scene_regions`](../../api/command_api.md#send_scene_regions)
+- [`send_transforms`](../../api/command_api.md#send_transforms)
+- [`send_rigidbodies`](../../api/command_api.md#send_rigidbodies)
+- [`send_bounds`](../../api/command_api.md#send_bounds)
+
+Output Data API:
+
+- [`SceneRegions`](../../api/output_data.md#SceneRegions) 
+- [`Transforms`](../../api/output_data.md#Transforms) 
+- [`Rigidbodies`](../../api/output_data.md#Rigidbodies) 
+- [`Bounds`](../../api/output_data.md#Bounds) 
diff --git a/Documentation/lessons/core_concepts/scenes.md b/Documentation/lessons/core_concepts/scenes.md
new file mode 100644
index 000000000..96f34962e
--- /dev/null
+++ b/Documentation/lessons/core_concepts/scenes.md
@@ -0,0 +1,176 @@
+##### Core Concepts
+
+# Scenes
+
+A **scene** is a static environment in a TDW simulation. It usually contains objects such as an empty room, outdoor terrain, etc. It might include additional 3D meshes such as trees, houses, etc., but these meshes are also static (they can't be moved or adjusted). Scenes can contain [objects](objects.md), [avatars](avatars.md), and other non-static entities.
+
+For most of the [TDW Command API](commands.md) to work, you must add a scene to the simulation. There can never be more than one scene in TDW; loading a new scene will discard the previous scene.
+
+There are several ways to load a scene in TDW:
+
+## Optional A: Procedurally generated indoor environment
+
+Creating a procedurally-generated interior scene (often abbreviated to "proc-gen room") in TDW requires several commands:
+
+2. [`create_exterior_walls`](../../api/command_api.md#create_exterior_walls) to create a room with exterior walls.
+3. (Optional) [`create_interior_walls`](../../api/command_api.md#create_interior_walls) to add create interior walls.
+4. (Optional) [There are many other proc-gen room commands as well.](../../api/command_api.md#ProcGenRoomCommand)
+
+These commands are relatively cumbersome to use, so TDW includes simple wrapper functions.
+
+This controller:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+c.communicate(TDWUtils.create_empty_room(12, 12))
+```
+
+...does the exact same thing as this controller:
+
+```python
+from tdw.controller import Controller
+
+c = Controller()
+c.communicate({'$type': 'create_exterior_walls',
+               'walls': [{'x': 0, 'y': 0}, {'x': 0, 'y': 1}, {'x': 0, 'y': 2}, {'x': 0, 'y': 3}, {'x': 0, 'y': 4}, {'x': 0, 'y': 5}, {'x': 0, 'y': 6}, {'x': 0, 'y': 7}, {'x': 0, 'y': 8}, {'x': 0, 'y': 9}, {'x': 0, 'y': 10}, {'x': 0, 'y': 11}, {'x': 1, 'y': 0}, {'x': 1, 'y': 11}, {'x': 2, 'y': 0}, {'x': 2, 'y': 11}, {'x': 3, 'y': 0}, {'x': 3, 'y': 11}, {'x': 4, 'y': 0}, {'x': 4, 'y': 11}, {'x': 5, 'y': 0}, {'x': 5, 'y': 11}, {'x': 6, 'y': 0}, {'x': 6, 'y': 11}, {'x': 7, 'y': 0}, {'x': 7, 'y': 11}, {'x': 8, 'y': 0}, {'x': 8, 'y': 11}, {'x': 9, 'y': 0}, {'x': 9, 'y': 11}, {'x': 10, 'y': 0}, {'x': 10, 'y': 11}, {'x': 11, 'y': 0}, {'x': 11, 'y': 1}, {'x': 11, 'y': 2}, {'x': 11, 'y': 3}, {'x': 11, 'y': 4}, {'x': 11, 'y': 5}, {'x': 11, 'y': 6}, {'x': 11, 'y': 7}, {'x': 11, 'y': 8}, {'x': 11, 'y': 9}, {'x': 11, 'y': 10}, {'x': 11, 'y': 11}]})
+
+```
+
+...which will create this scene:
+
+![](images/empty_room.png)
+
+**Note: The scene won't be actually be visible.** [The next page](avatars.md) will cover how to add rendering to TDW.
+
+## Option B: Download and load a streamed scene
+
+TDW includes many pre-generated photorealistic scenes. These scenes exist on a remote Amazon S3 server as ***asset bundles***, which are Unity3D-specific binary files that can be loaded into a Unity3D application (e.g. the TDW build) at runtime. To access a scene asset bundle, TDW downloads the scene into active memory (not to a local file).
+
+Scene asset bundles can be quite large; expect downloads to require up to several minutes. When a scene is loaded into TDW, the previous scene (if any) is discarded from memory; if you want to use it again, TDW will need to re-download it.
+
+To add a streamed scene to your simulation, send  [`add_scene`](../../api/command_api.md#add_scene). Because this command's parameters can be difficult to manage, TDW includes a helpful [`get_add_scene()` wrapper function](../../python/controller.md):
+
+```python
+from tdw.controller import Controller
+
+c = Controller()
+c.communicate(c.get_add_scene(scene_name="tdw_room"))
+```
+
+...does the exact same thing as this controller:
+
+```python
+from tdw.controller import Controller
+
+c = Controller()
+c.communicate({'$type': 'add_scene',
+               'name': 'tdw_room',
+               'url': 'https://tdw-public.s3.amazonaws.com/scenes/linux/2020.2/tdw_room'})
+```
+
+...which will create this scene:
+
+![](images/tdw_room.png)
+
+### Scene metadata records and the `SceneLibrarian`
+
+All asset bundles in TDW (including scenes) have associated metadata records. These records are stored in the `tdw` module as json files.
+
+This is what the metadata for the scene `tdw_room` (the scene in the previous example) looks like:
+
+```json
+{'name': 'tdw_room', 
+ 'urls': {
+     'Darwin': 'https://tdw-public.s3.amazonaws.com/scenes/osx/2020.2/tdw_room', 
+     'Linux': 'https://tdw-public.s3.amazonaws.com/scenes/linux/2020.2/tdw_room',
+     'Windows': 'https://tdw-public.s3.amazonaws.com/scenes/windows/2020.2/tdw_room'}, 
+ 'description': 'An interior space lit by sunlight from outside.', 
+ 'hdri': True, 
+ 'location': 'interior'}
+```
+
+TDW includes convenient wrapper classes for metadata records and collections of records. For scenes, records are stored in a [`SceneLibrarian`](../../python/librarian/scene_librarian.md#scenerecord-api). To access the record listed above:
+
+```python
+from tdw.librarian import SceneLibrarian
+
+librarian = SceneLibrarian()
+record = librarian.get_record("tdw_room")
+
+# tdw_room
+print(record.name)
+
+# Prints the URL for your operating system. 
+# For example, if you're using Linux, this will print record.urls["Linux"]
+print(record.get_url()) 
+```
+
+Records are stored in a list: `librarian.records`. You can iterate through the list. This will print the names of each streamed scene in TDW:
+
+```python
+from tdw.librarian import SceneLibrarian
+
+librarian = SceneLibrarian()
+for record in librarian.records:
+    print(record.name)
+```
+
+### Images of each scene
+
+**TODO**
+
+## Option C: Other options for creating a scene
+
+### Perlin noise terrain
+
+Send [`perlin_noise_terrain`](../../api/command_api.md#perlin_noise_terrain)  to generate a "terrain" mesh using Perlin noise. This controller:
+
+```python
+from tdw.controller import Controller
+
+c = Controller()
+c.communicate({"$type": "perlin_noise_terrain",
+               "size": {"x": 24, "y": 24},
+               "subdivisions": 1,
+               "turbulence": 1.75,
+               "origin": {"x": 0.5, "y": 0.5},
+               "texture_scale": {"x": 4, "y": 2},
+               "dynamic_friction": 0.25,
+               "static_friction": 0.4,
+               "bounciness": 0.2,
+               "max_y": 10})
+```
+
+...will create this scene:
+
+![](images/perlin_noise.png)
+
+### An empty scene
+
+It's possible to create a totally empty scene with the  [`create_empty_environment`](../../api/command_api.md#create_empty_environment) command. This is mainly for debugging TDW.
+
+***
+
+**Next: [Avatars and cameras](avatars.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Python API:
+
+- [`SceneLibrarian`](../../python/librarian/scene_librarian.md#scenerecord-api) (A collection of scene metadata records)
+- [`Controller.get_add_scene(scene_name)`](../../python/tdw_utils.md)
+- [`TDWUtils.create_empty_room(width, length)`](../../python/tdw_utils.md)
+
+Command API:
+
+- [`create_exterior_walls`](../../api/command_api.md#create_exterior_walls)
+- [`create_interior_walls`](../../api/command_api.md#create_interior_walls)
+- [`perlin_noise_terrain`](../../api/command_api.md#perlin_noise_terrain)
+- [`create_empty_environment`](../../api/command_api.md#create_empty_environment)
+- [`add_scene`](../../api/command_api.md#add_scene)
+
diff --git a/Documentation/lessons/objects_and_scenes/bounds.md b/Documentation/lessons/objects_and_scenes/bounds.md
new file mode 100644
index 000000000..8fb80a5a7
--- /dev/null
+++ b/Documentation/lessons/objects_and_scenes/bounds.md
@@ -0,0 +1,158 @@
+##### Objects and Scenes
+
+# `Bounds` output data
+
+[As noted earlier,](../core_concepts/objects.md) the object position returned by [`Transforms` output data](../../api/output_data.md#Transforms) is the **bottom-center** of the object. To get the *center* of the object, you need to request [`Bounds` output data](../../api/output_data.md#Bounds) with the [`send_bounds` command](../../api/command_api.md#send_bounds):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, Bounds
+
+c = Controller()
+object_id = c.get_unique_id()
+
+# Create the scene. Add an object. Request Bounds output data.
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(model_name="small_table_green_marble",
+                                       position={"x": 0, "y": 0, "z": 0},
+                                       object_id=object_id),
+                      {"$type": "send_bounds",
+                       "frequency": "once"}])
+
+# Get the center of the object.
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "boun":
+        bounds = Bounds(resp[i])
+        for j in range(bounds.get_num()):
+            if bounds.get_id(j) == object_id:
+                print(object_id, bounds.get_center(j))
+c.communicate({"$type": "terminate"})
+```
+
+The bounds data also includes the *top, bottom, left, right, front,* and *back* of each object. In this example, we'll use the *top* of the bounds to place another object on the table:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, Bounds
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+c = Controller()
+object_id = c.get_unique_id()
+
+# Add a camera and enable image capture.
+cam = ThirdPersonCamera(position={"x": 2.478, "y": 1.602, "z": 1.412},
+                        look_at={"x": 0, "y": 0.5, "z": 0},)
+output_directory = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("bounds")
+print(f"Images will be saved to: {output_directory.resolve()}")
+c.add_ons.append(cam)
+cap = ImageCapture(path=output_directory, avatar_ids=[cam.avatar_id], pass_masks=["_img"])
+c.add_ons.append(cap)
+# Create the scene. Add a table. Request Bounds output data.
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(model_name="small_table_green_marble",
+                                       position={"x": 0, "y": 0, "z": 0},
+                                       object_id=object_id),
+                      {"$type": "send_bounds",
+                       "frequency": "once"}])
+
+# Get the top of the table.
+top = (0, 0, 0)
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "boun":
+        bounds = Bounds(resp[i])
+        for j in range(bounds.get_num()):
+            if bounds.get_id(j) == object_id:
+                top = bounds.get_top(j)
+                break
+# Put an object on top of the table.
+c.communicate(c.get_add_object(model_name="jug01",
+                               position=TDWUtils.array_to_vector3(top),
+                               object_id=c.get_unique_id()))
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/object_on_table.jpg)
+
+## The `ObjectManager` add-on
+
+We've seen the [`ObjectManager` add-on in the Core Concepts documentation.](../core_concepts/output_data.md) If you need to frequently use bounds data, you can use the `ObjectManager` which will conveniently sort all bounds data by object per frame. 
+
+Note that:
+
+- In this example, the `ObjectManager` will include data for the table but *not* for the object on the table because that object didn't exist when the `ObjectManager` was initialized.
+- Bounds data is returned by the build as (x, y, z) tuples but in `ObjectManager` data those tuples are automatically converted into numpy arrays.
+
+This controller does the exact same thing as the previous example but uses an `ObjectManager`:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+from tdw.add_ons.object_manager import ObjectManager
+
+c = Controller()
+object_id = c.get_unique_id()
+
+# Add a camera and enable image capture.
+cam = ThirdPersonCamera(position={"x": 2.478, "y": 1.602, "z": 1.412},
+                        look_at={"x": 0, "y": 0.5, "z": 0},)
+output_directory = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("bounds")
+print(f"Images will be saved to: {output_directory.resolve()}")
+c.add_ons.append(cam)
+cap = ImageCapture(path=output_directory, avatar_ids=[cam.avatar_id], pass_masks=["_img"])
+c.add_ons.append(cap)
+
+# Add an ObjectManager.
+om = ObjectManager(transforms=False, bounds=True, rigidbodies=True)
+c.add_ons.append(om)
+
+# Create the scene. Add a table. Request Bounds output data.
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(model_name="small_table_green_marble",
+                                       position={"x": 0, "y": 0, "z": 0},
+                                       object_id=object_id)])
+
+# Get the top of the table.
+top = om.bounds[object_id].top
+# Put an object on top of the table.
+c.communicate(c.get_add_object(model_name="jug01",
+                               position=TDWUtils.array_to_vector3(top),
+                               object_id=c.get_unique_id()))
+c.communicate({"$type": "terminate"})
+```
+
+***
+
+**Next: [Procedural generation (objects)](proc_gen_objects.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [object_on_table.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/objects_and_scenes/object_on_table.py) Put an object on a table using `Bounds` output data.
+
+Python API:
+
+- [`TDWUtils.array_to_vector3(arr)`](../../python/tdw_utils.md) Convert an x, y, z array or tuple to an x, y, z dictionary.
+- [`ObjectManager`](../../python/add_ons/object_manager.md)
+
+Command API:
+
+- [`send_bounds`](../../api/command_api.md#send_bounds)
+
+Output Data API:
+
+- [`Bounds`](../../api/output_data.md#Bounds) 
+
diff --git a/Documentation/lessons/objects_and_scenes/floorplans.md b/Documentation/lessons/objects_and_scenes/floorplans.md
new file mode 100644
index 000000000..57f9bec92
--- /dev/null
+++ b/Documentation/lessons/objects_and_scenes/floorplans.md
@@ -0,0 +1,85 @@
+##### Objects and Scenes
+
+# Scripted object placement (floorplan layouts)
+
+TDW includes a number of predefined **"floorplan layouts"**. These are interior scenes with multiple rooms. Each scene has several "variants" which are purely cosmetic (such as changing what the floors look like) and three "layouts". Each layout is a list of objects and their positions, masses, and so on.
+
+To access the floorplan layouts, use a [`Floorplan`](../../python/add_ons/floorplan.md) add-on. The following example will load a scene and populate it with a layout. This controller also sends [`set_floorplan_roof`](../../api/command_api.md#set_floorplan_roof) to hide the roof of the house so that we can see the interior:
+
+```python
+from tdw.controller import Controller
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.floorplan import Floorplan
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+
+"""
+Generate a floorplan scene and populate it with a layout of objects.
+"""
+
+c = Controller()
+
+# Initialize the floorplan add-on.
+floorplan = Floorplan()
+# Scene 1, visual variant a, object layout 0.
+floorplan.init_scene(scene="1a", layout=0)
+
+# Add a camera and enable image capture.
+camera = ThirdPersonCamera(position={"x": 0, "y": 40, "z": 0},
+                           look_at={"x": 0, "y": 0, "z": 0},
+                           avatar_id="a")
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("floorplan_controller")
+print(f"Images will be saved to: {path}")
+capture = ImageCapture(avatar_ids=["a"], pass_masks=["_img"], path=path)
+
+c.add_ons.extend([floorplan, camera, capture])
+# Make the image 720p and hide the roof.
+c.communicate([{"$type": "set_screen_size",
+                "width": 1280,
+                "height": 720},
+               {"$type": "set_floorplan_roof",
+                "show": False}])
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/floorplan.jpg)
+
+## Valid scene+layout combinations
+
+In the `scene` parameter, the number (1, 2, 4, or 5) defines the geometry of the house and the letter (a, b, or c) defines the visual variant of that house . The `layout` parameter sets which list of objects to load.
+
+| `scene` | `layout` |
+| --- | --- |
+| 1a, 1b, or 1c | 0, 1, or 2 |
+| 2a, 2b, or 2c | 0, 1, or 2 |
+| 4a, 4b, or 4c | 0, 1, or 2 |
+| 5a, 5b, or 5c | 0, 1, or 2 |
+
+## Images of every scene+layout combination
+
+[**Images of every scene+layout combination can be found here.**](https://github.com/threedworld-mit/tdw/blob/master/Documentation/lessons/objects_and_scenes/images/floorplans) 
+
+***
+
+**Next: [Visual materials, textures, and colors](materials_textures_colors.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [floorplan.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/objects_and_scenes/floorplan.py) Initialize a floorplan scene and populate it with objects.
+
+Python API:
+
+-  [`Floorplan`](../../python/add_ons/floorplan.md)
+
+Command API:
+
+- [`set_screen_size`](../../api/command_api.md#set_screen_size)
+- [`set_floorplan_roof`](../../api/command_api.md#set_floorplan_roof)
+
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplan.jpg b/Documentation/lessons/objects_and_scenes/images/floorplan.jpg
new file mode 100644
index 000000000..bffd63e3d
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplan.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/1a_0.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/1a_0.jpg
new file mode 100644
index 000000000..13f38e981
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/1a_0.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/1a_1.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/1a_1.jpg
new file mode 100644
index 000000000..6ec9107f8
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/1a_1.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/1a_2.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/1a_2.jpg
new file mode 100644
index 000000000..f8970cfc9
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/1a_2.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/1b_0.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/1b_0.jpg
new file mode 100644
index 000000000..af37184f7
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/1b_0.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/1b_1.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/1b_1.jpg
new file mode 100644
index 000000000..c65c43237
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/1b_1.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/1b_2.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/1b_2.jpg
new file mode 100644
index 000000000..278331e06
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/1b_2.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/1c_0.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/1c_0.jpg
new file mode 100644
index 000000000..9511db895
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/1c_0.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/1c_1.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/1c_1.jpg
new file mode 100644
index 000000000..bce335135
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/1c_1.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/1c_2.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/1c_2.jpg
new file mode 100644
index 000000000..1340627bf
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/1c_2.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/2a_0.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/2a_0.jpg
new file mode 100644
index 000000000..f621203b5
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/2a_0.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/2a_1.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/2a_1.jpg
new file mode 100644
index 000000000..7fb01fee2
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/2a_1.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/2a_2.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/2a_2.jpg
new file mode 100644
index 000000000..d26fa0c52
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/2a_2.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/2b_0.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/2b_0.jpg
new file mode 100644
index 000000000..72c9d98cf
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/2b_0.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/2b_1.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/2b_1.jpg
new file mode 100644
index 000000000..92860e26d
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/2b_1.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/2b_2.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/2b_2.jpg
new file mode 100644
index 000000000..d8fd545c2
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/2b_2.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/2c_0.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/2c_0.jpg
new file mode 100644
index 000000000..2aea8e486
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/2c_0.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/2c_1.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/2c_1.jpg
new file mode 100644
index 000000000..33c17482c
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/2c_1.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/2c_2.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/2c_2.jpg
new file mode 100644
index 000000000..e2b38c836
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/2c_2.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/4a_0.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/4a_0.jpg
new file mode 100644
index 000000000..e037b62ff
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/4a_0.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/4a_1.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/4a_1.jpg
new file mode 100644
index 000000000..7ca8d935c
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/4a_1.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/4a_2.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/4a_2.jpg
new file mode 100644
index 000000000..e44ff3bcc
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/4a_2.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/4b_0.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/4b_0.jpg
new file mode 100644
index 000000000..81c1d7720
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/4b_0.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/4b_1.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/4b_1.jpg
new file mode 100644
index 000000000..b3fa7be1c
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/4b_1.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/4b_2.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/4b_2.jpg
new file mode 100644
index 000000000..ec5e3b8ff
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/4b_2.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/4c_0.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/4c_0.jpg
new file mode 100644
index 000000000..7cc9ccc6f
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/4c_0.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/4c_1.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/4c_1.jpg
new file mode 100644
index 000000000..1a92ae88e
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/4c_1.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/4c_2.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/4c_2.jpg
new file mode 100644
index 000000000..60459c3ed
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/4c_2.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/5a_0.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/5a_0.jpg
new file mode 100644
index 000000000..b6614d518
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/5a_0.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/5a_1.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/5a_1.jpg
new file mode 100644
index 000000000..d71dbb2a1
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/5a_1.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/5a_2.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/5a_2.jpg
new file mode 100644
index 000000000..9125707f7
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/5a_2.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/5b_0.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/5b_0.jpg
new file mode 100644
index 000000000..3aefa036e
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/5b_0.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/5b_1.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/5b_1.jpg
new file mode 100644
index 000000000..974684728
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/5b_1.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/5b_2.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/5b_2.jpg
new file mode 100644
index 000000000..85986fa83
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/5b_2.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/5c_0.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/5c_0.jpg
new file mode 100644
index 000000000..604fd97ef
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/5c_0.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/5c_1.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/5c_1.jpg
new file mode 100644
index 000000000..23bf5dd25
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/5c_1.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/floorplans/5c_2.jpg b/Documentation/lessons/objects_and_scenes/images/floorplans/5c_2.jpg
new file mode 100644
index 000000000..6f177656a
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/floorplans/5c_2.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/object_on_table.jpg b/Documentation/lessons/objects_and_scenes/images/object_on_table.jpg
new file mode 100644
index 000000000..fc96a2d4d
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/object_on_table.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/overlap.jpg b/Documentation/lessons/objects_and_scenes/images/overlap.jpg
new file mode 100644
index 000000000..349efbdc0
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/overlap.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/proc_gen_objects.jpg b/Documentation/lessons/objects_and_scenes/images/proc_gen_objects.jpg
new file mode 100644
index 000000000..7943bbf0a
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/proc_gen_objects.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/proc_gen_room/0.jpg b/Documentation/lessons/objects_and_scenes/images/proc_gen_room/0.jpg
new file mode 100644
index 000000000..c9b008ce4
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/proc_gen_room/0.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/proc_gen_room/1.jpg b/Documentation/lessons/objects_and_scenes/images/proc_gen_room/1.jpg
new file mode 100644
index 000000000..eaf9f25ac
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/proc_gen_room/1.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/proc_gen_room/2.jpg b/Documentation/lessons/objects_and_scenes/images/proc_gen_room/2.jpg
new file mode 100644
index 000000000..92cc4ada8
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/proc_gen_room/2.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/proc_gen_room/3.jpg b/Documentation/lessons/objects_and_scenes/images/proc_gen_room/3.jpg
new file mode 100644
index 000000000..3dd6322b6
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/proc_gen_room/3.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/raycast/0.png b/Documentation/lessons/objects_and_scenes/images/raycast/0.png
new file mode 100644
index 000000000..4ca6c4665
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/raycast/0.png differ
diff --git a/Documentation/lessons/objects_and_scenes/images/raycast/1.png b/Documentation/lessons/objects_and_scenes/images/raycast/1.png
new file mode 100644
index 000000000..9bcdb1805
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/raycast/1.png differ
diff --git a/Documentation/lessons/objects_and_scenes/images/raycast/spherecast.png b/Documentation/lessons/objects_and_scenes/images/raycast/spherecast.png
new file mode 100644
index 000000000..0be8e0ca1
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/raycast/spherecast.png differ
diff --git a/Documentation/lessons/objects_and_scenes/images/set_visual_material/0.jpg b/Documentation/lessons/objects_and_scenes/images/set_visual_material/0.jpg
new file mode 100644
index 000000000..288e44c46
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/set_visual_material/0.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/set_visual_material/1.jpg b/Documentation/lessons/objects_and_scenes/images/set_visual_material/1.jpg
new file mode 100644
index 000000000..ab731f846
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/set_visual_material/1.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/images/set_visual_material/2.jpg b/Documentation/lessons/objects_and_scenes/images/set_visual_material/2.jpg
new file mode 100644
index 000000000..6dcc0a8ce
Binary files /dev/null and b/Documentation/lessons/objects_and_scenes/images/set_visual_material/2.jpg differ
diff --git a/Documentation/lessons/objects_and_scenes/materials_textures_colors.md b/Documentation/lessons/objects_and_scenes/materials_textures_colors.md
new file mode 100644
index 000000000..c80168fe3
--- /dev/null
+++ b/Documentation/lessons/objects_and_scenes/materials_textures_colors.md
@@ -0,0 +1,314 @@
+##### Objects and Scenes
+
+# Visual materials, textures, and colors
+
+Unity differentiates between _visual materials_ and _textures_ and _colors_; these three terms are _not_ interchangeable. This document will give an overview of some of the fundamental differences.
+
+- Every model in TDW is composed of *n*  **meshes**. Multiple meshes *does not* mean that a model has joints or affordances. Many of TDW's models were purchased from a third party and whether or not there are multiple "sub-meshes" is entirely dependent on how the original artist designed it.
+- In TDW,  a **substructure** is a metadata list of and object's meshes plus each mesh's visual material(s).
+- A **visual material** (often abbreviated to "material") is a binary file containing an initialized _shader_. A shader is a tiny program that is used to render a 3D object. The material shader might contain info such the *texture*, *color*, and what happens when light hits the material (i.e. its shininess, bumpiness, and so on). Every mesh in a TDW model has at least one material but may have more.
+- A **texture**  is a .jpg or .png file. It is one of many aspects of a material. In computer graphics, the term _texture_ is often used interchangeably with _map_. In TDW, most most materials have one albedo texture. There can be other textures as well, such as a normal map, which can adjust what happens when light hits a model (for example, by creating grooves or bumps even if the underlying mesh is smooth).
+- A material's albedo texture can be tinted by a **color**. Set the color of a model with [`set_color`](../../api/command_api.md#set_color); see [this document](../core_concepts/objects.md) for example implementation.
+
+## Material asset bundles
+
+TDW includes hundreds of visual materials. These materials are stored as asset bundles, just like [models](../core_concepts/objects.md).
+
+The metadata for each material is stored in a [`MaterialLibrarian`](../../python/librarian/material_librarian.md):
+
+```python
+from tdw.librarian import MaterialLibrarian
+
+librarian = MaterialLibrarian()
+record = librarian.records[0]
+print(record.name)  # 3d_mesh_technical_fabric
+```
+
+The default materials of each model don't have semantic classification, but the material asset bundles do:
+
+```python
+from tdw.librarian import MaterialLibrarian
+
+librarian = MaterialLibrarian()
+record = librarian.records[0]
+print(record.name, record.type)  # 3d_mesh_technical_fabric Metal
+```
+
+You can sort materials by their semantic type:
+
+```python
+from tdw.librarian import MaterialLibrarian
+
+librarian = MaterialLibrarian()
+for material_type in librarian.get_material_types():
+    print(material_type)
+    for record in librarian.get_all_materials_of_type(material_type):
+        print("    " + record.name)
+    print("")
+```
+
+Unlike TDW's Model Librarians which group models by broad categories (for example, free vs. non-free), the Material Librarians group materials by *quality*. Each material has three quality levels: "low", "med", and "high". Higher-quality materials will look slightly better but take longer to download and use more memory:
+
+```python
+from requests import head
+from tdw.librarian import MaterialLibrarian
+
+material = "3d_mesh_technical_fabric"
+for librarian_name in MaterialLibrarian.get_library_filenames():
+    librarian = MaterialLibrarian(librarian_name)
+    record = librarian.get_record(material)
+    size = head(record.get_url()).headers["Content-Length"]
+    print(material, librarian_name, size)
+```
+
+Output:
+
+```
+3d_mesh_technical_fabric materials_med.json 4519259
+3d_mesh_technical_fabric materials_low.json 1236628
+3d_mesh_technical_fabric materials_high.json 16810082
+```
+
+## How to get images of every material in the library
+
+The TDW repo includes [a controller that will create images of every material in the material library](https://github.com/threedworld-mit/tdw/blob/master/Python/screenshotter.py):
+
+1. `cd path/to/tdw/Python` (replace `path/to` with the actual path)
+2. `python3 screenshotter.py --type materials`
+3. [Launch the build manually](../core_concepts/launch_build.md)
+
+Images will be saved to `~/TDWImages` (where `~` is your home directory).
+
+You can review the images with the [TDW Material Visualizer](https://github.com/threedworld-mit/tdw_visualizers).
+
+## Set the visual material of an object
+
+To set a visual material, you need to do at least the following steps:
+
+1. Add an object to the scene.
+2. Get the object's model substructure.
+3. Get a material metadata record.
+4. Add the material to the scene with the [`add_material` command ](../../api/command_api.md#add_material).
+5. Set the visual material of one of the object's sub-mesh's materials with the [`set_visual_material` command](../../api/command_api.md#set_visual_material).
+
+Metadata for an object's **substructure** (see the beginning of this document) is stored in [model records](../../python/librarian/model_librarian.md):
+
+```python
+from tdw.librarian import ModelLibrarian
+
+librarian = ModelLibrarian()
+record = librarian.get_record("034_vray")
+print(record.substructure)
+```
+
+Output:
+
+```
+[{'materials': ['cgaxis_electronics_34_01'], 'name': 'cgaxis_electronics_34_01'}, {'materials': ['cgaxis_electronics_34_02'], 'name': 'cgaxis_electronics_34_02'}, {'materials': ['cgaxis_electronics_34_03'], 'name': 'cgaxis_electronics_34_03'}, {'materials': ['cgaxis_electronics_34_04'], 'name': 'cgaxis_electronics_34_04'}, {'materials': ['cgaxis_electronics_34_05'], 'name': 'cgaxis_electronics_34_05'}, {'materials': ['cgaxis_electronics_34_06'], 'name': 'cgaxis_electronics_34_06'}, {'materials': ['SolidTiles_Opaque'], 'name': 'cgaxis_electronics_34_07'}, {'materials': ['SolidTiles_Opaque'], 'name': 'cgaxis_electronics_34_08'}, {'materials': ['SolidTiles_Transparent'], 'name': 'cgaxis_electronics_34_09'}]
+```
+
+The low-level commands required for setting an object's visual material(s) are relatively complicated but TDW includes some wrapper functions to make it easier. For the sake of understanding how it works, we'll start with the low-level API calls and then explain the wrapper functions.
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.librarian import ModelLibrarian, MaterialLibrarian
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+c = Controller()
+object_id = c.get_unique_id()
+
+material_record = MaterialLibrarian("materials_low.json").get_record("parquet_long_horizontal_clean")
+model_record = ModelLibrarian().get_record("white_lounger_chair")
+cam = ThirdPersonCamera(position={"x": 2, "y": 1.6, "z": -0.6},
+                        look_at=object_id)
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("set_visual_material")
+print(f"Images will be saved to: {path}")
+cap = ImageCapture(avatar_ids=[cam.avatar_id], pass_masks=["_img"], path=path)
+c.add_ons.extend([cam, cap])
+
+# Create the scene, add the object, add a visual material, and set the visual material of one of the model's sub-meshes.
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               {"$type": "add_object",
+                "name": model_record.name,
+                "url": model_record.get_url(),
+                "scale_factor": model_record.scale_factor,
+                "position": {"x": 0, "y": 0, "z": 0},
+                "rotation": {"x": 0, "y": 0, "z": 0},
+                "category": model_record.wcategory,
+                "id": object_id},
+               {"$type": "add_material",
+                "name": material_record.name,
+                "url": material_record.get_url()},
+               {"$type": "set_visual_material",
+                "material_index": 0,
+                "material_name": material_record.name,
+                "object_name": "Object017",
+                "id": object_id}])
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/set_visual_material/0.jpg)
+
+Like the `add_object` command, which has a helpful wrapper function (`Controller.get_add_object()`) and the `add_scene` command which has a `Controller.get_add_scene()` wrapper function, `add_material` has a `Controller.get_add_material()` command which adds the material without needing to first fetching the record (the metadata is handled automatically by the function):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.librarian import ModelLibrarian
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+c = Controller()
+object_id = c.get_unique_id()
+
+model_record = ModelLibrarian().get_record("white_lounger_chair")
+material_name = "parquet_long_horizontal_clean"
+cam = ThirdPersonCamera(position={"x": 2, "y": 1.6, "z": -0.6},
+                        look_at=object_id)
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("set_visual_material")
+print(f"Images will be saved to: {path}")
+cap = ImageCapture(avatar_ids=[cam.avatar_id], pass_masks=["_img"], path=path)
+c.add_ons.extend([cam, cap])
+
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               c.get_add_object(model_name=model_record.name,
+                                object_id=object_id),
+               c.get_add_material(material_name=material_name),
+               {"$type": "set_visual_material",
+                "material_index": 0,
+                "material_name": material_name,
+                "object_name": "Object017",
+                "id": object_id}])
+c.communicate({"$type": "terminate"})
+```
+
+You can see, though, that `set_visual_material` is still potentially difficult to use as it requires you to know ahead of time where each sub-object is, what its name is (in this case, Object017), and how many materials it has by default.
+
+Unfortunately, TDW doesn't currently have a database of this information as creating one would require an huge amount of effort and time.
+
+You can, however, set *all* of an object's visual materials at once with the wrapper function `TDWUtils.set_visual_material()`:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.librarian import ModelLibrarian
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+c = Controller()
+object_id = c.get_unique_id()
+
+model_record = ModelLibrarian().get_record("white_lounger_chair")
+cam = ThirdPersonCamera(position={"x": 2, "y": 1.6, "z": -0.6},
+                        look_at=object_id)
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("set_visual_material")
+print(f"Images will be saved to: {path}")
+cap = ImageCapture(avatar_ids=[cam.avatar_id], pass_masks=["_img"], path=path)
+c.add_ons.extend([cam, cap])
+
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name=model_record.name,
+                             object_id=object_id),
+            c.get_add_material(material_name=material_name)]
+# Set all of the object's visual materials.
+commands.extend(TDWUtils.set_visual_material(c=c, substructure=model_record.substructure, material="parquet_long_horizontal_clean", object_id=object_id))
+c.communicate(commands)
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/set_visual_material/1.jpg)
+
+Each of these properties is a _channel_.
+
+Every mesh in Unity _must_ have at least 1 material (or it will appear as horrible pink). It is possible for a mesh to have more than 1 material, and those materials can interact in myriad ways. Just a few examples of possibilities:
+-  Decals on a racecar; the car's paint and the decal could be handled as separate materials.)
+-  A puddle in the dirt might be handled as two materials on a flat surface that are blended together.
+
+Every model in the TDW model library is comprised of _n_ meshes, each of which have _m_ materials. You can switch any material of any mesh of any object by sending [`set_visual_material`](../../api/command_api.md#set_visual_material).
+
+## Set the texture scale
+
+It is possible to scale the textures of a material to make them appear larger or smaller with the [`set_texture_scale` command](../../api/command_api.md#set_texture_scale). By default, texture scales are always (1, 1) but this doesn't necessarily indicate an "actual" size in real-world units. Larger values mean that the texture will be *smaller* and repeat more often:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.librarian import ModelLibrarian
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+c = Controller()
+object_id = c.get_unique_id()
+
+model_record = ModelLibrarian().get_record("white_lounger_chair")
+material_name = "parquet_long_horizontal_clean"
+cam = ThirdPersonCamera(position={"x": 2, "y": 1.6, "z": -0.6},
+                        look_at=object_id)
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("set_visual_material")
+print(f"Images will be saved to: {path}")
+cap = ImageCapture(avatar_ids=[cam.avatar_id], pass_masks=["_img"], path=path)
+c.add_ons.extend([cam, cap])
+
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name=model_record.name,
+                             object_id=object_id),
+            c.get_add_material(material_name=material_name)]
+commands.extend(TDWUtils.set_visual_material(c=c, substructure=model_record.substructure, material=material_name, object_id=object_id))
+for sub_object in model_record.substructure:
+    commands.append({"$type": "set_texture_scale",
+                     "object_name": sub_object["name"],
+                     "id": object_id,
+                     "scale": {"x": 0.1, "y": 0.1}})
+c.communicate(commands)
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/set_visual_material/2.jpg)
+
+## Visual materials and realism
+
+Visual materials in TDW are realistic in the sense that many of them are derived directly from real world imagery data. However, in some cases it may not be possible to change the visual  material of an object and still have it look plausible. For example, a dog object's material might use a photographic texture, so using any  other material would look implausible.
+
+Switching visual materials is most effective for use cases the primary goal is to increase image variability, differentiate between objects, or other situations in which realism isn't the primary goal.
+
+
+***
+
+**Next: [Procedural generation (scenes)](proc_gen_room.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [set_visual_material.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/objects_and_scenes/set_visual_material.py) Set an object's visual material.
+
+Command API:
+
+- [`set_color`](../../api/command_api.md#set_color)
+- [`add_material`](../../api/command_api.md#add_material)
+- [`set_visual_material`](../../api/command_api.md#set_visual_material)
+- [`set_texture_scale`](../../api/command_api.md#set_texture_scale)
+
+Python API:
+
+- [`MaterialLibrarian`](../../python/librarian/material_librarian.md) Material librarian for metadata records.
+- [`Controller.get_add_material(material_name, library)`](../../python/controller.md) Get a valid `add_material` command.
+- [`TDWUtils.set_visual_material(controller, substructure, material, object_id)`](../../python/tdw_utils.md) Set the visual material for all materials.
+
+Utility applications:
+
+- [TDW Material Visualizer](https://github.com/threedworld-mit/tdw_visualizers)
diff --git a/Documentation/lessons/objects_and_scenes/overlap.md b/Documentation/lessons/objects_and_scenes/overlap.md
new file mode 100644
index 000000000..1b8e5f41d
--- /dev/null
+++ b/Documentation/lessons/objects_and_scenes/overlap.md
@@ -0,0 +1,203 @@
+##### Objects and Scenes
+
+# `Overlap` output data
+
+## `Overlap` output data
+
+An **overlap** is similar to a [raycast](raycast.md) but it doesn't shoot a ray; it just returns which objects, if any, are within a 3D shape. Overlaps can be very useful in procedural object placement.
+
+In TDW, you can request [`Overlap`](../../api/output_data.md#Overlap) output data by sending one of the following commands:
+
+- [`send_overlap_box`](../../api/command_api.md#send_overlap_box) 
+- [`send_overlap_sphere`](../../api/command_api.md#send_overlap_sphere) 
+- [`send_overlap_capsule`](../../api/command_api.md#send_overlap_capsule) 
+
+Like `Raycast` output data, `Overlap` output data has an ID and is always sent exactly once (never per-frame).
+
+## Determine if an object is in a bowl
+
+Suppose that [like in the previous example](raycast.md) we want to place an object in a bowl. This time, we'll position the object at a random point above the bowl and let it fall. We'll use [cached bounds data](../../python/librarian/model_librarian.md) to get the position and [`Rigidbody` output data](../../api/output_data.md#Rigidbody) to determine when the object stops moving.
+
+```python
+import numpy as np
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, Rigidbodies
+from tdw.librarian import ModelLibrarian
+
+# Create a random number generator with seed 0.
+rng = np.random.RandomState(0)
+
+model_librarian = ModelLibrarian()
+bowl_record = model_librarian.get_record("round_bowl_small_walnut")
+c = Controller()
+bowl_id = c.get_unique_id()
+jug_id = c.get_unique_id()
+
+# Set the position of the bowl.
+bowl_position = {"x": -1, "y": 0, "z": 0.5}
+
+# Pick a position slightly above the bowl.
+bowl_left_right = np.linalg.norm(TDWUtils.vector3_to_array(bowl_record.bounds["left"]) -
+                                 TDWUtils.vector3_to_array(bowl_record.bounds["right"]))
+bowl_front_back = np.linalg.norm(TDWUtils.vector3_to_array(bowl_record.bounds["front"]) -
+                                 TDWUtils.vector3_to_array(bowl_record.bounds["back"]))
+object_x = float(rng.uniform(bowl_position["x"] - (bowl_left_right / 2), bowl_position["x"] + (bowl_left_right / 2)))
+object_y = float(rng.uniform(2.5, 4))
+object_z = float(rng.uniform(bowl_position["z"] - (bowl_front_back / 2), bowl_position["z"] + (bowl_front_back / 2)))
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(position=bowl_position,
+                                       object_id=bowl_id,
+                                       model_name=bowl_record.name),
+                      c.get_add_object(position={"x": object_x, "y": object_y, "z": object_z},
+                                       object_id=jug_id,
+                                       model_name="jug03"),
+                      {"$type": "send_rigidbodies",
+                       "frequency": "always",
+                       "ids": [jug_id]}])
+sleeping = False
+while not sleeping:
+    for i in range(len(resp) - 1):
+        # Get rigidbody output data.
+        r_id = OutputData.get_data_type_id(resp[i])
+        if r_id == "rigi":
+            rigidbodies = Rigidbodies(resp[i])
+            # We know that there is only one object in the rigidbody data (the jug).
+            if rigidbodies.get_sleeping(0):
+                sleeping = True
+                break
+    resp = c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+Now we need to determine if the object is in the bowl. This is harder than it might seem!
+
+- We can visually check if the object is in the bowl. But this won't work if we need to *programmatically* know if the object is in the bowl.
+- We can't use bounds data because bounds data is box-shaped and the bowl is round.
+
+We'll define an overlap shape that is slightly smaller than the bowl. If the object is within this shape, then it must be in the bowl:
+
+```python
+import numpy as np
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+from tdw.output_data import OutputData, Rigidbodies, Overlap
+from tdw.librarian import ModelLibrarian
+
+# Create a random number generator with seed 0.
+rng = np.random.RandomState(0)
+
+model_librarian = ModelLibrarian()
+bowl_record = model_librarian.get_record("round_bowl_small_walnut")
+c = Controller()
+bowl_id = c.get_unique_id()
+jug_id = c.get_unique_id()
+camera = ThirdPersonCamera(position={"x": 0.5, "y": 1.6, "z": -1},
+                           look_at=bowl_id,
+                           avatar_id="a")
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("overlap")
+print(f"Images will be saved to: {path}")
+capture = ImageCapture(avatar_ids=[camera.avatar_id], path=path)
+c.add_ons.extend([camera, capture])
+
+# Set the position of the bowl.
+bowl_position = {"x": -1, "y": 0, "z": 0.5}
+
+# Pick a position slightly above the bowl.
+bowl_left_right = np.linalg.norm(TDWUtils.vector3_to_array(bowl_record.bounds["left"]) -
+                                 TDWUtils.vector3_to_array(bowl_record.bounds["right"]))
+bowl_front_back = np.linalg.norm(TDWUtils.vector3_to_array(bowl_record.bounds["front"]) -
+                                 TDWUtils.vector3_to_array(bowl_record.bounds["back"]))
+object_x = float(rng.uniform(bowl_position["x"] - (bowl_left_right / 2), bowl_position["x"] + (bowl_left_right / 2)))
+object_y = float(rng.uniform(2.5, 4))
+object_z = float(rng.uniform(bowl_position["z"] - (bowl_front_back / 2), bowl_position["z"] + (bowl_front_back / 2)))
+
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(position=bowl_position,
+                                       object_id=bowl_id,
+                                       model_name=bowl_record.name),
+                      c.get_add_object(position={"x": object_x, "y": object_y, "z": object_z},
+                                       object_id=jug_id,
+                                       model_name="jug03"),
+                      {"$type": "send_rigidbodies",
+                       "frequency": "always",
+                       "ids": [jug_id]}])
+sleeping = False
+while not sleeping:
+    for i in range(len(resp) - 1):
+        # Get rigidbody output data.
+        r_id = OutputData.get_data_type_id(resp[i])
+        if r_id == "rigi":
+            rigidbodies = Rigidbodies(resp[i])
+            # We know that there is only one object in the rigidbody data (the jug).
+            if rigidbodies.get_sleeping(0):
+                sleeping = True
+                break
+    resp = c.communicate([])
+
+# Define an overlap to determine if the object is in the bowl.
+overlap_id = c.get_unique_id()
+if bowl_left_right < bowl_front_back:
+    bowl_smaller_extent = bowl_left_right / 2
+else:
+    bowl_smaller_extent = bowl_front_back / 2
+# Shrink the extents a little more.
+bowl_smaller_extent *= 0.9
+resp = c.communicate({"$type": "send_overlap_sphere",
+                      "radius": bowl_smaller_extent,
+                      "position": {"x": bowl_position["x"],
+                                   "y": 0,
+                                   "z": bowl_position["z"]},
+                      "id": overlap_id})
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    # Get the overlap.
+    if r_id == "over":
+        overlap = Overlap(resp[i])
+        # Is this the correct overlap?
+        if overlap.get_id() == overlap_id:
+            object_ids = overlap.get_object_ids()
+            # Does the overlap contain the jug?
+            if jug_id in object_ids:
+                print("The jug is in the bowl!")
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+```
+The jug is in the bowl!
+```
+
+![](images/overlap.jpg)
+
+***
+
+**Next: [Reset a scene](reset_scene.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [overlap.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/objects_and_scenes/overlap.py) Use overlap data to determine if an object is in a bowl.
+
+Python API:
+
+- [`ModelRecord.bounds`](../../python/librarian/model_librarian.md)
+
+Command API:
+
+- [`send_overlap_box`](../../api/command_api.md#send_overlap_box) 
+- [`send_overlap_sphere`](../../api/command_api.md#send_overlap_sphere) 
+- [`send_overlap_capsule`](../../api/command_api.md#send_overlap_capsule) 
+- [`send_rigidbodies`](../../api/command_api.md#send_rigidbodies) 
+
+Output Data:
+
+- [`Overlap`](../../api/output_data.md#Overlap)
+- [`Rigidbodies`](../../api/output_data.md#Rigidbodies)
diff --git a/Documentation/lessons/objects_and_scenes/overview.md b/Documentation/lessons/objects_and_scenes/overview.md
new file mode 100644
index 000000000..f18ebc2db
--- /dev/null
+++ b/Documentation/lessons/objects_and_scenes/overview.md
@@ -0,0 +1,24 @@
+##### Objects and Scenes
+
+# Overview
+
+This tutorial will cover the basics of how to populate a [scene](../core_concepts/scenes.md) with [objects](../core_concepts/objects.md). Broadly speaking, there are two ways to do this:
+
+1. **Scripted object placement.** Objects are preselected and placed in predefined positions. These positions might be hardcoded into the controller, stored in a json file, etc. Most tutorials in the TDW documentation use scripted object placement.
+2. **Procedural generation.** Objects are algorithmically selected and arranged in a scene.
+
+Procedural generation ("proc-gen") is a common technique in video game design to generate highly variable environments. However, there are many caveats to this approach:
+
+- There isn't a "canonical" way to do proc-gen because the specifics of what is needed in any given scene vary too much. Accordingly, TDW doesn't include any procedural generators; you are expected to write your own. This tutorial includes some simple proc-gen examples.
+- At a certain degree of complexity, procedural generation won't save you time. If you only need several good-looking scenes, then you're probably better off just using scripted object placement.
+- It can be difficult to account for edge cases in procedural generation, i.e. unusual situations caused by rare interactions between objects. 
+
+That said, procedural generation is very useful and is implemented in many of TDW's high-level APIs.
+
+In this tutorial, you'll first learn about "floorplan layouts", which use scripted object placement to generate interior environments. Then, you'll learn how to procedurally add objects to a scene.
+
+***
+
+**Next: [Scripted object placement (floorplan layouts)](floorplans.md)**
+
+[Return to the README](../../../README.md)
\ No newline at end of file
diff --git a/Documentation/lessons/objects_and_scenes/proc_gen_objects.md b/Documentation/lessons/objects_and_scenes/proc_gen_objects.md
new file mode 100644
index 000000000..e688abc5f
--- /dev/null
+++ b/Documentation/lessons/objects_and_scenes/proc_gen_objects.md
@@ -0,0 +1,646 @@
+##### Objects and Scenes
+
+# Procedural generation  (objects)
+
+This document will walk you through a simple example of how to use procedural generation to populate a scene with objects. [As noted at the start of this tutorial](overview.md), procedural generation is a powerful technique for quickly generating large numbers of scene environments. However, by its very nature, there is no single "canonical" algorithm for procedural generation. This document will describe *a* way to populate a scene with objects, not *the* way. It is also incomplete; the end of this document lists just some of the ways to improve the example code.
+
+## 1. Choose a random table and a random chair
+
+First, we're going to use numpy to create a RandomState with a seed. This is useful if you want to replicate the exact same procedurally generated scene:
+
+```python
+import numpy as np
+
+rng = np.random.RandomState(seed=0)
+```
+
+Next, we need to get the `wnid` associated with `table` and with `chair`:
+
+```python
+from tdw.librarian import ModelLibrarian
+
+librarian = ModelLibrarian()
+wnids_and_categories = librarian.get_model_wnids_and_wcategories()
+for wnid in wnids_and_categories:
+    if wnids_and_categories[wnid] == "chair":
+        print("chair", wnid) # chair n03001627
+    elif wnids_and_categories[wnid] == "table":
+        print("table", wnid) # table n04379243
+```
+
+Having done that, we can get a list of all tables and a list of all chairs. We'll then filter out any models flagged as `do_not_use`:
+
+```python
+from tdw.librarian import ModelLibrarian
+
+librarian = ModelLibrarian()
+tables = librarian.get_all_models_in_wnid("n04379243")
+chairs = librarian.get_all_models_in_wnid("n03001627")
+tables = [record for record in tables if not record.do_not_use]
+chairs = [record for record in chairs if not record.do_not_use]
+```
+
+Then, we'll randomly select a chair model and a table model:
+
+```python
+import numpy as np
+from tdw.librarian import ModelLibrarian
+
+rng = np.random.RandomState(seed=0)
+librarian = ModelLibrarian()
+tables = librarian.get_all_models_in_wnid("n04379243")
+chairs = librarian.get_all_models_in_wnid("n03001627")
+tables = [record for record in tables if not record.do_not_use]
+chairs = [record for record in chairs if not record.do_not_use]
+table = tables[rng.randint(0, len(tables))]
+chair = chairs[rng.randint(0, len(chairs))]
+```
+
+## 2. Create a scene
+
+[As explained earlier in this tutorial](proc_gen_room.md), room generation can get quite complicated. We can also use a [streamed scene asset bundle](../core_concepts/scenes.md) which has less variability but looks better. Because this tutorial is about *object placement* rather than scene generation, we're just going to use the empty 12x12 room used in most tutorials:
+
+```python
+import numpy as np
+from tdw.librarian import ModelLibrarian
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+rng = np.random.RandomState(seed=0)
+librarian = ModelLibrarian()
+tables = librarian.get_all_models_in_wnid("n04379243")
+chairs = librarian.get_all_models_in_wnid("n03001627")
+tables = [record for record in tables if not record.do_not_use]
+chairs = [record for record in chairs if not record.do_not_use]
+table = tables[rng.randint(0, len(tables))]
+chair = chairs[rng.randint(0, len(chairs))]
+
+c = Controller()
+commands = [TDWUtils.create_empty_room(12, 12)]
+```
+
+## 3. Choose a position to add the table
+
+We need to make sure that there is space for the table and its chairs. To do this, we'll used cached bounds data in the record to get the maximum extend of the table and chair:
+
+```python
+import numpy as np
+from tdw.librarian import ModelLibrarian, ModelRecord
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+class ProcGen(Controller):
+    @staticmethod
+    def get_longest_extent(record: ModelRecord) -> float:
+        left = TDWUtils.vector3_to_array(record.bounds["left"])
+        right = TDWUtils.vector3_to_array(record.bounds["right"])
+        front = TDWUtils.vector3_to_array(record.bounds["front"])
+        back = TDWUtils.vector3_to_array(record.bounds["back"])
+        left_right: float = np.linalg.norm(left - right)
+        front_back: float = np.linalg.norm(front - back)
+        if left_right > front_back:
+            return left_right
+        else:
+            return front_back
+    
+    def run(self) -> None:
+        rng = np.random.RandomState(seed=0)
+        librarian = ModelLibrarian()
+        tables = librarian.get_all_models_in_wnid("n04379243")
+        chairs = librarian.get_all_models_in_wnid("n03001627")
+        tables = [record for record in tables if not record.do_not_use]
+        chairs = [record for record in chairs if not record.do_not_use]
+        table = tables[rng.randint(0, len(tables))]
+        chair = chairs[rng.randint(0, len(chairs))]
+        
+        table_extents = ProcGen.get_longest_extent(table)
+        chair_extents = ProcGen.get_longest_extent(chair)
+        
+        commands = [TDWUtils.create_empty_room(12, 12)]
+        
+if __name__ == "__main__":
+    c = ProcGen()
+    c.run()
+```
+
+The table and chairs need to be placed in a free circle with a radius of `table_extents + chair_extents + n` where n is a little extra space. Using this radius and the known dimensions of the room, we can define a position for the table. Note that we're using `rng` in a few different functions now, so it's now defined as a field in the constructor. We'll also rotate the table for the sake of variability:
+
+```python
+import numpy as np
+from tdw.librarian import ModelLibrarian, ModelRecord
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+class ProcGen(Controller):
+    def __init__(self, port: int = 1071, launch_build: bool = True, random_seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(random_seed)
+
+    @staticmethod
+    def get_longest_extent(record: ModelRecord) -> float:
+        left = TDWUtils.vector3_to_array(record.bounds["left"])
+        right = TDWUtils.vector3_to_array(record.bounds["right"])
+        front = TDWUtils.vector3_to_array(record.bounds["front"])
+        back = TDWUtils.vector3_to_array(record.bounds["back"])
+        left_right: float = np.linalg.norm(left - right)
+        front_back: float = np.linalg.norm(front - back)
+        if left_right > front_back:
+            return left_right
+        else:
+            return front_back
+
+    def get_table_placement_coordinate(self, radius: float) -> float:
+        q = float(self.rng.uniform(0, 6 - radius))
+        if self.rng.random() < 0.5:
+            q *= -1
+        return q
+
+    def run(self) -> None:
+        librarian = ModelLibrarian()
+        tables = librarian.get_all_models_in_wnid("n04379243")
+        chairs = librarian.get_all_models_in_wnid("n03001627")
+        tables = [record for record in tables if not record.do_not_use]
+        chairs = [record for record in chairs if not record.do_not_use]
+        table = tables[self.rng.randint(0, len(tables))]
+        chair = chairs[self.rng.randint(0, len(chairs))]
+
+        table_extents = ProcGen.get_longest_extent(table)
+        chair_extents = ProcGen.get_longest_extent(chair)
+        table_placement_radius = table_extents + chair_extents + 1.15
+        table_x = self.get_table_placement_coordinate(table_placement_radius)
+        table_z = self.get_table_placement_coordinate(table_placement_radius)
+        table_id = self.get_unique_id()
+
+        commands = [TDWUtils.create_empty_room(12, 12),
+                    self.get_add_object(model_name=table.name,
+                                        position={"x": table_x, "y": 0, "z": table_z},
+                                        rotation={"x": 0, "y": float(self.rng.uniform(-360, 360)), "z": 0},
+                                        object_id=table_id)]
+
+if __name__ == "__main__":
+    c = ProcGen()
+    c.run()
+```
+
+The easiest way to place chairs around a rotated table will be to used the table's rotated `Bounds` table. So, we'll request `Bounds` output data, and then send the commands we've listed so far:
+
+```python
+import numpy as np
+from tdw.librarian import ModelLibrarian, ModelRecord
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import Bounds
+
+class ProcGen(Controller):
+    def __init__(self, port: int = 1071, launch_build: bool = True, random_seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(random_seed)
+
+    @staticmethod
+    def get_longest_extent(record: ModelRecord) -> float:
+        left = TDWUtils.vector3_to_array(record.bounds["left"])
+        right = TDWUtils.vector3_to_array(record.bounds["right"])
+        front = TDWUtils.vector3_to_array(record.bounds["front"])
+        back = TDWUtils.vector3_to_array(record.bounds["back"])
+        left_right: float = np.linalg.norm(left - right)
+        front_back: float = np.linalg.norm(front - back)
+        if left_right > front_back:
+            return left_right
+        else:
+            return front_back
+
+    def get_table_placement_coordinate(self, radius: float) -> float:
+        q = float(self.rng.uniform(0, 6 - radius))
+        if self.rng.random() < 0.5:
+            q *= -1
+        return q
+
+    def run(self) -> None:
+        librarian = ModelLibrarian()
+        tables = librarian.get_all_models_in_wnid("n04379243")
+        chairs = librarian.get_all_models_in_wnid("n03001627")
+        tables = [record for record in tables if not record.do_not_use]
+        chairs = [record for record in chairs if not record.do_not_use]
+        table = tables[self.rng.randint(0, len(tables))]
+        chair = chairs[self.rng.randint(0, len(chairs))]
+
+        table_extents = ProcGen.get_longest_extent(table)
+        chair_extents = ProcGen.get_longest_extent(chair)
+        table_placement_radius = table_extents + chair_extents + 1.15
+        table_x = self.get_table_placement_coordinate(table_placement_radius)
+        table_z = self.get_table_placement_coordinate(table_placement_radius)
+        table_id = self.get_unique_id()
+
+        resp = self.communicate([TDWUtils.create_empty_room(12, 12),
+                                 self.get_add_object(model_name=table.name,
+                                                     position={"x": table_x, "y": 0, "z": table_z},
+                                                     rotation={"x": 0, "y": float(self.rng.uniform(-360, 360)), "z": 0},
+                                                     object_id=table_id),
+                                 {"$type": "send_bounds",
+                                  "frequency": "once",
+                                  "ids": [table_id]}])
+
+if __name__ == "__main__":
+    c = ProcGen()
+    c.run()
+```
+
+## 3. Place chairs around the table
+
+Parse `resp` to get the table's rotated bounds:
+
+```python
+import numpy as np
+from tdw.librarian import ModelLibrarian, ModelRecord
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import Bounds
+
+class ProcGen(Controller):
+    def __init__(self, port: int = 1071, launch_build: bool = True, random_seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(random_seed)
+
+    @staticmethod
+    def get_longest_extent(record: ModelRecord) -> float:
+        left = TDWUtils.vector3_to_array(record.bounds["left"])
+        right = TDWUtils.vector3_to_array(record.bounds["right"])
+        front = TDWUtils.vector3_to_array(record.bounds["front"])
+        back = TDWUtils.vector3_to_array(record.bounds["back"])
+        left_right: float = np.linalg.norm(left - right)
+        front_back: float = np.linalg.norm(front - back)
+        if left_right > front_back:
+            return left_right
+        else:
+            return front_back
+
+    def get_table_placement_coordinate(self, radius: float) -> float:
+        q = float(self.rng.uniform(0, 6 - radius))
+        if self.rng.random() < 0.5:
+            q *= -1
+        return q
+
+    def run(self) -> None:
+        librarian = ModelLibrarian()
+        tables = librarian.get_all_models_in_wnid("n04379243")
+        chairs = librarian.get_all_models_in_wnid("n03001627")
+        tables = [record for record in tables if not record.do_not_use]
+        chairs = [record for record in chairs if not record.do_not_use]
+        table = tables[self.rng.randint(0, len(tables))]
+        chair = chairs[self.rng.randint(0, len(chairs))]
+
+        table_extents = ProcGen.get_longest_extent(table)
+        chair_extents = ProcGen.get_longest_extent(chair)
+        table_placement_radius = table_extents + chair_extents + 1.15
+        table_x = self.get_table_placement_coordinate(table_placement_radius)
+        table_z = self.get_table_placement_coordinate(table_placement_radius)
+        table_id = self.get_unique_id()
+
+        resp = self.communicate([TDWUtils.create_empty_room(12, 12),
+                                 self.get_add_object(model_name=table.name,
+                                                     position={"x": table_x, "y": 0, "z": table_z},
+                                                     rotation={"x": 0, "y": float(self.rng.uniform(-360, 360)), "z": 0},
+                                                     object_id=table_id),
+                                 {"$type": "send_bounds",
+                                  "frequency": "once",
+                                  "ids": [table_id]}])
+        # We know tha this is the only output data on this frame.
+        bounds = Bounds(resp[0])
+        # We know that the table is the only object in the output data.
+        table_center = np.array(bounds.get_center(0))
+        table_left = np.array(bounds.get_left(0))
+        table_right = np.array(bounds.get_right(0))
+        table_front = np.array(bounds.get_front(0))
+        table_back = np.array(bounds.get_back(0))
+
+if __name__ == "__main__":
+    c = ProcGen()
+    c.run()
+```
+
+We can use the bound positions of the table to get positions for the chairs. But we don't want to place the chairs *at* the bounds positions because then they'd intersect with the chair. Instead, they need to be offset from the bounds points. We'll define a vector from the center to the bounds point and offset each chair along that vector:
+
+```python
+import numpy as np
+from tdw.librarian import ModelLibrarian, ModelRecord
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import Bounds
+
+class ProcGen(Controller):
+    def __init__(self, port: int = 1071, launch_build: bool = True, random_seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(random_seed)
+
+    @staticmethod
+    def get_longest_extent(record: ModelRecord) -> float:
+        left = TDWUtils.vector3_to_array(record.bounds["left"])
+        right = TDWUtils.vector3_to_array(record.bounds["right"])
+        front = TDWUtils.vector3_to_array(record.bounds["front"])
+        back = TDWUtils.vector3_to_array(record.bounds["back"])
+        left_right: float = np.linalg.norm(left - right)
+        front_back: float = np.linalg.norm(front - back)
+        if left_right > front_back:
+            return left_right
+        else:
+            return front_back
+
+    def get_table_placement_coordinate(self, radius: float) -> float:
+        q = float(self.rng.uniform(0, 6 - radius))
+        if self.rng.random() < 0.5:
+            q *= -1
+        return q
+
+    def get_chair_position(self, table_center: np.array, table_bound_point: np.array) -> np.array:
+        position_to_center = table_bound_point - table_center
+        position_to_center_normalized = position_to_center / np.linalg.norm(position_to_center)
+        chair_position = table_bound_point + (position_to_center_normalized * self.rng.uniform(0.5, 0.125))
+        chair_position[1] = 0
+        return chair_position
+
+    def run(self) -> None:
+        librarian = ModelLibrarian()
+        tables = librarian.get_all_models_in_wnid("n04379243")
+        chairs = librarian.get_all_models_in_wnid("n03001627")
+        tables = [record for record in tables if not record.do_not_use]
+        chairs = [record for record in chairs if not record.do_not_use]
+        table = tables[self.rng.randint(0, len(tables))]
+        chair = chairs[self.rng.randint(0, len(chairs))]
+
+        table_extents = ProcGen.get_longest_extent(table)
+        chair_extents = ProcGen.get_longest_extent(chair)
+        table_placement_radius = table_extents + chair_extents + 1.15
+        table_x = self.get_table_placement_coordinate(table_placement_radius)
+        table_z = self.get_table_placement_coordinate(table_placement_radius)
+        table_id = self.get_unique_id()
+
+        resp = self.communicate([TDWUtils.create_empty_room(12, 12),
+                                 self.get_add_object(model_name=table.name,
+                                                     position={"x": table_x, "y": 0, "z": table_z},
+                                                     rotation={"x": 0, "y": float(self.rng.uniform(-360, 360)), "z": 0},
+                                                     object_id=table_id),
+                                 {"$type": "send_bounds",
+                                  "frequency": "once",
+                                  "ids": [table_id]}])
+        # We know tha this is the only output data on this frame.
+        bounds = Bounds(resp[0])
+        # We know that the table is the only object in the output data.
+        table_center = np.array(bounds.get_center(0))
+        chair_positions = [self.get_chair_position(table_center=table_center, 
+                                                   table_bound_point=np.array(bounds.get_left(0))),
+                           self.get_chair_position(table_center=table_center,
+                                                   table_bound_point=np.array(bounds.get_right(0))),
+                           self.get_chair_position(table_center=table_center,
+                                                   table_bound_point=np.array(bounds.get_front(0))),
+                           self.get_chair_position(table_center=table_center,
+                                                   table_bound_point=np.array(bounds.get_back(0)))]
+
+if __name__ == "__main__":
+    c = ProcGen()
+    c.run()
+```
+
+To add each chair, we'll introduce two new commands. [`object_look_at_position`](../../api/command_api.md#object_look_at_position) rotates an object to point towards a position--in this case, the table's bottom-center position. [`rotate_object_by`](../../api/command_api.md#rotate_object_by) rotates an object by an angle an axis; we'll use this to rotate the chairs slightly for the sake of variability.
+
+```python
+import numpy as np
+from tdw.librarian import ModelLibrarian, ModelRecord
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import Bounds
+
+class ProcGen(Controller):
+    def __init__(self, port: int = 1071, launch_build: bool = True, random_seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(random_seed)
+
+    @staticmethod
+    def get_longest_extent(record: ModelRecord) -> float:
+        left = TDWUtils.vector3_to_array(record.bounds["left"])
+        right = TDWUtils.vector3_to_array(record.bounds["right"])
+        front = TDWUtils.vector3_to_array(record.bounds["front"])
+        back = TDWUtils.vector3_to_array(record.bounds["back"])
+        left_right: float = np.linalg.norm(left - right)
+        front_back: float = np.linalg.norm(front - back)
+        if left_right > front_back:
+            return left_right
+        else:
+            return front_back
+
+    def get_table_placement_coordinate(self, radius: float) -> float:
+        q = float(self.rng.uniform(0, 6 - radius))
+        if self.rng.random() < 0.5:
+            q *= -1
+        return q
+
+    def get_chair_position(self, table_center: np.array, table_bound_point: np.array) -> np.array:
+        position_to_center = table_bound_point - table_center
+        position_to_center_normalized = position_to_center / np.linalg.norm(position_to_center)
+        chair_position = table_bound_point + (position_to_center_normalized * self.rng.uniform(0.5, 0.125))
+        chair_position[1] = 0
+        return chair_position
+
+    def run(self) -> None:
+        librarian = ModelLibrarian()
+        tables = librarian.get_all_models_in_wnid("n04379243")
+        chairs = librarian.get_all_models_in_wnid("n03001627")
+        tables = [record for record in tables if not record.do_not_use]
+        chairs = [record for record in chairs if not record.do_not_use]
+        table = tables[self.rng.randint(0, len(tables))]
+        chair = chairs[self.rng.randint(0, len(chairs))]
+
+        table_extents = ProcGen.get_longest_extent(table)
+        chair_extents = ProcGen.get_longest_extent(chair)
+        table_placement_radius = table_extents + chair_extents + 1.15
+        table_x = self.get_table_placement_coordinate(table_placement_radius)
+        table_z = self.get_table_placement_coordinate(table_placement_radius)
+        table_id = self.get_unique_id()
+
+        resp = self.communicate([TDWUtils.create_empty_room(12, 12),
+                                 self.get_add_object(model_name=table.name,
+                                                     position={"x": table_x, "y": 0, "z": table_z},
+                                                     rotation={"x": 0, "y": float(self.rng.uniform(-360, 360)), "z": 0},
+                                                     object_id=table_id),
+                                 {"$type": "send_bounds",
+                                  "frequency": "once",
+                                  "ids": [table_id]}])
+        # We know tha this is the only output data on this frame.
+        bounds = Bounds(resp[0])
+        # We know that the table is the only object in the output data.
+        table_center = np.array(bounds.get_center(0))
+        chair_positions = [self.get_chair_position(table_center=table_center,
+                                                   table_bound_point=np.array(bounds.get_left(0))),
+                           self.get_chair_position(table_center=table_center,
+                                                   table_bound_point=np.array(bounds.get_right(0))),
+                           self.get_chair_position(table_center=table_center,
+                                                   table_bound_point=np.array(bounds.get_front(0))),
+                           self.get_chair_position(table_center=table_center,
+                                                   table_bound_point=np.array(bounds.get_back(0)))]
+        table_bottom = TDWUtils.array_to_vector3(bounds.get_bottom(0))
+        commands = []
+        for chair_position in chair_positions:
+            object_id = self.get_unique_id()
+            commands.extend([self.get_add_object(model_name=chair.name,
+                                                 position=TDWUtils.array_to_vector3(chair_position),
+                                                 object_id=object_id),
+                             {"$type": "object_look_at_position",
+                              "position": table_bottom,
+                              "id": object_id},
+                             {"$type": "rotate_object_by",
+                              "angle": float(self.rng.uniform(-20, 20)),
+                              "id": object_id,
+                              "axis": "yaw"}])
+        self.communicate(commands)
+
+if __name__ == "__main__":
+    c = ProcGen()
+    c.run()
+```
+
+## 4. Add a camera
+
+Let's add a camera and enable image capture so we can view our handiwork. We'll take the top of the table's bounds and offset the camera from that point and point the camera at the table:
+
+```python
+import numpy as np
+from tdw.librarian import ModelLibrarian, ModelRecord
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import Bounds
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+class ProcGen(Controller):
+    def __init__(self, port: int = 1071, launch_build: bool = True, random_seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(random_seed)
+
+    @staticmethod
+    def get_longest_extent(record: ModelRecord) -> float:
+        left = TDWUtils.vector3_to_array(record.bounds["left"])
+        right = TDWUtils.vector3_to_array(record.bounds["right"])
+        front = TDWUtils.vector3_to_array(record.bounds["front"])
+        back = TDWUtils.vector3_to_array(record.bounds["back"])
+        left_right: float = np.linalg.norm(left - right)
+        front_back: float = np.linalg.norm(front - back)
+        if left_right > front_back:
+            return left_right
+        else:
+            return front_back
+
+    def get_table_placement_coordinate(self, radius: float) -> float:
+        q = float(self.rng.uniform(0, 6 - radius))
+        if self.rng.random() < 0.5:
+            q *= -1
+        return q
+
+    def get_chair_position(self, table_center: np.array, table_bound_point: np.array) -> np.array:
+        position_to_center = table_bound_point - table_center
+        position_to_center_normalized = position_to_center / np.linalg.norm(position_to_center)
+        chair_position = table_bound_point + (position_to_center_normalized * self.rng.uniform(0.5, 0.125))
+        chair_position[1] = 0
+        return chair_position
+
+    def run(self) -> None:
+        librarian = ModelLibrarian()
+        tables = librarian.get_all_models_in_wnid("n04379243")
+        chairs = librarian.get_all_models_in_wnid("n03001627")
+        tables = [record for record in tables if not record.do_not_use]
+        chairs = [record for record in chairs if not record.do_not_use]
+        table = tables[self.rng.randint(0, len(tables))]
+        chair = chairs[self.rng.randint(0, len(chairs))]
+
+        table_extents = ProcGen.get_longest_extent(table)
+        chair_extents = ProcGen.get_longest_extent(chair)
+        table_placement_radius = table_extents + chair_extents + 1.15
+        table_x = self.get_table_placement_coordinate(table_placement_radius)
+        table_z = self.get_table_placement_coordinate(table_placement_radius)
+        table_id = self.get_unique_id()
+
+        resp = self.communicate([TDWUtils.create_empty_room(12, 12),
+                                 self.get_add_object(model_name=table.name,
+                                                     position={"x": table_x, "y": 0, "z": table_z},
+                                                     rotation={"x": 0, "y": float(self.rng.uniform(-360, 360)), "z": 0},
+                                                     object_id=table_id),
+                                 {"$type": "send_bounds",
+                                  "frequency": "once",
+                                  "ids": [table_id]}])
+        # We know tha this is the only output data on this frame.
+        bounds = Bounds(resp[0])
+        # We know that the table is the only object in the output data.
+        table_center = np.array(bounds.get_center(0))
+        chair_positions = [self.get_chair_position(table_center=table_center,
+                                                   table_bound_point=np.array(bounds.get_left(0))),
+                           self.get_chair_position(table_center=table_center,
+                                                   table_bound_point=np.array(bounds.get_right(0))),
+                           self.get_chair_position(table_center=table_center,
+                                                   table_bound_point=np.array(bounds.get_front(0))),
+                           self.get_chair_position(table_center=table_center,
+                                                   table_bound_point=np.array(bounds.get_back(0)))]
+        table_top = bounds.get_top(0)
+        camera = ThirdPersonCamera(position={"x": table_top[0] + 1.5, "y": table_top[1] + 0.7, "z": table_top[2] - 2},
+                                   look_at=TDWUtils.array_to_vector3(table_top))
+        path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("proc_gen_objects")
+        print(f"Images will be saved to: {path}")
+        capture = ImageCapture(avatar_ids=[camera.avatar_id], pass_masks=["_img"], path=path)
+        self.add_ons.extend([camera, capture])
+        table_bottom = TDWUtils.array_to_vector3(bounds.get_bottom(0))
+        commands = []
+        for chair_position in chair_positions:
+            object_id = self.get_unique_id()
+            commands.extend([self.get_add_object(model_name=chair.name,
+                                                 position=TDWUtils.array_to_vector3(chair_position),
+                                                 object_id=object_id),
+                             {"$type": "object_look_at_position",
+                              "position": table_bottom,
+                              "id": object_id},
+                             {"$type": "rotate_object_by",
+                              "angle": float(self.rng.uniform(-20, 20)),
+                              "id": object_id,
+                              "axis": "yaw"}])
+        self.communicate(commands)
+        self.communicate({"$type": "terminate"})
+
+if __name__ == "__main__":
+    c = ProcGen()
+    c.run()
+```
+
+Result:
+
+![](images/proc_gen_objects.jpg)
+
+## 5. Further improvements
+
+As mentioned earlier, procedural generation is an unbounded problem and there's *always* room for improvement. You can expand upon this example in a number of ways:
+
+- Refine the process of adding chairs so that they're slightly under the table top.
+- Improve the placement of the table+chairs so that it works in a multi-room scene (such that the objects won't intersect with any interior walls).
+- Add plates, cups, and so on to the top of the table.
+
+***
+
+**Next: [`Raycast` output data](raycast.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [proc_gen_objects.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/objects_and_scenes/proc_gen_objects.py) Using procedural generation, add a table to scene and add chairs around the table.
+
+Python API:
+
+- [`ModelLibrarian.get_model_wnids_and_wcategories()`](../../python/librarian/model_librarian.md) Get a dictionary of WordNet IDs and categories.
+- [`ModelLibrarian.get_all_models_in_wnid(wnid)`](../../python/librarian/model_librarian.md) Get all models with this WordNet ID.
+- [`ModelRecord.do_not_use`](../../python/librarian/model_librarian.md) If True, don't use this model.
+- [`ModelRecord.bounds`](../../python/librarian/model_librarian.md) Cached bounds of a model.
+- [`TDWUtils.vector3_to_array`](../../python/tdw_utils.md) Concert an (x, y, z) dictionary to an [x, y, z] numpy array.
+
+Command API:
+
+- [`object_look_at_position`](../../api/command_api.md#object_look_at_position) 
+- [`rotate_object_by`](../../api/command_api.md#rotate_object_by)
+
diff --git a/Documentation/lessons/objects_and_scenes/proc_gen_room.md b/Documentation/lessons/objects_and_scenes/proc_gen_room.md
new file mode 100644
index 000000000..f2895b579
--- /dev/null
+++ b/Documentation/lessons/objects_and_scenes/proc_gen_room.md
@@ -0,0 +1,429 @@
+##### Objects and Scenes
+
+# Procedural generation (scenes)
+
+Having reviewed [TDW's built-in system for scripted object placement](floorplans.md), the rest of this tutorial will cover how to procedurally generate environments.
+
+TDW includes a "ProcGen Room" scene for procedurally generating interior environments. We've been utilizing this every time we call `TDWUtils.create_empty_room(12, 12)`. However, TDW is capable of generating arbitrary indoor environments, with the caveat that each "section" of wall is always 1 meter long and walls must always be at 90 degree angles.
+
+## 1. Create a random number generator
+
+It can be useful to define a random number generator with a fixed seed so that you can re-create the exact same scene as needed:
+
+```python
+from typing import List
+import numpy as np
+from tdw.controller import Controller
+
+class ProcGenRoom(Controller):
+    def __init__(self, port: int = 1071, launch_build: bool = True, seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(seed)
+```
+
+## 2. Procedurally generate the walls of an indoor scene
+
+This tutorial will define an L-shaped scene with interior walls.
+
+To start, we'll define the bounds of the room with a randomly-sized numpy array, where 0 = no walls, 1 = exterior walls, and 2 = interior walls:
+
+```python
+from typing import List
+import numpy as np
+from tdw.controller import Controller
+
+class ProcGenRoom(Controller):
+    def __init__(self, port: int = 1071, launch_build: bool = True, seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(seed)
+
+    def create_scene(self) -> List[dict]:
+        width: int = self.rng.randint(12, 18)
+        length: int = self.rng.randint(14, 20)
+        room_arr: np.array = np.zeros(shape=(width, length), dtype=int)
+```
+
+Define a position to start turning south and generate "walls" up to that point:
+
+```python
+from typing import List
+import numpy as np
+from tdw.controller import Controller
+
+class ProcGenRoom(Controller):
+    def __init__(self, port: int = 1071, launch_build: bool = True, seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(seed)
+
+    def create_scene(self) -> List[dict]:
+        width: int = self.rng.randint(12, 18)
+        length: int = self.rng.randint(14, 20)
+        room_arr: np.array = np.zeros(shape=(width, length), dtype=int)
+        # Define the uppermost width-wise wall.
+        turn_south_at = int(length * 0.75) + self.rng.randint(1, 3)
+        for i in range(turn_south_at + 1):
+            room_arr[0, i] = 1
+        print(room_arr)
+```
+
+Using similar logic, we'll define the rest of the L-shape:
+
+```python
+from typing import List
+import numpy as np
+from tdw.controller import Controller
+
+class ProcGenRoom(Controller):
+    def __init__(self, port: int = 1071, launch_build: bool = True, seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(seed)
+
+    def create_scene(self) -> List[dict]:
+        width: int = self.rng.randint(12, 18)
+        length: int = self.rng.randint(14, 20)
+        room_arr: np.array = np.zeros(shape=(width, length), dtype=int)
+        # Define the uppermost width-wise wall.
+        turn_south_at = int(length * 0.75) + self.rng.randint(1, 3)
+        for i in range(turn_south_at + 1):
+            room_arr[0, i] = 1
+        turn_west_at = int(width * 0.75) + self.rng.randint(0, 2)
+        for i in range(turn_west_at + 1):
+            room_arr[i, turn_south_at] = 1
+        turn_north_at = turn_south_at - self.rng.randint(4, 6)
+        for i in range(turn_north_at, turn_south_at):
+            room_arr[turn_west_at, i] = 1
+        turn_west_at_2 = self.rng.randint(4, 6)
+        for i in range(turn_west_at_2, turn_west_at):
+            room_arr[i, turn_north_at] = 1
+        for i in range(turn_north_at):
+            room_arr[turn_west_at_2, i] = 1
+        for i in range(turn_west_at_2):
+            room_arr[i, 0] = 1
+        print(room_arr)
+```
+
+Next, add interior walls at the corner of the L, leaving some space for an entryway:
+
+```python
+from typing import List
+import numpy as np
+from tdw.controller import Controller
+
+class ProcGenRoom(Controller):
+    def __init__(self, port: int = 1071, launch_build: bool = True, seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(seed)
+
+    def create_scene(self) -> List[dict]:
+        width: int = self.rng.randint(12, 18)
+        length: int = self.rng.randint(14, 20)
+        room_arr: np.array = np.zeros(shape=(width, length), dtype=int)
+        # Define the uppermost width-wise wall.
+        turn_south_at = int(length * 0.75) + self.rng.randint(1, 3)
+        for i in range(turn_south_at + 1):
+            room_arr[0, i] = 1
+        turn_west_at = int(width * 0.75) + self.rng.randint(0, 2)
+        for i in range(turn_west_at + 1):
+            room_arr[i, turn_south_at] = 1
+        turn_north_at = turn_south_at - self.rng.randint(4, 6)
+        for i in range(turn_north_at, turn_south_at):
+            room_arr[turn_west_at, i] = 1
+        turn_west_at_2 = self.rng.randint(4, 6)
+        for i in range(turn_west_at_2, turn_west_at):
+            room_arr[i, turn_north_at] = 1
+        for i in range(turn_north_at):
+            room_arr[turn_west_at_2, i] = 1
+        for i in range(turn_west_at_2):
+            room_arr[i, 0] = 1
+        # Create interior walls.
+        if self.rng.random() < 0.5:
+            interior_wall_0 = range(turn_north_at + 1, turn_south_at - 1)
+            interior_wall_1 = range(1, turn_west_at_2 - 1)
+        else:
+            interior_wall_0 = range(turn_north_at + 2, turn_south_at)
+            interior_wall_1 = range(2, turn_west_at_2)
+        for i in interior_wall_0:
+            room_arr[turn_west_at_2, i] = 2
+        for i in interior_wall_1:
+            room_arr[i, turn_north_at] = 2
+        print(room_arr)
+```
+
+We now have completed the interior and exterior walls. We will convert the array into commands, add an avatar, and send the commands:
+
+```python
+from typing import List
+import numpy as np
+from tdw.controller import Controller
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+
+class ProcGenRoom(Controller):
+    def __init__(self, port: int = 1071, launch_build: bool = True, seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(seed)
+
+    def create_scene(self) -> List[dict]:
+        width: int = self.rng.randint(12, 18)
+        length: int = self.rng.randint(14, 20)
+        room_arr: np.array = np.zeros(shape=(width, length), dtype=int)
+        # Define the uppermost width-wise wall.
+        turn_south_at = int(length * 0.75) + self.rng.randint(1, 3)
+        for i in range(turn_south_at + 1):
+            room_arr[0, i] = 1
+        turn_west_at = int(width * 0.75) + self.rng.randint(0, 2)
+        for i in range(turn_west_at + 1):
+            room_arr[i, turn_south_at] = 1
+        turn_north_at = turn_south_at - self.rng.randint(4, 6)
+        for i in range(turn_north_at, turn_south_at):
+            room_arr[turn_west_at, i] = 1
+        turn_west_at_2 = self.rng.randint(4, 6)
+        for i in range(turn_west_at_2, turn_west_at):
+            room_arr[i, turn_north_at] = 1
+        for i in range(turn_north_at):
+            room_arr[turn_west_at_2, i] = 1
+        for i in range(turn_west_at_2):
+            room_arr[i, 0] = 1
+        # Create interior walls.
+        if self.rng.random() < 0.5:
+            interior_wall_0 = range(turn_north_at + 1, turn_south_at - 1)
+            interior_wall_1 = range(1, turn_west_at_2 - 1)
+        else:
+            interior_wall_0 = range(turn_north_at + 2, turn_south_at)
+            interior_wall_1 = range(2, turn_west_at_2)
+        for i in interior_wall_0:
+            room_arr[turn_west_at_2, i] = 2
+        for i in interior_wall_1:
+            room_arr[i, turn_north_at] = 2
+        # Convert the array to commands.
+        exterior_walls: List[dict] = list()
+        interior_walls: List[dict] = list()
+        for ix, iy in np.ndindex(room_arr.shape):
+            if room_arr[ix, iy] == 1:
+                exterior_walls.append({"x": ix, "y": iy})
+            elif room_arr[ix, iy] == 2:
+                interior_walls.append({"x": ix, "y": iy})
+        # load_scene typically gets sent in the controller constructor.
+        return [{"$type": "load_scene",
+                 "scene_name": "ProcGenScene"},
+                {"$type": "create_exterior_walls",
+                 "walls": exterior_walls},
+                {"$type": "create_interior_walls",
+                 "walls": interior_walls}]
+
+    def run(self) -> None:
+        path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("proc_gen_room")
+        print(f"Images will be saved to {path}")
+        camera = ThirdPersonCamera(avatar_id="a", position={"x": 0, "y": 20, "z": 0}, look_at={"x": 0, "y": 0, "z": 0})
+        capture = ImageCapture(avatar_ids=["a"], pass_masks=["_img"], path=path)
+        self.add_ons.extend([camera, capture])
+        self.communicate(self.create_scene())
+        self.communicate({"$type": "terminate"})
+
+
+if __name__ == "__main__":
+    c = ProcGenRoom()
+    c.run()
+```
+
+Result:
+
+![](images/proc_gen_room/0.jpg)
+
+## 3. Add and adjust the ceiling
+
+Add a ceiling with [`create_proc_gen_ceiling`](../../api/command_api.md#create_proc_gen_ceiling). Remove it with [`destroy_proc_gen_ceiling`](../../api/command_api.md#destroy_proc_gen_ceiling).
+
+The ceiling is subdivided into 1x1 meter "tiles". You can selectively add or destroy ceiling tiles with [`create_proc_gen_ceiling_tiles`](../../api/command_api.md#create_proc_gen_ceiling_tiles) and [`destroy_proc_gen_ceiling_tiles`](../../api/command_api.md#destroy_proc_gen_ceiling_tiles), respectively.
+
+## 3. Set colors and visual materials
+
+It is possible to set the [visual materials and colors](materials_textures_colors.md) of the floor, walls, and ceiling of a proc-gen room. In each case, make sure you first send  [`add_material` or `c.get_add_material(material_name)`](materials_textures_colors.md) to load the material asset bundle into memory (just like you would with an object).
+
+|             | Set visual material                                          | Set texture scale                                            | Set color                                                    |
+| ----------- | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
+| **Floor**   | [`set_proc_gen_floor_material`](../../api/command_api.md#set_proc_gen_floor_material) | [`set_proc_gen_floor_texture_scale`](../../api/command_api.md#set_proc_gen_floor_texture_scale) | [`set_proc_gen_floor_color`](../../api/command_api.md#set_proc_gen_floor_color) |
+| **Walls**   | [`set_proc_gen_walls_material`](../../api/command_api.md#set_proc_gen_walls_material) | [`set_proc_gen_walls_texture_scale`](../../api/command_api.md#set_proc_gen_walls_texture_scale) | [`set_proc_gen_walls_color`](../../api/command_api.md#set_proc_gen_walls_color) |
+| **Ceiling** | [`set_proc_gen_ceiling_material`](../../api/command_api.md#set_proc_gen_ceiling_material) | [`set_proc_gen_ceiling_texture_scale`](../../api/command_api.md#set_proc_gen_ceiling_texture_scale) | [`set_proc_gen_ceiling_color`](../../api/command_api.md#set_proc_gen_ceiling_color) |
+
+```python
+from typing import List
+import numpy as np
+from tdw.controller import Controller
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+
+class ProcGenRoom(Controller):
+    """
+    Procedurally create an interior scene.
+    """
+
+    def __init__(self, port: int = 1071, launch_build: bool = True, seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(seed)
+
+    def create_scene(self) -> List[dict]:
+        width: int = self.rng.randint(12, 18)
+        length: int = self.rng.randint(14, 20)
+        room_arr: np.array = np.zeros(shape=(width, length), dtype=int)
+        # Define the uppermost width-wise wall.
+        turn_south_at = int(length * 0.75) + self.rng.randint(1, 3)
+        for i in range(turn_south_at + 1):
+            room_arr[0, i] = 1
+        turn_west_at = int(width * 0.75) + self.rng.randint(0, 2)
+        for i in range(turn_west_at + 1):
+            room_arr[i, turn_south_at] = 1
+        turn_north_at = turn_south_at - self.rng.randint(4, 6)
+        for i in range(turn_north_at, turn_south_at):
+            room_arr[turn_west_at, i] = 1
+        turn_west_at_2 = self.rng.randint(4, 6)
+        for i in range(turn_west_at_2, turn_west_at):
+            room_arr[i, turn_north_at] = 1
+        for i in range(turn_north_at):
+            room_arr[turn_west_at_2, i] = 1
+        for i in range(turn_west_at_2):
+            room_arr[i, 0] = 1
+        # Create interior walls.
+        if self.rng.random() < 0.5:
+            interior_wall_0 = range(turn_north_at + 1, turn_south_at - 1)
+            interior_wall_1 = range(1, turn_west_at_2 - 1)
+        else:
+            interior_wall_0 = range(turn_north_at + 2, turn_south_at)
+            interior_wall_1 = range(2, turn_west_at_2)
+        for i in interior_wall_0:
+            room_arr[turn_west_at_2, i] = 2
+        for i in interior_wall_1:
+            room_arr[i, turn_north_at] = 2
+        # Convert the array to commands.
+        exterior_walls: List[dict] = list()
+        interior_walls: List[dict] = list()
+        for ix, iy in np.ndindex(room_arr.shape):
+            if room_arr[ix, iy] == 1:
+                exterior_walls.append({"x": ix, "y": iy})
+            elif room_arr[ix, iy] == 2:
+                interior_walls.append({"x": ix, "y": iy})
+        # load_scene typically gets sent by calling c.start()
+        return [{"$type": "load_scene",
+                 "scene_name": "ProcGenScene"},
+                {"$type": "create_exterior_walls",
+                 "walls": exterior_walls},
+                {"$type": "create_interior_walls",
+                 "walls": interior_walls}]
+
+    def set_floor(self) -> List[dict]:
+        materials = ["parquet_wood_mahogany", "parquet_long_horizontal_clean", "parquet_wood_red_cedar"]
+        material_name = materials[self.rng.randint(0, len(materials))]
+        texture_scale: float = float(self.rng.uniform(4, 4.5))
+        return [self.get_add_material(material_name=material_name),
+                {"$type": "set_proc_gen_floor_material",
+                 "name": material_name},
+                {"$type": "set_proc_gen_floor_texture_scale",
+                 "scale": {"x": texture_scale, "y": texture_scale}},
+                {"$type": "set_proc_gen_floor_color",
+                 "color": {"r": float(self.rng.uniform(0.7, 1)),
+                           "g": float(self.rng.uniform(0.7, 1)),
+                           "b": float(self.rng.uniform(0.7, 1)),
+                           "a": 1.0}}]
+
+    def set_walls(self) -> List[dict]:
+        materials = ["cinderblock_wall", "concrete_tiles_linear_grey", "old_limestone_wall_reinforced"]
+        material_name = materials[self.rng.randint(0, len(materials))]
+        texture_scale: float = float(self.rng.uniform(0.2, 0.3))
+        return [self.get_add_material(material_name=material_name),
+                {"$type": "set_proc_gen_walls_material",
+                 "name": material_name},
+                {"$type": "set_proc_gen_walls_texture_scale",
+                 "scale": {"x": texture_scale, "y": texture_scale}},
+                {"$type": "set_proc_gen_walls_color",
+                 "color": {"r": float(self.rng.uniform(0.7, 1)),
+                           "g": float(self.rng.uniform(0.7, 1)),
+                           "b": float(self.rng.uniform(0.7, 1)),
+                           "a": 1.0}}]
+
+    def set_ceiling(self) -> List[dict]:
+        materials = ["bricks_red_regular", "bricks_chatham_gray_used", "bricks_salem_matt_used"]
+        material_name = materials[self.rng.randint(0, len(materials))]
+        texture_scale: float = float(self.rng.uniform(0.1, 0.2))
+        return [{"$type": "create_proc_gen_ceiling"},
+                self.get_add_material(material_name=material_name),
+                {"$type": "set_proc_gen_ceiling_material",
+                 "name": material_name},
+                {"$type": "set_proc_gen_ceiling_texture_scale",
+                 "scale": {"x": texture_scale, "y": texture_scale}},
+                {"$type": "set_proc_gen_ceiling_color",
+                 "color": {"r": float(self.rng.uniform(0.7, 1)),
+                           "g": float(self.rng.uniform(0.7, 1)),
+                           "b": float(self.rng.uniform(0.7, 1)),
+                           "a": 1.0}}]
+
+    def run(self) -> None:
+        path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("proc_gen_room")
+        print(f"Images will be saved to {path}")
+        camera = ThirdPersonCamera(avatar_id="a", position={"x": 0, "y": 20, "z": 0}, look_at={"x": 0, "y": 0, "z": 0})
+        capture = ImageCapture(avatar_ids=["a"], pass_masks=["_img"], path=path)
+        self.add_ons.extend([camera, capture])
+
+        # Create the scene.
+        self.communicate(self.create_scene())
+        # Set the floor.
+        self.communicate(self.set_floor())
+        # Set the walls.
+        self.communicate(self.set_walls())
+        # Set the ceiling.
+        self.communicate(self.set_ceiling())
+        # End the simulation.
+        self.communicate({"$type": "terminate"})
+
+
+if __name__ == "__main__":
+    c = ProcGenRoom()
+    c.run()
+```
+
+Result:
+
+![](images/proc_gen_room/1.jpg) ![](images/proc_gen_room/2.jpg) ![](images/proc_gen_room/3.jpg)
+
+***
+
+**Next: [Units and data formats](units.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [proc_gen_room.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/objects_and_scenes/proc_gen_room.py) Procedurally create an interior scene.
+- [proc_gen_room_from_image.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/objects_and_scenes/proc_gen_room_from_image.py) Generate a room from a .png file.
+
+Python API:
+
+- [`TDWUtils.get_box(width, length)`](../../python/tdw_utils.md)
+
+Command API:
+
+- General:
+  - [`load_scene`](../../api/command_api.md#load_scene)
+  - [`create_exterior_walls`](../../api/command_api.md#create_exterior_walls)
+  - [`create_interior_walls`](../../api/command_api.md#create_interior_walls)
+- Floor:
+  - [`set_proc_gen_floor_material`](../../api/command_api.md#set_proc_gen_floor_material)
+  - [`set_proc_gen_floor_texture_scale`](../../api/command_api.md#set_proc_gen_floor_texture_scale)
+  - [`set_proc_gen_floor_color`](../../api/command_api.md#set_proc_gen_floor_color)
+- Walls:
+  - [`set_proc_gen_walls_material`](../../api/command_api.md#set_proc_gen_walls_material)
+  - [`set_proc_gen_walls_texture_scale`](../../api/command_api.md#set_proc_gen_walls_texture_scale)
+  - [`set_proc_gen_walls_color`](../../api/command_api.md#set_proc_gen_walls_color)
+- Ceiling:
+  - [`create_proc_gen_ceiling`](../../api/command_api.md#create_proc_gen_ceiling)
+  - [`destroy_proc_gen_ceiling`](../../api/command_api.md#destroy_proc_gen_ceiling)
+  - [`create_proc_gen_ceiling_tiles`](../../api/command_api.md#create_proc_gen_ceiling_tiles)
+  - [`destroy_proc_gen_ceiling_tiles`](../../api/command_api.md#destroy_proc_gen_ceiling_tiles)
+  - [`set_proc_gen_ceiling_material`](../../api/command_api.md#set_proc_gen_ceiling_material)
+  - [`set_proc_gen_ceiling_texture_scale`](../../api/command_api.md#set_proc_gen_ceiling_texture_scale)
+  - [`set_proc_gen_ceiling_color`](../../api/command_api.md#set_proc_gen_ceiling_color)
diff --git a/Documentation/lessons/objects_and_scenes/raycast.md b/Documentation/lessons/objects_and_scenes/raycast.md
new file mode 100644
index 000000000..7e7c9e492
--- /dev/null
+++ b/Documentation/lessons/objects_and_scenes/raycast.md
@@ -0,0 +1,194 @@
+##### Objects and Scenes
+
+# `Raycast` output data
+
+A **raycast** is a ray cast from an origin position to a destination position; it returns useful information such as whether the ray hit something in between the two points (and if so, what it hit and where). Raycasts are commonly used in video games to solve many problems such as determining line of sight or procedural generation. 
+
+In TDW, you can raycast by sending [`send_raycast`](../../api/command_api.md#send_raycast) which returns [`Raycast`](../../api/output_data.md#Raycast) output data.
+
+There's a few unusual qualities to raycast data that should be noted here:
+
+- `send_raycast` has an `id` parameter. The returned `Raycast` data will have a matching ID. You can use this ID to send multiple `send_raycast` commands on the same frame and match them to their output.
+- Raycasting does *not* require an avatar (camera).
+- Unlike output data you've seen so far such as [`Transforms`](../core_concepts/output_data.md) or [`Bounds`](bounds.md), `Raycast` output data is always sent exactly once; there isn't an option to raycast per-frame.
+
+## Raycasts and object placement
+
+Suppose we want to add a bowl to the scene and put an object in the bowl. [`Bounds` output data](bounds.md) alone is insufficient for this because it will tell us the y (height) value of the bottom of the bowl but not y value for the top side of the interior surface.
+
+In the below examples, we'll  first learn how to raycast and then use the raycast data to place an object in a bowl.
+
+To start, we'll create a scene with a bowl and enable image capture:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+c = Controller()
+bowl_id = c.get_unique_id()
+
+camera = ThirdPersonCamera(position={"x": 0.5, "y": 1.6, "z": -1},
+                           look_at=bowl_id,
+                           avatar_id="a")
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("raycast")
+print(f"Images will be saved to: {path}")
+capture = ImageCapture(avatar_ids=[camera.avatar_id], path=path, png=True)
+c.add_ons.extend([camera, capture])
+
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               c.get_add_object(position={"x": -1, "y": 0, "z": 0.5},
+                                object_id=bowl_id,
+                                model_name="round_bowl_small_walnut")])
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/raycast/0.png)
+
+Next, we will raycast from a point above the bowl to a point below the bowl. The ray will stop at a point on the surface of the bowl. Then, we'll add an object at that point:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+from tdw.output_data import OutputData, Raycast
+
+c = Controller()
+bowl_id = c.get_unique_id()
+
+camera = ThirdPersonCamera(position={"x": 0.5, "y": 1.6, "z": -1},
+                           look_at=bowl_id,
+                           avatar_id="a")
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("raycast")
+print(f"Images will be saved to: {path}")
+capture = ImageCapture(avatar_ids=[camera.avatar_id], path=path, png=True)
+c.add_ons.extend([camera, capture])
+
+bowl_position = {"x": -1, "y": 0, "z": 0.5}
+
+raycast_id = c.get_unique_id()
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(position=bowl_position,
+                                       object_id=bowl_id,
+                                       model_name="round_bowl_small_walnut"),
+                      {"$type": "send_raycast",
+                       "origin": {"x": bowl_position["x"], "y": 100, "z": bowl_position["z"]},
+                       "destination": {"x": bowl_position["x"], "y": -100, "z": bowl_position["z"]},
+                       "id": raycast_id}])
+commands = []
+for i in range(len(resp) - 1):
+    # Get raycast output data.
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "rayc":
+        raycast = Raycast(resp[i])
+        # Is this the same raycast that we requested?
+        if raycast.get_raycast_id() == raycast_id:
+            # Did the raycast hit anything (including the floor)?
+            # Did the raycast hit an object?
+            # Did the raycast hit the bowl?
+            if raycast.get_hit() and raycast.get_hit_object() and raycast.get_object_id() == bowl_id:
+                # Get the point that the raycast hit.
+                position = raycast.get_point()
+                # Add an object at that position.
+                commands.append(c.get_add_object(model_name="jug03",
+                                                 position=TDWUtils.array_to_vector3(position),
+                                                 object_id=c.get_unique_id()))
+c.communicate(commands)
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/raycast/1.png)
+
+## Shape casts
+
+It's possible to cast *shapes* from an origin to a destination. They will return multiple `Raycast` data objects with information regarding what the shape hit along the way.
+
+The commands are:
+
+-  [`send_boxcast`](../../api/command_api.md#send_boxcast)
+-  [`send_spherecast`](../../api/command_api.md#send_spherecast)
+
+In this example, we'll add a bowl to the scene and then spherecast from a point above it. We'll then get all of the points of the spherecast on the bowl and then place an object at a randomly chosen point:
+
+```python
+import random
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+from tdw.output_data import OutputData, Raycast
+
+c = Controller()
+bowl_id = c.get_unique_id()
+
+camera = ThirdPersonCamera(position={"x": 0.5, "y": 1.6, "z": -1},
+                           look_at=bowl_id,
+                           avatar_id="a")
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("spherecast")
+print(f"Images will be saved to: {path}")
+capture = ImageCapture(avatar_ids=[camera.avatar_id], path=path, png=True)
+c.add_ons.extend([camera, capture])
+
+bowl_position = {"x": -1, "y": 0, "z": 0.5}
+
+raycast_id = c.get_unique_id()
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(position=bowl_position,
+                                       object_id=bowl_id,
+                                       model_name="round_bowl_small_walnut"),
+                      {"$type": "send_spherecast",
+                       "radius": 0.3,
+                       "origin": {"x": bowl_position["x"], "y": 100, "z": bowl_position["z"]},
+                       "destination": {"x": bowl_position["x"], "y": -100, "z": bowl_position["z"]},
+                       "id": raycast_id}])
+hits = []
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "rayc":
+        raycast = Raycast(resp[i])
+        if raycast.get_raycast_id() == raycast_id:
+            if raycast.get_hit() and raycast.get_hit_object() and raycast.get_object_id() == bowl_id:
+                hits.append(raycast.get_point())
+jug_position = random.choice(hits)
+# Add an object at a random position on the bowl.
+c.communicate(c.get_add_object(model_name="jug03",
+                               position=TDWUtils.array_to_vector3(jug_position),
+                               object_id=c.get_unique_id()))
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/raycast/spherecast.png)
+
+***
+
+**Next: [`Overlap` output data](overlap.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [raycast.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/objects_and_scenes/raycast.py) Use raycast data to place an object in a bowl.
+- [spherecast.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/objects_and_scenes/spherecast.py) Use spherecast data to place an object in a bowl.
+
+Command API:
+
+-  [`send_raycast`](../../api/command_api.md#send_raycast)
+-  [`send_boxcast`](../../api/command_api.md#send_boxcast)
+-  [`send_spherecast`](../../api/command_api.md#send_spherecast)
+
+Output Data:
+
+- [`Raycast`](../../api/output_data.md#Raycast)
diff --git a/Documentation/lessons/objects_and_scenes/reset_scene.md b/Documentation/lessons/objects_and_scenes/reset_scene.md
new file mode 100644
index 000000000..528bbc7de
--- /dev/null
+++ b/Documentation/lessons/objects_and_scenes/reset_scene.md
@@ -0,0 +1,154 @@
+##### Objects and Scenes
+
+# Reset a scene
+
+In general, the goal of resetting a scene is to revert the scene and its objects to an initial state. 
+
+Resetting a scene is typically very fast because of how TDW manages [asset bundles](https://docs.unity3d.com/Manual/AssetBundlesIntro.html) in memory.
+
+Object asset bundles stay in memory unless otherwise requested. This means that while the *first* time you add an object, it can take a long time to load, but adding subsequent copies of the object will be extremely fast. Even if an object is destroyed, its asset bundle will stay in memory.
+
+Scene asset bundles are handled slightly differently: When a new scene is loaded, the previous scene is unloaded from memory.
+
+Therefore, to reset a scene, there are two techniques in TDW that yield best results:
+
+1. **Don't unload the scene unless you have to.** Group trials by scene. For example, if a trial can occur in one of six scenes, do trials per scene:
+
+```python
+from tdw.controller import Controller
+
+class ResetScene(Controller):
+    def do_trials(self, num_trials: int):
+        scenes = ["tdw_room", "iceland_beach", "lava_field", "abandoned_factory"]
+        # Divided the total number of trials by the number of scenes.
+        num_trials_per_scene = int(num_trials / len(scenes))
+        # Initialize a scene for the next batch of trials.
+        for scene in scenes:
+            self.communicate(self.get_add_scene(scene_name=scene))
+            # Do trials for this scene.
+            self.do_trials_in_scene(num_trials=num_trials_per_scene)
+        self.communicate({"$type": "terminate"})
+               
+    def do_trials_in_scene(self, num_trials: int):
+        for i in range(num_trials):
+            # Your code here.
+            pass
+
+
+if __name__ == "__main__":
+    c = ResetScene()
+    c.do_trials(num_trials=10000)
+```
+
+2. **Destroy all objects in the scene with [`destroy_object`](../../api/command_api.md#destroy_object) or [`destroy_all_objects`](../../api/command_api.md#destroy_all_objects)**. `destroy_object` destroys a specific object and `destroy_all_objects` destroys all objects in the scene. You should also set the `"frequency"` of any ongoing output data commands such as [`send_rigidbodies`](../../api/command_api.md#send_rigidbodies) to `"never"`; otherwise, they'll try to continue to send output data for non-existent objects.
+
+   In the following example, an object is added to the scene above floor level and allowed to fall. The trial finishes when the object stops moving. Then the object is destroyed and the controller stops requesting output data:
+
+```python
+import numpy as np
+from tdw.controller import Controller
+from tdw.output_data import OutputData, Rigidbodies
+
+class ResetScene(Controller):
+    def __init__(self, port: int = 1071, launch_build: bool = True, random_seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(random_seed)
+
+    def do_trials(self, num_trials: int):
+        scenes = ["tdw_room", "iceland_beach", "lava_field", "abandoned_factory"]
+        # Divided the total number of trials by the number of scenes.
+        num_trials_per_scene = int(num_trials / len(scenes))
+        # Initialize a scene for the next batch of trials.
+        for scene in scenes:
+            self.communicate(self.get_add_scene(scene_name=scene))
+            # Do trials for this scene.
+            self.do_trials_in_scene(num_trials=num_trials_per_scene)
+        self.communicate({"$type": "terminate"})
+
+    def do_trials_in_scene(self, num_trials: int):
+        for i in range(num_trials):
+            # Add an object with a random rotation and starting height.
+            object_id = self.get_unique_id()
+            resp = self.communicate([self.get_add_object(model_name="rh10",
+                                                         position={"x": 0, "y": self.rng.uniform(1.6, 3), "z": 0},
+                                                         rotation={"x": self.rng.uniform(-360, 360),
+                                                                   "y": self.rng.uniform(-360, 360),
+                                                                   "z": self.rng.uniform(-360, 360)},
+                                                         object_id=object_id),
+                                     {"$type": "send_rigidbodies",
+                                      "frequency": "always"}])
+
+            done = False
+            while not done:
+                # Check if the object stopped moving.
+                for j in range(len(resp) - 1):
+                    r_id = OutputData.get_data_type_id(resp[j])
+                    if r_id == "rigi":
+                        rigidbodies = Rigidbodies(resp[j])
+                        for k in range(rigidbodies.get_num()):
+                            if rigidbodies.get_id(k) == object_id:
+                                done = rigidbodies.get_sleeping(k)
+                # Advance another frame.
+                resp = self.communicate([])
+
+            # Reset the scene by destroying the object.
+            self.communicate([{"$type": "destroy_object",
+                               "id": object_id},
+                              {"$type": "send_rigidbodies",
+                               "frequency": "never"}])
+
+
+if __name__ == "__main__":
+    c = ResetScene()
+    c.do_trials(num_trials=10000)
+```
+
+## Unloading asset bundles from memory
+
+Model asset bundles can require a lot of memory. You can check the size of an asset bundle via `record.asset_bundle_sizes`:
+
+```python
+from tdw.librarian import ModelLibrarian
+
+total_size = 0
+lib = ModelLibrarian()
+for record in lib.records:
+    total_size += record.asset_bundle_sizes["Linux"]
+print(total_size / (1 << 30), "GB")  # 1.5575027372688055 GB
+```
+
+[Other model libraries in TDW](../3d_models/overview.md) can be much larger than this: For example, the full model library is approximately 61 GB.
+
+If your simulation includes many models, but not all at the same time, you might want to periodically unload model asset bundles from memory with the [`unload_asset_bundles` command](../../api/command_api.md#unload_asset_bundles). This will free up your machine's memory. However, the next time you create an object, the build will need to re-download and re-load the asset bundle back into memory.
+
+## Reset a scene to an arbitrary state
+
+Unfortunately, it isn't possible to reset to an arbitrary frame in the middle of a trial because Unity's physics engine isn't set up to allow "snapshots" of its current state. It's only possible to reset to an initial state, not a state during a trial.
+
+***
+
+**This is the last document in the "Objects and Scenes" tutorial.**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [reset_scene.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/objects_and_scenes/reset_scene.py) Create multiple trials of an object falling and reset the scene between trials.
+
+Python API:
+
+- [`ModelRecord.asset_bundle_sizes`](../../python/librarian/model_librarian.md) The size of each asset bundle per operating system.
+
+Command API:
+
+- [`destroy_object`](../../api/command_api.md#destroy_object)
+- [`destroy_all_objects`](../../api/command_api.md#destroy_all_objects)
+- [`send_rigidbodies`](../../api/command_api.md#send_rigidbodies)
+- [`unload_asset_bundles`](../../api/command_api.md#unload_asset_bundles)
+
+Output Data API:
+
+- [`Rigidbodies`](../../api/output_data.md#Rigidbodies)
+
diff --git a/Documentation/lessons/objects_and_scenes/units.md b/Documentation/lessons/objects_and_scenes/units.md
new file mode 100644
index 000000000..1a692e639
--- /dev/null
+++ b/Documentation/lessons/objects_and_scenes/units.md
@@ -0,0 +1,56 @@
+##### Objects and Scenes
+
+# Units and data formats
+
+## Units
+
+- Distance is always in meters.
+- Rotation is always in degrees.
+- Velocity is always in meters per second.
+- Angular velocity is always in degrees per second.
+- Force is always in Newtons.
+- For directional vectors such as positions and forces:
+  - Positive x is to the right.
+  - Positive y is up.
+  - Positive z is forward.
+
+## Data formats: Command API
+
+| Type                                       | Format                                                       | Example                              |
+| ------------------------------------------ | ------------------------------------------------------------ | ------------------------------------ |
+| Directional vector (position, force, etc.) | An (x, y, z) dictionary.                                     | `{"x": 0.1, "y": 0, "z": -2}`        |
+| Euler angles                               | An (x, y, z) dictionary.                                     | `{"x": 20, "y": -5, "z": 0}`         |
+| Quaternion                                 | An (x, y, z, w) dictionary.                                  | `{"x": 0, "y": 0, "z": 0, "w": 1}`   |
+| Color                                      | An (r, g, b, a) dictionary where each value is a float between 0 and 1. | `{"r": 1, "b": 0.2, "g": 0, "a": 1}` |
+
+## Data formats: Output Data
+
+| Type                                       | Format                                                       | Example        |
+| ------------------------------------------ | ------------------------------------------------------------ | -------------- |
+| Directional vector (position, force, etc.) | An (x, y, z) tuple                                           | `(0.1, 0, -2)` |
+| Euler angles                               | An (x, y, z) tuple                                           | `(20, -5, 0)`  |
+| Quaternion                                 | An (x, y, z, w) tuple                                        | `(0, 0, 0, 1)` |
+| Color                                      | An (r, g, b) tuple where each element is a byte value between 0 and 255 | `(255, 0, 34)` |
+
+## Useful wrapper functions in `TDWUtils`
+
+| Wrapper function            | Result                                               |
+| --------------------------- | ---------------------------------------------------- |
+| `vector3_to_array(vector3)` | Convert an (x, y, z) dictionary to a numpy array.    |
+| `array_to_vector3(arr)`     | Convert an [x, y, z] numpy array to a dictionary.    |
+| `vector4_to_array(vector4)` | Convert an (x, y, z, w) dictionary to a numpy  array |
+| `array_to_vector4(arr)`     | Convert an [x, y, z, w] numpy array to a dictionary. |
+| `color_to_array(color`)     | Convert an (r, g, b, a) dictionary to a numpy array. |
+| `array_to_color(arr)`       | Convert an [r, g, b, a] numpy array to a dictionary. |
+
+***
+
+**Next: [`Bounds` output data](bounds.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Python API:
+
+- [`TDWUtils`](../../python/tdw_utils.md)
diff --git a/Documentation/lessons/physx/collisions.md b/Documentation/lessons/physx/collisions.md
new file mode 100644
index 000000000..00ce54ddb
--- /dev/null
+++ b/Documentation/lessons/physx/collisions.md
@@ -0,0 +1,257 @@
+##### Physics (PhysX)
+
+# `Collision` output data
+
+In [PhysX](physx.md), collisions occur automatically whenever an object comes into contact with another object:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+c = Controller()
+camera = ThirdPersonCamera(position={"x": -3, "y": 2.1, "z": 0.5},
+                           look_at={"x": 0, "y": 0, "z": 0})
+c.add_ons.append(camera)
+commands = [TDWUtils.create_empty_room(12, 12)]
+commands.extend(c.get_add_physics_object(model_name="iron_box",
+                                         object_id=c.get_unique_id(),
+                                         position={"x": 0, "y": 3, "z": 0}))
+commands.extend(c.get_add_physics_object(model_name="rh10",
+                                         object_id=c.get_unique_id(),
+                                         position={"x": 0, "y": 0, "z": 0}))
+c.communicate(commands)
+for i in range(200):
+    c.communicate([])
+c.communicate({"$type": "terminate"})
+
+```
+
+Result:
+
+![](images/collision.gif)
+
+A **collision** is a discrete event at points of contact between a pair of objects. Collisions can occur between two non-kinematic objects as well as a kinematic object and a non-kinematic object.
+
+There are three types of collision events:
+
+- Enter (the collision began on this frame)
+- Stay (the collision has persisted on this frame)
+- Exit (the collision has ended on this frame)
+
+To listen for collisions in TDW, send [`send_collisions`](../../api/command_api.md#send_collisions):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+
+commands = [TDWUtils.create_empty_room(12, 12)]
+dropped_object_id = c.get_unique_id()
+ground_object_id = c.get_unique_id()
+# Add two objects.
+commands.extend(c.get_add_physics_object(model_name="iron_box",
+                                         object_id=dropped_object_id,
+                                         position={"x": 0, "y": 3, "z": 0}))
+commands.extend(c.get_add_physics_object(model_name="rh10",
+                                         object_id=ground_object_id,
+                                         position={"x": 0, "y": 0, "z": 0}))
+# Request collisions data.
+commands.append({"$type": "send_collisions",
+                 "enter": True, 
+                 "stay": False,
+                 "exit": False, 
+                 "collision_types": ["obj"]})
+c.communicate(commands)
+```
+
+`send_collision` is structured differently than output data commands you've seen so far, such as [`send_rigidbodies`](../../api/command_api.md#send_rigidbodies):
+
+- There isn't an optional `"ids"` parameter to request collision data for certain objects; you can request data for all of them or none.
+- There isn't an optional `"frequency"` parameter; collision data is sent whenever a collision event occurs and *only* when a collision event occurs.
+
+Note that we've set `"stay"` to `False`; collision stay events are *very* frequent. Setting this to `True` can *significantly* slow down your simulation.
+
+Having sent `send_collisions`, we'll receive collision output data. Unlike most output data such as [`Rigidbodies`](../../api/output_data.md#Rigidbodies), every collision is returned as a *separate* [`Collision`](../../api/output_data.md#Collision) object:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, Collision
+
+c = Controller()
+
+commands = [TDWUtils.create_empty_room(12, 12)]
+dropped_object_id = c.get_unique_id()
+ground_object_id = c.get_unique_id()
+# Add two objects.
+commands.extend(c.get_add_physics_object(model_name="iron_box",
+                                         object_id=dropped_object_id,
+                                         position={"x": 0, "y": 3, "z": 0}))
+commands.extend(c.get_add_physics_object(model_name="rh10",
+                                         object_id=ground_object_id,
+                                         position={"x": 0, "y": 0, "z": 0}))
+# Request collisions data.
+commands.append({"$type": "send_collisions",
+                 "enter": True,
+                 "stay": False,
+                 "exit": False,
+                 "collision_types": ["obj"]})
+resp = c.communicate(commands)
+
+for i in range(100):
+    for j in range(len(resp) - 1):
+        r_id = OutputData.get_data_type_id(resp[j])
+        if r_id == "coll":
+            collision = Collision(resp[j])
+            print(i, collision.get_collider_id(), collision.get_collidee_id(), collision.get_state(),
+                  collision.get_relative_velocity())
+            for k in range(collision.get_num_contacts()):
+                print("\t", collision.get_contact_normal(k), collision.get_contact_point(k))
+    resp = c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+- Collider ID and collidee ID are the objects IDs of the two colliding objects. The build will only send unique collision events--you'll never receive a second `Collision` object on the same frame with the collider and collidee reversed.
+- State is the event type ("enter", "stay", or "exit")
+- Relative velocity is the relative velocity between the two colliding objects
+- Contact points are specific points of contact during a collision. The normal defines the vector of the collision and the point is the position of the contact. 
+
+## `EnvironmentCollision` output data
+
+In TDW (but not in PhysX), there is a distinction between background meshes with colliders (the scene) and dynamic objects.
+
+Meshes in scenes aren't cached like objects; they don't have IDs, they don't return object data, and so on. Accordingly, the build can't write `Collision` data if an object collides with a scene mesh.
+
+To deal with this, there is a separate [`EnvironmentCollision`](../../api/output_data.md#EnvironmentCollision) output data type for collisions between the object and the environment. To request this data, send `send_collisions` but add `"env"` to the list of `"collision_types"`:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, Collision, EnvironmentCollision
+
+c = Controller()
+
+commands = [TDWUtils.create_empty_room(12, 12)]
+dropped_object_id = c.get_unique_id()
+ground_object_id = c.get_unique_id()
+# Add two objects.
+commands.extend(c.get_add_physics_object(model_name="iron_box",
+                                         object_id=dropped_object_id,
+                                         position={"x": 0, "y": 3, "z": 0}))
+commands.extend(c.get_add_physics_object(model_name="rh10",
+                                         object_id=ground_object_id,
+                                         position={"x": 0, "y": 0, "z": 0}))
+# Request collisions data.
+commands.append({"$type": "send_collisions",
+                 "enter": True,
+                 "stay": False,
+                 "exit": False,
+                 "collision_types": ["obj", "env"]})
+resp = c.communicate(commands)
+
+for i in range(100):
+    for j in range(len(resp) - 1):
+        r_id = OutputData.get_data_type_id(resp[j])
+        if r_id == "coll":
+            collision = Collision(resp[j])
+            print(i, collision.get_collider_id(), collision.get_collidee_id(), collision.get_state(),
+                  collision.get_relative_velocity())
+            for k in range(collision.get_num_contacts()):
+                print("\t", collision.get_contact_normal(k), collision.get_contact_point(k))
+        elif r_id == "enco":
+            environment_collision = EnvironmentCollision(resp[j])
+            print(i, environment_collision.get_object_id(), environment_collision.get_state(),
+                  environment_collision.get_floor())
+            for k in range(environment_collision.get_num_contacts()):
+                print("\t", environment_collision.get_contact_normal(k), environment_collision.get_contact_point(k))
+    resp = c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+- State and contact point data is the same as in `Collision` output data.
+- Floor is True if the object collided with the floor or surface of the scene.
+- `EnvironmentCollision` doesn't include relative velocity.
+
+## The `CollisionManager` add-on
+
+TDW includes a convenient [`CollisionManager` add-on](../../python/add_ons/collision_manager.md) for handling collision data. The `CollisionManager` will automatically send `send_collisions` when it is initialized.
+
+- While the output data returns velocities, contact normals, and so on as (x, y, z) tuples, this data is converted to numpy arrays by the `CollisionManager`.
+- `CollisionManager.obj_collisions` is a dictionary where each key is an [`IntPair`](../../api/int_pair.md) of object IDs.
+- `CollisionManager.env_collisions` is a dictionary where each key is an object ID.
+
+The following example does the same thing as the previous example but uses a `CollisionManager`:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.collision_manager import CollisionManager
+
+c = Controller()
+
+# Add a collision manager
+collision_manager = CollisionManager(enter=True, stay=False, exit=False, objects=True, environment=True)
+c.add_ons.append(collision_manager)
+
+commands = [TDWUtils.create_empty_room(12, 12)]
+dropped_object_id = c.get_unique_id()
+ground_object_id = c.get_unique_id()
+# Add two objects.
+commands.extend(c.get_add_physics_object(model_name="iron_box",
+                                         object_id=dropped_object_id,
+                                         position={"x": 0, "y": 3, "z": 0}))
+commands.extend(c.get_add_physics_object(model_name="rh10",
+                                         object_id=ground_object_id,
+                                         position={"x": 0, "y": 0, "z": 0}))
+c.communicate(commands)
+for i in range(100):
+    for object_ids in collision_manager.obj_collisions:
+        print(i, object_ids.int1, object_ids.int2, collision_manager.obj_collisions[object_ids].state,
+              collision_manager.obj_collisions[object_ids].relative_velocity)
+        for normal, point in zip(collision_manager.obj_collisions[object_ids].normals, 
+                                 collision_manager.obj_collisions[object_ids].points):
+            print("\t", normal, point)
+    for object_id in collision_manager.env_collisions:
+        print(i, object_id, collision_manager.env_collisions[object_id].state,
+              collision_manager.env_collisions[object_id].floor)
+        for normal, point in zip(collision_manager.env_collisions[object_id].normals, 
+                                 collision_manager.env_collisions[object_id].points):
+            print("\t", normal, point)
+    resp = c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+## Resetting a scene
+
+When you want to [reset a scene](../objects_and_scenes/reset_scene.md), be aware that you'll need to re-send `send_collisions` again.
+
+If you're using a `CollisionManager`, set `collision_mananger.initialized = False` , which will make the manager re-initialize on the next frame.
+
+***
+
+**Next: [Apply forces to objects](forces.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [collisions.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/physx/collisions.py) Listen for collisions with a `CollisionManager`.
+
+Python API:
+
+- [`CollisionManager`](../../python/add_ons/collision_manager.md)
+- [`IntPair`](../../api/int_pair.md)
+
+Command API:
+
+- [`send_collisions`](../../api/command_api.md#send_collisions)
+
+Output Data API:
+
+- [`Collision`](../../api/output_data.md#Collision)
+- [`EnvironmentCollision`](../../api/output_data.md#EnvironmentCollision)
+- [`Rigidbodies`](../../api/output_data.md#Rigidbodies)
diff --git a/Documentation/lessons/physx/composite_objects.md b/Documentation/lessons/physx/composite_objects.md
new file mode 100644
index 000000000..73a72b332
--- /dev/null
+++ b/Documentation/lessons/physx/composite_objects.md
@@ -0,0 +1,95 @@
+##### Physics (PhysX)
+
+# Composite objects (objects with affordances)
+
+**Composite objects** are objects in TDW that have multiple "sub-objects". Sub-objects are differences from [sub-*meshes*](../objects_and_scenes/materials_textures_colors.md) in that they appear in [output data](../core_concepts/output_data.md) as separate objects with separate IDs and [segmentation colors](../visual_perception/id.md).
+
+Composite objects can be:
+
+- Objects with hinged joints such as a fridge with doors
+- Objects with hinged motorized joints such as a fan
+- Multiple disconnected objects such as a pot with a lid 
+- An object with a light source such as a lamp
+
+## Composite objects in the model library
+
+Usually, composite objects in the [model library](../core_concepts/objects.md) have a `_composite` suffix in their names. However, a more reliable way to find composite objects is to test the `composite_object` boolean value:
+
+```python
+from tdw.librarian import ModelLibrarian
+
+lib = ModelLibrarian()
+for record in lib.records:
+    if record.composite_object and not record.do_not_use:
+        print(record.name)
+```
+
+## Manipulating composite objects
+
+Sub-objects will respond to TDW commands just like any other object; you can, for example, [apply forces](forces.md) to individual sub-objects. Sub-objects likewise appear as separate objects in the output data.
+
+Some sub-objects have additional functionality and specialized commands. To determine what they are, you must first send [`send_composite_objects`](../../api/command_api.md#send_composite_objects) which returns [`CompositeObjects`](../../api/output_data.md#CompositeObjects) output data:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData,CompositeObjects
+
+c = Controller()
+object_id = c.get_unique_id()
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(model_name="puzzle_box_composite",
+                                       object_id=object_id),
+                      {"$type": "send_composite_objects"}])
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "comp":
+        composite_objects = CompositeObjects(resp[i])
+        # Iterate through each composite object.
+        for j in range(composite_objects.get_num()):
+            # Get the target composite object.
+            if composite_objects.get_object_id(j) == object_id:
+                # Iterate through each sub-object.
+                for k in range(composite_objects.get_num_sub_objects(j)):
+                    sub_object_id = composite_objects.get_sub_object_id(j, k)
+                    sub_object_machine_type = composite_objects.get_sub_object_machine_type(j, k)
+                    print(sub_object_id, sub_object_machine_type)
+    c.communicate({"$type": "terminate"})
+```
+
+The "sub-object machine type" determines which API command can be used for this sub-object:
+
+| Machine type | Behavior                                                     | Example                | Command                                                      |
+| ------------ | ------------------------------------------------------------ | ---------------------- | ------------------------------------------------------------ |
+| `"light"`    | Can be turned on and off.                                    | A lightbulb            | [`set_sub_object_light`](../../api/command_api.md#set_sub_object_light) |
+| `"motor"`    | Can rotate on a pivot point around an axis by applying a target velocity and a force magnitude. | A helicopter propeller | [`set_motor`](../../api/command_api.md#set_motor)            |
+| `"hinge"`    | Swings freely on a pivot point around an axis.               | A box with a lid       |                                                              |
+| `"spring"`   | Can rotate on a pivot point around an axis by applying a target position. The motion will appear "spring-like". | Jack-in-the-box        | [`set_spring`](../../api/command_api.md#set_spring)          |
+| `"none"`     | (no mechanism)                                               | A chest of drawers     |                                                              |
+
+***
+
+**Next: [Skip physics frames](step_physics.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [composite_object.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/physx/composite_object.py) Demonstration of how to use composite sub-objects with a test model.
+
+Python API:
+
+- [`ModelRecord.composite_object`](../../python/librarian/model_librarian.md) True if the record is for a composite object.
+
+Command API:
+
+- [`set_sub_object_light`](../../api/command_api.md#set_sub_object_light)
+- [`set_motor`](../../api/command_api.md#set_motor)
+- [`set_spring`](../../api/command_api.md#set_spring) 
+- [`send_composite_objects`](../../api/command_api.md#send_composite_objects)
+
+Output Data:
+
+- [`CompositeObjects`](../../api/output_data.md#CompositeObjects) 
\ No newline at end of file
diff --git a/Documentation/lessons/physx/disable_physics.md b/Documentation/lessons/physx/disable_physics.md
new file mode 100644
index 000000000..0d131695d
--- /dev/null
+++ b/Documentation/lessons/physx/disable_physics.md
@@ -0,0 +1,49 @@
+##### Physics (PhysX)
+
+# Disable physics
+
+By default, physics (and the PhysX physics engine) is enabled in TDW.
+
+It is possible to *disable* physics by sending [`simulate_physics`](../../api/command_api.md#simulate_physics):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+c = Controller()
+camera = ThirdPersonCamera(position={"x": -3, "y": 2.1, "z": 0.5},
+                           look_at={"x": 0, "y": 0, "z": 0})
+c.add_ons.append(camera)
+commands = [TDWUtils.create_empty_room(12, 12),
+            {"$type": "simulate_physics",
+             "value": False}]
+commands.extend(c.get_add_physics_object(model_name="iron_box",
+                                         object_id=c.get_unique_id(),
+                                         position={"x": 0, "y": 1, "z": 0}))
+c.communicate(commands)
+
+# The model will hang in mid-air.
+for i in range(200):
+    c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+Typically, users want to disable physics if they are setting up a scene that involves image capture but not physics. 
+
+Besides the obvious (objects won't move due to physics), there are some other effects of disabling physics:
+
+1. [Collision detection](collisions.md) is disabled.
+2. [Raycasts](../../objects_and_scenes/raycast.md) and [overlaps](../../objects_and_scenes/overlaps.md) won't work.
+
+***
+
+**This is the last document in the "Physics (PhysX)" tutorial.**
+
+[Return to the README](../../../README.md)
+
+***
+
+Command API:
+
+- [`simulate_physics`](../../api/command_api.md#simulate_physics)
\ No newline at end of file
diff --git a/Documentation/lessons/physx/forces.md b/Documentation/lessons/physx/forces.md
new file mode 100644
index 000000000..4066e4ae6
--- /dev/null
+++ b/Documentation/lessons/physx/forces.md
@@ -0,0 +1,238 @@
+##### Physics (PhysX)
+
+# Apply forces to objects
+
+So far, this tutorial has covered examples of physical interactions that occur due to gravitational force. It is also possible to programmatically apply additional forces to objects.
+
+## Apply forces and torques
+
+The simplest way to do this is by sending [`apply_force_to_object`](../../api/command_api.md#apply_force_to_object). The `"force"` parameter is the force vector in Newtons. The force is applied instantly as an impulse.
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.object_manager import ObjectManager
+
+c = Controller()
+camera = ThirdPersonCamera(position={"x": -3, "y": 2.1, "z": 0.5},
+                           look_at={"x": 0, "y": 0, "z": 0})
+object_manager = ObjectManager(rigidbodies=True)
+c.add_ons.extend([object_manager, camera])
+object_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12)]
+commands.extend(c.get_add_physics_object(model_name="small_table_green_marble",
+                                         object_id=object_id))
+
+# Apply a force.
+commands.append({"$type": "apply_force_to_object",
+                 "id": object_id,
+                 "force": {"x": -8, "y": 700, "z": 5}})
+
+c.communicate(commands)
+
+while not object_manager.rigidbodies[object_id].sleeping:
+    c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/forces/apply_force_to_object.gif)
+
+You can add a torque by sending [`apply_torque_to_object`](../../api/command_api.md#apply_torque_to_object):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.object_manager import ObjectManager
+
+c = Controller()
+camera = ThirdPersonCamera(position={"x": -3, "y": 2.1, "z": 0.5},
+                           look_at={"x": 0, "y": 0, "z": 0})
+object_manager = ObjectManager(rigidbodies=True)
+c.add_ons.extend([object_manager, camera])
+object_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12)]
+commands.extend(c.get_add_physics_object(model_name="small_table_green_marble",
+                                         object_id=object_id))
+
+# Apply a force and a torque.
+commands.extend([{"$type": "apply_force_to_object",
+                  "id": object_id,
+                  "force": {"x": -8, "y": 700, "z": 5}},
+                 {"$type": "apply_torque_to_object",
+                  "id": object_id,
+                  "torque": {"x": 50, "y": 120, "z": 1}}])
+
+c.communicate(commands)
+
+while not object_manager.rigidbodies[object_id].sleeping:
+    c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/forces/apply_torque_to_object.gif)
+
+To apply a directional force at a specific position, send  [`apply_force_at_position`](../../api/command_api.md#apply_force_at_position):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.object_manager import ObjectManager
+
+c = Controller()
+camera = ThirdPersonCamera(position={"x": -3, "y": 2.1, "z": 0.5},
+                           look_at={"x": 0, "y": 0, "z": 0})
+object_manager = ObjectManager(rigidbodies=True)
+c.add_ons.extend([object_manager, camera])
+object_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12)]
+commands.extend(c.get_add_physics_object(model_name="small_table_green_marble",
+                                         object_id=object_id))
+
+# Apply a force at a position.
+commands.append({"$type": "apply_force_at_position",
+                 "id": object_id,
+                 "force": {"x": 0, "y": 110, "z": 0},
+                 "position": {"x": 0, "y": 0.2, "z": -0.5}})
+
+c.communicate(commands)
+
+while not object_manager.rigidbodies[object_id].sleeping:
+    c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/forces/apply_force_at_position.gif)
+
+## Apply a force along a forward directional vector
+
+Send  [`apply_force_magnitude_to_object`](../../api/command_api.md#apply_force_magnitude_to_object) to apply a force of a given magnitude along an objects forward directional vector. This can be convenient for aiming an object at a position.
+
+In this example, we'll add a table. Using an [`ObjectManager`](../../python/add_ons/object_manager.md), we'll get the bounds of the table and its top position. Then we'll add a ball; on the same frame that the ball is added, we'll orient the ball using the command [`object_look_at_position`](../../api/command_api.md#object_look_at_position) and apply a force using  [`apply_force_magnitude_to_object`](../../api/command_api.md#apply_force_magnitude_to_object).
+
+The simulation ends when the ball stops moving. Note that we also need to re-initialize the `ObjectManager` in order to include the ball's rigidbody data because the ball was added after the `ObjectManager` was initialized.
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.object_manager import ObjectManager
+
+c = Controller()
+camera = ThirdPersonCamera(position={"x": -4.5, "y": 2.1, "z": 0.5},
+                           look_at={"x": 0, "y": 0, "z": 0})
+object_manager = ObjectManager(transforms=False, bounds=True, rigidbodies=True)
+c.add_ons.extend([camera, object_manager])
+table_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12)]
+commands.extend(c.get_add_physics_object(model_name="small_table_green_marble",
+                                         object_id=table_id))
+c.communicate(commands)
+
+# Get the top of the table.
+table_top = object_manager.bounds[table_id].top
+
+ball_id = c.get_unique_id()
+# Add a ball. Note that this is from the models_special.json model library.
+commands = c.get_add_physics_object(model_name="prim_sphere",
+                                    library="models_special.json",
+                                    position={"x": 0.5, "y": 4, "z": -1.3},
+                                    scale_factor={"x": 0.2, "y": 0.2, "z": 0.2},
+                                    default_physics_values=False,
+                                    mass=10,
+                                    dynamic_friction=0.3,
+                                    static_friction=0.3,
+                                    bounciness=0.7,
+                                    object_id=ball_id)
+# Orient the ball to look at the top of the table. Apply a force.
+commands.extend([{"$type": "object_look_at_position",
+                 "position": TDWUtils.array_to_vector3(table_top),
+                 "id": ball_id},
+                 {"$type": "apply_force_magnitude_to_object",
+                  "magnitude": 60,
+                  "id": ball_id}])
+# Re-initialize the object manager.
+object_manager.initialized = False
+c.communicate(commands)
+
+# Wait until the ball stops moving.
+while not object_manager.rigidbodies[ball_id].sleeping:
+    c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/forces/apply_force_magnitude_to_object.gif)
+
+## Apply constant forces and torques
+
+You can apply a *constant* directional force and torque to an object with [`add_constant_force`](../../api/command_api.md#add_constant_force). Unlike other force commands, the physics engine will continuously apply force per physics step:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+c = Controller()
+camera = ThirdPersonCamera(position={"x": -3, "y": 2.1, "z": 0.5},
+                           look_at={"x": 0, "y": 0, "z": 0})
+c.add_ons.append(camera)
+object_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12)]
+commands.extend(c.get_add_physics_object(model_name="small_table_green_marble",
+                                         object_id=object_id))
+
+# Apply a constant force.
+commands.append({"$type": "add_constant_force",
+                 "id": object_id,
+                 "force": {"x": 0, "y": 0, "z": 0},
+                 "relative_force": {"x": 0, "y": 1200, "z": 0},
+                 "torque": {"x": 0, "y": 0, "z": 0},
+                 "relative_torque": {"x": 0, "y": 0, "z": 0}})
+
+c.communicate(commands)
+
+for i in range(200):
+    c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/forces/add_constant_force.gif)
+
+***
+
+**Next: [Composite objects (objects with affordances)](composite_objects.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [ball_bounce.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/physx/ball_bounce.py) Bounce a ball on a table.
+- [collision_and_friction.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/physx/collision_and_friction.py) Collide an object with another with varying physics values.
+- [forcefield.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/physx/forcefield.py) Simulate a "forcefield" that objects will bounce off of.
+
+Python API
+
+- [`ObjectManager`](../../python/add_ons/object_manager.md)
+
+Command API:
+
+- [`apply_force_to_object`](../../api/command_api.md#apply_force_to_object)
+- [`apply_torque_to_object`](../../api/command_api.md#apply_torque_to_object)
+- [`apply_force_at_position`](../../api/command_api.md#apply_force_at_position)
+-  [`apply_force_magnitude_to_object`](../../api/command_api.md#apply_force_magnitude_to_object)
+- [`object_look_at_position`](../../api/command_api.md#object_look_at_position)
+- [`add_constant_force`](../../api/command_api.md#add_constant_force)
\ No newline at end of file
diff --git a/Documentation/lessons/physx/images/colliders.png b/Documentation/lessons/physx/images/colliders.png
new file mode 100644
index 000000000..6eb44c94a
Binary files /dev/null and b/Documentation/lessons/physx/images/colliders.png differ
diff --git a/Documentation/lessons/physx/images/collision.gif b/Documentation/lessons/physx/images/collision.gif
new file mode 100644
index 000000000..7030885d8
Binary files /dev/null and b/Documentation/lessons/physx/images/collision.gif differ
diff --git a/Documentation/lessons/physx/images/forces/add_constant_force.gif b/Documentation/lessons/physx/images/forces/add_constant_force.gif
new file mode 100644
index 000000000..f7960c673
Binary files /dev/null and b/Documentation/lessons/physx/images/forces/add_constant_force.gif differ
diff --git a/Documentation/lessons/physx/images/forces/apply_force_at_position.gif b/Documentation/lessons/physx/images/forces/apply_force_at_position.gif
new file mode 100644
index 000000000..0f861b302
Binary files /dev/null and b/Documentation/lessons/physx/images/forces/apply_force_at_position.gif differ
diff --git a/Documentation/lessons/physx/images/forces/apply_force_magnitude_to_object.gif b/Documentation/lessons/physx/images/forces/apply_force_magnitude_to_object.gif
new file mode 100644
index 000000000..6013e0eed
Binary files /dev/null and b/Documentation/lessons/physx/images/forces/apply_force_magnitude_to_object.gif differ
diff --git a/Documentation/lessons/physx/images/forces/apply_force_to_object.gif b/Documentation/lessons/physx/images/forces/apply_force_to_object.gif
new file mode 100644
index 000000000..62ffd42fb
Binary files /dev/null and b/Documentation/lessons/physx/images/forces/apply_force_to_object.gif differ
diff --git a/Documentation/lessons/physx/images/forces/apply_torque_to_object.gif b/Documentation/lessons/physx/images/forces/apply_torque_to_object.gif
new file mode 100644
index 000000000..df6782adb
Binary files /dev/null and b/Documentation/lessons/physx/images/forces/apply_torque_to_object.gif differ
diff --git a/Documentation/lessons/physx/images/step_physics.gif b/Documentation/lessons/physx/images/step_physics.gif
new file mode 100644
index 000000000..712224d4a
Binary files /dev/null and b/Documentation/lessons/physx/images/step_physics.gif differ
diff --git a/Documentation/lessons/physx/overview.md b/Documentation/lessons/physx/overview.md
new file mode 100644
index 000000000..31ee3d9a1
--- /dev/null
+++ b/Documentation/lessons/physx/overview.md
@@ -0,0 +1,38 @@
+##### Physics
+
+# Overview
+
+TDW supports three types of physics simulations:
+
+1. [PhysX](https://docs.unity3d.com/2020.1/Documentation/Manual/PhysicsOverview.html) is the Unity Engine's built-in physics engine. It only supports rigidbody physics, which means  that behavior such as deformable bodies, fluids, cloth, etc. cannot be simulated. PhysX is relatively stable but not 100% deterministic. Because it is the default option, most controllers in TDW use PhysX.
+2. [Flex](https://docs.nvidia.com/gameworks/content/gameworkslibrary/physx/flex/index.html) is a physics engine that can simulate rigidbodies, soft bodies, cloth, and fluids. It is less stable than PhysX. Only a subset of TDW [objects](../core_concepts/objects.md) can be used in Flex.
+3. It is possible to totally disable physics in TDW, which can be useful if you just want to gather image data.
+
+## Core concepts of physics engines
+
+PhysX and Flex were both made for video game development, and therefore share a number of core concepts:
+
+- In TDW, rendered frames, output data frames, and physics frames are identical. Every time you call `communicate()`, the build receives and executes commands, advances one physics frame, renders the scene (if applicable) and returns output data. There is an exception to this though, which will be covered later in this tutorial.
+- You don't need to have an agent in the scene to apply a force. Forces can be applied directly to an object.
+- The physics engine automatically updates the scene state in the background. You don't need to simulate physics from a controller. For example, if you want to roll a ball down a ramp, you only need to add the ball, add the ramp, and apply a small force. The physics engine will handle the rest of the motion.
+
+## High-level API: `tdw_physics`
+
+[`tdw_physics`](https://github.com/alters-mit/tdw_physics) is a high-level API for writing physics simulation controllers and outputting data to a standardized format. It includes PhysX and Flex scenarios.
+
+`tdw_physics` can be very useful for quickly creating physics-enabled trials but it assumes that you know how the underlying system works. For this reason, we recommend that you read the rest of the tutorial and then try using `tdw_physics`.
+
+***
+
+**Next:**
+
+- **[PhysX](physx/physx.md)** (We recommend you read the PhysX tutorial regardless of whether or not you intend to only use Flex)
+- **[Flex](flex/flex.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+High-level API:
+
+- [`tdw_physics`](https://github.com/alters-mit/tdw_physics)
\ No newline at end of file
diff --git a/Documentation/lessons/physx/physics_objects.md b/Documentation/lessons/physx/physics_objects.md
new file mode 100644
index 000000000..e81753424
--- /dev/null
+++ b/Documentation/lessons/physx/physics_objects.md
@@ -0,0 +1,229 @@
+##### Physics (PhysX)
+
+# Object physics parameters
+
+So far, we've added objects to a scene in TDW like this:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+object_id = c.get_unique_id()
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               c.get_add_object(model_name="iron_box",
+                                object_id=object_id,
+                                position={"x": 0, "y": 0, "z": 0},
+                                rotation={"x": 0, "y": 0, "z": 0},
+                                library="models_core.json")])
+```
+
+But this usually sets unrealistic physics parameters for an object.
+
+Every object in TDW has additional the following additional physics parameters:
+
+- Mass
+- Dynamic friction
+- Static friction
+- Bounciness
+- Kinematic state
+
+Dynamic friction, static friction, and bounciness don't have precise real-world analogues. They are defined in PhysX by the object's [physic material](https://docs.unity3d.com/Manual/class-PhysicMaterial.html).
+
+The object's kinematic state determines whether it will respond to physics. Other objects will respond to kinematic objects. This can be useful for large objects or objects "attached" to a wall such as a cabinet, where you don't want the object to move but you do want it to affect other objects.
+
+## Default physics values and `get_add_physics_object()`
+
+TDW includes default physics values for some, but not all, models. We are continuously assigning default physics values for additional models.
+
+The `Controller` class has a dictionary called `DEFAULT_PHYSICS_VALUES` which stores all default physics values. They keys of the dictionary are model names:
+
+```python
+from tdw.controller import Controller
+
+model_name = "iron_box"
+print(model_name in Controller.DEFAULT_PHYSICS_VALUES) # True
+```
+
+To apply these physics values, the `Controller` class includes a wrapper function: [`get_add_physics_object()`](../.../python/controller.md). This is similar to `Controller.get_add_object()` but there is a key difference: This controller returns a *list* of commands rather than a *single* command. Note that in this example we're using *extend* instead of *append* to add a list to another list:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+object_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12)]
+
+# Add a list of commands.
+commands.extend(c.get_add_physics_object(model_name="iron_box",
+                                         object_id=object_id,
+                                         position={"x": 0, "y": 0, "z": 0},
+                                         rotation={"x": 0, "y": 0, "z": 0}))
+c.communicate(commands)
+```
+
+This example does the exact same thing as the previous example, but using only low-level TDW commands:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+object_id = c.get_unique_id()
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               {"$type": "add_object", 
+                "name": "iron_box", 
+                "url": "https://tdw-public.s3.amazonaws.com/models/linux/2018-2019.1/iron_box", 
+                "scale_factor": 1.0, 
+                "position": {"x": 0, "y": 0, "z": 0}, 
+                "category": "box", 
+                "id": object_id}, 
+               {"$type": "rotate_object_to_euler_angles", 
+                "euler_angles": {"x": 0, "y": 0, "z": 0}, 
+                "id": object_id}, 
+               {"$type": "set_kinematic_state",
+                "id": object_id, 
+                "is_kinematic": False, 
+                "use_gravity": True}, 
+               {"$type": "set_mass",
+                "mass": 0.65, 
+                "id": object_id},
+               {"$type": "set_physic_material",
+                "dynamic_friction": 0.45, 
+                "static_friction": 0.48, 
+                "bounciness": 0.5, 
+                "id": object_id}])
+```
+
+## Non-default physics values and `get_add_physics_object()`
+
+You might want to set non-default physics values for an object for many reasons, including:
+
+- You want to vary the object's behavior per trial
+- The object doesn't have default physics values
+
+`Controller.get_add_physics_object()` has optional parameters that can be set. If `default_physics_values` is False, then the function will read the non-default values:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+object_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12)]
+
+commands.extend(c.get_add_physics_object(model_name="iron_box",
+                                         object_id=object_id,
+                                         position={"x": 0, "y": 0, "z": 0},
+                                         rotation={"x": 0, "y": 0, "z": 0},
+                                         default_physics_values=False,
+                                         mass=0.65,
+                                         dynamic_friction=0.45,
+                                         static_friction=0.48,
+                                         bounciness=0.5,
+                                         kinematic=False,
+                                         gravity=True))
+c.communicate(commands)
+```
+
+Given the nature of parameters with default values in Python, it is of course possible to simplify the above example to this:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+object_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12)]
+
+# This uses the default values for position and rotation (0, 0, 0) and for the kinematic state (non-kinematic, gravity-enabled).
+commands.extend(c.get_add_physics_object(model_name="iron_box",
+                                         object_id=object_id,
+                                         default_physics_values=False,
+                                         mass=0.65,
+                                         dynamic_friction=0.45,
+                                         static_friction=0.48,
+                                         bounciness=0.5))
+c.communicate(commands)
+```
+
+## How physics parameters affect an object
+
+[As mentioned previously,](overview.md) PhysX is structured such that once an object starts moving, the physics engine will automatically update its state.
+
+In this example, the controller's behavior is divided into "trials". In each trial, an object is created with random physics values and dropped. We'll use an [`ObjectManager`](../../python/add_ons/object_manager.md) to track whether the object is "sleeping" i.e. it has stopped moving; this will be covered in more depth [in the next document of this tutorial](rigidbodies.md). At the end of the trial, we'll reset the scene by sending [`destroy_object`](../../api/command_api.md#destroy_object).
+
+```python
+import numpy as np
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.object_manager import ObjectManager
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+c = Controller()
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+# Do a number of trials.
+num_trials = 10
+
+# Set the 0 to another number to use a different random seed.
+rng = np.random.RandomState(0)
+
+# Add a camera and an object manager.
+camera = ThirdPersonCamera(position={"x": 3, "y": 2.5, "z": -1},
+                           look_at={"x": 0, "y": 0, "z": 0})
+object_manager = ObjectManager(transforms=True, rigidbodies=True)
+c.add_ons.extend([camera, object_manager])
+
+# Run the trials.
+for i in range(10):
+    object_id = c.get_unique_id()
+    # Add the object.
+    c.communicate(c.get_add_physics_object(model_name="iron_box",
+                                           object_id=object_id,
+                                           position={"x": 0, "y": 7, "z": 0},
+                                           default_physics_values=False,
+                                           mass=float(rng.uniform(0.5, 6)),
+                                           dynamic_friction=float(rng.uniform(0, 1)),
+                                           static_friction=float(rng.uniform(0, 1)),
+                                           bounciness=float(rng.uniform(0, 1))))
+    done = False
+    while not done:
+        c.communicate([])
+        done = object_manager.rigidbodies[object_id].sleeping
+    # Destroy the object.
+    c.communicate({"$type": "destroy_object",
+                   "id": object_id})
+    # Mark the object manager as requiring re-initialization.
+    object_manager.initialized = False
+c.communicate({"$type": "terminate"})
+```
+
+***
+
+**Next: [`Rigidbodies` output data](rigidbodies.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [physics_values.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/physx/physics_values.py) Drop an object with varying physics values and observe its behavior.
+
+Python API:
+
+- [`Controller.get_add_object`](../../api/python/controller.md)
+- [`Controller.get_add_physics_object`](../../api/python/controller.md)
+- [`ObjectManager`](../../python/add_ons/object_manager.md)
+
+Command API:
+
+- [`add_object`](../../api/command_api.md#add_object)
+- [`rotate_object_to_euler_angles`](../../api/command_api.md#rotate_object_to_euler_angles)
+- [`set_kinematic_state`](../../api/command_api.md#set_kinematic_state)
+- [`set_mass`](../../api/command_api.md#set_mass)
+- [`set_physic_material`](../../api/command_api.md#set_physic_material)
+- [`destroy_object`](../../api/command_api.md#destroy_object)
+
diff --git a/Documentation/lessons/physx/physx.md b/Documentation/lessons/physx/physx.md
new file mode 100644
index 000000000..cd1b3af04
--- /dev/null
+++ b/Documentation/lessons/physx/physx.md
@@ -0,0 +1,43 @@
+##### Physics (PhysX)
+
+# PhysX - Overview
+
+[PhysX](https://docs.unity3d.com/2020.1/Documentation/Manual/PhysicsOverview.html) is the Unity Engine's built-in physics engine. Because it is the default option, most controllers in TDW use PhysX.
+
+## Core concepts of PhysX
+
+In PhysX, objects can optionally be physics-enabled. In a video game, if you want to add a UI effect like a blinking arrow pointing to a location, you don't actually want the arrow to interact with the game's physics state.
+
+In TDW, all [objects](../core_concepts/objects.md) *are* physics-enabled, meaning that they have [Rigidbodies](https://docs.unity3d.com/Manual/class-Rigidbody.html). The rigidbody has mass, velocity, momentum, friction, and so on.
+
+Objects also have [colliders](https://docs.unity3d.com/Manual/CollidersOverview.html). These are invisible 3D meshes defining the physical shape of the object. All objects in TDW have colliders:
+
+![](images/colliders.png)
+
+When an object moves or collides with another object, the physics engine automatically updates its state.
+
+## Common mistakes when using PhysX
+
+- It is possible to add multiple objects to TDW such that their bodies intersect each other. This is technically possible in PhysX but *not* recommended! The objects will have very glitchy and unpredictable behavior.
+- Objects in TDW initially have a mass of 1 kg and default friction values, meaning that you might apply a force to an object and it will respond in unexpected ways. The reason for this and how to set reasonable physics parameters [will be covered in the next page of this tutorial](physics_objects.md).
+- You should adjust the state of an object using non-physics commands such as [`teleport_object`](../../api/command_api.md#teleport_object) only when an object is first created. After that, you should only use commands such as [`apply_force_to_object`](../../api/command_api.md#apply_force_to_object). If you don't, PhysX will interpret the command as a *very* fast motion. And because the motion is technically being handled outside of the physics state, it can result in glitchy behavior.
+
+## Limitations
+
+- PhysX has been optimized for video games and is therefore built to interpolate between physics states--the objective is to yield a *reasonable* result and keep the game running at a steady framerate. As such, it's optimized for *precision*. Most notably, PhysX is not a deterministic physics engine.
+- The collider meshes usually don't *exactly* match the render meshes of an object. This is because collider meshes must be convex, and therefore many objects are composed of multiple convex *collider hull* sub-meshes. The collider meshes in TDW actually match the render meshes very accurately for a Unity Engine simulation! But it's still possible that there will be some noticeable occasional discrepancies; most commonly, objects will sometimes seem to float slightly above the floor.
+- PhysX can only simulate rigidbody physics. It can't simulate soft body deformation, cloth, or fluids.
+- PhysX is closed source and it's therefore impossible to make low-level adjustments or optimizations to the physics engine.
+
+***
+
+**Next: [Object physics parameters](physics_objects.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Command API:
+
+- [`teleport_object`](../../api/command_api.md#teleport_object)
+- [`apply_force_to_object`](../../api/command_api.md#apply_force_to_object)
diff --git a/Documentation/lessons/physx/rigidbodies.md b/Documentation/lessons/physx/rigidbodies.md
new file mode 100644
index 000000000..da8bac4ec
--- /dev/null
+++ b/Documentation/lessons/physx/rigidbodies.md
@@ -0,0 +1,119 @@
+##### Physics (PhysX)
+
+# `Rigidbodies` output data
+
+Send the command [`send_rigidbodies`](../../api/command_api.md#send_rigidbodies) to receive [`Rigidbodies`](../../api/output_data.md#Rigidbodies) dynamic data (data that changes per-frame such as velocity). Send [`send_static_rigidbodies`](../../api/command_api.md#send_static_rigidbodies) to receive  [`StaticRigidbodies`](../../api/output_data.md#StaticRigidbodies) static data (data that isn't expected to change per-frame such as mass).
+
+`Rigidbodies` output data also contains:
+
+- Object ID
+- Velocity (the directional velocity)
+- Angular velocity
+- Sleeping: [PhysX](physx.md) makes a low-level distinction between objects that are moving and objects that are inert; inert objects are considered "sleeping".
+
+`StaticRigidbodies` output data contains:
+
+- Object ID
+- Mass
+- Dynamic friction
+- Static friction
+- Bounciness
+- Kinematic state ([whether the object responds to physics](physics_objects.md))
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, Rigidbodies, StaticRigidbodies
+
+c = Controller()
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(model_name="iron_box",
+                                       position={"x": 0, "y": 3, "z": 0},
+                                       object_id=c.get_unique_id()),
+                      {"$type": "send_rigidbodies",
+                       "frequency": "always"},
+                      {"$type": "send_static_rigidbodies",
+                       "frequency": "once"}])
+
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "rigi":
+        rigi = Rigidbodies(resp[i])
+        for j in range(rigi.get_num()):
+            object_id = rigi.get_id(j)
+            velocity = rigi.get_velocity(j)
+            angular_velocity = rigi.get_angular_velocity(j)
+            sleeping = rigi.get_sleeping(j)
+    elif r_id == "srig":
+        srig = StaticRigidbodies(resp[i])
+        for j in range(srig.get_num()):
+            object_id = srig.get_id(j)
+            mass = srig.get_mass(j)
+            kinematic = srig.get_kinematic(j)
+            dynamic_friction = srig.get_dynamic_friction(j)
+            static_friction = srig.get_static_friction(j)
+            bounciness = srig.get_bounciness(j)
+c.communicate({"$type": "terminate"})
+```
+
+## The `ObjectManager` add-on
+
+We've seen the [`ObjectManager` add-on in the Core Concepts documentation.](../core_concepts/output_data.md) If you need to frequently use rigidbody data, you can use the `ObjectManager` which will conveniently sort all rigidbody data by object per frame. If `rigidbodies=True` in the constructor, the `ObjectManager` will send `send_rigidbodies` when it is first initialized and parse `Rigidbodies` data per frame.
+
+Note that in `Rigidbodies` output data, velocity and angular velocity as (x, y, z) tuples, but in the `ObjectManager` these parameters are conveniently converted into numpy arrays.
+
+Static rigidbody data is always cached in `objects_static` regardless of whether `rigidbodies=True`.
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.object_manager import ObjectManager
+
+c = Controller()
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+# Add the object manager.
+object_manager = ObjectManager(transforms=True, rigidbodies=True)
+c.add_ons.extend([object_manager])
+object_id = c.get_unique_id()
+# Add the object.
+c.communicate(c.get_add_physics_object(model_name="iron_box",
+                                       object_id=object_id,
+                                       position={"x": 0, "y": 7, "z": 0}))
+
+for o_id in object_manager.objects_static:
+    print(o_id, object_manager.objects_static[o_id].mass)
+
+# Let the object fall.
+while not object_manager.rigidbodies[object_id].sleeping:
+    print(object_manager.rigidbodies[object_id].velocity)
+    c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+***
+
+**Next: [`Collision` output data](collisions.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [physics_values.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/physx/physics_values.py) Drop an object with varying physics values and observe its behavior.
+
+Python API:
+
+- [`ObjectManager`](../../python/add_ons/object_manager.md)
+
+Command API:
+
+- [`send_rigidbodies`](../../api/command_api.md#send_rigidbodies)
+- [`send_static_rigidbodies`](../../api/command_api.md#send_rsend_static_rigidbodiesigidbodies)
+
+Output Data API:
+
+- [`Rigidbodies`](../../api/output_data.md#Rigidbodies)
+- [`StaticRigidbodies`](../../api/output_data.md#StaticRigidbodies)
+
diff --git a/Documentation/lessons/physx/step_physics.md b/Documentation/lessons/physx/step_physics.md
new file mode 100644
index 000000000..12c685c7b
--- /dev/null
+++ b/Documentation/lessons/physx/step_physics.md
@@ -0,0 +1,115 @@
+##### Physics (PhysX)
+
+# Skip physics frames
+
+[As mentioned in the overview page of this tutorial](overview.md), the build advances the physics state by 1 frame every time `Controller.communicate()` is called.
+
+Sometimes, though, you might want to "skip" physics frames between `communicate()` calls. You can do this with the [`step_physics`](../../api/command_api.md) command or the [`StepPhysics`](../../python/add_ons/step_physics.md) add-on (which just sends `step_physics` on every frame).
+
+`step_physics` doesn't *truly* skip physics frames; the frames *do* occur between `communicate()` calls:
+
+| Call                                                     | Number of physics frames |
+| -------------------------------------------------------- | ------------------------ |
+| `c.communicate([TDWUtils.create_empty_room(12, 12)])`    | 1                        |
+| `c.communicate([])`                                      | 1                        |
+| `c.communicate({"$type": "step_physics", "frames": 1})`  | 2                        |
+| `c.communicate({"$type": "step_physics", "frames": 10})` | 11                       |
+
+The main reason to skip physics frames is for speed improvements--preparing and sending output data is slow.
+
+In this example, an object is dropped twice. The first time, no physics frames are skipped. The second time, `{"$type": "step_physics", "frames": 10}` is sent every frame (via a `StepPhysics` add-on):
+
+```python
+from time import time
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.object_manager import ObjectManager
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.step_physics import StepPhysics
+
+
+class StepPhysicsSpeed(Controller):
+    """
+    Compare the speed of a simulation without skipping physics frames vs. skipping physics frames.
+    """
+
+    def __init__(self, port: int = 1071, check_version: bool = True, launch_build: bool = True):
+        super().__init__(port=port, check_version=check_version, launch_build=launch_build)
+        # Add a camera and an object manager.
+        self.object_manager: ObjectManager = ObjectManager(transforms=False, rigidbodies=True)
+        camera = ThirdPersonCamera(position={"x": 3, "y": 2.5, "z": -1},
+                                   look_at={"x": 0, "y": 0, "z": 0})
+        self.add_ons.extend([self.object_manager, camera])
+        self.communicate(TDWUtils.create_empty_room(12, 12))
+
+    def trial(self) -> float:
+        """
+        Wait for an object to fall.
+
+        :return: The elapsed time.
+        """
+
+        object_id = self.get_unique_id()
+        # Reset the object manager.
+        self.object_manager.initialized = False
+        # Add the object.
+        self.communicate(self.get_add_physics_object(model_name="iron_box",
+                                                     object_id=object_id,
+                                                     position={"x": 0, "y": 30, "z": 0}))
+        t0 = time()
+        # Wait for the object to fall.
+        while not self.object_manager.rigidbodies[object_id].sleeping:
+            self.communicate([])
+        t1 = time() - t0
+        # Destroy the object.
+        self.communicate({"$type": "destroy_object",
+                          "id": object_id})
+        return t1
+
+    def run(self) -> None:
+        t_no_skip = self.trial()
+        # Add a StepPhysics add-on.
+        self.add_ons.append(StepPhysics(num_frames=10))
+        t_skip_frames = self.trial()
+        print(t_no_skip, t_skip_frames)
+        self.communicate({"$type": "terminate"})
+
+
+if __name__ == "__main__":
+    c = StepPhysicsSpeed()
+    c.run()
+```
+
+Result:
+
+```
+2.8298497200012207 0.3419990539550781
+```
+
+![](images/step_physics.gif)
+
+The motion in both trials are the same, but in the second one physics steps are skipped such that it is much faster and some rendered frames (notably, during the bounces) are missing.
+
+## Collisions and skipped frames
+
+[Collision data](collisions.md) *is* returned for any skipped frames (assuming you've already sent `send_collisions`). For example, if you call `resp = c.communicate({"$type": "step_physics", "frames": 10})`, the simulation will advance 11 frames; if there are are collision events on frames 1, 2, 5, and 9, `resp` will contain each of those collision events.
+
+***
+
+**Next: [Disable physics](disable_physics.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [step_physics_speed.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/physx/ball_bounce.py) Compare the speed of a simulation without skipping physics frames vs. skipping physics frames.
+
+Python API
+
+- [`StepPhysics`](../../python/add_ons/step_physics.md)
+
+Command API:
+
+- [`step_physics`](../../api/command_api.md)
\ No newline at end of file
diff --git a/Documentation/lessons/robots/add_camera.md b/Documentation/lessons/robots/add_camera.md
new file mode 100644
index 000000000..be9bbc056
--- /dev/null
+++ b/Documentation/lessons/robots/add_camera.md
@@ -0,0 +1,132 @@
+##### Robots
+
+# Add a camera to a robot
+
+[As noted earlier,](overview.md) robots are not avatars. This means that avatar commands aren't compatible with robots and that robots don't have cameras.
+
+That said, it's possible to add a third-person camera avatar to the scene and "attach" the avatar to a robot.
+
+In this example, we'll will add an avatar and a robot to the scene:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+
+c = Controller()
+robot_id = c.get_unique_id()
+robot = Robot(name="ur5", robot_id=robot_id)
+c.add_ons.append(robot)
+commands = [TDWUtils.create_empty_room(12, 12)]
+commands.extend(TDWUtils.create_avatar(avatar_id="avatar"))
+c.communicate(commands)
+```
+
+In the backend, the object hierarchy looks like this:
+
+```
+ur5
+....shoulder_link
+........upper_arm_link
+............forearm_link
+................wrist_1_link
+....................wrist_2_link
+........................wrist_3_link
+avatar
+```
+
+If we send [`parent_avatar_to_robot`](../../api/command_api.md#parent_avatar_to_robot), the avatar will be attached to a specified joint or body part:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+
+c = Controller()
+robot_id = c.get_unique_id()
+robot = Robot(name="ur5", robot_id=robot_id)
+c.add_ons.append(robot)
+commands = [TDWUtils.create_empty_room(12, 12)]
+commands.extend(TDWUtils.create_avatar(avatar_id="avatar"))
+c.communicate(commands)
+
+joint_id = robot.static.joint_ids_by_name["wrist_3_link"]
+joint_position = robot.dynamic.joints[joint_id].position
+c.communicate({"$type": "parent_avatar_to_robot",
+               "position": {"x": 0, "y": 0, "z": 0},
+               "body_part_id": joint_id,
+               "avatar_id": "avatar",
+               "id": robot_id})
+```
+
+Now, the backend hierarchy looks like this:
+
+```
+ur5
+....shoulder_link
+........upper_arm_link
+............forearm_link
+................wrist_1_link
+....................wrist_2_link
+........................wrist_3_link
+............................avatar
+```
+
+If we move the arm joints, the camera will move with the joints:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+c = Controller()
+robot_id = c.get_unique_id()
+robot = Robot(name="ur5", robot_id=robot_id)
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("parent_robot_to_avatar")
+print(f"Images will be saved to: {path}")
+capture = ImageCapture(avatar_ids=["avatar"], path=path)
+c.add_ons.extend([robot, capture])
+commands = [TDWUtils.create_empty_room(12, 12)]
+commands.extend(TDWUtils.create_avatar(avatar_id="avatar"))
+c.communicate(commands)
+
+joint_id = robot.static.joint_ids_by_name["wrist_3_link"]
+joint_position = robot.dynamic.joints[joint_id].position
+robot.set_joint_targets(targets={robot.static.joint_ids_by_name["shoulder_link"]: -70,
+                                 robot.static.joint_ids_by_name["forearm_link"]: -55,
+                                 robot.static.joint_ids_by_name["wrist_3_link"]: 60})
+c.communicate({"$type": "parent_avatar_to_robot",
+               "position": {"x": 0, "y": 0, "z": 0},
+               "body_part_id": joint_id,
+               "avatar_id": "avatar",
+               "id": robot_id})
+while robot.joints_are_moving():
+    c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/parent_avatar_to_robot.gif)
+
+***
+
+**This is the last document in the "Robots" tutorial.**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [robot_camera.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/robots/robot_camera.py) Add a camera to a UR5 robot.
+
+Python API:
+
+- [`Robot`](../../python/add_ons/robot.md)
+
+Command API:
+
+- [`parent_avatar_to_robot`](../../api/command_api.md#parent_avatar_to_robot)
\ No newline at end of file
diff --git a/Documentation/lessons/robots/collision_detection.md b/Documentation/lessons/robots/collision_detection.md
new file mode 100644
index 000000000..69e2ed871
--- /dev/null
+++ b/Documentation/lessons/robots/collision_detection.md
@@ -0,0 +1,119 @@
+##### Robots
+
+# Robot collision detection
+
+Collision detection works exactly the same for robots as it does for [objects](../physx/collisions.md). The only significant difference is in how robots are initialized.
+
+Robot joints are handled via [ArticulationBodies](https://docs.unity3d.com/2020.1/Documentation/ScriptReference/ArticulationBody.html). ArticulationBodies are very stable when used correctly but brittle when used incorrectly. 
+
+In Unity, if two objects with rigidbodies intersect each other, Unity will try to resolve the issue, sometimes with glitchy behavior. If an object with an ArticulationBody intersects with an object with a Rigibody, the build will likely *crash*. Be careful where you position your robots!
+
+Some of the robots in TDW, such as `ur5` and `ur10`, initially intersect with the floor. When collision detection is enabled (either by sending [`send_collisions`](../../api/command_api.md#send_collisions) or by initializing a [`CollisionManager`](../../python/add_ons/collision_manager.md)), **a robot must not be intersecting any objects or environment meshes such as the floor.** If there are any intersections, the robot will severely glitch, or the build will crash, or both.
+
+To resolve this, some robots have *initial joint targets* that are automatically set by the `Robot` add-on. These targets are stored in `RobotRecord.targets`:
+
+```python
+from tdw.librarian import RobotLibrarian
+
+lib = RobotLibrarian()
+record = lib.get_record("ur5")
+print(record.targets) # {'upper_arm_link': {'type': 'revolute', 'target': -5}}
+```
+
+To ensure that collision detection can be enabled, wait for the robot to reach its initial targets. Once this is done, you can initialize collision detection.
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+
+c = Controller()
+robot = Robot(name="ur5",
+              position={"x": 1, "y": 0, "z": -2},
+              rotation={"x": 0, "y": 0, "z": 0},
+              robot_id=c.get_unique_id())
+c.add_ons.append(robot)
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+while robot.joints_are_moving():
+    c.communicate([])
+
+# The robot has moved to its initial pose. Enable collision detection.
+c.communicate({"$type": "send_collisions",
+               "enter": True,
+               "stay": False,
+               "exit": False,
+               "collision_types": ["obj", "env"]})
+c.communicate({"$type": "terminate"})
+```
+
+We can check the robot's dynamic data to determine whether objects have collided with it:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+from tdw.add_ons.object_manager import ObjectManager
+
+c = Controller()
+robot = Robot(name="ur5",
+              position={"x": 1, "y": 0, "z": -2},
+              rotation={"x": 0, "y": 0, "z": 0},
+              robot_id=c.get_unique_id())
+c.add_ons.append(robot)
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+while robot.joints_are_moving():
+    c.communicate([])
+
+object_manager = ObjectManager(transforms=True, rigidbodies=True)
+c.add_ons.append(object_manager)
+
+object_id = c.get_unique_id()
+c.communicate([c.get_add_object(model_name="iron_box",
+                                position={"x": 1, "y": 10, "z": -2},
+                                object_id=object_id),
+               {"$type": "send_collisions",
+                "enter": True,
+                "stay": False,
+                "exit": False,
+                "collision_types": ["obj", "env"]}])
+
+# Wait for the object to stop moving.
+while not object_manager.rigidbodies[object_id].sleeping:
+    c.communicate([])
+    for collision in robot.dynamic.collisions_with_objects:
+        joint_id = collision[0]
+        print("Collision between iron_box and " + robot.static.joints[joint_id].name)
+c.communicate({"$type": "terminate"})
+```
+
+Output:
+
+```
+Collision between iron_box and shoulder_link
+Collision between iron_box and upper_arm_link
+Collision between iron_box and ur5(Clone)
+```
+
+***
+
+**Next: [Select a robot](select_robot.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [collision_detection.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/robots/set_joint_targets.py) Enable collision detection for a UR5 robot.
+
+Python API:
+
+- [`Robot`](../../python/add_ons/robot.md)
+- [`CollisionManager`](../../python/add_ons/collision_manager.md)
+- [`ObjectManager`](../../python/add_ons/object_manager.md)
+
+Command API:
+
+- [`send_collisions`](../../api/command_api.md#send_collisions)
\ No newline at end of file
diff --git a/Documentation/lessons/robots/custom_robots.md b/Documentation/lessons/robots/custom_robots.md
new file mode 100644
index 000000000..0dad6556e
--- /dev/null
+++ b/Documentation/lessons/robots/custom_robots.md
@@ -0,0 +1,190 @@
+##### Robots
+
+# Add your own robots to TDW
+
+It is possible to add your own robots into TDW from a .urdf or .xacro file. However, the robot must first be converted into an asset bundle (just like [objects](../3d_models/custom_models.md)). To do so, you'll need to use TDW's [`RobotCreator`](../../python/robot_creator.py).
+
+The `RobotCreator` can download a .urdf or .xacro file plus all relevant textures, meshes, etc. or it can use local files.
+
+## Requirements
+
+- Windows 10, OS X, or Linux
+  - On a remote Linux server, you'll need a valid virtual display (see the `display` parameter of the constructor)
+- Unity Editor 2020.2 (must be installed via Unity Hub)
+- Python3 and the `tdw` module
+- git
+
+### ROS and .xacro file requirements
+
+If you want to use a .xacro file, `RobotCreator` can convert it to a usable .urdf file, provided that you first install ROS. If you already have a .urdf file, you don't need to install ROS.
+
+[This is the source of the installation instructions listed below.](http://wiki.ros.org/Installation/Ubuntu)
+
+**On Linux:**
+
+- `sudo apt-key adv --keyserver 'hkp://keyserver.ubuntu.com:80' --recv-key C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654`
+- `curl -sSL 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0xC1CF6E31E6BADE8868B172B4F42ED6FBAB17C654' | sudo apt-key add -`
+- `sudo ros-melodic-ros-base`
+- `sudo apt-get install rosbash`
+- `sudo apt-get install ros-melodic-xacro`
+
+**On Windows:**
+
+- Install Ubuntu 18 on WSL 2
+- `wsl sudo apt-key adv --keyserver 'hkp://keyserver.ubuntu.com:80' --recv-key C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654`
+- `wsl curl -sSL 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0xC1CF6E31E6BADE8868B172B4F42ED6FBAB17C654' | sudo apt-key add -`
+- `wsl sudo ros-melodic-ros-base`
+- `wsl sudo apt-get install rosbash`
+- `wsl sudo apt-get install ros-melodic-xacro`
+
+**On OS X:**
+
+ROS isn't well-supported on OS X. You can try following installation instructions [here](http://wiki.ros.org/Installation/).
+
+
+## Usage
+
+To create an asset bundle of the UR5 robot:
+
+```python
+from tdw.robot_creator import RobotCreator
+
+r = RobotCreator()
+record = r.create_asset_bundles(
+urdf_url="https://github.com/ros-industrial/robot_movement_interface/blob/master/dependencies/ur_description/urdf/ur5_robot.urdf",
+xacro_args=None,
+required_repo_urls=None,
+immovable=True,
+up="y")
+print(record.name)
+print(record.urls)
+```
+
+Most of the parameters are optional, so this can be simplified to:
+
+```python
+from tdw.robot_creator import RobotCreator
+
+r = RobotCreator()
+record = r.create_asset_bundles(urdf_url="https://github.com/ros-industrial/robot_movement_interface/blob/master/dependencies/ur_description/urdf/ur5_robot.urdf")
+print(record.name)
+print(record.urls)
+```
+
+The first time that this script is run, it will clone [the robot_creator repo](https://github.com/alters-mit/robot_creator) (a Unity project used for creating robots) to your home directory.
+
+## Edit the prefab
+
+`RobotCreator.create_asset_bundles()` creates a .prefab file in the `robot_creator` Unity project. This is an intermediate file that is required for building the asset bundle.
+
+It's usually worth testing and editing the prefab before finalizing the asset bundle. To do this, first run `RobotCreator` as described above, and then do the following:
+
+1. Create a prefab of the robot.
+2. Open robot_creator Unity project in Unity 2020.2; the project is located at `~/robot_creator` (where `~` is your home directory).
+3. In the Unity Editor project window, double-click `Scenes -> SampleScene`
+4. In the Unity Editor project window, search for the name of the robot. Click the file and drag it into the scene view.
+5. Press play.
+
+### Common problems and solutions during prefab creation
+
+| Problem | Solution |
+| --- | --- |
+| Prefab creation seems to hang. | This is because sometimes the physics hull collider meshes are very complicated and require more time to generate. Let the process run. |
+| Got an error during prefab creation: `Root object of the robot doesn't have an ArticulationBody.` | Open the project, double-click the prefab, and add an ArticulationBody to the root object. Adjust the parenting hierarchy of the robot such that the ArticulationBodies beneath the root are direct children. This is bad: `root -> non-articulation -> articulation` and this is good: `root -> articulation` |
+
+### Common problems and solutions while testing a prefab in the Unity Editor project
+
+| Problem | Solution |
+| --- | --- |
+| Arms are flailing. | Usually this is because the colliders are parented to the wrong object. Double-click the prefab and make sure each `Collisions` object is parented to the matching ArticulationBody object. |
+| Robot falls apart and there are `AABB` errors | You have too many ArticulationBodies. Unity supports a maximum of 65 (1 parent, 64 children). Double-click the prefab and delete any redundant ArticulationBodies. |
+| The base of the robot is below (0, 0, 0) | Double-click the prefab and adjust the y position of the child objects. |
+| Joints snap to a weird angle. | Usually this is because there are overlapping physics colliders. Double-click the prefab and in the Hierarchy panel click the root object. The green wireframe meshes in the Scene View are the physics colliders. Try deleting or disabling colliders near the glitching joint. |
+| The robot tips over. | Set `immovable=True` in `create_asset_bundles()`. If that doesn't work, double-click the prefab. In the Hierarchy panel, click the ArticulationBody that you think is causing the robot to tilt. In the Inspector panel, click "Add Component". Add: `Center Of Mass`. Adjust the center of mass in the Inspector until the robot stops tipping. |
+
+## Create an asset bundle from a prefab
+
+Once the robot prefab is working correctly, you can create asset bundles without overwriting the prefab:
+
+```python
+from tdw.robot_creator import RobotCreator
+
+r = RobotCreator()
+urdf_url = "https://github.com/ros-industrial/robot_movement_interface/blob/master/dependencies/ur_description/urdf/ur5_robot.urdf"
+record = r.create_asset_bundles(urdf_url=urdf_url)
+print(record.name)
+print(record.urls)
+
+asset_bundles = rc.prefab_to_asset_bundles(name=name)
+record = RobotCreator.get_record(name=record.name, urdf_url=urdf_url, immovable=True, asset_bundles=asset_bundles)
+```
+
+## Store metadata
+
+`RobotCreator.create_asset_bundles()` returns a [`RobotRecord`](../../python/librarian/robot_librarian.md) metadata object, which contains the files paths to the asset bundle.
+
+You can store a `RobotRecord` object in a [`RobotLibrarian`](../../python/librarian/robot_librarian.md), which is saved as a JSON file.
+
+To create a `RobotLibrarian`:
+
+```python
+from tdw.librarian import RobotLibrarian
+
+RobotLibrarian.create_library(path="my_robot_librarian.json", description="Custom Robot Librarian")
+```
+
+To create asset bundles and save the `RobotRecord` metadata:
+
+```python
+from tdw.robot_creator import RobotCreator
+from tdw.librarian import RobotLibrarian
+
+r = RobotCreator()
+
+# Create the asset bundles and generate a metadata record.
+record = r.create_asset_bundles(urdf_url="https://github.com/ros-industrial/robot_movement_interface/blob/master/dependencies/ur_description/urdf/ur5_robot.urdf")
+
+# Add the record to the local library.
+lib = RobotLibrarian("my_robot_librarian.json")
+lib.add_or_update_record(record=record, overwrite=False, write=True)
+```
+
+## Add the robot to TDW
+
+To add the robot to a TDW scene, use a `Robot` add-on but set the `source` parameter to your custom `RobotLibrarian`:
+
+```python
+from pathlib import Path
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.librarian import RobotLibrarian
+from tdw.add_ons.robot import Robot
+
+lib_path = "my_robot_librarian.json"
+# Create your robot library if it doesn't already exist.
+if not Path(lib_path).exists():
+    RobotLibrarian.create_library(path=lib_path, description="Custom Robot Librarian")
+# Load your robot library.
+lib = RobotLibrarian(lib_path)
+
+robot_id = 0
+robot = Robot(name="ur5",
+              source=lib,
+              robot_id=robot_id)
+c = Controller()
+c.add_ons.append(robot)
+c.communicate(TDWUtils.create_empty_room(12, 12))
+```
+
+***
+
+**Next: [Robotics API (low-level)](low_level_api.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Python API:
+
+- [`RobotCreator`](../../python/robot_creator.py)
+- [`RobotLibrarian`](../../python/librarian/robot_librarian.md)
\ No newline at end of file
diff --git a/Documentation/lessons/robots/images/overview/reach_high.gif b/Documentation/lessons/robots/images/overview/reach_high.gif
new file mode 100644
index 000000000..43d19e9ba
Binary files /dev/null and b/Documentation/lessons/robots/images/overview/reach_high.gif differ
diff --git a/Documentation/lessons/robots/images/overview/ur5.gif b/Documentation/lessons/robots/images/overview/ur5.gif
new file mode 100644
index 000000000..c1f542e19
Binary files /dev/null and b/Documentation/lessons/robots/images/overview/ur5.gif differ
diff --git a/Documentation/lessons/robots/images/parent_avatar_to_robot.gif b/Documentation/lessons/robots/images/parent_avatar_to_robot.gif
new file mode 100644
index 000000000..4a69dbb77
Binary files /dev/null and b/Documentation/lessons/robots/images/parent_avatar_to_robot.gif differ
diff --git a/Documentation/lessons/robots/images/robot_add_on/add_robot.jpg b/Documentation/lessons/robots/images/robot_add_on/add_robot.jpg
new file mode 100644
index 000000000..d29752780
Binary files /dev/null and b/Documentation/lessons/robots/images/robot_add_on/add_robot.jpg differ
diff --git a/Documentation/lessons/robots/images/robot_add_on/set_joint_targets.gif b/Documentation/lessons/robots/images/robot_add_on/set_joint_targets.gif
new file mode 100644
index 000000000..7dee554c4
Binary files /dev/null and b/Documentation/lessons/robots/images/robot_add_on/set_joint_targets.gif differ
diff --git a/Documentation/lessons/robots/low_level_api.md b/Documentation/lessons/robots/low_level_api.md
new file mode 100644
index 000000000..acb77c929
--- /dev/null
+++ b/Documentation/lessons/robots/low_level_api.md
@@ -0,0 +1,338 @@
+##### Robots
+
+# Robotics API (low-level)
+
+So far, this tutorial has utilized the [`Robot` add-on](../../Python/add_ons/robot.md) for robotics simulations. Like all other add-ons in TDW, the `Robot` add-on is nothing more than a convenient wrapper class for lower-level commands. It is possible to control a robot using low-level commands, and there are a few commands that aren't implemented in the `Robot` add on.
+
+## Add a robot to the scene
+
+Add a robot to the scene with the [`add_robot` command](../../api/command_api.md#add_robot) or the wrapper function [`Controller.get_add_robot()`](../../Python/controller.md):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               c.get_add_robot(name="ur5",
+                               robot_id=c.get_unique_id())])
+```
+
+The  example below does the exact same thing as the example above:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+c = Controller()
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               {"$type": "add_robot",
+                "id": c.get_unique_id(),
+                "position": {"x": 0, "y": 0, "z": 0},
+                "rotation": {"x": 0, "y": 0, "z": 0},
+                "name": "ur5",
+                "url": "https://tdw-public.s3.amazonaws.com/robots/windows/2020.2/ur5"}])
+```
+
+## Get static robot data
+
+Send [`send_static_robots`](../../api/command_api.md#send_static_robots) to receive [`StaticRobot`](../../api/output_data.md#StaticRobot) output data per robot. This data includes the ID, name, mass, segmentation color, and drive information of each joint. In this example we're only going to read the joint names and IDs but you can check the  [`StaticRobot`](../../api/output_data.md#StaticRobot) API for how to get more static data.
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, StaticRobot
+
+c = Controller()
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_robot(name="ur5",
+                                      robot_id=c.get_unique_id()),
+                      {"$type": "send_static_robots"}])
+joint_names_and_ids = dict()
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "srob":
+        srob = StaticRobot(resp[i])
+        for j in range(srob.get_num_joints()):
+            joint_id = srob.get_joint_id(j)
+            joint_name = srob.get_joint_name(j)
+            joint_names_and_ids[joint_name] = joint_id
+```
+
+## Set joint targets or add joint forces
+
+Once you have the names and IDs of each joint you can set target angles and positions, or target torques and forces. These commands will *start* the joint moving towards a target but won't wait for them to finish moving.
+
+| Joint type    | Target command                                               | Force command                                                |
+| ------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
+| `"revolute"`  | [`set_revolute_target`](../../api/command_api.md#set_revolute_target) | [`add_torque_to_revolute`](../../api/command_api.md#add_torque_to_revolute) |
+| `"prismatic"` | [`set_prismatic_target`](../../api/command_api.md#set_prismatic_target) | [`add_force_to_prismatic`](../../api/command_api.md#add_force_to_prismatic) |
+| `"spherical"` | [`set_spherical_target`](../../api/command_api.md#set_spherical_target) | [`add_torque_to_spherical`](../../api/command_api.md#`add_torque_to_spherical`) |
+| `"fixed"`     |                                                              |                                                              |
+
+In this example, we happen to know that all of the joints in a UR5 robot are revolute.
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, StaticRobot
+
+c = Controller()
+robot_id = c.get_unique_id()
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_robot(name="ur5",
+                                      robot_id=robot_id),
+                      {"$type": "send_static_robots"}])
+joint_names_and_ids = dict()
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "srob":
+        srob = StaticRobot(resp[i])
+        for j in range(srob.get_num_joints()):
+            joint_id = srob.get_joint_id(j)
+            joint_name = srob.get_joint_name(j)
+            joint_names_and_ids[joint_name] = joint_id
+
+c.communicate([{"$type": "set_revolute_target",
+                "id": robot_id,
+                "joint_id": joint_names_and_ids["shoulder_link"],
+                "target": 70},
+               {"$type": "set_revolute_target",
+                "id": robot_id,
+                "joint_id": joint_names_and_ids["forearm_link"],
+                "target": -60}])
+```
+
+## Get dynamic robot data and check if the joints are still moving
+
+To determine whether the robot's joints are still moving, you'll need to send [`send_robots`](../../command_api.md#send_robots) which will return [`Robot`](../../output_data.md#Robot) output data. In this example, we'll get the starting position of the joints. Per frame, we'll then parse `Robot` output data and compare it to the previous positions. If all of the joints have stopped moving or nearly stopped moving, the controller ends:
+
+```python
+import numpy as np
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, StaticRobot, Robot
+
+c = Controller()
+robot_id = c.get_unique_id()
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_robot(name="ur5",
+                                      robot_id=robot_id),
+                      {"$type": "send_static_robots"},
+                      {"$type": "send_robots",
+                       "frequency": "always"}])
+joint_names_and_ids = dict()
+joint_positions_0 = dict()
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "srob":
+        srob = StaticRobot(resp[i])
+        for j in range(srob.get_num_joints()):
+            joint_id = srob.get_joint_id(j)
+            joint_name = srob.get_joint_name(j)
+            joint_names_and_ids[joint_name] = joint_id
+    elif r_id == "robo":
+        robo = Robot(resp[i])
+        for j in range(robo.get_num_joints()):
+            joint_id = robo.get_joint_id(j)
+            joint_position = robo.get_joint_position(j)
+            joint_positions_0[joint_id] = np.array(joint_position)
+
+resp = c.communicate([{"$type": "set_revolute_target",
+                       "id": robot_id,
+                       "joint_id": joint_names_and_ids["shoulder_link"],
+                       "target": 70},
+                      {"$type": "set_revolute_target",
+                       "id": robot_id,
+                       "joint_id": joint_names_and_ids["forearm_link"],
+                       "target": -60}])
+done = False
+while not done:
+    done = True
+    # Get the current joint positions.
+    joint_positions_1 = dict()
+    for i in range(len(resp) - 1):
+        r_id = OutputData.get_data_type_id(resp[i])
+        if r_id == "robo":
+            robo = Robot(resp[i])
+            for j in range(robo.get_num_joints()):
+                joint_id = robo.get_joint_id(j)
+                joint_position = robo.get_joint_position(j)
+                joint_positions_1[joint_id] = np.array(joint_position)
+    # Check if any of the joints are still moving.
+    for j0, j1 in zip(joint_positions_0, joint_positions_1):
+        if np.linalg.norm(joint_positions_0[j0] - joint_positions_1[j1]) > 0.001:
+            done = False
+            break
+    joint_positions_0 = joint_positions_1
+    resp = c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+## Robots and immovability
+
+Some robots are initially *immovable* meaning that their root object won't move or rotate regardless of the forces applied to it. To determine whether a robot is immovable, check the `immovable` field in the record:
+
+```python
+from tdw.librarian import RobotLibrarian
+
+lib = RobotLibrarian()
+record = lib.get_record("ur5")
+print(record.immovable)  # True
+```
+
+You can toggle whether a robot is immovable with the command [`set_immovable`](../../api/command_api.md#set_immovable).
+
+*Regardless* of immovability, it is possible to instantly move and rotate a robot without applying forces by sending [`teleport_robot`](../../api/command_api.md#teleport_robot).
+
+## Set static robot parameters
+
+It is possible to adjust many static robot parameters:
+
+- [`set_robot_drive`](../../api/command_api.md#set_robot_drive) sets drive parameters for a given joint.
+- [`set_robot_joint_friction`](../../api/command_api.md#set_robot_joint_friction) sets the friction coefficient of a given joint.
+- [`set_robot_joint_mass`](../../api/command_api.md#set_robot_joint_mass) sets the mass of a given joint.
+- [`set_robot_joint_physic_material`](../../api/command_api.md#set_robot_joint_physic_material) sets the [physic material](../physx/physics_objects.md) of a given joint.
+
+```python
+import numpy as np
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, StaticRobot
+
+c = Controller()
+robot_id = c.get_unique_id()
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_robot(name="ur5",
+                                      robot_id=robot_id),
+                      {"$type": "send_static_robots"}])
+commands = []
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "srob":
+        srob = StaticRobot(resp[i])
+        for j in range(srob.get_num_joints()):
+            joint_id = srob.get_joint_id(j)
+            joint_name = srob.get_joint_name(j)
+            # Adjust the shoulder link.
+            if joint_name == "shoulder_link":
+                # Adjust the mass.
+                mass = srob.get_joint_mass(j)
+                commands.append({"$type": "set_robot_joint_mass",
+                                 "mass": mass + 2,
+                                 "joint_id": joint_id,
+                                 "id": robot_id})
+                # Adjust the friction coefficient.
+                commands.append({"$type": "set_robot_joint_friction",
+                                 "joint_id": joint_id,
+                                 "friction": 0.3,
+                                 "id": robot_id})
+                # Set the physic material.
+                commands.append({"$type": "set_robot_joint_physic_material",
+                                 "dynamic_friction": 0.3, 
+                                 "static_friction": 0.3, 
+                                 "bounciness": 0.7,
+                                 "joint_id": joint_id,
+                                 "id": robot_id})
+                # Adjust the joint drives.
+                for k in range(srob.get_num_joint_drives(j)):
+                    lower_limit = srob.get_joint_drive_lower_limit(j, k)
+                    upper_limit = srob.get_joint_drive_upper_limit(j, k)
+                    force_limit = srob.get_joint_drive_force_limit(j, k)
+                    stiffness = srob.get_joint_drive_stiffness(j, k)
+                    damping = srob.get_joint_drive_damping(j, k)
+                    axis = srob.get_joint_drive_axis(j, k)
+                    commands.append({"$type": "set_robot_joint_drive",
+                                     "joint_id": joint_id,
+                                     "axis": axis,
+                                     "lower_limit": lower_limit - 15,
+                                     "upper_limit": upper_limit + 15,
+                                     "force_limit": force_limit * 1.5,
+                                     "stiffness": stiffness * 1.3,
+                                     "damping": damping * 0.8,
+                                     "id": robot_id})
+c.communicate(commands)
+```
+
+You can of course use any low-level API commands with any higher-level add-on, and the `Robot` add-on is no exception. However, the cached static data won't automatically update if you adjust static parameters.
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+
+c = Controller()
+robot_id = c.get_unique_id()
+robot = Robot(name="ur5", robot_id=robot_id)
+c.add_ons.append(robot)
+c.communicate(TDWUtils.create_empty_room(12, 12))
+commands = []
+for joint_id in robot.static.joints:
+    joint = robot.static.joints[joint_id]
+    commands.extend([{"$type": "set_robot_joint_mass",
+                      "mass": joint.mass + 2,
+                      "joint_id": joint_id,
+                      "id": robot_id},
+                     {"$type": "set_robot_joint_friction",
+                      "joint_id": joint_id,
+                      "friction": 0.3,
+                      "id": robot_id},
+                     {"$type": "set_robot_joint_physic_material",
+                      "dynamic_friction": 0.3,
+                      "static_friction": 0.3,
+                      "bounciness": 0.7,
+                      "joint_id": joint_id,
+                      "id": robot_id}])
+    for axis in joint.drives:
+        commands.append({"$type": "set_robot_joint_drive",
+                         "joint_id": joint_id,
+                         "axis": axis,
+                         "lower_limit": joint.drives[axis].limits[0] - 15,
+                         "upper_limit": joint.drives[axis].limits[1] + 15,
+                         "force_limit": joint.drives[axis].force_limit * 1.5,
+                         "stiffness": joint.drives[axis].stiffness * 1.3,
+                         "damping": joint.drives[axis].damping * 0.8,
+                         "id": robot_id})
+c.communicate(commands)
+```
+
+***
+
+**Next: [Add a camera to a robot](add_camera.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [robot_arm.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/robots/robot_arm.py) Control a UR5 robot with low-level commands.
+
+Python API:
+
+- [`Robot`](../../python/add_ons/robot.md)
+- [`Controller.get_add_robot()`](../../python/controller.md)
+- [`RobotLibrarian`](../../python/librarian/robot_librarian.md)
+
+Command API:
+
+- [`add_robot`](../../api/command_api.md#add_robot)
+- [`send_static_robots`](../../api/command_api.md#send_static_robots)
+- [`send_robots`](../../api/command_api.md#send_robots)
+- [`set_revolute_target`](../../api/command_api.md#set_revolute_target)
+- [`set_prismatic_target`](../../api/command_api.md#set_prismatic_target)
+- [`set_spherical_target`](../../api/command_api.md#set_spherical_target)
+- [`add_torque_to_revolute`](../../api/command_api.md#add_torque_to_revolute)
+- [`add_force_to_prismatic`](../../api/command_api.md#add_force_to_prismatic)
+- [`add_torque_to_spherical`](../../api/command_api.md#add_torque_to_spherical)
+- [`set_immovable`](../../api/command_api.md#set_immovable)
+- [`teleport_robot`](../../api/command_api.md#teleport_robot)
+- [`set_robot_drive`](../../api/command_api.md#set_robot_drive)
+- [`set_robot_joint_friction`](../../api/command_api.md#set_robot_joint_friction)
+- [`set_robot_joint_mass`](../../api/command_api.md#set_robot_joint_mass)
+- [`set_robot_joint_physic_material`](../../api/command_api.md#set_robot_joint_physic_material)
+
+Output Data API:
+
+- [`StaticRobot`](../../api/output_data.md#StaticRobot)
+- [`Robot`](../../api/output_data.md#Robot)
\ No newline at end of file
diff --git a/Documentation/lessons/robots/overview.md b/Documentation/lessons/robots/overview.md
new file mode 100644
index 000000000..b788edaff
--- /dev/null
+++ b/Documentation/lessons/robots/overview.md
@@ -0,0 +1,19 @@
+##### Robots
+
+# Overview
+
+*Robots in TDW is handled via the PhysX physics engine. If you haven't done so already, we strongly recommend you read the [physics tutorial](../physx/overview.md).*
+
+TDW includes a full robotics API. You can add robots to a scene and either set target angles or positions for their joints, or apply forces and torques.
+
+This tutorial will cover how to add and control robots in TDW using a higher-level API (the `Robot` add-on) and lower-level API commands.
+
+Robots are agents but are *not* [avatars](../core_concepts/avatars.md) in TDW. Avatar commands won't work with robots.
+
+![](images/overview/ur5.gif)
+
+***
+
+**Next: [The `Robot` add-on](robot_add_on.md)**
+
+[Return to the README](../../../README.md)
\ No newline at end of file
diff --git a/Documentation/lessons/robots/robot_add_on.md b/Documentation/lessons/robots/robot_add_on.md
new file mode 100644
index 000000000..ce1184a37
--- /dev/null
+++ b/Documentation/lessons/robots/robot_add_on.md
@@ -0,0 +1,384 @@
+##### Robots
+
+# The `Robot` add-on
+
+The `Robot` add-on is a high-level add-on for adding and controlling a robot in TDW.
+
+Robots have unique integer IDs (just like objects).
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+c = Controller()
+
+# Add a robot.
+robot_id = c.get_unique_id()
+robot = Robot(name="ur5",
+              position={"x": 1, "y": 0, "z": -2},
+              rotation={"x": 0, "y": 0, "z": 0},
+              robot_id=robot_id)
+
+# Add a camera and enable image capture.
+camera = ThirdPersonCamera(avatar_id="a",
+                           position={"x": -0.7, "y": 1.7, "z": 0.5},
+                           look_at=robot_id)
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("robot_add_on")
+print(f"Images will be saved to: {path}")
+capture = ImageCapture(avatar_ids=["a"], path=path)
+
+c.add_ons.extend([robot, camera, capture])
+
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/robot_add_on/add_robot.jpg)
+
+## Articulation bodies, collisions, initial joint targets
+
+Robot joints are handled via [ArticulationBodies](https://docs.unity3d.com/2020.1/Documentation/ScriptReference/ArticulationBody.html). ArticulationBodies are very stable when used correctly but brittle when used incorrectly. 
+
+In Unity, if two objects with rigidbodies intersect each other, Unity will try to resolve the issue, sometimes with glitchy behavior. If an object with an ArticulationBody intersects with an object with a Rigibody, the build will likely *crash*. Be careful where you position your robots!
+
+## Static robot output data
+
+Static robot data includes static data that doesn't change once the robot is created. It is stored in the `Robot` object as `self.static`, a [`RobotStatic`](../../python/robot_data/robot_static.md) data object.
+
+This will add a robot to the scene and print its ID and whether the robot is immovable:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+
+c = Controller()
+robot = Robot(name="ur5",
+              position={"x": 1, "y": 0, "z": -2},
+              rotation={"x": 0, "y": 0, "z": 0},
+              robot_id=c.get_unique_id())
+c.add_ons.append(robot)
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+print(robot.static.robot_id)
+print(robot.static.immovable)
+
+c.communicate({"$type": "terminate"})
+```
+
+### Static joint data
+
+Static data for each joint in the robot is stored in the dictionary `RobotStatic.joints`. Key = joint ID (an integer); value = [`JointStatic`](../../python/robot_data/joint_static.md) data.
+
+Each joint has a unique ID and [segmentation color](../visual_perception/id.md) as well as other properties such as mass.
+
+Each joint has a [`JointType`](../../python/robot_data/joint_type.md) expressed as an enum value. There are four types of joints: revolute, prismatic, spherical, and fixed.
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+
+c = Controller()
+robot = Robot(name="ur5",
+              position={"x": 1, "y": 0, "z": -2},
+              rotation={"x": 0, "y": 0, "z": 0},
+              robot_id=c.get_unique_id())
+c.add_ons.append(robot)
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+for joint_id in robot.static.joints:
+    joint_name = robot.static.joints[joint_id].name
+    joint_mass = robot.static.joints[joint_id].mass
+    joint_segmentation_color = robot.static.joints[joint_id].segmentation_color
+    joint_type = robot.static.joints[joint_id].joint_type
+    print(joint_name, joint_mass, joint_segmentation_color, joint_type)
+
+c.communicate({"$type": "terminate"})
+```
+
+Each [joint](../../python/robot_data/joint_static.md) has 0-3 *drives* which are stored in the dictionary `JointStatic.drives`. Key = the drive axis (`"x"`, `"y"`, or `"z"`) and value = a [`Drive`](../../python/robot_data/drive.md). A `Drive` object stores static physics values such as the force limit:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+
+c = Controller()
+robot = Robot(name="ur5",
+              position={"x": 1, "y": 0, "z": -2},
+              rotation={"x": 0, "y": 0, "z": 0},
+              robot_id=c.get_unique_id())
+c.add_ons.append(robot)
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+for joint_id in robot.static.joints:
+    for axis in robot.static.joints[joint_id].drives:
+        drive = robot.static.joints[joint_id].drives[axis]
+        print(joint_id, drive.axis, drive.damping, drive.stiffness, drive.limits, drive.force_limit)
+
+c.communicate({"$type": "terminate"})
+```
+
+`RobotStatic` also stores  dictionary of names and joint IDs for convenience: `joint_ids_by_name`: 
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+
+c = Controller()
+robot = Robot(name="ur5",
+              position={"x": 1, "y": 0, "z": -2},
+              rotation={"x": 0, "y": 0, "z": 0},
+              robot_id=c.get_unique_id())
+c.add_ons.append(robot)
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+for joint_name in robot.static.joint_ids_by_name:
+    joint_id = robot.static.joint_ids_by_name
+    print(joint_name, joint_id)
+
+c.communicate({"$type": "terminate"})
+```
+
+### Static data for non-moving body parts
+
+A robot might have non-moving body parts in addition to joints. Static data for these body parts is stored in `RobotStatic.non_moving`. Key = body part ID. Value = [`NonMoving`](../.../python/robot_data/non_moving.md) data (name, ID, and segmentation color). Note that `NonMoving` objects *do* have segmentation colors, but they're massless.
+
+To get a flat list of all body part and joint IDs in a robot, read `RobotStatic.body_parts` (a list of IDs).
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+
+c = Controller()
+robot = Robot(name="ur5",
+              position={"x": 1, "y": 0, "z": -2},
+              rotation={"x": 0, "y": 0, "z": 0},
+              robot_id=c.get_unique_id())
+c.add_ons.append(robot)
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+for body_part_id in robot.static.non_moving:
+    name = robot.static.non_moving[body_part_id]
+    segmentation_color = robot.static.non_moving[body_part_id]
+    print(body_part_id, name, segmentation_color)
+
+print(robot.static.body_parts)
+
+c.communicate({"$type": "terminate"})
+```
+
+
+## Dynamic robot output data
+
+Dynamic robot data includes data that is updated per-frame. It is stored in the `Robot` object as `self.dynamic`, a [`RobotDynamic`](../../python/robot_data/robot_dynamic.md) data object. 
+
+`RobotDynamic` includes whether the robot's current [`Transform`](../../python/object_data/transform.md) data (position, forward directional vector, and rotation, each expressed as a numpy array):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+
+c = Controller()
+robot = Robot(name="ur5",
+              position={"x": 1, "y": 0, "z": -2},
+              rotation={"x": 0, "y": 0, "z": 0},
+              robot_id=c.get_unique_id())
+c.add_ons.append(robot)
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+print(robot.dynamic.transform.position,
+      robot.dynamic.transform.forward,
+      robot.dynamic.transform.rotation)
+c.communicate({"$type": "terminate"})
+```
+
+#### Dynamic joint data
+
+Dynamic joint data is stored in `self.dynamic.joints`, a dictionary. Key = Joint ID. Value = A [`JointDynamic`](../../python/robot_data/joint_dynamic.md) data object.
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+
+c = Controller()
+robot = Robot(name="ur5",
+              position={"x": 1, "y": 0, "z": -2},
+              rotation={"x": 0, "y": 0, "z": 0},
+              robot_id=c.get_unique_id())
+c.add_ons.append(robot)
+c.communicate(TDWUtils.create_empty_room(12, 12))
+for joint_id in robot.dynamic.joints:
+    print(joint_id,
+          robot.dynamic.joints[joint_id].position,
+          robot.dynamic.joints[joint_id].moving,
+          robot.dynamic.joints[joint_id].angles)
+c.communicate({"$type": "terminate"})
+```
+
+## Set joint targets
+
+You can manipulate a robot by calling `self.set_joint_targets(targets)`. The `targets` parameter is a dictionary: Key = joint ID, Value = target.
+
+| [`JointType`](../../python/robot_data/joint_type.md) | Target type                   | Target units |
+| ---------------------------------------------------- | ----------------------------- | ------------ |
+| `revolute`                                           | float                         | degrees      |
+| `prismatic`                                          | float                         | meters       |
+| `spherical`                                          | dictionary (keys are x, y, z) | degrees      |
+| `fixed`                                              | N/A                           | N/A          |
+
+While the joints are moving, `self.joints_are_moving()` will return True. To wait until a robot is done moving, call `communicate()` until `self.joints_are_moving() == False`:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+c = Controller()
+robot_id = c.get_unique_id()
+robot = Robot(name="ur5",
+              position={"x": 1, "y": 0, "z": -2},
+              rotation={"x": 0, "y": 0, "z": 0},
+              robot_id=robot_id)
+# Add a camera and enable image capture.
+camera = ThirdPersonCamera(avatar_id="a",
+                           position={"x": -0.1, "y": 1.7, "z": 0.1},
+                           look_at=robot_id)
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("set_joint_targets")
+print(f"Images will be saved to: {path}")
+capture = ImageCapture(avatar_ids=["a"], path=path)
+c.add_ons.extend([robot, camera, capture])
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+# Set the initial pose.
+while robot.joints_are_moving():
+    c.communicate([])
+
+# Strike a cool pose.
+robot.set_joint_targets(targets={robot.static.joint_ids_by_name["shoulder_link"]: 50,
+                                 robot.static.joint_ids_by_name["forearm_link"]: -60})
+
+# Wait for the joints to stop moving.
+while robot.joints_are_moving():
+    c.communicate([])
+
+c.communicate({"$type": "terminate"})
+```
+
+![](images/robot_add_on/set_joint_targets.gif)
+
+## Set joint forces
+
+It is also possible to directly apply forces and torques to joints via `self.add_joint_forces(forces)`. The `forces` parameter is a dictionary: Key = joint ID, value = the force or torque (measured in Newtons):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+c = Controller()
+robot_id = c.get_unique_id()
+robot = Robot(name="ur5",
+              position={"x": 1, "y": 0, "z": -2},
+              rotation={"x": 0, "y": 0, "z": 0},
+              robot_id=robot_id)
+# Add a camera and enable image capture.
+camera = ThirdPersonCamera(avatar_id="a",
+                           position={"x": -0.1, "y": 1.7, "z": 0.1},
+                           look_at=robot_id)
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("add_joint_forces")
+print(f"Images will be saved to: {path}")
+capture = ImageCapture(avatar_ids=["a"], path=path)
+c.add_ons.extend([robot, camera, capture])
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+# Set the initial pose.
+while robot.joints_are_moving():
+    c.communicate([])
+
+# Strike a cool pose.
+robot.add_joint_forces(forces={robot.static.joint_ids_by_name["shoulder_link"]: 500,
+                               robot.static.joint_ids_by_name["forearm_link"]: -600})
+
+# Wait for the joints to stop moving.
+while robot.joints_are_moving():
+    c.communicate([])
+
+c.communicate({"$type": "terminate"})
+```
+
+## Stop joints
+
+Stop joints in mid-motion by calling `self.stop_joints()`. You can optionally set the `joint_ids` parameter to stop specific joints.
+
+ In this controller, the shoulder link will start to rotate to 70 degrees but will be stopped at 30 degrees:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+
+c = Controller()
+robot_id = c.get_unique_id()
+robot = Robot(name="ur5",
+              position={"x": 1, "y": 0, "z": -2},
+              rotation={"x": 0, "y": 0, "z": 0},
+              robot_id=robot_id)
+c.add_ons.append(robot)
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+while robot.joints_are_moving():
+    c.communicate([])
+
+shoulder_id = robot.static.joint_ids_by_name["shoulder_link"]
+robot.set_joint_targets(targets={shoulder_id: 70})
+
+while robot.joints_are_moving() and robot.dynamic.joints[shoulder_id].angles[0] < 30:
+    c.communicate([])
+
+robot.stop_joints(joint_ids=[shoulder_id])
+
+c.communicate({"$type": "terminate"})
+```
+
+***
+
+**Next: [Robot collision detection](collision_detection.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [set_joint_targets.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/robots/set_joint_targets.py) Set the joint targets of a UR5 robot.
+
+Python API:
+
+- [`Robot`](../../python/add_ons/robot.md)
+- [`RobotSatic`](../../python/robot_data/joint_static.md)
+  - [`JointStatic`](../../python/robot_data/joint_static.md)
+    - [`JointType`](../../python/robot_data/joint_type.md)
+    - [`Drive`](../../python/robot_data/drive.md)
+    - [`NonMoving`](../../python/robot_data/non_moving.md)
+- [`RobotDynamic`](../../python/robot_data/robot_dynamic.md)
+  - [`JointDynamic`](../../python/robot_data/joint_dynamic.md)
+- [`Transform`](../../python/object_data/transform.md)
diff --git a/Documentation/lessons/robots/select_robot.md b/Documentation/lessons/robots/select_robot.md
new file mode 100644
index 000000000..f5a3dd69c
--- /dev/null
+++ b/Documentation/lessons/robots/select_robot.md
@@ -0,0 +1,58 @@
+##### Robots
+
+# Select a robot
+
+Robots are handled in TDW very similarly to [objects](../core_concepts.md). The robots are asset bundles stored on a remote S3 server. There is a corresponding [`RobotLibrarian`](../../python/librarian/robot_librarian.md) collection of metadata records.
+
+This will print the name of each robot that is included by default in TDW:
+
+```python
+from tdw.librarian import RobotLibrarian
+
+lib = RobotLibrarian()
+for record in lib.records:
+    print(record.name)
+```
+
+Output *(this will change as more robots are added to TDW)*:
+
+```
+baxter
+fetch
+niryo_one
+sawyer
+shadowhand
+ur10
+ur3
+ur5
+```
+
+To select a robot, simply set the `name` field in the [`Robot`](../../python/add_ons/robot.md) constructor to a valid robot:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+
+c = Controller()
+robot_id = c.get_unique_id()
+robot = Robot(name="baxter",
+              robot_id=robot_id)
+c.add_ons.append(robot)
+c.communicate(TDWUtils.create_empty_room(12, 12))
+c.communicate({"$type": "terminate"})
+```
+
+***
+
+**Next: [Add your own robots to TDW](custom_robots.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Python API:
+
+- [`RobotLibrarian`](../../python/librarian/robot_librarian.md)
+- [`Robot`](../../python/add_ons/robot.md)
+
diff --git a/Documentation/lessons/setup/install.md b/Documentation/lessons/setup/install.md
new file mode 100644
index 000000000..bdc7927af
--- /dev/null
+++ b/Documentation/lessons/setup/install.md
@@ -0,0 +1,223 @@
+##### Setup
+
+# Installing TDW
+
+TDW is two applications that communicate with each other over a TCP/IP socket. You need to have both to run TDW. They can be on the same machine or on separate machines (such as a laptop and a remote server).
+
+- **The build** is the 3D simulation environment. It is a windowed application that requires a display and a GPU.
+- **The controller** is a Python script that communicates with the build. You write your own controller script (though this repo contains many examples).
+
+This document will explain how to install both the build executable and the Python code required to write TDW controller scripts.
+
+## System Requirements
+
+- Windows, OS X, or Linux (we've tested TDW on Ubuntu 16, 18, and 20)
+- Python 3.6+
+- A GPU, the faster the better, with up-to-date drivers. It is possible to run TDW without a GPU but you will lose some speed and photorealism.
+- For [audio/video recording](misc_frontend/video.md), you will need an audio driver.
+- [Flex](misc_frontend/flex.md) has complicated requirements; please refer to the linked document.
+- **Additional Linux server requirements:**
+  - An active X server
+  - nvidia graphics card
+  - nvidia drivers (> 418.xx)
+  - The server must have 8 or less GPUs. (If there are more, you can temporarily disable them by editing xorg.conf)
+- **Additional Docker container requirements:**
+  - Set Docker to run as a user in the Docker group. See this [link](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-docker-on-ubuntu-16-04).
+  - Please test your nvidia-docker setup prior to starting TDW with Docker. The following command should execute succesfully: `docker run --gpus all nvidia/cuda:9.0-base nvidia-smi`
+  - [nvidia-container-toolkit](https://github.com/NVIDIA/nvidia-docker)
+
+## Install TDW on a personal computer
+
+**We recommend installing TDW on a personal computer before attempting to install TDW on a remote server.** Installing on a personal computer is easier and it may help you understand the fundamentals of TDW.
+
+1. OS X and Linux: **`sudo pip3 install tdw`** Windows: **`pip3 install tdw --user`** 
+2. Create this Python script and run it:
+
+```python
+from tdw.controller import Controller
+
+c = Controller()
+print("Hello world!")
+c.communicate({"$type": "terminate"})
+```
+
+**Result:** The terminal window will print messages about downloading a build. Then, it will launch a windowed application, print `Hello world!`, kill the windowed application process, and exit.
+
+## Install TDW on a remote Linux server
+
+#### Install NVIDIA and X on your server
+
+1. Download and install latest NVIDIA drivers for the machine
+2. Install xorg and dependencies `sudo apt-get install -y gcc make pkg-config xorg`
+3. Run `nvidia-xconfig --query-gpu-info`. This will list all the GPUs and their bus ids
+4. Run `nvidia-xconfig --no-xinerama --probe-all-gpus --use-display-device=none`. This will generate xorg.conf (/etc/X11/xorg.conf) file
+5. Make *n* copies of this file for *n* GPUs. You can name each file as xorg-1.conf, xorg-2.conf ... xorg-n.conf
+6. Edit each file:
+   1. Remove ServerLayout and Screen section
+   2. Edit Device section to include the BusID and BoardName field of the corresponding GPU. You can get GPU list by running `nvidia-xconfig --query-gpu-info`
+
+For example if `nvidia-xconfig --query-gpu-info` outputs two GPUs:
+
+```
+Number of GPUs: 2
+
+GPU #0:
+  Name      : Tesla V100-PCIE-16GB
+  UUID      : GPU-bbf6c915-de29-6e08-90e6-0da7981a590b
+  PCI BusID : PCI:0:7:0
+
+  Number of Display Devices: 0
+
+
+GPU #1:
+  Name      : Tesla V100-PCIE-16GB
+  UUID      : GPU-2a69c672-c895-5671-00ba-14ac43a9ec39
+  PCI BusID : PCI:0:8:0
+
+  Number of Display Devices: 0
+```
+
+Then create two xorg.conf files and edit the device section for first file:
+
+```
+This ->
+Section "Device"
+    Identifier     "Device0"
+    Driver         "nvidia"
+    VendorName     "NVIDIA Corporation"
+EndSection
+To ->
+Section "Device"
+    Identifier     "Device0"
+    Driver         "nvidia"
+    VendorName     "NVIDIA Corporation"
+    BoardName      "Tesla V100-PCIE-16GB"
+    BusID          "PCI:0:7:0"
+EndSection
+```
+
+And
+
+```
+This ->
+Section "Device"
+    Identifier     "Device1"
+    Driver         "nvidia"
+    VendorName     "NVIDIA Corporation"
+EndSection
+To ->
+Section "Device"
+    Identifier     "Device1"
+    Driver         "nvidia"
+    VendorName     "NVIDIA Corporation"
+    BoardName      "Tesla V100-PCIE-16GB"
+    BusID          "PCI:0:8:0"
+EndSection
+```
+
+7. Run x-server. For each xorg configuration file run `sudo nohup Xorg :<Display server name> -config <configuration file name> & `:
+
+```
+sudo nohup Xorg :1 -config /etc/X11/xorg-1.conf & 
+```
+
+```
+sudo nohup Xorg :2 -config /etc/X11/xorg-2.conf &
+```
+
+8. When successfully done, running `nvidia-smi` should show the x-server proccess with corresponding GPU version
+
+#### Install TDW
+
+1. `sudo pip3 install tdw`
+2. Download [the latest build of TDW](https://github.com/threedworld-mit/tdw/releases/latest/) and extract the zip file.
+3. Verify that you can run TDW by running a minimal example. Create this Python script and save it as `my_controlller.py`:
+
+```python
+from tdw.controller import Controller
+
+port = 1071  # This is the default port. You can change this.
+c = Controller(launch_build=False, port=port) 
+print("Hello world!")
+c.communicate({"$type": "terminate"})
+```
+
+4. In one shell window, run the controller: `python3 my_controller.py`
+5. Open another shell window.
+6. In the second shell window, `cd` to the location of the build (`TDW.x86.64`)
+7. In the second window, launch the build. Include the `DISPLAY` environment variable. This should match the display number of the virtual display (see "Install", above):
+
+```bash
+DISPLAY=:0.0 ./TDW.x86_64 -port=1071
+```
+
+## Install TDW in a Docker container
+
+1. Follow the above instructions for installing X on your server.
+2. Install Docker and nvidia-docker. [Read this for more information.](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html#docker)
+3. Allow the X server to accept local connections:
+
+```bash
+xhost +local:root
+```
+
+4. Pull a Docker container that matches your version of TDW:
+
+```bash
+cd tdw/Docker
+```
+
+
+```bash
+./pull.sh
+```
+
+5. Verify that you can run TDW by running a minimal example. Create this Python script and save it as `my_controlller.py`:
+
+```python
+from tdw.controller import Controller
+
+port = 1071  # This is the default port. You can change this.
+c = Controller(launch_build=False, port=port) 
+print("Hello world!")
+c.communicate({"$type": "terminate"})
+```
+
+6. Run your controller.
+7. Launch the container. [This is an example launch script.](https://github.com/threedworld-mit/tdw/blob/master/Docker/start_container.sh) You can also [use xpra for remote rendering](https://github.com/threedworld-mit/tdw/blob/master/Docker/start_container_xpra.sh).
+
+We have [many other useful Docker scripts](https://github.com/threedworld-mit/tdw/tree/master/Docker).
+
+It's also possible to run Docker within Docker (such as a Tensorflow container). Pass these to your Docker initialization options:
+
+```bash
+-v /var/run/docker.sock:/var/run/docker.sock
+-v /usr/bin/docker:/bin/docker
+```
+
+## Install TDW on a personal computer and a remote Linux server
+
+1. Follow directions for installing TDW on your personal computer.
+2. Follow instructions for installing X on your Linux server.
+3. On the Linux server, download [the latest build of TDW](https://github.com/threedworld-mit/tdw/releases/latest/) and extract the zip file.
+4. On the Linux server,  `cd` to the location of the build (`TDW.x86.64`)
+5. On the Linux server, launch the build. Include:
+   - The `DISPLAY` environment variable, which should match the display number of the virtual display (see "Install", above)
+   - The `-port` parameter (which should match the port of the controller)
+   - The `-address` parameter (which should match the network address of the controller)
+
+```bash
+DISPLAY=:0.0 ./TDW.x86_64 -port=1071 -address=replace_with_the_controller_address
+```
+
+***
+
+**Next: [Upgrading TDW](upgrade.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [hello_world.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/setup/hello_world.py)
diff --git a/Documentation/lessons/setup/upgrade.md b/Documentation/lessons/setup/upgrade.md
new file mode 100644
index 000000000..aeadf4b64
--- /dev/null
+++ b/Documentation/lessons/setup/upgrade.md
@@ -0,0 +1,29 @@
+##### Setup
+
+# Upgrading TDW
+
+## Upgrade TDW on a personal computer
+
+1. OS X and Linux: **`sudo pip3 install tdw -U`** Windows: **`pip3 install tdw -U --user`**
+2. The next time you run a controller, the TDW simulation application (the build) will automatically be updated. You can optionally download [the latest build of TDW](https://github.com/threedworld-mit/tdw/releases/latest/).
+3. If this is a major upgrade (for example, from TDW v1.9.0 to TDW v1.10.0), read the [upgrade guide](../../upgrade_guides/v1.7_to_v1.8.md).
+4. Read the [changelog](../../Changelog.md).
+
+## Upgrade TDW on a remote Linux server
+
+1. **`sudo pip3 install tdw -U`**
+2. Download [the latest build of TDW](https://github.com/threedworld-mit/tdw/releases/latest/) and extract the zip file.
+3. If this is a major upgrade (for example, from TDW v1.9.0 to TDW v1.10.0), read the [upgrade guide](../../upgrade_guides/v1.7_to_v1.8.md).
+4. Read the [changelog](../../Changelog.md).
+
+## When to upgrade (versioning in TDW)
+
+- **The second number of the version (`9` in the case of `v1.9.0`) represents a *major* release.** Major releases usually introduce changes that can break a controller, such as removing commands or modifying Python function parameters. They typically also include new features. For example, TDW v1.8.0 added robotics to the API. In most cases, you'll be able to upgrade to a major release of TDW provided you edit your controller code. **Please read the [upgrade guide](../../upgrade_guides/v1.8_to_v1.9.md) and the [changelog](../../Changelog.md) before upgrading.**
+- **The third number of version (`0` in the case of `v1.9.0`) represents a *minor* release.** These releases include bug fixes and new functionality such as new commands or Python wrapper functions. You should always upgrade to the latest minor release. Please read the [changelog](../../Changelog.md) before upgrading.
+- **The Python module has an additional fourth number (`2` in the case of `1.9.0.2`).** These are minor patches that are almost always released to fix bugs, add models to the model library, or other features that don't actually involve the source code of the build. You should always upgrade to the latest patch release. Patch releases are not noted in the changelog.
+
+***
+
+**This is the end of the setup guide for TDW. We recommend you continue reading the [Core Concepts guide](../core_concepts/controller.md).**
+
+[Return to the README](../../../README.md)
diff --git a/Documentation/lessons/visual_perception/category.md b/Documentation/lessons/visual_perception/category.md
new file mode 100644
index 000000000..a3f137ad6
--- /dev/null
+++ b/Documentation/lessons/visual_perception/category.md
@@ -0,0 +1,164 @@
+##### Visual Perception
+
+# Semantic category segmentation colors (`_category` pass)
+
+The `_category` pass is similar to the `_id` pass but it assigns colors based on the semantic category of the objects in the scene. Note that the two items on the table are in the same category and therefore receive the same color:
+
+![](images/category_0000.png)
+
+## Model category metadata
+
+You can get each object's category via the [`ModelLibrarian`](../../python/librarian/model_librarian.md):
+
+```python
+from tdw.librarian import ModelLibrarian
+
+lib = ModelLibrarian()
+
+for object_name in ["small_table_green_marble", "rh10", "jug01", "jug05"]:
+    record = lib.get_record(object_name)
+    print(object_name, record.wcategory)
+```
+
+Result:
+
+```
+small_table_green_marble table
+rh10 toy
+jug01 jug
+jug05 jug
+```
+
+## `Categories` output data
+
+To get each object's category at runtime, send [`send_segmentation_colors`](../../api/command_api.md#send_segmentation_colors):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, SegmentationColors
+
+c = Controller()
+object_id_0 = c.get_unique_id()
+object_id_1 = c.get_unique_id()
+object_id_2 = c.get_unique_id()
+object_id_3 = c.get_unique_id()
+object_names = {object_id_0: "small_table_green_marble",
+                object_id_1: "rh10",
+                object_id_2: "jug01",
+                object_id_3: "jug05"}
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(object_names[object_id_0],
+                                       object_id=object_id_0),
+                      c.get_add_object(object_names[object_id_1],
+                                       position={"x": 0.7, "y": 0, "z": 0.4},
+                                       rotation={"x": 0, "y": 30, "z": 0},
+                                       object_id=object_id_1),
+                      c.get_add_object(model_name=object_names[object_id_2],
+                                       position={"x": -0.3, "y": 0.9, "z": 0.2},
+                                       object_id=object_id_2),
+                      c.get_add_object(object_names[object_id_3],
+                                       position={"x": 0.3, "y": 0.9, "z": -0.2},
+                                       object_id=object_id_3),
+                      {"$type": "send_segmentation_colors",
+                       "frequency": "once"}])
+# Get each object category.
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "segm":
+        segm = SegmentationColors(resp[i])
+        for j in range(segm.get_num()):
+            object_id = segm.get_object_id(j)
+            object_category = segm.get_object_category(j)
+            print(object_id, object_category)
+c.communicate({"$type": "terminate"})
+```
+
+To get the color of each category, send [`send_categories`](../../api/command_api.md#send_categories), which will return [`Categories`](../../api/output_data.md#Categories) output data:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, SegmentationColors, Categories
+
+"""
+Get the _category pass and category data per object.
+"""
+
+c = Controller()
+object_id_0 = c.get_unique_id()
+object_id_1 = c.get_unique_id()
+object_id_2 = c.get_unique_id()
+object_id_3 = c.get_unique_id()
+object_names = {object_id_0: "small_table_green_marble",
+                object_id_1: "rh10",
+                object_id_2: "jug01",
+                object_id_3: "jug05"}
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(object_names[object_id_0],
+                                       object_id=object_id_0),
+                      c.get_add_object(object_names[object_id_1],
+                                       position={"x": 0.7, "y": 0, "z": 0.4},
+                                       rotation={"x": 0, "y": 30, "z": 0},
+                                       object_id=object_id_1),
+                      c.get_add_object(model_name=object_names[object_id_2],
+                                       position={"x": -0.3, "y": 0.9, "z": 0.2},
+                                       object_id=object_id_2),
+                      c.get_add_object(object_names[object_id_3],
+                                       position={"x": 0.3, "y": 0.9, "z": -0.2},
+                                       object_id=object_id_3),
+                      {"$type": "send_categories",
+                       "frequency": "once"},
+                      {"$type": "send_segmentation_colors",
+                       "frequency": "once"}])
+# Get each object category and category color.
+object_categories = dict()
+category_colors = dict()
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "segm":
+        segm = SegmentationColors(resp[i])
+        for j in range(segm.get_num()):
+            object_id = segm.get_object_id(j)
+            object_category = segm.get_object_category(j)
+            object_categories[object_id] = object_category
+    elif r_id == "cate":
+        cate = Categories(resp[i])
+        for j in range(cate.get_num_categories()):
+            category_name = cate.get_category_name(j)
+            category_color = cate.get_category_color(j)
+            category_colors[category_name] = category_color
+# Print the category colors of each object.
+for object_id in object_categories:
+    category_name = object_categories[object_id]
+    object_category_color = category_colors[category_name]
+    print(object_id, category_name, object_category_color)
+c.communicate({"$type": "terminate"})
+```
+
+Note that `SegmentationColors` and `Categories` return only static data and do not need to be sent per-frame.
+
+***
+
+**Next: [Depth maps (`_depth` and `_depth_simple` passes)](depth.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [category.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/visual_perception/category.py) Example implementation of the `_category` pass and `Categories` output data.
+
+Command API:
+
+- [`send_segmentation_colors`](../../api/command_api.md#send_segmentation_colors)
+- [`send_categories`](../../api/command_api.md#send_categories)
+
+Output Data API:
+
+- [`SegmentationColors`](../../api/output_data.md#SegmentationColors)
+- [`Categories`](../../api/output_data.md#Categories)
+
+
+
diff --git a/Documentation/lessons/visual_perception/depth.md b/Documentation/lessons/visual_perception/depth.md
new file mode 100644
index 000000000..c6b72c675
--- /dev/null
+++ b/Documentation/lessons/visual_perception/depth.md
@@ -0,0 +1,180 @@
+##### Visual Perception
+
+# Depth maps (`_depth` and `_depth_simple` passes)
+
+## The `_depth` pass
+
+The `_depth` pass encodes depth values per pixel into 3 bytes (RGB). The resulting image looks strange but is very accurate:
+
+![](images/depth_0000.png)
+
+## The `_depth_simple` pass
+
+The `_depth_simple` pass is similar to the `_depth` pass but encodes depth values to grayscale (1 byte). This makes it less accurate but somewhat easier to use. The first image is a `_depth_simple` image; it is very dark but it *is* accurate. The second image is the same as the first, but with the brightness/contrast adjusted:
+
+![](images/depth_simple_0000.png) ![](images/depth_simple_0000_contrast.png)
+
+
+
+## Convert a depth pass to depth values
+
+To convert the image into depth values, you can use `TDWUtils.get_depth_values()`:
+
+```python
+import matplotlib.pyplot as plt
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+"""
+Convert the _depth pass to depth values and plot them using matplotlib.
+"""
+
+c = Controller()
+object_id_0 = c.get_unique_id()
+object_id_1 = c.get_unique_id()
+object_id_2 = c.get_unique_id()
+object_id_3 = c.get_unique_id()
+object_names = {object_id_0: "small_table_green_marble",
+                object_id_1: "rh10",
+                object_id_2: "jug01",
+                object_id_3: "jug05"}
+
+cam = ThirdPersonCamera(position={"x": 2.478, "y": 1.602, "z": 1.412},
+                        look_at={"x": 0, "y": 0.2, "z": 0},
+                        avatar_id="a")
+output_directory = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("depth")
+print(f"Images will be saved to: {output_directory.resolve()}")
+c.add_ons.append(cam)
+cap = ImageCapture(path=output_directory, avatar_ids=["a"], pass_masks=["_depth"])
+c.add_ons.append(cap)
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               c.get_add_object(object_names[object_id_0],
+                                object_id=object_id_0),
+               c.get_add_object(object_names[object_id_1],
+                                position={"x": 0.7, "y": 0, "z": 0.4},
+                                rotation={"x": 0, "y": 30, "z": 0},
+                                object_id=object_id_1),
+               c.get_add_object(model_name=object_names[object_id_2],
+                                position={"x": -0.3, "y": 0.9, "z": 0.2},
+                                object_id=object_id_2),
+               c.get_add_object(object_names[object_id_3],
+                                position={"x": 0.3, "y": 0.9, "z": -0.2},
+                                object_id=object_id_3)])
+images = cap.images["a"]
+for i in range(images.get_num_passes()):
+    if images.get_pass_mask(i) == "_depth":
+        depth_values = TDWUtils.get_depth_values(images.get_image(i), depth_pass="_depth",
+                                                 width=images.get_width(), height=images.get_height())
+        plt.imshow(depth_values)
+        plt.show()
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/depth_plt.png)
+
+## Convert depth values to point clouds
+
+It is possible to convert the `_depth` pass into a point cloud. To this we need to add 3 things to the previous example:
+
+- Send the [`send_camera_matrices`](../../api/command_api.md#send_camera_matrices) command.
+- Parse the output data to receive [`CameraMatrices`](../../api/output_data.md#CameraMatrices).
+- Call [`TDWUtils.get_point_cloud(depth, filename, camera_matrix)`](../../python/tdw_utils.md).
+
+```python
+import matplotlib.pyplot as plt
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.output_data import OutputData, CameraMatrices
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+"""
+Convert the _depth pass to depth values, plot them using matplotlib, and generate a point cloud
+"""
+
+c = Controller()
+object_id_0 = c.get_unique_id()
+object_id_1 = c.get_unique_id()
+object_id_2 = c.get_unique_id()
+object_id_3 = c.get_unique_id()
+object_names = {object_id_0: "small_table_green_marble",
+                object_id_1: "rh10",
+                object_id_2: "jug01",
+                object_id_3: "jug05"}
+
+cam = ThirdPersonCamera(position={"x": 2.478, "y": 1.602, "z": 1.412},
+                        look_at={"x": 0, "y": 0.2, "z": 0},
+                        avatar_id="a")
+output_directory = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("depth")
+print(f"Images will be saved to: {output_directory.resolve()}")
+c.add_ons.append(cam)
+cap = ImageCapture(path=output_directory, avatar_ids=["a"], pass_masks=["_depth"])
+c.add_ons.append(cap)
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(object_names[object_id_0],
+                                       object_id=object_id_0),
+                      c.get_add_object(object_names[object_id_1],
+                                       position={"x": 0.7, "y": 0, "z": 0.4},
+                                       rotation={"x": 0, "y": 30, "z": 0},
+                                       object_id=object_id_1),
+                      c.get_add_object(model_name=object_names[object_id_2],
+                                       position={"x": -0.3, "y": 0.9, "z": 0.2},
+                                       object_id=object_id_2),
+                      c.get_add_object(object_names[object_id_3],
+                                       position={"x": 0.3, "y": 0.9, "z": -0.2},
+                                       object_id=object_id_3),
+                      {"$type": "send_camera_matrices",
+                       "frequency": "always"}])
+# Get the camera matrix.
+camera_matrix = None
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "cama":
+        camera_matrix = CameraMatrices(resp[i]).get_camera_matrix()
+images = cap.images["a"]
+for i in range(images.get_num_passes()):
+    if images.get_pass_mask(i) == "_depth":
+        # Get the depth values.
+        depth_values = TDWUtils.get_depth_values(images.get_image(i), depth_pass="_depth",
+                                                 width=images.get_width(), height=images.get_height())
+        
+        # Comment out these two lines on a Linux server.
+        plt.imshow(depth_values)
+        plt.show()
+        
+        # Convert the depth values to a point cloud.
+        point_cloud_filename = str(output_directory.joinpath("point_cloud.txt").resolve())
+        TDWUtils.get_point_cloud(depth=depth_values, filename=point_cloud_filename, camera_matrix=camera_matrix)
+c.communicate({"$type": "terminate"})
+```
+
+***
+
+**Next: [Motion perception (`_flow` pass)](flow.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [depth.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/visual_perception/depth.py) Save a depth pass, get the depth values, and save a point cloud.
+
+Python API:
+
+- [`TDWUtils.get_depth_values(image, depth_pass, width, height, near_plane, far_plane)`](../../python/tdw_utils.md) 
+- [`TDWUtils.get_point_cloud(depth, filename, camera_matrix)`](../../python/tdw_utils.md) 
+
+Command API:
+
+- [`send_camera_matrices`](../../api/command_api.md#send_camera_matrices)
+
+Output Data API:
+
+- [`CameraMatrices`](../../api/output_data.md#CameraMatrices)
diff --git a/Documentation/lessons/visual_perception/flow.md b/Documentation/lessons/visual_perception/flow.md
new file mode 100644
index 000000000..26b612a24
--- /dev/null
+++ b/Documentation/lessons/visual_perception/flow.md
@@ -0,0 +1,81 @@
+##### Visual Perception
+
+# Motion perception (`_flow` pass)
+
+In the `_flow` pass, pixels are colored according to their motion in relation to the camera. In the example used above, nothing is moving, so the image is totally black. We can add motion to the scene with the [`apply_force_to_object`](../../api/command_api.md#apply_force_to_object) command:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+"""
+Get the _flow pass.
+"""
+
+c = Controller()
+object_id_0 = c.get_unique_id()
+object_id_1 = c.get_unique_id()
+object_id_2 = c.get_unique_id()
+object_id_3 = c.get_unique_id()
+object_names = {object_id_0: "small_table_green_marble",
+                object_id_1: "rh10",
+                object_id_2: "jug01",
+                object_id_3: "jug05"}
+
+output_directory = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("flow")
+
+# Enable image capture for the _flow pass.
+print(f"Images will be saved to: {output_directory}")
+capture = ImageCapture(path=output_directory, pass_masks=["_flow"], avatar_ids=["a"])
+c.add_ons.append(capture)
+
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(object_names[object_id_0],
+                             object_id=object_id_0),
+            c.get_add_object(object_names[object_id_1],
+                             position={"x": 0.7, "y": 0, "z": 0.4},
+                             rotation={"x": 0, "y": 30, "z": 0},
+                             object_id=object_id_1),
+            c.get_add_object(model_name=object_names[object_id_2],
+                             position={"x": -0.3, "y": 0.9, "z": 0.2},
+                             object_id=object_id_2),
+            c.get_add_object(object_names[object_id_3],
+                             position={"x": 0.3, "y": 0.9, "z": -0.2},
+                             object_id=object_id_3),
+            {"$type": "apply_force_to_object",
+             "id": object_id_1,
+             "force": {"x": 0, "y": 5, "z": -200}}]
+
+commands.extend(TDWUtils.create_avatar(position={"x": 2.478, "y": 1.602, "z": 1.412},
+                                       look_at={"x": 0, "y": 0.2, "z": 0},
+                                       avatar_id="a"))
+c.communicate(commands)
+for i in range(3):
+    c.communicate([])
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/flow_0001.png) ![](images/flow_0002.png) ![](images/flow_0003.png)
+
+***
+
+**Next: [Other image passes (`_mask`, `_normals`, and `_albedo` passes)](other_passes.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [flow.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/visual_perception/flow.py) Enable `_flow` pass image capture and apply a force.
+
+Command API:
+
+- [`apply_force_to_object`](../../api/command_api.md#apply_force_to_object)
+
+
+
diff --git a/Documentation/lessons/visual_perception/id.md b/Documentation/lessons/visual_perception/id.md
new file mode 100644
index 000000000..5c748253e
--- /dev/null
+++ b/Documentation/lessons/visual_perception/id.md
@@ -0,0 +1,233 @@
+##### Visual Perception
+
+# Instance ID segmentation colors (`_id` pass)
+
+Each object in a TDW scene has a random and unique **segmentation color**. The `_id` capture pass shows the segmentation colors of each object in the scene:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+c = Controller()
+object_id_0 = c.get_unique_id()
+object_id_1 = c.get_unique_id()
+object_id_2 = c.get_unique_id()
+object_id_3 = c.get_unique_id()
+object_names = {object_id_0: "small_table_green_marble",
+                object_id_1: "rh10",
+                object_id_2: "jug01",
+                object_id_3: "jug05"}
+
+cam = ThirdPersonCamera(position={"x": 2.478, "y": 1.602, "z": 1.412},
+                        look_at={"x": 0, "y": 0.2, "z": 0},
+                        avatar_id="a")
+output_directory = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("id_pass")
+print(f"Images will be saved to: {output_directory.resolve()}")
+c.add_ons.append(cam)
+cap = ImageCapture(path=output_directory, avatar_ids=["a"], pass_masks=["_id"])
+c.add_ons.append(cap)
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               c.get_add_object(object_names[object_id_0],
+                                object_id=object_id_0),
+               c.get_add_object(object_names[object_id_1],
+                                position={"x": 0.7, "y": 0, "z": 0.4},
+                                rotation={"x": 0, "y": 30, "z": 0},
+                                object_id=object_id_1),
+               c.get_add_object(model_name=object_names[object_id_2],
+                                position={"x": -0.3, "y": 0.9, "z": 0.2},
+                                object_id=object_id_2),
+               c.get_add_object(object_names[object_id_3],
+                                position={"x": 0.3, "y": 0.9, "z": -0.2},
+                                object_id=object_id_3)])
+c.communicate({"$type": "terminate"})
+```
+
+Result:
+
+![](images/id_0000.png)
+
+## `SegmentationColors` output data
+
+You can get a mapping of the segmentation colors of each object in the scene with the [`send_segmentation_colors`](../../api/command_api.md#send_segmentation_colors), which will return [`SegmentationColors`](../../api/output_data.md#SegmentationColors) output data. You can use this information to determine which objects are in the image and to what extent they are in the image.
+
+You should only send `send_segmentation_colors` once after adding all objects in the scene. It is static data that won't change per frame.
+
+```python
+from collections import Counter
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+from tdw.output_data import OutputData, SegmentationColors
+
+c = Controller()
+object_id_0 = c.get_unique_id()
+object_id_1 = c.get_unique_id()
+object_id_2 = c.get_unique_id()
+object_id_3 = c.get_unique_id()
+object_names = {object_id_0: "small_table_green_marble",
+                object_id_1: "rh10",
+                object_id_2: "jug01",
+                object_id_3: "jug05"}
+
+cam = ThirdPersonCamera(position={"x": 2.478, "y": 1.602, "z": 1.412},
+                        look_at={"x": 0, "y": 0.2, "z": 0},
+                        avatar_id="a")
+output_directory = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("id_pass")
+print(f"Images will be saved to: {output_directory.resolve()}")
+c.add_ons.append(cam)
+cap = ImageCapture(path=output_directory, avatar_ids=["a"], pass_masks=["_id"])
+c.add_ons.append(cap)
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(object_names[object_id_0],
+                                       object_id=object_id_0),
+                      c.get_add_object(object_names[object_id_1],
+                                       position={"x": 0.7, "y": 0, "z": 0.4},
+                                       rotation={"x": 0, "y": 30, "z": 0},
+                                       object_id=object_id_1),
+                      c.get_add_object(model_name=object_names[object_id_2],
+                                       position={"x": -0.3, "y": 0.9, "z": 0.2},
+                                       object_id=object_id_2),
+                      c.get_add_object(object_names[object_id_3],
+                                       position={"x": 0.3, "y": 0.9, "z": -0.2},
+                                       object_id=object_id_3),
+                      {"$type": "send_segmentation_colors",
+                       "frequency": "once"}])
+segmentation_colors = dict()
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    # Get segmentation color output data.
+    if r_id == "segm":
+        segm = SegmentationColors(resp[i])
+        for j in range(segm.get_num()):
+            object_id = segm.get_object_id(j)
+            object_name = object_names[object_id]
+            segmentation_color = segm.get_object_color(j)
+            segmentation_colors[object_id] = segmentation_color
+
+# The total number of pixels in the image.
+num_pixels = 256 * 256
+# Get PIL images from `ImageCapture`.
+pil_image = cap.get_pil_images()["a"]["_id"]
+colors = Counter(pil_image.getdata())
+# Get the percentage of the image occupied by each object.
+for object_id in segmentation_colors:
+    segmentation_color = segmentation_colors[object_id]
+    object_name = object_names[object_id]
+    if segmentation_color not in colors:
+        print(object_name + " is not in the image!")
+    else:
+        print(object_name + " is in the frame!", float(colors[segmentation_color]) / num_pixels)
+c.communicate({"$type": "terminate"})
+```
+
+Output:
+
+```
+small_table_green_marble is in the frame! 0.04205322265625
+rh10 is in the frame! 0.03570556640625
+jug01 is in the frame! 0.0045013427734375
+jug05 is in the frame! 0.003204345703125
+```
+
+## `IdPassSegmentationColors` output data
+
+If you only need to know which segmentation colors are in the image, as opposed to *to what extent* an object is in the image, you can send [`send_id_pass_segmentation_colors`](../../api/command_api.md#send_id_pass_segmentation_colors), which will return [`IdPassSegmentationColors`](../../api/output_data.md#IdPassSegmentationColors).
+
+Sometimes, this is faster than the `_id` pass. The build scans an image for unique colors before creating the output data. The more objects are in the image, the slower the process. If there are only a few objects in the frame, `IdPassSegementationColors` is significantly faster than the `_id` pass but it _can_ be slower if there are many objects in the frame:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, SegmentationColors, IdPassSegmentationColors
+
+"""
+Get the IDs of each object in the frame.
+"""
+
+c = Controller()
+object_id_0 = c.get_unique_id()
+object_id_1 = c.get_unique_id()
+object_id_2 = c.get_unique_id()
+object_id_3 = c.get_unique_id()
+object_names = {object_id_0: "small_table_green_marble",
+                object_id_1: "rh10",
+                object_id_2: "jug01",
+                object_id_3: "jug05"}
+
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(object_names[object_id_0],
+                             object_id=object_id_0),
+            c.get_add_object(object_names[object_id_1],
+                             position={"x": 0.7, "y": 0, "z": 0.4},
+                             rotation={"x": 0, "y": 30, "z": 0},
+                             object_id=object_id_1),
+            c.get_add_object(model_name=object_names[object_id_2],
+                             position={"x": -0.3, "y": 0.9, "z": 0.2},
+                             object_id=object_id_2),
+            c.get_add_object(object_names[object_id_3],
+                             position={"x": 0.3, "y": 0.9, "z": -0.2},
+                             object_id=object_id_3)]
+
+commands.extend(TDWUtils.create_avatar(position={"x": 2.478, "y": 1.602, "z": 1.412},
+                                       look_at={"x": 0, "y": 0.2, "z": 0},
+                                       avatar_id="a"))
+commands.extend([{"$type": "set_pass_masks",
+                  "avatar_id": "a",
+                  "pass_masks": ["_id"]},
+                 {"$type": "send_segmentation_colors",
+                  "frequency": "once"},
+                 {"$type": "send_id_pass_segmentation_colors",
+                  "frequency": "always"}])
+resp = c.communicate(commands)
+# Get each segmentation color.
+segmentation_colors_per_object = dict()
+segmentation_colors_in_image = list()
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    # Get segmentation color output data.
+    if r_id == "segm":
+        segm = SegmentationColors(resp[i])
+        for j in range(segm.get_num()):
+            object_id = segm.get_object_id(j)
+            object_name = object_names[object_id]
+            segmentation_color = segm.get_object_color(j)
+            segmentation_colors_per_object[object_id] = segmentation_color
+    elif r_id == "ipsc":
+        ipsc = IdPassSegmentationColors(resp[i])
+        for j in range(ipsc.get_num_segmentation_colors()):
+            segmentation_colors_in_image.append(ipsc.get_segmentation_color(j))
+# Print the ID of each object in the image.
+for object_id in segmentation_colors_per_object:
+    if segmentation_colors_per_object[object_id] in segmentation_colors_in_image:
+        print(object_id)
+c.communicate({"$type": "terminate"})
+```
+
+***
+
+**Next: [Semantic category segmentation colors (`_category` pass)](category.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [id.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/visual_perception/id.py) Example implementation of the `_id` pass.
+- [id_pass_segmentation_colors.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/visual_perception/id_pass_segmentation_colors.py) Example implementation of `IdPassSegmentationColors` pass.
+
+Command API:
+
+- [`send_segmentation_colors`](../../api/command_api.md#send_segmentation_colors)
+- [`send_id_pass_segmentation_colors`](../../api/command_api.md#send_id_pass_segmentation_colors)
+
+Output Data API:
+
+- [`SegmentationColors`](../../api/output_data.md#SegmentationColors)
+- [`IdPassSegmentationColors`](../../api/output_data.md#IdPassSegmentationColors)
+
diff --git a/Documentation/lessons/visual_perception/images/albedo_0000.png b/Documentation/lessons/visual_perception/images/albedo_0000.png
new file mode 100644
index 000000000..5c23566e5
Binary files /dev/null and b/Documentation/lessons/visual_perception/images/albedo_0000.png differ
diff --git a/Documentation/lessons/visual_perception/images/category_0000.png b/Documentation/lessons/visual_perception/images/category_0000.png
new file mode 100644
index 000000000..f8e5da131
Binary files /dev/null and b/Documentation/lessons/visual_perception/images/category_0000.png differ
diff --git a/Documentation/lessons/visual_perception/images/depth_0000.png b/Documentation/lessons/visual_perception/images/depth_0000.png
new file mode 100644
index 000000000..e37233058
Binary files /dev/null and b/Documentation/lessons/visual_perception/images/depth_0000.png differ
diff --git a/Documentation/lessons/visual_perception/images/depth_plt.png b/Documentation/lessons/visual_perception/images/depth_plt.png
new file mode 100644
index 000000000..4b47b186e
Binary files /dev/null and b/Documentation/lessons/visual_perception/images/depth_plt.png differ
diff --git a/Documentation/lessons/visual_perception/images/depth_simple_0000.png b/Documentation/lessons/visual_perception/images/depth_simple_0000.png
new file mode 100644
index 000000000..5fb7d2be5
Binary files /dev/null and b/Documentation/lessons/visual_perception/images/depth_simple_0000.png differ
diff --git a/Documentation/lessons/visual_perception/images/depth_simple_0000_contrast.png b/Documentation/lessons/visual_perception/images/depth_simple_0000_contrast.png
new file mode 100644
index 000000000..e7980166e
Binary files /dev/null and b/Documentation/lessons/visual_perception/images/depth_simple_0000_contrast.png differ
diff --git a/Documentation/lessons/visual_perception/images/flow_0001.png b/Documentation/lessons/visual_perception/images/flow_0001.png
new file mode 100644
index 000000000..d3b14d592
Binary files /dev/null and b/Documentation/lessons/visual_perception/images/flow_0001.png differ
diff --git a/Documentation/lessons/visual_perception/images/flow_0002.png b/Documentation/lessons/visual_perception/images/flow_0002.png
new file mode 100644
index 000000000..8d7e4b254
Binary files /dev/null and b/Documentation/lessons/visual_perception/images/flow_0002.png differ
diff --git a/Documentation/lessons/visual_perception/images/flow_0003.png b/Documentation/lessons/visual_perception/images/flow_0003.png
new file mode 100644
index 000000000..5f93e6477
Binary files /dev/null and b/Documentation/lessons/visual_perception/images/flow_0003.png differ
diff --git a/Documentation/lessons/visual_perception/images/id_0000.png b/Documentation/lessons/visual_perception/images/id_0000.png
new file mode 100644
index 000000000..471266e55
Binary files /dev/null and b/Documentation/lessons/visual_perception/images/id_0000.png differ
diff --git a/Documentation/lessons/visual_perception/images/mask_0000.png b/Documentation/lessons/visual_perception/images/mask_0000.png
new file mode 100644
index 000000000..e902b7ead
Binary files /dev/null and b/Documentation/lessons/visual_perception/images/mask_0000.png differ
diff --git a/Documentation/lessons/visual_perception/images/normals_0000.png b/Documentation/lessons/visual_perception/images/normals_0000.png
new file mode 100644
index 000000000..6ec943981
Binary files /dev/null and b/Documentation/lessons/visual_perception/images/normals_0000.png differ
diff --git a/Documentation/lessons/visual_perception/occlusion.md b/Documentation/lessons/visual_perception/occlusion.md
new file mode 100644
index 000000000..666d3fb27
--- /dev/null
+++ b/Documentation/lessons/visual_perception/occlusion.md
@@ -0,0 +1,108 @@
+##### Visual Perception
+
+# `Occlusion` output data
+
+[`Occlusion`](../../api/output_data.md#Occlusion) output data isn't a capture pass, though in the backend code it does use image data.
+
+Internally, the build will capture two `_mask` passes, one with environment objects and one without. The resulting occlusion value is the ratio of the overall grayscale values of the images:
+
+```python
+from typing import List
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData
+from tdw.output_data import Occlusion as Occl
+
+"""
+Use occlusion data to measure to what extent objects in the scene are occluded.
+"""
+
+
+class Occlusion(Controller):
+    def run(self) -> None:
+        """
+        Create a scene with a row of objects and an avatar.
+        Get occlusion data.
+        Put a wall in front of the objects and get occlusion data again.
+        """
+
+        # Create the scene.
+        commands = [TDWUtils.create_empty_room(12, 12)]
+        # Add some objects.
+        x = -2
+        for i in range(5):
+            commands.append(self.get_add_object(object_id=i,
+                                                model_name="iron_box",
+                                                position={"x": x, "y": 0, "z": 0}))
+            x += 0.66
+        # Add an avatar.
+        commands.extend(TDWUtils.create_avatar(position={"x": 2, "y": 0.9, "z": 0.88},
+                                               look_at=TDWUtils.VECTOR3_ZERO))
+        # Request Occlusion output data per frame.
+        commands.append({"$type": "send_occlusion",
+                         "frequency": "once"})
+        resp = self.communicate(commands)
+        self.parse_resp(resp=resp)
+
+        # Treat some of the objects as occluders.
+        resp = self.communicate({"$type": "send_occlusion",
+                                 "frequency": "once",
+                                 "object_ids": [2, 4, 5]})
+        self.parse_resp(resp=resp)
+
+        # Place a wall in front of all of the objects.
+        resp = self.communicate([{"$type": "send_occlusion",
+                                  "frequency": "once"},
+                                 {"$type": "create_interior_walls",
+                                  "walls": [{"x": 7, "y": 5}, {"x": 7, "y": 6}]}])
+        self.parse_resp(resp=resp)
+        self.communicate({"$type": "terminate"})
+
+    @staticmethod
+    def parse_resp(resp: List[bytes]) -> None:
+        """
+        Parse the output data and print the occlusion.
+
+        :param resp: The response from the build.
+        """
+
+        for i in range(len(resp) - 1):
+            r_id = OutputData.get_data_type_id(resp[i])
+            if r_id == "occl":
+                occl = Occl(resp[i])
+                print(occl.get_occluded())
+
+
+if __name__ == "__main__":
+    c = Occlusion(launch_build=False)
+    c.run()
+```
+
+Result:
+
+```
+0.0
+0.375
+0.625
+```
+
+***
+
+**This is the last document in the "Visual Perception" tutorial.**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [occlusion.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/visual_perception/occlusion.py) Example implementation of `Occlusion` output data.
+
+Command API:
+
+- [`send_occlusion`](../../api/command_api.md#send_occlusion)
+
+Output Data API:
+
+- [`Occlusion`](../../api/output_data.md#Occlusion)
+
diff --git a/Documentation/lessons/visual_perception/other_passes.md b/Documentation/lessons/visual_perception/other_passes.md
new file mode 100644
index 000000000..e45a924bf
--- /dev/null
+++ b/Documentation/lessons/visual_perception/other_passes.md
@@ -0,0 +1,28 @@
+##### Visual Perception
+
+# Other image passes (`_mask`, `_normals`, and `_albedo` passes)
+
+## The `_mask` pass
+
+The `_mask` pass is similar to the `_id` and `_category` passes but all objects are the same color:
+
+![](images/mask_0000.png)
+
+## The `_normals` pass
+
+In the `_normals` pass, surfaces are colored according to their orientation in relation to the camera:
+
+![](images/normals_0000.png)
+
+## The `_albedo` pass
+
+The `_albedo` pass has only color and texture, as if lit with only ambient light.
+
+![](images/albedo_0000.png)
+
+***
+
+**Next: [`Occlusion` output data](occlusion.md)**
+
+[Return to the README](../../../README.md)
+
diff --git a/Documentation/lessons/visual_perception/overview.md b/Documentation/lessons/visual_perception/overview.md
new file mode 100644
index 000000000..6023f2b83
--- /dev/null
+++ b/Documentation/lessons/visual_perception/overview.md
@@ -0,0 +1,104 @@
+##### Visual Perception
+
+# Overview
+
+The [Core Concepts guide](../core_concepts/images.md) explained how to initialize image capture for the the `_img` pass. There are many other image passes available in TDW. It is possible to receive multiple capture passes on the same frame by setting the `pass_masks` parameter of the `set_pass_mask` command. In this example, the controller will receive and `_img` pass and an `_id` pass:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, Images
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+c = Controller()
+object_id = c.get_unique_id()
+
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name="iron_box",
+                             position={"x": 0, "y": 0, "z": 0},
+                             object_id=object_id)]
+commands.extend(TDWUtils.create_avatar(position={"x": 2, "y": 1.6, "z": -0.6},
+                                       avatar_id="a",
+                                       look_at={"x": 0, "y": 0, "z": 0}))
+commands.extend([{"$type": "set_pass_masks",
+                  "pass_masks": ["_img", "_id"],
+                  "avatar_id": "a"},
+                 {"$type": "send_images",
+                  "frequency": "always",
+                  "ids": ["a"]}])
+
+resp = c.communicate(commands)
+output_directory = str(EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("send_images_2").resolve())
+print(f"Images will be saved to: {output_directory}")
+
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    # Get Images output data.
+    if r_id == "imag":
+        images = Images(resp[i])
+        # Determine which avatar captured the image.
+        if images.get_avatar_id() == "a":
+            TDWUtils.save_images(images=images, filename="0", output_directory=output_directory)
+c.communicate({"$type": "terminate"})
+```
+
+Or, with the `ImageCapture` add-on:
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+c = Controller()
+object_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name="iron_box",
+                             position={"x": 0, "y": 0, "z": 0},
+                             object_id=object_id)]
+commands.extend(TDWUtils.create_avatar(position={"x": 2, "y": 1.6, "z": -0.6},
+                                       avatar_id="a",
+                                       look_at={"x": 0, "y": 0, "z": 0}))
+c.add_ons.append(cam)
+output_directory = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("send_images_2")
+print(f"Images will be saved to: {output_directory}")
+
+# Add the ImageCapture add-on.
+cap = ImageCapture(path=output_directory, avatar_ids=["a"], pass_masks=["_img", "_id"])
+c.add_ons.append(cap)
+
+c.communicate(commands)
+c.communicate({"$type": "terminate"})
+```
+
+## Capture passes and output data
+
+Some visual perception data is image data such as the segmentation color passes and the depth passes. Some visual perception data is *not* image data; in some cases, it is faster and more convenient to return only a small amount of relevant information. This tutorial will cover examples of both image and non-image visual perception data.
+
+***
+
+**Next: [Instance ID segmentation colors (`_id` pass)](id.md)**
+
+[Return to the README](../../../README.md)
+
+***
+
+Example controllers:
+
+- [send_images.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/core_concepts/send_images.py) Capture an image and save it to disk.
+- [image_capture.py](https://github.com/threedworld-mit/tdw/blob/master/Python/example_controllers/core_concepts/image_capture.py) Example implementation of the ImageCapture add-on.
+
+Python API:
+
+- [`ImageCapture`](../../python/add_ons/image_capture.md)  (add-on that saves images every frame)
+- [`TDWUtils.save_images(images, filename, output_directory)`](../../python/tdw_utils.md)  (Save all capture passes)
+
+Command API:
+
+- [`set_pass_masks`](../../api/command_api.md#set_pass_masks)
+- [`send_images`](../../api/command_api.md#send_images)
+
+Output Data API:
+
+- [`Images`](../../api/output_data.md#Images) 
diff --git a/Documentation/misc_frontend/add_local_object.md b/Documentation/misc_frontend/add_local_object.md
deleted file mode 100644
index 013f3a65c..000000000
--- a/Documentation/misc_frontend/add_local_object.md
+++ /dev/null
@@ -1,68 +0,0 @@
-# How to Add Your Own Models to TDW
-
-You can add your own objects to the build by doing the following:
-
-1. Create a .fbx  or .obj+.mtl file.
-2. Use the [`AssetBundleCreator`](../python/asset_bundle_creator.md) to create an asset bundle and local metadata file.
-3. Run a controller that includes the [command](../api/command_api.md) `add_object`.
-4. (Optional) Store the object metadata in a custom [library file](../python/librarian/model_librarian.md).
-
-**For example implementation, see: `tdw/Python/example_controllers/local_object.py`**
-
-### Requirements
-
-- Windows 10, OS X, or Linux
-- (Windows only) [Visual C++ 2012 Redistributable](https://www.microsoft.com/en-au/download/confirmation.aspx?id=30679)
-- The `tdw` module
-- Python 3.6+
-- Unity Hub
-- Unity Editor 2020.2 (installed via Unity Hub)
-  - Build options must enabled for Windows, OS X, and Linux (these can  be set when installing Unity).
-- A .fbx or .obj+.mtl model
-
-### What `AssetBundleCreator` does
-
-**Asset bundles** are binaries that Unity Engine can read at runtime. All of TDW's models, scenes, materials, etc. are stored as _asset bundles_ on a remote S3 server, but you can create your own object asset bundles on your local machine and load them into the build the same way (with the `add_object` command).
-
-`AssetBundleCreator.create_asset_bundle` will do the following:
-
-1. Create a new Unity project on your local machine and add some scripts to it (this project will be used to generate the asset bundle).
-2. Create a few extra files from your source 3D model in order to generate physics colliders. This process can take a while; be patient.
-3. Create asset bundles from the resulting files.
-4. Create a metadata record.
-
-Once the asset bundles and metadata are created, you don't need to create them again; move them out of the `asset_bundle_creator/Assets` folder to wherever you want them to be (you can also safely keep them in the project folder, but if you generate another model with the same name, the original file will be overwritten).
-
-### How to Export .fbx files from Blender 2.8
-
-Blender can be fussy when exporting to Unity. If you export a .obj, you shouldn't have any problems. If you export a .fbx, there may be problems with the model's scale, as well as the collider's scale.
-
-To set correct .fbx model scaling in Blender:
-
-1. Set the scale of your model to 0.5
-
-![](../images/blender_export/0_set_scale.png)
-
-2. Making sure that the model is still selected, apply the scale: **ctrl+a+s**
-3. Making sure that the model is still selected, export the .fbx file. Make sure you're exporting only the selected object, and set the scaling to "FBX Units Scale".
-
-<img src="../images/blender_export/1_export.png" style="zoom:50%;" />
-
-### Creating Many Asset Bundles
-
-You could convert many models to asset bundles by creating a loop that repeatedly calls `AssetBundleCreator.create_asset_bundles()`. **This is not a good idea.** Repeatedly calling Unity from a Python script is actually very slow. (It also appears to slow down over many consecutive calls).
-
-Instead, consider using `create_many_asset_bundles(library_path)` for large numbers of models. This function will walk through `asset_bundle_creator/Assets/Resources/models/` searching for .obj and .fbx models. It will convert these models to asset bundles.
-
-The `library_path` parameter is the path to the local [model records database](../python/librarian/model_librarian.md). 
-
-### Troubleshooting
-
-- Make sure that Unity Editor and Visual Studio are _closed_ when running `AssetBundleCreator`.
-- Make sure that you have installed the Editor _via Unity Hub_; the Python script assumes this when trying to find the editor .exe
-- If `AssetBundleCreator` is crashing with errors, please let us know; copy+paste the error when you do.
-
-### Roadmap
-
-- Add support for OS X. _(Linux support isn't likely in the near future, as the pipeline requires Unity Editor.)_
-
diff --git a/Documentation/misc_frontend/depth.md b/Documentation/misc_frontend/depth.md
deleted file mode 100644
index 684c35144..000000000
--- a/Documentation/misc_frontend/depth.md
+++ /dev/null
@@ -1,86 +0,0 @@
-# Depth Maps
-
-Depth passes are image that encode depth information into pixel colors. To receive a depth pass, you need to add an avatar to the scene, set the pass masks to include `_depth`, and then request images:
-
-```python
-import numpy as np
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.output_data import OutputData, Images, CameraMatrices
-
-c = Controller()
-c.start()
-# Create an empty room.
-commands = [TDWUtils.create_empty_room(12, 12)]
-# Add the avatar.
-commands.extend(TDWUtils.create_avatar(position={"x": 1.57, "y": 3, "z": 3.56}, 
-                                       look_at=TDWUtils.VECTOR3_ZERO))
-# Add an object. Request image data that includes the depth pass.
-commands.extend([c.get_add_object("trunck", object_id=0),
-                 {"$type": "set_pass_masks",
-                  "pass_masks": ["_depth"]},
-                 {"$type": "send_images"}])
-resp = c.communicate(commands)
-images = None
-depth_image = None
-for i in range(len(resp) - 1):
-    r_id = OutputData.get_data_type_id(resp[i])
-    # Get the image.
-    if r_id == "imag":
-        images = Images(resp[i])
-        for j in range(images.get_num_passes()):
-            if images.get_pass_mask(j) == "_depth":
-                depth_image = images.get_image(j)
-```
-
-## Types of depth passes
-
-- `_depth_simple` is a grayscale depth map.
-- `_depth` is a more accurate pass that encodes depth values to a 24-bit RGB value.
-
-## How to get depth values
-
-Decode a depth pass into an array of depth floats with `TDWUtils.get_depth_values()`.
-
-```python
-# Get the depth values of each pixel.
-depth = TDWUtils.get_depth_values(image=depth_image, width=images.get_width(), height=images.get_height(), far_plane=far_plane, near_plane=near_plane)
-```
-
-The default values of `width`, `height`, `far_plane`, and `near_plane` all correspond to default camera values in TDW. To adjust them, see the commands `set_screen_size` and `set_camera_clipping_planes`.
-
-## How to create a point cloud
-
-Use the decoded depth values to create a point cloud with `TDWUtils.get_point_cloud()`. You will also need camera matrix data.
-
-```python
-commands.extend([c.get_add_object("trunck", object_id=0),
-                 {"$type": "set_pass_masks",
-                  "pass_masks": ["_depth"]},
-                 {"$type": "send_images"},
-                 {"$type": "send_camera_matrices"}])
-resp = c.communicate(commands)
-
-depth_image = None
-camera_matrix = None
-images = None
-for i in range(len(resp) - 1):
-    r_id = OutputData.get_data_type_id(resp[i])
-    # Get the image.
-    if r_id == "imag":
-        images = Images(resp[i])
-        for j in range(images.get_num_passes()):
-            if images.get_pass_mask(j) == depth_pass:
-                depth_image = images.get_image(j)
-    # Get the camera matrix.
-    elif r_id == "cama":
-        camera_matrix = CameraMatrices(resp[i]).get_camera_matrix()
-
-depth = TDWUtils.get_depth_values(image=depth_image, width=images.get_width(), height=images.get_height())
-point_cloud = TDWUtils.get_point_cloud(depth=depth, camera_matrix=camera_matrix)
-```
-
-## Example
-
-For a controller that combines all of the code snippets in this document, see: `tdw/Python/example_controllers/depth_shader.py`
-
diff --git a/Documentation/misc_frontend/materials_textures_colors.md b/Documentation/misc_frontend/materials_textures_colors.md
deleted file mode 100644
index 11d69216a..000000000
--- a/Documentation/misc_frontend/materials_textures_colors.md
+++ /dev/null
@@ -1,48 +0,0 @@
-# Materials, Textures, and Colors
-
-Unity differentiates between _materials_ and _textures_ and _colors_; these three terms are _not_ interchangeable.
-
-This document will give an overview of some of the fundamental differences.
-
-## Material
-
-A _material_ is a binary file containing an initialized _shader_. A shader is a tiny program that is used to render a 3D object. The material shader might contain info such as:
-
-- To what extent the material is transparent
-- To what extent the material is "bumpy"
-- The _texture_ and _color_ of the material
-
-Each of these properties is a _channel_.
-
-Every mesh in Unity _must_ have at least 1 material (or it will appear as horrible pink). It is possible for a mesh to have more than 1 material, and those materials can interact in myriad ways. Just a few examples of possibilities:
--  Decals on a racecar; the car's paint and the decal could be handled as separate materials.)
--  A puddle in the dirt might be handled as two materials on a flat surface that are blended together.
-
-Every model in the TDW model library is comprised of _n_ meshes, each of which have _m_ materials. You can switch any material of any mesh of any object by sending [`set_visual_material`](../api/command_api.md#set_visual_material).
-
-## Texture
-
-A _texture_ is a .jpg or .png file. It is one of many aspects of a material. In computer graphics, the term _texture_ is often used interchangeably with _map_.
-
-Intuition would suggest that the texture is the "visual" (_albedo_) channel of the material. This is not always the case, though:
-
-- Some materials don't have textures, and instead have just a solid _color_.
-- Some textures are actually meant to control specialized aspects of the material. For example, the "normal map" will modify the "bumpiness" of a material. Conversely, if you change the albedo texture of a material, the normal mapping and other channels of the material will remain unmodified.
-- A material can sometimes have multiple albedo textures layered on top of each other, such as puddles on the ground, or decals on a racecar. Whether you'd want to handle multiple visual components of an object as textures or materials depends a lot on how they're being used in the simulation.
-
-_Textures_ will "tile" across a material, repeating in a grid pattern. While they tile _effectively_ depends on whether the texture is "seamless". Many textures are not meant to tile, and the "grid" is effectively 1x1.
-
-## Color
-
-A _color_ is an RGBA color. The albedo channel of a material usually has a color property. The material with/without a texture can be tinted with a color.
-
-# Ask yourself: "What do I want?"
-
-If you want to control how an object/wall/floor/avatar "looks" in TDW, you need to decide what you're actually trying to accomplish.
-
-| "I want to be able to change the..." | Pro | Con | Can I do it? |
-| ---------- | --- | --- | --- |
-| **Material** | It is likely that it will look "good" by default. For example, a "metallic" material will have a metallic texture and will reflect light in a metallic manner. | You are limited to the materials in our material library. (The library will continuously expand.) | Yes<br><br>Models: [`set_visual_material`](../api/command_api.md#set_visual_material)<br>Proc-gen floors: [`set_proc_gen_floor_material`](../api/command_api.md#set_proc_gen_floor_material)<br>Proc-gen walls:  [`set_proc_gen_walls_material`](../api/command_api.md#set_proc_gen_walls_material) |
-| **Texture** | You can in principle set the texture of an existing material by just sending in the image as a byte array. This effectively means inifinite variability. | You still won't have control over the "normal maps", the "emissive" controls, or any other channel in the shader. For example, switching to a "metallic" _texture_ won't necessarily allow the object to "shine" like a metal. | No |
-| **Color** | Switching colors is computationally fast at runtime. | This "tints" existing textures rather than replacing them. | Yes<br><br>Models: [`set_color`](../api/command_api.md#set_color)<br>Avatars: [`set_avatar_color`](../api/command_api.md#set_avatar_color) |
-
diff --git a/Documentation/misc_frontend/physics.md b/Documentation/misc_frontend/physics.md
deleted file mode 100644
index 2ad55ab9b..000000000
--- a/Documentation/misc_frontend/physics.md
+++ /dev/null
@@ -1,64 +0,0 @@
-# TDW Physics
-
-This document includes common questions or problems users have encountered when creating physics simulations in TDW.
-
-### "Objects are passing through each other, physics is non-deterministic, physics feels 'floaty', etc."
-
-These [commands](../api/command_api.md) can improve the overall physics quality of your simulation:
-
-| Command                         | Effect                                                       |
-| ------------------------------- | ------------------------------------------------------------ |
-| `set_physics_solver_iterations` | Set the number of physics solver iterations, which affects the overall accuracy of the physics engine. |
-| `set_time_step`                 | Set the physics time step per frame. The actual affect though isn't totally intuitive; see [documentation](../api/command_api.md#set_time_step). |
-
-See [Physics Determinism](../benchmark/determinism.md) for a benchmark.
-
-### "Objects are not behaving realistically."
-
-Try these commands:
-
-| Command                                               | Effect                                                       |
-| ----------------------------------------------------- | ------------------------------------------------------------ |
-| `set_mass`                                            | Set the mass of the object; many models may initially have a lower mass than you might expect. |
-| `set_avatar_mass`                                     | Set the mass of an avatar.                                   |
-| `set_physic_material`                                 | Assign friction and "bounciness" values to an object.        |
-| `set_avatar_physic_material`                          | Assign friction and "bounciness" values to an avatar.        |
-| `set_semantic_material`<br>`set_semantic_material_to` | Assign friction and "bounciness" values by material type.    |
-
-### "I want to stop an object."
-
-Send the `set_avatar_drag` or `set_object_drag` command. See [avatar movement documentation](avatar_movement.md) (the section about avatar drag applies to objects as well; just send `set_object_drag` instead).
-
-### "I want to simulate multiple physics frames per step."
-
-Send the `step_physics` command.
-
-### "I want to use soft-bodies, cloth, fluids, etc."
-
-See [NVIDIA Flex documentation](flex.md).
-
-### "I want to disable physics."
-
-Try these commands:
-
-| Command                      | Effect                                                       |
-| ---------------------------- | ------------------------------------------------------------ |
-| `simulate_physics`           | If `"value" == False`, TDW will continue to advance frames, but won't simulate physics. |
-| `set_avatar_kinematic_state` | Set an avatar's Rigidbody to be kinematic or not. A kinematic object won't respond to PhysX physics. |
-| `set_kinematic_state`        | Set an object's Rigidbody to be kinematic or not. A kinematic object won't respond to PhysX physics. |
-
-### "I want to include audio in my physics simulation."
-
-See [impact sounds documentation](impact_sounds.md).
-
-### "I want to receive physics metadata."
-
-This [output data](../api/output_data.md) may be useful:
-
-| Output Data   | Command            | Contents                                |
-| ------------- | ------------------ | --------------------------------------- |
-| `Transforms`  | `send_transforms`  | Positions, rotations, etc.              |
-| `Rigidbodies` | `send_rigidbodies` | Velocities, masses, etc.                |
-| `Collision`   | `send_collisions`  | Relative velocity, contact points, etc. |
-
-Additionally, see the [TDW Physics repo](https://github.com/alters-mit/tdw_physics) for a useful set of controllers for generating physics datasets.
\ No newline at end of file
diff --git a/Documentation/misc_frontend/releases.md b/Documentation/misc_frontend/releases.md
deleted file mode 100644
index d42528f99..000000000
--- a/Documentation/misc_frontend/releases.md
+++ /dev/null
@@ -1,71 +0,0 @@
-# TDW Releases
-
-There are three types of releases in TDW:
-
-### 1. Latest stable release
-
-The **latest stable release** is usually what we recommend users download, unless they need cutting-edge functionality. This release is often behind the latest commit in `master` but always corresponds to a tagged commit such as `v1.6.0`
-
-The [`tdw` Python module](../python/tdw.md) always matches the latest stable release.
-
-### 2. Unstable release
-
-The **unstable release** contains code and functionality that is still a work-in-progress and might alter the API such that you must rewrite your controller (for example, by removing a command).
-
-The unstable release is removed when the next stable release is uploaded (for example, `v1.6.0_unstable` is removed when `v1.6.0` is uploaded).
-
-The unstable release is always up-to-date with the latest commit on `master`. On the Releases page, it is always marked `pre-release`. 
-
-To update the `tdw` module to an unstable release, you must uninstall and reinstall the module:
-
-1. `pip3 uninstall tdw`
-2. `cd path/to/tdw/Python` (Replace `path/to` with the actual path.)
-3. `git checkout master`
-4. `pip3 install -e .` (Don't forget the `.`)
-
-### 3. Old stable releases
-
-To downgrade to an older stable release:
-
-1. `pip3 install tdw==version` (Replace version with a valid version, such as `v1.6.0`)
-2. Download the appropriate build from the Releases page.
-
-Additionally, each release is a tagged commit that you can checkout to, for example: 
-
-`git checkout v1.6.0`
-
-## Versioning
-
-### Minor Releases
-
-_Example:_ `v1.6.0` → `v1.6.1`
-
-These are incremental updates. You can safely upgrade without having to alter your own code.
-
-### Major Releases
-
-_Example:_ `v1.6.2` → `v1.7.0`
-
-These releases usually introduce major additions to TDW. They also typically introduce API-breaking changes (such as removing or modifying commands).
-
-## Updates
-
-When you first run a controller on a machine, it will automatically download a build from the same version.  The build will be downloaded to: `~/tdw_build` (where `~` is your home directory). To prevent the build from launching:
-
-```python
-c = Controller(launch_build=False)
-```
-
-The controller will check to make sure that you have an up-to-date version of the `tdw` Python module by querying PyPi; if there is a mis-match  (`1.6.0` vs. `1.6.1`; `1.6.1` vs. `1.7.0`, etc.) it will offer suggestions for how to upgrade.
-
-Then, the controller will compare your installed version of TDW to the version of the downloaded build. If the downloaded build is out-of-date, it will be replaced with a newer version.
-
-To suppress all version checks:
-
-```python
-c = Controller(check_version=False)
-```
-
-## Changelog
-
-All changes to TDW are recorded in the [changelog](../Changelog.md).
\ No newline at end of file
diff --git a/Documentation/misc_frontend/reset_scene.md b/Documentation/misc_frontend/reset_scene.md
deleted file mode 100644
index 489e34c8d..000000000
--- a/Documentation/misc_frontend/reset_scene.md
+++ /dev/null
@@ -1,45 +0,0 @@
-# Reset a Scene
-
-## Reset a Scene to Original State
-
-There are many ways to reset a scene in TDW to its original state. Below are the most common strategies:
-
-### 1. Destroy the scene and rebuild it
-
-Reloading a scene always destroys all objects and avatars within it. If you have your initialization code in a single function or set of functions, you can do this:
-
-```python
-def reset():
-    c.start()
-    c.communicate(TDWUtils.create_empty_room(20, 20))
-    c.communicate(TDWUtils.create_avatar())
-    c.add_object("iron_box", position={"x": 0, "y": y, "z": 0})
-    
-
-reset()
-```
-
-### 2. Selectively destroy parts of the scene and rebuild 
-
-If you only need to destroy some parts of the scene, destroy those.
-
-In this example, the objects and avatars are destroyed, but not the room:
-
-```python
-def create():
-    c.start()
-    c.communicate(TDWUtils.create_empty_room(20, 20))
-
-def reset():
-    c.communicate({"$type": "destroy_all_objects"})
-    c.communicate(TDWUtils.create_avatar())
-    c.add_object("iron_box", position={"x": 0, "y": y, "z": 0})
-    
-
-create()
-reset()
-```
-
-## Reset a Scene to an Arbitrary State
-
-Unfortunately, this is not supported in TDW. It isn't possible to take a "snapshot" of the Unity physics engine, or of TDW's "global state", and reload that snapshot later. You _can_ create your own snapshots to some extent; for example, if all you need is positional and visual material data, you can cache that in your controller.
\ No newline at end of file
diff --git a/Documentation/misc_frontend/robots.md b/Documentation/misc_frontend/robots.md
deleted file mode 100644
index 3bc5f26ec..000000000
--- a/Documentation/misc_frontend/robots.md
+++ /dev/null
@@ -1,165 +0,0 @@
-# TDW Robotics
-
-## Robotics API
-
-TDW's robots are handled very similarly to how 3D models are handled. They are stored in a robot library and have [corresponding metadata records](../python/librarian/robot_librarian.md). 
-
-Add robots to the scene using either [the `add_robot` command or `Controller.get_add_robot()`](../python/librarian/robot_librarian.md#command-api).
-
-| Command                   | Description                                                  |
-| ------------------------- | ------------------------------------------------------------ |
-| `add_robot`               | Add a robot to the scene.                                    |
-| `destroy_robot`           | Destroys a robot in the scene.                               |
-| `set_immovable`           | Set whether or not the root object of the robot is immovable. Its joints will still be moveable. |
-| `set_prismatic_target`    | Set the target position of a prismatic robot joint. Per frame, the joint will move towards the target until it is either no longer possible to do so (i.e. due to physics) or because it has reached the target position. |
-| `set_revolute_target`     | Set the target angle of a revolute robot joint. Per frame, the joint will revolve towards the target until it is either no longer possible to do so (i.e. due to physics) or because it has reached the target angle. |
-| `set_spherical_target`    | Set the target angles (x, y, z) of a spherical robot joint. Per frame, the joint will revolve towards the targets until it is either no longer possible to do so (i.e. due to physics) or because it has reached the target angles. |
-| `add_force_to_prismatic`  | Add a force to a prismatic joint.                            |
-| `add_torque_to_revolute`  | Add a torque to a revolute joint.                            |
-| `add_torque_to_spherical` | Add a torque to a spherical joint.                           |
-| `send_robots`             | [Send data for each robot in the scene](https://github.com/threedworld-mit/tdw/blob/master/Documentation/api/output_data.md#Robot) (including Magnebots). |
-| `send_static_robots`      | [Send static data for each robot in the scene](https://github.com/threedworld-mit/tdw/blob/master/Documentation/api/output_data.md#StaticRobot) (including Magnebots). |
-| `parent_avatar_to_robot`  | Parent an avatar to a robot.                                 |
-| `set_robot_joint_drive`   | Set static joint drive parameters.                           |
-| `set_robot_joint_mass`    | Set the mass of a robot joint.                               |
-| `teleport_robot`          | Teleport the robot to a new position and rotation. This is a sudden movement that might disrupt the physics simulation. You should only use this command if you really need to (for example, if the robot falls over). |
-
-### Joint targets
-
-| Joint Type | Degrees of freedom | Units   |
-| ---------- | ------------------ | ------- |
-| fixed      | 0                  |         |
-| revolute   | 1                  | degrees |
-| prismatic  | 1                  | meters  |
-| spherical  | 3                  | degrees |
-
-Once the target is set, the joint still needs to move to the target. You can determine if joints are still moving by checking the [`Robot.get_joint_positions()`](https://github.com/threedworld-mit/tdw/blob/master/Documentation/api/output_data.md#Robot). See `robot_arm.py` for example implementation.
-
-Targets are always *cumulative*, not deltas. If you do this:
-
-1. Set revolute target to `720`
-2. Wait
-3. Set revolute target to `0`
-4. Wait
-
-You will get the following behavior:
-
-1. Joint turns 720 degrees
-2. Joint turns -720 degrees
-
-## Robots and Avatars
-
-TDW has a built-in concept of what an "Avatar" is, which has implications for which commands can be sent to which objects in the scene. Robots are *not* avatars; they are *robots*.
-
-Robots by default don't have cameras. However, you can add a camera to a robot by first creating an avatar and then parenting that avatar to the robot. See: `example_controllers/robot_camera.py` for example implementation.
-
-## Robots Currently in TDW
-
-TDW includes many real-world robots by default. Metadata records of each robot are stored in a [`RobotLibrarian`](../python/librarian/robot_librarian.md).
-
- To get a list of available robots:
-
-```python
-from tdw.librarian import RobotLibrarian
-
-lib = RobotLibrarian()
-for record in lib.records:
-    print(record.name)
-```
-
-To search for a robot:
-
-```python
-from tdw.librarian import RobotLibrarian
-
-lib = RobotLibrarian()
-
-record = lib.get_record("ur3")
-if record is not None:
-    print(record.name, record.urls)
-    
-records = lib.search_records("ur3")
-for record in records:
-    print(record)
-```
-
-To add a robot:
-
-```python
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.librarian import RobotLibrarian
-
-c = Controller(launch_build=False)
-c.start()
-
-lib = RobotLibrarian()
-record = lib.get_record(name="ur3")
-
-c.communicate([TDWUtils.create_empty_room(12, 12),
-               c.get_add_robot(name=record.name,
-                               robot_id=0,
-                               position={"x": 0.5, "y": 0, "z": 2})])
-```
-
-## How to Add a Robot to TDW
-
-**It is possible to import any robot into TDW, given a .urdf or .xacro file.** To do so, use a `RobotCreator`. [Read this document to learn more about the API and installation requirements](../python/robot_creator.md).
-
-## Magnebot API
-
-The Magnebot is a specialized robot in TDW that can use "magnets" to pick up objects.
-
-[**Get the high-level Magnebot API here.**](https://github.com/alters-mit/magnebot)
-
-![](../images/robots/reach_high.gif)
-
-Add a Magnebot to the scene with the `add_magnebot` command (`add_robot` will *not* add a Magnebot!). With the exception of `add_robot`, all other commands in the Robotics API are compatible with the Magnebot. 
-
-<img src="../images/robots/magnebot_front.jpg" style="zoom: 67%;" />
-
-<img src="../images/robots/magnebot_back.jpg" style="zoom:67%;" />
-
-| Joint               | Command                |
-| ------------------- | ---------------------- |
-| `column`            | `set_revolute_target`  |
-| `torso`             | `set_prismatic_target` |
-| `shoulder_left`     | `set_spherical_target` |
-| `shoulder_right`    | `set_spherical_target` |
-| `elbow_left`        | `set_revolute_target`  |
-| `elbow_right`       | `set_revolute_target`  |
-| `wrist_left`        | `set_spherical_target` |
-| `wrist_right`       | `set_spherical_target` |
-| `magnet_left`       |                        |
-| `magnet_right`      |                        |
-| `wheel_left_front`  | `set_revolute_target`  |
-| `wheel_right_front` | `set_revolute_target`  |
-| `wheel_left_back`   | `set_revolute_target`  |
-| `wheel_right_back`  | `set_revolute_target`  |
-
-To get the corresponding name and object ID of each joint, see `send_static_robots`.
-
-There are also additional commands that are specific to the Magnebot:
-
-| Command              | Description                                                  |
-| -------------------- | ------------------------------------------------------------ |
-| `set_magnet_targets` | Set the objects that the Magnebot magnet will try to pick up. If the magnet collides with any of these objects, the Magnebot will pick up them up. |
-| `detach_from_magnet` | Detach an object from a Magnebot magnet.                     |
-| `send_magnebots`     | [Send data for each Magnebot in the scene.](https://github.com/threedworld-mit/tdw/blob/master/Documentation/api/output_data.md#Magnebot) This includes the IDs of any held objects. |
-
-### Picking up objects with magnets
-
-The Magnebot's magnets "picks up" objects by first colliding with a target object (see `set_magnet_targets`), then caching Rigidbody data (mass, angular drag, etc.), adding the object's mass to the magnet's mass, then destroying the Rigidbody component, then parenting the object to the magnet. The Magnebot "puts down" the object by recreating the Rigidbody, subtracting the mass, and unparenting the object. This is to ensure maximum physics stability.
-
-The upshot is that Rigidbody commands such as `set_mass` will throw an error if the object is being held by a Magnebot magnet. Data returned by `send_rigidbodies` will be 0s if the object is being held (for example, the mass will be 0).
-
-## Example controllers
-
-[**See the high-level Magnebot API for additional examples.**](https://github.com/alters-mit/magnebot)
-
-| Controller        | Description                                  |
-| ----------------- | -------------------------------------------- |
-| `magnebot.py`     | Add a Magnebot and move it around the scene. |
-| `robot_arm.py`    | Move the joints of a robot arm.              |
-| `robot_camera.py` | Add a camera to a Magnebot.                  |
-
diff --git a/Documentation/misc_frontend/scene_setup.md b/Documentation/misc_frontend/scene_setup.md
deleted file mode 100644
index 1d5cdadac..000000000
--- a/Documentation/misc_frontend/scene_setup.md
+++ /dev/null
@@ -1,67 +0,0 @@
-# Scene Setup
-
-The following are useful tips for "staging" a scene in TDW (adding objects, adding avatars, etc.) They are not meant to be the final word on how to stage a scene; some techniques will work better for your particular use-case than others.
-
-### Compartmentalize your code
-
-This is a technique often used in game design. If you compartmentalize the code required to initialize the scene from the rest of the simulation, you can iterate on refining the initialization without having to run anything else.
-
-```python
-class YourController(Controller):
-	def initialize(self):
-		# Your code here
-
-	def do_trial(self):
-		# Your code here
-
-c = YourController()
-
-# I only want to test initialization, so I'll just call initialize() for now...
-c.initialize()
-# c.do_trial()s
-```
-
-### Use the keyboard to position objects
-
-You can position objects in the scene by using the arrow keys. See `example_controllers/keyboard.py` for an example of keyboard input. To record the final positions and rotations of the objects, you can send `send_transforms`.
-
-```python
-from getch import getch
-from tdw.output_data import Transforms
-
-# Code to initialize the controller and scene goes here.
-
-o_id = c.add_object("iron_box")
-
-c.communicate({"$type": "send_transforms",
-			   "frequency": "always"})
-
-char = getch()
-
-if char == b'w':
-	resp = c.communicate({"$type": "teleport_object",
-						  "position": {"x": 1, "y": 0, "z": 0}, 
-						  "id": o_id})
-	transform = Transforms(resp[0])
-    
-    # Get the position and rotation of the iron_box object.
-	position = transform.get_position(0)
-	rotation = transform.get_rotation(0)
-```
-
-### Use the `simulate_physics` command
-
-Often, objects will move from the time you add them to the scene to the time when you've fully initialized the scene. This is because every list of commands you send to the build steps forward one physics frame. (For more information on how physics frames work, see the [Command API Guide](../api/command_api_guide.md)). 
-
-Send `simulate_physics` to disable physics stepping. The physics engine won't respond to any new objects, forces, etc. until you manually re-enable it.
-
-```python
-c.communicate({"$type": "simulate_physics",
-			   "value": False})
-
-# Code to add objects goes here.
-
-# Re-enable physics.
-c.communicate({"$type": "simulate_physics",
-			   "value": True})
-```
diff --git a/Documentation/misc_frontend/shapenet.md b/Documentation/misc_frontend/shapenet.md
deleted file mode 100644
index 4136e8920..000000000
--- a/Documentation/misc_frontend/shapenet.md
+++ /dev/null
@@ -1,84 +0,0 @@
-# ShapeNet and TDW
-
-You can add [ShapeNet](https://www.shapenet.org/) models to TDW by [converting them to asset bundles](add_local_object.md).
-
-TDW includes a script to convert both ShapeNet sets "out of the box": `Python/shapenet/shapenet.py`
-
-## Requirements
-
-You must download and extract the relevant ShapeNet zip files into the following directory structure:
-
-- ShapeNet Core v2: Just unzip and you're all set.
-- ShapeNet SEM: Download models, textures, and metadata.csv. They must all be in the same directory:
-
-```
-<root>
-....models/
-....textures/
-....metadata.csv
-```
-
-**Additional requirements:**
-
-- The latest version of Unity 2019.4
-- Python 3.6+
-- The [`tdw` Python module](../python/tdw.md)
-- Windows 10, Linux, or OS X
-
-## Usage
-
-```bash
-python shapenet.py [OPTIONS]
-```
-
-#### Arguments
-
-| Argument             | Type | Description                                                  | Default                  |
-| -------------------- | ---- | ------------------------------------------------------------ | ------------------------ |
-| `--src`              | str  | The absolute path to the root source directory.              | `"D:/ShapeNetCore.v2"`   |
-| `--dest`             | str  | The absolute path to the root destination directory.         | `"D:/tdw_shapenet_core"` |
-| `--set`              | str  | Which ShapeNet set this is.<br>_Choices:_ `"core"` or `"sem"` | `"core"`                 |
-| `--batch_size`       | int  | The number of models per batch.                              | 1000                     |
-| `--vhacd_resolution` | int  | Higher value=better-fitting colliders and slower build process. | 8000000                  |
-| --first_batch_only   | N/A  | Output only the first batch. Useful for testing purposes.    | N/A                      |
-
-## What the script will do
-
-1. Create a [records database file](../python/librarian/model_librarian.md) of all of the models. (Some of the metadata fields such as `bounds` will require Unity further down the creation pipeline.)
-2. (ShapeNet SEM only): Move all images in the `textures/` folder to `models/`
-3. Move _batches_ of .obj models, .mtl files, etc. to the asset_bundle_creator project. Default batch size is 1000.
-4. Create collider .objs for each model .obj (unless one already exists).
-5. Create prefabs.
-6. Create asset bundles from the prefabs.
-7. Update the database file with accurate metadata.
-8. Move the asset bundles to a destination directory.
-9. Remove all asset files (models, images, etc.) used to create the asset bundles.
-
-For ShapeNet Core v2, every time a model is added to the _batch_, the Python script will make a few changes to the files. These changes are harmless, unless you're using these models for unrelated research.
-
-## Time required
-
-**This is a very slow process.** It can take multiple weeks to process all of the models.
-
-To speed up the process, set `--vhacd_resolution` to a low value (e.g. 1000). This will make the physics colliders fit the model poorly, but will vastly speed up the overall asset bundle creation process. _Even then, expect the whole process to require several days._
-
-**The process can be paused, restarted, etc.** Once you've generated the initial library file, the script will scan for existing asset bundles and won't try to re-create them.
-
-## How to use the models in TDW
-
-Once you've created the asset bundles, you can add them to TDW like any other asset bundles:
-
-```python
-from tdw.librarian import ModelLibrarian
-from tdw.controller import Controller
-
-shapenet_library_path = "path/to/shapenet/records.json"
-
-c = Controller()
-c.model_librarian = ModelLibrarian(library=shapenet_library_path)
-c.start()
-c.communicate({"$type": "create_empty_environment"})
-
-c.add_object(c.model_librarian.records[0].name)
-```
-
diff --git a/Python/example_controllers/cube.fbx b/Python/example_controllers/3d_models/cube.fbx
similarity index 100%
rename from Python/example_controllers/cube.fbx
rename to Python/example_controllers/3d_models/cube.fbx
diff --git a/Python/example_controllers/local_object.py b/Python/example_controllers/3d_models/local_object.py
similarity index 50%
rename from Python/example_controllers/local_object.py
rename to Python/example_controllers/3d_models/local_object.py
index 372cf9efb..783ac91fd 100644
--- a/Python/example_controllers/local_object.py
+++ b/Python/example_controllers/3d_models/local_object.py
@@ -1,45 +1,44 @@
-from platform import system
-from tdw.asset_bundle_creator import AssetBundleCreator
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.backend.platforms import SYSTEM_TO_UNITY
-
-"""
-Create a local asset bundle and load it into TDW.
-
-See `Documentation/misc_frontend/add_local_object.md` for how to run the Asset Bundle Creator.
-"""
-
-
-class LocalObject:
-    @staticmethod
-    def run():
-        # Create the asset bundle and the record.
-        asset_bundle_paths, record_path = AssetBundleCreator().create_asset_bundle("cube.fbx", True, 123, "", 1)
-        # Get the name of the bundle for this platform. For example, Windows -> "StandaloneWindows64"
-        bundle = SYSTEM_TO_UNITY[system()]
-        # Get the correct asset bundle path.
-        for p in asset_bundle_paths:
-            # Get the path to the asset bundle.
-            if bundle in str(p.parent.resolve()):
-                url = "file:///" + str(p.resolve())
-
-                # Launch the controller.
-                c = Controller()
-                c.start()
-                # Create the environment.
-                # Add the object.
-                commands = [{"$type": "create_empty_environment"},
-                            {"$type": "add_object",
-                             "name": "cube",
-                             "url": url,
-                             "scale_factor": 1,
-                             "id": c.get_unique_id()}]
-                # Create the avatar.
-                commands.extend(TDWUtils.create_avatar(position={"x": 0, "y": 0, "z": -3.6}))
-                c.communicate(commands)
-                return
-
-
-if __name__ == "__main__":
-    LocalObject.run()
+from platform import system
+from tdw.asset_bundle_creator import AssetBundleCreator
+from tdw.controller import Controller
+from tdw.backend.platforms import SYSTEM_TO_UNITY
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+"""
+Create a local asset bundle and load it into TDW.
+"""
+
+
+class LocalObject:
+    @staticmethod
+    def run():
+        # Create the asset bundle and the record.
+        asset_bundle_paths, record_path = AssetBundleCreator().create_asset_bundle("cube.fbx", True, 123, "", 1)
+        # Get the name of the bundle for this platform. For example, Windows -> "StandaloneWindows64"
+        bundle = SYSTEM_TO_UNITY[system()]
+        # Get the correct asset bundle path.
+        for p in asset_bundle_paths:
+            # Get the path to the asset bundle.
+            if bundle in str(p.parent.resolve()):
+                url = "file:///" + str(p.resolve())
+
+                # Launch the controller.
+                c = Controller()
+                camera = ThirdPersonCamera(position={"x": 0, "y": 0, "z": -3.6}, avatar_id="a")
+                images_path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("local_object")
+                print(f"Images will be saved to: {images_path}")
+                capture = ImageCapture(avatar_ids=["a"], path=images_path)
+                c.add_ons.extend([camera, capture])
+                c.communicate([{"$type": "create_empty_environment"},
+                               {"$type": "add_object",
+                                "name": "cube",
+                                "url": url,
+                                "scale_factor": 1,
+                                "id": c.get_unique_id()}])
+                c.communicate({"$type": "terminate"})
+
+
+if __name__ == "__main__":
+    LocalObject.run()
diff --git a/Python/example_controllers/add_materials.py b/Python/example_controllers/add_materials.py
deleted file mode 100644
index 7efd26faf..000000000
--- a/Python/example_controllers/add_materials.py
+++ /dev/null
@@ -1,47 +0,0 @@
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.librarian import MaterialLibrarian
-from time import sleep
-
-
-"""
-Different ways to add the same material to a scene.
-"""
-
-
-class AddMaterials(Controller):
-    def create_scene(self):
-        """
-        Initialize a blank room.
-        """
-
-        self.start()
-        commands = [TDWUtils.create_empty_room(12, 12)]
-        commands.extend(TDWUtils.create_avatar(position={"x": 5, "y": 10, "z": 5}, look_at=TDWUtils.VECTOR3_ZERO))
-        self.communicate(commands)
-
-    def run(self):
-        self.create_scene()
-
-        print("Using Controller.get_add_material wrapper function:")
-
-        self.communicate([self.get_add_material("parquet_long_horizontal_clean"),
-                          {"$type": "set_proc_gen_floor_material",
-                           "name": "parquet_long_horizontal_clean"}])
-
-        sleep(5)
-
-        self.create_scene()
-
-        print("Using the add_material command without any wrappers:")
-        lib = MaterialLibrarian()
-        record = lib.get_record("parquet_long_horizontal_clean")
-        self.communicate([{"$type": "add_material",
-                           "name": record.name,
-                           "url": record.get_url()},
-                          {"$type": "set_proc_gen_floor_material",
-                           "name": "parquet_long_horizontal_clean"}])
-
-
-if __name__ == "__main__":
-    AddMaterials().run()
diff --git a/Python/example_controllers/add_objects.py b/Python/example_controllers/add_objects.py
deleted file mode 100644
index 4bc206e7c..000000000
--- a/Python/example_controllers/add_objects.py
+++ /dev/null
@@ -1,65 +0,0 @@
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.librarian import ModelLibrarian
-
-
-"""
-Different ways to add the same object to a scene.
-"""
-
-
-class AddObjects(Controller):
-    def run(self):
-        self.start()
-        self.communicate(TDWUtils.create_empty_room(20, 20))
-        self.communicate(TDWUtils.create_avatar(position={"x": 0, "y": 3, "z": -6},
-                                                look_at=TDWUtils.VECTOR3_ZERO))
-        model_name = "rh10"
-
-        z = -3
-
-        x = -1.5
-        print("With the add_object command (complex syntax but you have maximum control):")
-        record = ModelLibrarian().get_record(model_name)
-        self.communicate({"$type": "add_object",
-                          "name": model_name,
-                          "url": record.get_url(),
-                          "scale_factor": record.scale_factor,
-                          "position": {"x": x, "y": 0, "z": z},
-                          "rotation": TDWUtils.VECTOR3_ZERO,
-                          "category": record.wcategory,
-                          "id": self.get_unique_id()})
-
-        x = 0
-        print("With the wrapper function Controller.add_object() "
-              "(easy to use, but you can't add additional commands to this frame):")
-        self.add_object(model_name=model_name,
-                        position={"x": x, "y": 0, "z": z},
-                        rotation=TDWUtils.VECTOR3_ZERO, 
-                        library="models_core.json")
-
-        x = 1.5
-        print("With the wrapper function Controller.get_add_object() "
-              "(harder to use, but you can add commands to this frame):")
-        self.communicate(self.get_add_object(model_name=model_name,
-                                             object_id=self.get_unique_id(),
-                                             position={"x": x, "y": 0, "z": 0},
-                                             rotation=TDWUtils.VECTOR3_ZERO,
-                                             library="models_core.json"))
-
-        print("With the add_object command, minus all optional parameters (the model won't scale properly!):")
-        self.communicate({"$type": "add_object",
-                          "name": model_name,
-                          "url": record.get_url(),
-                          "id": self.get_unique_id()})
-
-        print("With the wrapper function Controller.add_object(), minus all optional parameters:")
-        self.add_object(model_name)
-
-        print("With the wrapper function Controller.get_add_object(), minus all optional parameters:")
-        self.communicate(self.get_add_object(model_name=model_name,
-                                             object_id=self.get_unique_id()))
-
-
-if __name__ == "__main__":
-    AddObjects().run()
diff --git a/Python/example_controllers/camera_controls/follow.py b/Python/example_controllers/camera_controls/follow.py
new file mode 100644
index 000000000..87a3f01ec
--- /dev/null
+++ b/Python/example_controllers/camera_controls/follow.py
@@ -0,0 +1,32 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+
+"""
+Follow the position of an object.
+"""
+
+c = Controller()
+
+object_id = c.get_unique_id()
+# Create a third-person camera that will follow the object.
+cam = ThirdPersonCamera(avatar_id="a",
+                        position={"x": 2, "y": 1.6, "z": -0.6},
+                        follow_object=object_id,
+                        follow_rotate=False,
+                        look_at=object_id)
+c.add_ons.append(cam)
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               c.get_add_object(model_name="iron_box",
+                                library="models_core.json",
+                                position={"x": 1, "y": 0, "z": -0.5},
+                                object_id=object_id),
+               {"$type": "apply_force_to_object",
+                "id": object_id,
+                "force": {"x": -15, "y": 2, "z": 3}}])
+for i in range(100):
+    c.communicate([])
+# Stop following the object.
+cam.follow_object = None
+c.communicate({"$type": "terminate"})
\ No newline at end of file
diff --git a/Python/example_controllers/cinematic_camera_controls.py b/Python/example_controllers/cinematic_camera_controls.py
index 9af09e111..da262aa59 100644
--- a/Python/example_controllers/cinematic_camera_controls.py
+++ b/Python/example_controllers/cinematic_camera_controls.py
@@ -6,6 +6,10 @@
 
 
 class CinematicCameraController(Controller):
+    """
+    Example implementation of the `CinematicCamera` add-on.
+    """
+
     def run(self) -> None:
         object_id = 0
         self.start()
diff --git a/Python/example_controllers/collisions.py b/Python/example_controllers/collisions.py
deleted file mode 100644
index dabe61cf8..000000000
--- a/Python/example_controllers/collisions.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.collisions import Collisions
-
-"""
-Receive collision output data and read it as a `Collisions` object.
-"""
-
-if __name__ == "__main__":
-    c = Controller(launch_build=False)
-    c.start()
-    resp = c.communicate([TDWUtils.create_empty_room(12, 12),
-                          c.get_add_object(model_name="rh10", position={"x": 0, "y": 1, "z": 0}, object_id=0),
-                          c.get_add_object(model_name="rh10", position={"x": 0, "y": 2, "z": 0}, object_id=1),
-                          {"$type": "send_collisions",
-                           "enter": True,
-                           "stay": True,
-                           "exit": True,
-                           "collision_types": ["obj", "env"]}])
-
-    for i in range(200):
-        # Read collision data.
-        collisions = Collisions(resp=resp)
-        # Only print to the console if there were any collisions on this frame.
-        if len(collisions.obj_collisions) > 0 and len(collisions.env_collisions) > 0:
-            print(f"Frame {i}")
-            if len(collisions.obj_collisions) > 0:
-                print("\tObject-object collisions")
-                for ids in collisions.obj_collisions:
-                    state = collisions.obj_collisions[ids].state
-                    print(f"\t\t{ids}\t{state}")
-            if len(collisions.env_collisions) > 0:
-                print("\tObject-environment collisions")
-                for object_id in collisions.env_collisions:
-                    state = collisions.env_collisions[object_id].state
-                    print(f"\t\t{object_id}\t{state}")
-
-        # Advance another frame.
-        resp = c.communicate([])
-    c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/collisions_and_friction.py b/Python/example_controllers/collisions_and_friction.py
deleted file mode 100644
index 545b61351..000000000
--- a/Python/example_controllers/collisions_and_friction.py
+++ /dev/null
@@ -1,115 +0,0 @@
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.output_data import OutputData, Collision, EnvironmentCollision
-
-
-"""
-- Listen for collisions between objects.
-- Adjust the friction values of objects.
-"""
-
-
-class CollisionsAndFriction(Controller):
-    def trial(self, d_f, s_f, b_f, d_c, s_c, b_c, collision_types):
-        """
-        Collide a chair with a fridge.
-
-        :param d_f: The dynamic friction of the fridge.
-        :param s_f: The static friction of the fridge.
-        :param b_f: The bounciness of the fridge.
-        :param d_c: The dynamic friction of the chair.
-        :param s_c: The static friction of the chair.
-        :param b_c: The bounciness of the chair.
-        :param collision_types: The types of collisions to listen for.
-        """
-
-        print("###\n\nNew Trial\n\n###\n")
-
-        fridge_id = 0
-        chair_id = 1
-
-        # Destroy all objects currently in the scene.
-        init_commands = [{"$type": "destroy_all_objects"}]
-        # Create the avatar.
-        init_commands.extend(TDWUtils.create_avatar(position={"x": 1, "y": 2.5, "z": 5},
-                                                    look_at=TDWUtils.VECTOR3_ZERO))
-        # Add the objects.
-        # Set the masses and physic materials.
-        # Apply a force to the chair.
-        # Receive collision data (note that by setting "stay" to True, you will receive a LOT of data;
-        # see "Performance Optimizations" documentation.)
-        init_commands.extend([self.get_add_object("fridge_large", object_id=fridge_id),
-                              self.get_add_object("chair_billiani_doll", object_id=chair_id,
-                                                  position={"x": 4, "y": 0, "z": 0}),
-                              {"$type": "set_mass",
-                               "id": fridge_id,
-                               "mass": 40},
-                              {"$type": "set_mass",
-                               "id": chair_id,
-                               "mass": 20},
-                              {"$type": "set_physic_material",
-                               "id": fridge_id,
-                               "dynamic_friction": d_f,
-                               "static_friction": s_f,
-                               "bounciness": b_f},
-                              {"$type": "set_physic_material",
-                               "id": chair_id,
-                               "dynamic_friction": d_c,
-                               "static_friction": s_c,
-                               "bounciness": b_c},
-                              {"$type": "apply_force_to_object",
-                               "force": {"x": -200, "y": 0, "z": 0},
-                               "id": chair_id},
-                              {"$type": "send_collisions",
-                               "collision_types": collision_types,
-                               "enter": True,
-                               "exit": True,
-                               "stay": True}])
-        self.communicate(init_commands)
-
-        # Iterate through 500 frames.
-        # Every frame, listen for collisions, and parse the output data.
-        for i in range(500):
-            resp = self.communicate([])
-            if len(resp) > 1:
-                for r in resp[:-1]:
-                    r_id = OutputData.get_data_type_id(r)
-                    # There was a collision between two objects.
-                    if r_id == "coll":
-                        collision = Collision(r)
-                        print("Collision between two objects:")
-                        print("\tEvent: " + collision.get_state())
-                        print("\tCollider: " + str(collision.get_collider_id()))
-                        print("\tCollidee: " + str(collision.get_collidee_id()))
-                        print("\tRelative velocity: " + str(collision.get_relative_velocity()))
-                        print("\tContacts:")
-                        for j in range(collision.get_num_contacts()):
-                            print(str(collision.get_contact_normal(j)) + "\t" + str(collision.get_contact_point(j)))
-                    # There was a collision between an object and the environment.
-                    elif r_id == "enco":
-                        collision = EnvironmentCollision(r)
-                        print("Collision between an object and the environment:")
-                        print("\tEvent: " + collision.get_state())
-                        print("\tCollider: " + str(collision.get_object_id()))
-                        print("\tContacts:")
-                        for j in range(collision.get_num_contacts()):
-                            print(str(collision.get_contact_normal(j)) + "\t" + str(collision.get_contact_point(j)))
-                    else:
-                        raise Exception(r_id)
-
-    def run(self):
-        self.start()
-
-        # Create the room.
-        self.communicate(TDWUtils.create_empty_room(12, 12))
-
-        # Run a trial.
-        self.trial(0.1, 0.12, 0.6, 0.8, 0.75, 0.1, ["obj"])
-
-        # Run a trial with different friction and bounciness parameters.
-        # Listen for environment collisions.
-        self.trial(0.9, 0.85, 1.0, 0.2, 0.75, 1.0, ["obj", "env"])
-
-
-if __name__ == "__main__":
-    CollisionsAndFriction().run()
diff --git a/Python/example_controllers/composite_object.py b/Python/example_controllers/composite_object.py
deleted file mode 100644
index 2a21e6c2e..000000000
--- a/Python/example_controllers/composite_object.py
+++ /dev/null
@@ -1,117 +0,0 @@
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.output_data import OutputData, CompositeObjects, IdPassSegmentationColors, SegmentationColors
-from pathlib import Path
-import platform
-
-
-"""
-Create a composite object from a local asset bundle.
-Test that the object loaded correctly.
-Apply sub-object commands to the sub-objects.
-"""
-
-
-class CompositeObject(Controller):
-    def run(self):
-        self.start()
-        self.communicate(TDWUtils.create_empty_room(12, 12))
-
-        # Find the local asset bundle for this platform.
-        url = "file:///" + str(Path("composite_objects/" + platform.system() + "/test_composite_object").resolve())
-
-        # Add the local object.
-        o_id = self.get_unique_id()
-        self.communicate([{"$type": "add_object",
-                           "name": "test_composite_object",
-                           "url": url,
-                           "scale_factor": 1,
-                           "id": o_id},
-                          {"$type": "set_mass",
-                           "id": o_id,
-                           "mass": 100}])
-
-        self.communicate(TDWUtils.create_avatar(position={"x": 0, "y": 1.49, "z": -2.77}))
-
-        # Test that each sub-object has a unique segmentation color.
-        resp = self.communicate({"$type": "send_segmentation_colors",
-                                 "ids": []})
-        colors = SegmentationColors(resp[0])
-        segmentation_colors = []
-        # There are 4 objects: The parent object, the motor, the "base" of the motor, and the light.
-        assert colors.get_num() == 4, colors.get_num()
-        print("Segmentation colors:")
-        for i in range(colors.get_num()):
-            print(colors.get_object_id(i), colors.get_object_color(i))
-
-            # Cache the color for the next test.
-            segmentation_colors.append(colors.get_object_color(i))
-
-        # Test that each sub-object is visible to the avatar.
-        resp = self.communicate({"$type": "send_id_pass_segmentation_colors",
-                                 "frequency": "once"})
-        colors = IdPassSegmentationColors(resp[0])
-        # There are three visible objects (the light won't be visible in the _id pass).
-        assert colors.get_num_segmentation_colors() == 3
-
-        print("\nObserved colors:")
-
-        # Test that the colors observed by the avatar are in the cache.
-        for i in range(colors.get_num_segmentation_colors()):
-            observed_color = colors.get_segmentation_color(i)
-            assert observed_color in segmentation_colors
-            print(observed_color)
-
-        # Get composite objects data.
-        resp = self.communicate({"$type": "send_composite_objects"})
-        assert len(resp) > 1
-        assert OutputData.get_data_type_id(resp[0]) == "comp"
-
-        o = CompositeObjects(resp[0])
-        assert o.get_num() == 1
-        # There are 3 sub-objects: The motor, the "base" of the motor, and the light.
-        assert o.get_num_sub_objects(0) == 3
-
-        print("\nCompositeObjects: ")
-
-        commands = []
-        lights = []
-
-        # Iterate through each sub-object.
-        for s in range(o.get_num_sub_objects(0)):
-            sub_object_id = o.get_sub_object_id(0, s)
-            s_type = o.get_sub_object_machine_type(0, s)
-            print(sub_object_id, s_type)
-
-            # Add commands depending on the type of sub-object.
-            if s_type == "motor":
-                # Start the motor.
-                commands.append({"$type": "set_motor",
-                                 "target_velocity": 500,
-                                 "force": 500,
-                                 "id": sub_object_id})
-            elif s_type == "light":
-                commands.append({"$type": "set_sub_object_light",
-                                 "is_on": True,
-                                 "id": sub_object_id})
-                lights.append(sub_object_id)
-
-        self.communicate(commands)
-
-        is_on = True
-        for i in range(1000):
-            commands = []
-            # Every 50 frames, blink the lights on and off.
-            if i % 50 == 0:
-                is_on = not is_on
-                for light in lights:
-                    commands.append({"$type": "set_sub_object_light",
-                                     "is_on": is_on,
-                                     "id": light})
-            else:
-                commands.append({"$type": "do_nothing"})
-            self.communicate(commands)
-
-
-if __name__ == "__main__":
-    CompositeObject(launch_build=False).run()
diff --git a/Python/example_controllers/core_concepts/image_capture.py b/Python/example_controllers/core_concepts/image_capture.py
new file mode 100644
index 000000000..d702e8daf
--- /dev/null
+++ b/Python/example_controllers/core_concepts/image_capture.py
@@ -0,0 +1,31 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+"""
+Example implementation of the ImageCapture add-on.
+"""
+
+c = Controller()
+object_id = c.get_unique_id()
+camera = ThirdPersonCamera(position={"x": 2, "y": 1.6, "z": -0.6},
+                           look_at=object_id,
+                           avatar_id="a")
+c.add_ons.append(camera)
+
+# Add the ImageCapture add-on.
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("image_capture")
+print(f"Images will be save to: {path.resolve()}")
+capture = ImageCapture(path=path, avatar_ids=["a"], pass_masks=["_img", "_id"])
+c.add_ons.append(capture)
+
+# This will create the scene and the object.
+# Then, the ThirdPersonCamera add-on will create an avatar.
+# Then, the ImageCapture add-on will save an image to disk.
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(model_name="iron_box",
+                                       position={"x": 1, "y": 0, "z": -0.5},
+                                       object_id=object_id)])
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/core_concepts/move_avatar.py b/Python/example_controllers/core_concepts/move_avatar.py
new file mode 100644
index 000000000..cc47cfd32
--- /dev/null
+++ b/Python/example_controllers/core_concepts/move_avatar.py
@@ -0,0 +1,29 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+
+"""
+Move the avatar and rotate the camera.
+Note that in this example, the `avatar_id` parameters are missing. That's because they all default to "a".
+"""
+
+c = Controller()
+y = 5.7
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               {"$type": "create_avatar",
+                "type": "A_Img_Caps_Kinematic",
+                "id": "a"},
+               {"$type": "teleport_avatar_to",
+                "position": {"x": -1, "y": y, "z": -3.8}},
+               {"$type": "rotate_sensor_container_by",
+                "axis": "pitch",
+                "angle": 26},
+               {"$type": "set_target_framerate",
+                "framerate": 30}])
+for i in range(20):
+    y += 0.1
+    c.communicate([{"$type": "rotate_sensor_container_by",
+                    "axis": "yaw",
+                    "angle": 2},
+                   {"$type": "teleport_avatar_to",
+                    "position": {"x": -1, "y": y, "z": -3.8}}])
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/core_concepts/object_manager.py b/Python/example_controllers/core_concepts/object_manager.py
new file mode 100644
index 000000000..005bcac45
--- /dev/null
+++ b/Python/example_controllers/core_concepts/object_manager.py
@@ -0,0 +1,41 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.object_manager import ObjectManager
+
+
+"""
+This is very similar to object_output_data.py except that it uses an ObjectManager add-on.
+"""
+
+c = Controller()
+om = ObjectManager(transforms=True, rigidbodies=True, bounds=False)
+c.add_ons.append(om)
+
+# Send the commands.
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(model_name="iron_box",
+                                       position={"x": 0, "y": 6, "z": 0},
+                                       rotation={"x": 25, "y": 38, "z": -10},
+                                       object_id=c.get_unique_id())])
+
+# Print the name and category of each object.
+for object_id in om.objects_static:
+    print(object_id, om.objects_static[object_id].name, om.objects_static[object_id].category)
+
+# Run the simulation until all objects stop moving.
+positions = dict()
+sleeping = False
+while not sleeping:
+    sleeping = True
+    for object_id in om.rigidbodies:
+        if not om.rigidbodies[object_id].sleeping:
+            sleeping = False
+    # Remember the position
+    for object_id in om.transforms:
+        if object_id not in positions:
+            positions[object_id] = list()
+        positions[object_id].append(om.transforms[object_id].position)
+    # Advance once frame.
+    c.communicate([])
+print(positions)
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/core_concepts/object_output_data.py b/Python/example_controllers/core_concepts/object_output_data.py
new file mode 100644
index 000000000..6317a7ce4
--- /dev/null
+++ b/Python/example_controllers/core_concepts/object_output_data.py
@@ -0,0 +1,63 @@
+import numpy as np
+from typing import List
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, Transforms, Rigidbodies
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+"""
+Generate object output data per-frame until all objects stop moving.
+Save the data to disk as a numpy array.
+"""
+
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("object_output_data/output.npy")
+if not path.parent.exists():
+    path.parent.mkdir(parents=True)
+
+c = Controller()
+print(f"Output will be saved to: {path.resolve()}")
+object_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name="iron_box",
+                             position={"x": 0, "y": 6, "z": 0},
+                             rotation={"x": 25, "y": 38, "z": -10},
+                             object_id=object_id),
+            {"$type": "send_transforms",
+             "frequency": "always",
+             "ids": [object_id]},
+            {"$type": "send_rigidbodies",
+             "frequency": "always",
+             "ids": [object_id]}]
+
+# Send the commands.
+resp = c.communicate(commands)
+
+# The position of the object per frame.
+positions: List[np.array] = list()
+# If True, the object has stopped moving.
+sleeping = False
+
+# Wait for the object to stop moving.
+while not sleeping:
+    for i in range(len(resp) - 1):
+        # Get the output data ID.
+        r_id = OutputData.get_data_type_id(resp[i])
+        # This is transforms output data.
+        if r_id == "tran":
+            transforms = Transforms(resp[i])
+            for j in range(transforms.get_num()):
+                if transforms.get_id(j) == object_id:
+                    # Log the position.
+                    positions.append(np.array(transforms.get_position(j)))
+        elif r_id == "rigi":
+            rigidbodies = Rigidbodies(resp[i])
+            for j in range(rigidbodies.get_num()):
+                if rigidbodies.get_id(j) == object_id:
+                    # Check if the object is sleeping.
+                    sleeping = rigidbodies.get_sleeping(j)
+    # Advance another frame and continue the loop.
+    if not sleeping:
+        resp = c.communicate([])
+# Save the numpy array (remove the file extension).
+np.save(str(path.resolve())[:-4], np.array(positions))
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/core_concepts/panorama.py b/Python/example_controllers/core_concepts/panorama.py
new file mode 100644
index 000000000..c106e2fc8
--- /dev/null
+++ b/Python/example_controllers/core_concepts/panorama.py
@@ -0,0 +1,97 @@
+from math import radians, sin, cos
+from tdw.controller import Controller
+from tdw.output_data import OutputData, Images
+from tdw.tdw_utils import TDWUtils
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+
+class Panorama(Controller):
+    """
+    Capture a series of images around a model to form a 360-degree panorama.
+    """
+
+    def __init__(self, port: int = 1071, launch_build: bool = True):
+        self.output_directory = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("panorama")
+        print(f"Images will be saved to: {self.output_directory.resolve()}")
+
+        super().__init__(port=port, launch_build=launch_build)
+
+    def do_panorama(self, model_name: str) -> None:
+        """
+        Create panorama images of a single object.
+
+        :param model_name: The name of the object model.
+        """
+
+        object_id = self.get_unique_id()
+        # Create the model, teleport the avatar, and look at the object. Enable jpgs. Request images per frame.
+        self.communicate([self.get_add_object(model_name=model_name, object_id=object_id),
+                          {"$type": "teleport_avatar_to",
+                           "avatar_id": "a",
+                           "position": {"x": 5, "y": 2.5, "z": 0}},
+                          {"$type": "look_at",
+                           "avatar_id": "a",
+                           "object_id": object_id},
+                          {"$type": "set_img_pass_encoding",
+                           "value": False},
+                          {"$type": "send_images",
+                           "frequency": "always"}])
+
+        # Set the starting parameters of the rotation.
+        d_theta = 15
+        rotations = int(360 / d_theta)
+        theta = 0
+        x = 3
+        y = 2.5
+        z = 0
+        c_x = 0
+        c_z = 0
+
+        for i in range(rotations):
+            # Get the new position.
+            rad = radians(theta)
+            x_rot = cos(rad) * (x - c_x) - sin(rad) * (z - c_z) + c_x
+            z_rot = sin(rad) * (x - c_x) + cos(rad) * (z - c_z) + c_z
+
+            # Teleport the avatar to the next position.
+            # Rotate the avatar's camera to look at the object.
+            # Receive an image.
+            resp = self.communicate([{"$type": "teleport_avatar_to",
+                                      "avatar_id": "a",
+                                      "position": {"x": x_rot, "y": y, "z": z_rot}},
+                                     {"$type": "look_at",
+                                      "avatar_id": "a",
+                                      "object_id": object_id}])
+            # Save the images.
+            for j in range(len(resp) - 1):
+                r_id = OutputData.get_data_type_id(resp[j])
+                if r_id == "imag":
+                    TDWUtils.save_images(Images(resp[j]), str(TDWUtils.zero_padding(i)), str(self.output_directory.resolve()))
+
+            # Increment the angle.
+            theta += d_theta
+
+        # Stop image capture.
+        # Destroy the target object.
+        self.communicate([{"$type": "send_images",
+                          "frequency": "never"},
+                          {"$type": "destroy_object",
+                           "id": object_id}])
+
+    def run(self):
+        # Add a scene, create an avatar, and set the pass masks.
+        self.communicate([self.get_add_scene(scene_name="suburb_scene_2018"),
+                          {"$type": "create_avatar",
+                           "type": "A_Img_Caps_Kinematic",
+                           "id": "a"},
+                          {"$type": "set_pass_masks",
+                           "avatar_id": "a",
+                           "pass_masks": ["_img"]}])
+        # Capture panorama images.
+        for model_name in ["chair_billiani_doll", "small_table_green_marble"]:
+            self.do_panorama(model_name)
+        self.communicate({"$type": "terminate"})
+
+
+if __name__ == "__main__":
+    Panorama().run()
diff --git a/Python/example_controllers/core_concepts/red_box.py b/Python/example_controllers/core_concepts/red_box.py
new file mode 100644
index 000000000..631e0a1df
--- /dev/null
+++ b/Python/example_controllers/core_concepts/red_box.py
@@ -0,0 +1,28 @@
+from time import sleep
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+"""
+Add a box and make it red.
+"""
+
+c = Controller()
+
+# Generate a unique object ID.
+object_id = c.get_unique_id()
+# Add a camera and look at the object (we haven't added the object yet but this will execute after adding the object).
+cam = ThirdPersonCamera(position={"x": 2, "y": 1.6, "z": -0.6},
+                        look_at=object_id)
+c.add_ons.append(cam)
+# Create the scene, add the object, and make the object red.
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               c.get_add_object(model_name="iron_box",
+                                library="models_core.json",
+                                position={"x": 1, "y": 0, "z": -0.5},
+                                object_id=object_id),
+               {"$type": "set_color",
+                "color": {"r": 1.0, "g": 0, "b": 0, "a": 1.0},
+                "id": object_id}])
+sleep(2)
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/core_concepts/send_images.py b/Python/example_controllers/core_concepts/send_images.py
new file mode 100644
index 000000000..b79342832
--- /dev/null
+++ b/Python/example_controllers/core_concepts/send_images.py
@@ -0,0 +1,47 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, Images
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+"""
+Create a scene, add an object, and save the image.
+"""
+
+c = Controller()
+object_id = c.get_unique_id()
+
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name="iron_box",
+                             position={"x": 0, "y": 0, "z": 0},
+                             object_id=object_id)]
+commands.extend(TDWUtils.create_avatar(position={"x": 2, "y": 1.6, "z": -0.6},
+                                       avatar_id="a",
+                                       look_at={"x": 0, "y": 0, "z": 0}))
+commands.extend([{"$type": "set_pass_masks",
+                  "pass_masks": ["_img"],
+                  "avatar_id": "a"},
+                 {"$type": "send_images",
+                  "frequency": "always",
+                  "ids": ["a"]}])
+
+resp = c.communicate(commands)
+output_directory = str(EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("send_images").resolve())
+print(f"Images will be saved to: {output_directory}")
+
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    # Get Images output data.
+    if r_id == "imag":
+        images = Images(resp[i])
+        # Determine which avatar captured the image.
+        if images.get_avatar_id() == "a":
+            # Iterate throught each capture pass.
+            for j in range(images.get_num_passes()):
+                # This is the _img pass.
+                if images.get_pass_mask(j) == "_img":
+                    image_arr = images.get_image(j)
+                    # Get a PIL image.
+                    pil_image = TDWUtils.get_pil_image(images=images, index=j)
+            # Save the image.
+            TDWUtils.save_images(images=images, filename="0", output_directory=output_directory)
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/core_concepts/third_person_camera.py b/Python/example_controllers/core_concepts/third_person_camera.py
new file mode 100644
index 000000000..1183aa602
--- /dev/null
+++ b/Python/example_controllers/core_concepts/third_person_camera.py
@@ -0,0 +1,28 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+"""
+Create a simple scene and add a third-person camera avatar.
+"""
+
+c = Controller()
+
+# Create the third-person camera.
+cam = ThirdPersonCamera(avatar_id="a",
+                        position={"x": -1, "y": 5.7, "z": -3.8},
+                        rotation={"x": 26, "y": 0, "z": 0})
+
+# Append the third-person camera add-on.
+c.add_ons.append(cam)
+c.communicate([TDWUtils.create_empty_room(12, 12),
+               {"$type": "set_target_framerate",
+                "framerate": 30}])
+for i in range(20):
+    # Raise the camera up by 0.1 meters.
+    cam.teleport(position={"x": 0, "y": 0.1, "z": 0},
+                 absolute=False)
+    # Rotate around the yaw axis by 2 degrees.
+    cam.rotate(rotation={"x": 0, "y": 2, "z": 0})
+    c.communicate([])
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/depth_shader.py b/Python/example_controllers/depth_shader.py
deleted file mode 100644
index d0ef8eb50..000000000
--- a/Python/example_controllers/depth_shader.py
+++ /dev/null
@@ -1,71 +0,0 @@
-from pathlib import Path
-import numpy as np
-import matplotlib.pyplot as plt
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.output_data import OutputData, Images, CameraMatrices
-
-
-"""
-Capture a _depth image and calculate the depth values of each pixel.
-"""
-
-
-class DepthShader(Controller):
-    def run(self):
-        self.start()
-
-        depth_pass = "_depth"
-
-        # Create an empty room.
-        # Set the screen size.
-        commands = [TDWUtils.create_empty_room(12, 12),
-                    {"$type": "set_screen_size",
-                     "width": 512,
-                     "height": 512}]
-        # Add the avatar.
-        commands.extend(TDWUtils.create_avatar(position={"x": 1.57, "y": 3, "z": 3.56}, look_at=TDWUtils.VECTOR3_ZERO))
-        # Add an object.
-        # Request images and camera matrices.
-        commands.extend([self.get_add_object("trunck", object_id=0),
-                         {"$type": "set_pass_masks",
-                          "pass_masks": [depth_pass]},
-                         {"$type": "send_images"},
-                         {"$type": "send_camera_matrices"}])
-        resp = self.communicate(commands)
-
-        depth_image = None
-        camera_matrix = None
-        images = None
-        for i in range(len(resp) - 1):
-            r_id = OutputData.get_data_type_id(resp[i])
-            # Get the image.
-            if r_id == "imag":
-                images = Images(resp[i])
-                for j in range(images.get_num_passes()):
-                    if images.get_pass_mask(j) == depth_pass:
-                        depth_image = images.get_image(j)
-            # Get the camera matrix.
-            elif r_id == "cama":
-                camera_matrix = CameraMatrices(resp[i]).get_camera_matrix()
-        # Save the image.
-        TDWUtils.save_images(images=images, output_directory="D:/depth_shader", filename="0", append_pass=True)
-        # Get the depth values of each pixel.
-        depth = TDWUtils.get_depth_values(image=depth_image, width=images.get_width(),  height=images.get_height())
-        print(np.min(depth), np.max(depth))
-        print(depth)
-        np.save("depth", depth)
-        np.save("camera_matrix", camera_matrix)
-
-        # Get a point cloud and write it to disk.
-        point_cloud_filename = "point_cloud.txt"
-        print(f"Point cloud saved to: {Path(point_cloud_filename)}")
-        TDWUtils.get_point_cloud(depth=depth, filename=point_cloud_filename, camera_matrix=camera_matrix)
-        # Show the depth values.
-        plt.imshow(depth)
-        plt.show()
-        self.communicate({"$type": "terminate"})
-
-
-if __name__ == "__main__":
-    DepthShader(launch_build=False).run()
diff --git a/Python/example_controllers/forcefield.py b/Python/example_controllers/forcefield.py
deleted file mode 100644
index 7295e43dd..000000000
--- a/Python/example_controllers/forcefield.py
+++ /dev/null
@@ -1,90 +0,0 @@
-from tdw.controller import Controller
-from tdw.output_data import Transforms
-from tdw.tdw_utils import TDWUtils
-from tdw.librarian import ModelLibrarian
-import numpy as np
-
-
-"""
-Simulate a "forcefield" that objects will bounce off of.
-"""
-
-
-class ForceField(Controller):
-    def run(self):
-        rng = np.random.RandomState(0)
-        self.model_librarian = ModelLibrarian("models_special.json")
-        self.start()
-        commands = [TDWUtils.create_empty_room(100, 100)]
-
-        # The starting height of the objects.
-        y = 10
-        # The radius of the circle of objects.
-        r = 7.0
-        # The mass of each object.
-        mass = 5
-
-        # Get all points within the circle defined by the radius.
-        p0 = np.array((0, 0))
-        o_id = 0
-        for x in np.arange(-r, r, 1):
-            for z in np.arange(-r, r, 1):
-                p1 = np.array((x, z))
-                dist = np.linalg.norm(p0 - p1)
-                if dist < r:
-                    # Add an object.
-                    # Set its mass, physics properties, and color.
-                    commands.extend([self.get_add_object("prim_cone",
-                                                         object_id=o_id,
-                                                         position={"x": x, "y": y, "z": z},
-                                                         rotation={"x": 0, "y": 0, "z": 180}),
-                                     {"$type": "set_mass",
-                                      "id": o_id,
-                                      "mass": mass},
-                                     {"$type": "set_physic_material",
-                                      "dynamic_friction": 0.8,
-                                      "static_friction": 0.7,
-                                      "bounciness": 0.5,
-                                      "id": o_id},
-                                     {"$type": "set_color",
-                                      "color": {"r": rng.random_sample(),
-                                                "g": rng.random_sample(),
-                                                "b": rng.random_sample(),
-                                                "a": 1.0},
-                                      "id": o_id}])
-                    o_id += 1
-        # Request transforms per frame.
-        commands.extend([{"$type": "send_transforms",
-                          "frequency": "always"}])
-        # Create an avatar to observe the grisly spectacle.
-        avatar_position = {"x": -20, "y": 8, "z": 18}
-        commands.extend(TDWUtils.create_avatar(position=avatar_position, look_at=TDWUtils.VECTOR3_ZERO))
-        commands.append({"$type": "set_focus_distance",
-                         "focus_distance": TDWUtils.get_distance(avatar_position, TDWUtils.VECTOR3_ZERO)})
-        resp = self.communicate(commands)
-
-        # If an objects are this far away from (0, 0, 0) the forcefield "activates".
-        forcefield_radius = 5
-        # The forcefield will bounce objects away at this force.
-        forcefield_force = -10
-        zeros = np.array((0, 0, 0))
-        for i in range(1000):
-            transforms = Transforms(resp[0])
-            commands = []
-            for j in range(transforms.get_num()):
-                pos = transforms.get_position(j)
-                pos = np.array(pos)
-                # If the object is in the forcefield, apply a force.
-                if TDWUtils.get_distance(TDWUtils.array_to_vector3(pos), TDWUtils.VECTOR3_ZERO) <= forcefield_radius:
-                    # Get the normalized directional vector and multiply it by the force magnitude.
-                    d = zeros - pos
-                    d = d / np.linalg.norm(d)
-                    d = d * forcefield_force
-                    commands.append({"$type": "apply_force_to_object",
-                                     "id": transforms.get_id(j),
-                                     "force": TDWUtils.array_to_vector3(d)})
-            resp = self.communicate(commands)
-
-
-if __name__ == "__main__":
-    ForceField().run()
diff --git a/Python/example_controllers/objects_and_images.py b/Python/example_controllers/objects_and_images.py
deleted file mode 100644
index 076a78daf..000000000
--- a/Python/example_controllers/objects_and_images.py
+++ /dev/null
@@ -1,76 +0,0 @@
-import os
-import shutil
-from time import sleep
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.output_data import Images
-
-
-"""
-Create a few objects, and avatar, and capture images of the objects.
-"""
-
-
-class ObjectsAndImages(Controller):
-    def run(self):
-        # Create the output directory.
-        output_directory = "example_output"
-        if os.path.exists(output_directory):
-            shutil.rmtree(output_directory)
-            sleep(0.5)
-            os.mkdir(output_directory)
-        print(f"Images will be saved to: {output_directory}")
-
-        self.start()
-        # Create an empty room.
-        self.communicate(TDWUtils.create_empty_room(12, 12))
-
-        # Add the objects.
-        lamp_id = self.add_object("alma_floor_lamp",
-                                  position={"x": 1, "y": 0, "z": 0},
-                                  rotation={"x": 0, "y": 90, "z": 0})
-        self.add_object("live_edge_coffee_table",
-                        position={"x": 1.5, "y": 0, "z": 1.5},
-                        rotation={"x": 0, "y": 30, "z": 0})
-        self.add_object("small_table_green_marble",
-                        position={"x": -0.9, "y": 0, "z": -1.35})
-
-        # Create the avatar.
-        self.communicate({"$type": "create_avatar",
-                          "type": "A_Img_Caps_Kinematic",
-                          "id": "a"})
-
-        # Try to find a valid position on the NavMesh.
-        x, y, z = TDWUtils.get_random_position_on_nav_mesh(self, 12, 12)
-
-        # Teleport the avatar to the valid position.
-        # Apply a force to the lamp.
-        # Set the pass masks to _img.
-        # Enable image capture.
-        self.communicate([{"$type": "teleport_avatar_to",
-                           "avatar_id": "a",
-                           "position": {"x": x, "y": 1.5, "z": z}},
-                          {"$type": "apply_force_to_object",
-                           "force": {"x": 2, "y": 1, "z": 0},
-                           "id": lamp_id},
-                          {"$type": "set_pass_masks",
-                           "avatar_id": "a",
-                           "pass_masks": ["_img", "_id"]},
-                          {"$type": "send_images",
-                           "frequency": "always"},
-                          ])
-
-        # Capture 100 images.
-        for i in range(100):
-            # Look at the lamp.
-            resp = self.communicate({"$type": "look_at",
-                                     "avatar_id": "a",
-                                     "object_id": lamp_id,
-                                     "use_centroid": True})
-            images = Images(resp[0])
-            # Save the image.
-            TDWUtils.save_images(images, TDWUtils.zero_padding(i), output_directory=output_directory)
-
-
-if __name__ == "__main__":
-    ObjectsAndImages().run()
diff --git a/Python/example_controllers/objects_and_scenes/floorplan.py b/Python/example_controllers/objects_and_scenes/floorplan.py
new file mode 100644
index 000000000..40e411670
--- /dev/null
+++ b/Python/example_controllers/objects_and_scenes/floorplan.py
@@ -0,0 +1,34 @@
+from tdw.controller import Controller
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.floorplan import Floorplan
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+
+"""
+Generate a floorplan scene and populate it with a layout of objects.
+"""
+
+c = Controller()
+
+# Initialize the floorplan add-on.
+floorplan = Floorplan()
+# Scene 1, visual variant a, object layout 0.
+floorplan.init_scene(scene="1a", layout=0)
+
+# Add a camera and enable image capture.
+camera = ThirdPersonCamera(position={"x": 0, "y": 40, "z": 0},
+                           look_at={"x": 0, "y": 0, "z": 0},
+                           avatar_id="a")
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("floorplan_controller")
+print(f"Images will be saved to: {path}")
+capture = ImageCapture(avatar_ids=["a"], pass_masks=["_img"], path=path)
+
+c.add_ons.extend([floorplan, camera, capture])
+# Make the image 720p and hide the roof.
+c.communicate([{"$type": "set_screen_size",
+                "width": 1280,
+                "height": 720},
+               {"$type": "set_floorplan_roof",
+                "show": False}])
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/objects_and_scenes/object_on_table.py b/Python/example_controllers/objects_and_scenes/object_on_table.py
new file mode 100644
index 000000000..c8095281d
--- /dev/null
+++ b/Python/example_controllers/objects_and_scenes/object_on_table.py
@@ -0,0 +1,45 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, Bounds
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+"""
+Use Bounds data to put an object on a table.
+"""
+
+c = Controller()
+object_id = c.get_unique_id()
+
+# Add a camera and enable image capture.
+cam = ThirdPersonCamera(position={"x": 2.478, "y": 1.602, "z": 1.412},
+                        look_at={"x": 0, "y": 0.5, "z": 0},)
+output_directory = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("bounds")
+print(f"Images will be saved to: {output_directory.resolve()}")
+c.add_ons.append(cam)
+cap = ImageCapture(path=output_directory, avatar_ids=[cam.avatar_id], pass_masks=["_img"])
+c.add_ons.append(cap)
+# Create the scene. Add a table. Request Bounds output data.
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(model_name="small_table_green_marble",
+                                       position={"x": 0, "y": 0, "z": 0},
+                                       object_id=object_id),
+                      {"$type": "send_bounds",
+                       "frequency": "once"}])
+
+# Get the top of the table.
+top = (0, 0, 0)
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "boun":
+        bounds = Bounds(resp[i])
+        for j in range(bounds.get_num()):
+            if bounds.get_id(j) == object_id:
+                top = bounds.get_top(j)
+                break
+# Put an object on top of the table.
+c.communicate(c.get_add_object(model_name="jug01",
+                               position=TDWUtils.array_to_vector3(top),
+                               object_id=c.get_unique_id()))
+c.communicate({"$type": "terminate"})
\ No newline at end of file
diff --git a/Python/example_controllers/objects_and_scenes/overlap.py b/Python/example_controllers/objects_and_scenes/overlap.py
new file mode 100644
index 000000000..3513bae1d
--- /dev/null
+++ b/Python/example_controllers/objects_and_scenes/overlap.py
@@ -0,0 +1,90 @@
+import numpy as np
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+from tdw.output_data import OutputData, Rigidbodies, Overlap
+from tdw.librarian import ModelLibrarian
+
+"""
+Use overlap data to determine if an object is in a bowl.
+"""
+
+# Create a random number generator with seed 0.
+rng = np.random.RandomState(0)
+
+model_librarian = ModelLibrarian()
+bowl_record = model_librarian.get_record("round_bowl_small_walnut")
+c = Controller()
+bowl_id = c.get_unique_id()
+jug_id = c.get_unique_id()
+camera = ThirdPersonCamera(position={"x": 0.5, "y": 1.6, "z": -1},
+                           look_at=bowl_id,
+                           avatar_id="a")
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("overlap")
+print(f"Images will be saved to: {path}")
+capture = ImageCapture(avatar_ids=[camera.avatar_id], path=path)
+c.add_ons.extend([camera, capture])
+
+# Set the position of the bowl.
+bowl_position = {"x": -1, "y": 0, "z": 0.5}
+
+# Pick a position slightly above the bowl.
+bowl_left_right = np.linalg.norm(TDWUtils.vector3_to_array(bowl_record.bounds["left"]) -
+                                 TDWUtils.vector3_to_array(bowl_record.bounds["right"]))
+bowl_front_back = np.linalg.norm(TDWUtils.vector3_to_array(bowl_record.bounds["front"]) -
+                                 TDWUtils.vector3_to_array(bowl_record.bounds["back"]))
+object_x = float(rng.uniform(bowl_position["x"] - (bowl_left_right / 2), bowl_position["x"] + (bowl_left_right / 2)))
+object_y = float(rng.uniform(2.5, 4))
+object_z = float(rng.uniform(bowl_position["z"] - (bowl_front_back / 2), bowl_position["z"] + (bowl_front_back / 2)))
+
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(position=bowl_position,
+                                       object_id=bowl_id,
+                                       model_name=bowl_record.name),
+                      c.get_add_object(position={"x": object_x, "y": object_y, "z": object_z},
+                                       object_id=jug_id,
+                                       model_name="jug03"),
+                      {"$type": "send_rigidbodies",
+                       "frequency": "always",
+                       "ids": [jug_id]}])
+sleeping = False
+while not sleeping:
+    for i in range(len(resp) - 1):
+        # Get rigidbody output data.
+        r_id = OutputData.get_data_type_id(resp[i])
+        if r_id == "rigi":
+            rigidbodies = Rigidbodies(resp[i])
+            # We know that there is only one object in the rigidbody data (the jug).
+            if rigidbodies.get_sleeping(0):
+                sleeping = True
+                break
+    resp = c.communicate([])
+
+# Define an overlap to determine if the object is in the bowl.
+overlap_id = c.get_unique_id()
+if bowl_left_right < bowl_front_back:
+    bowl_smaller_extent = bowl_left_right / 2
+else:
+    bowl_smaller_extent = bowl_front_back / 2
+# Shrink the extents a little more.
+bowl_smaller_extent *= 0.9
+resp = c.communicate({"$type": "send_overlap_sphere",
+                      "radius": bowl_smaller_extent,
+                      "position": {"x": bowl_position["x"],
+                                   "y": 0,
+                                   "z": bowl_position["z"]},
+                      "id": overlap_id})
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    # Get the overlap.
+    if r_id == "over":
+        overlap = Overlap(resp[i])
+        # Is this the correct overlap?
+        if overlap.get_id() == overlap_id:
+            object_ids = overlap.get_object_ids()
+            # Does the overlap contain the jug?
+            if jug_id in object_ids:
+                print("The jug is in the bowl!")
+c.communicate({"$type": "terminate"})
\ No newline at end of file
diff --git a/Python/example_controllers/objects_and_scenes/proc_gen_objects.py b/Python/example_controllers/objects_and_scenes/proc_gen_objects.py
new file mode 100644
index 000000000..ed2947f96
--- /dev/null
+++ b/Python/example_controllers/objects_and_scenes/proc_gen_objects.py
@@ -0,0 +1,108 @@
+import numpy as np
+from tdw.librarian import ModelLibrarian, ModelRecord
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import Bounds
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+
+class ProcGen(Controller):
+    """
+    Using procedural generation, add a table to scene and add chairs around the table.
+    """
+
+    def __init__(self, port: int = 1071, launch_build: bool = True, random_seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(random_seed)
+
+    @staticmethod
+    def get_longest_extent(record: ModelRecord) -> float:
+        left = TDWUtils.vector3_to_array(record.bounds["left"])
+        right = TDWUtils.vector3_to_array(record.bounds["right"])
+        front = TDWUtils.vector3_to_array(record.bounds["front"])
+        back = TDWUtils.vector3_to_array(record.bounds["back"])
+        left_right: float = np.linalg.norm(left - right)
+        front_back: float = np.linalg.norm(front - back)
+        if left_right > front_back:
+            return left_right
+        else:
+            return front_back
+
+    def get_table_placement_coordinate(self, radius: float) -> float:
+        q = float(self.rng.uniform(0, 6 - radius))
+        if self.rng.random() < 0.5:
+            q *= -1
+        return q
+
+    def get_chair_position(self, table_center: np.array, table_bound_point: np.array) -> np.array:
+        position_to_center = table_bound_point - table_center
+        position_to_center_normalized = position_to_center / np.linalg.norm(position_to_center)
+        chair_position = table_bound_point + (position_to_center_normalized * self.rng.uniform(0.5, 0.125))
+        chair_position[1] = 0
+        return chair_position
+
+    def run(self) -> None:
+        librarian = ModelLibrarian()
+        tables = librarian.get_all_models_in_wnid("n04379243")
+        chairs = librarian.get_all_models_in_wnid("n03001627")
+        tables = [record for record in tables if not record.do_not_use]
+        chairs = [record for record in chairs if not record.do_not_use]
+        table = tables[self.rng.randint(0, len(tables))]
+        chair = chairs[self.rng.randint(0, len(chairs))]
+
+        table_extents = ProcGen.get_longest_extent(table)
+        chair_extents = ProcGen.get_longest_extent(chair)
+        table_placement_radius = table_extents + chair_extents + 1.15
+        table_x = self.get_table_placement_coordinate(table_placement_radius)
+        table_z = self.get_table_placement_coordinate(table_placement_radius)
+        table_id = self.get_unique_id()
+
+        resp = self.communicate([TDWUtils.create_empty_room(12, 12),
+                                 self.get_add_object(model_name=table.name,
+                                                     position={"x": table_x, "y": 0, "z": table_z},
+                                                     rotation={"x": 0, "y": float(self.rng.uniform(-360, 360)), "z": 0},
+                                                     object_id=table_id),
+                                 {"$type": "send_bounds",
+                                  "frequency": "once",
+                                  "ids": [table_id]}])
+        # We know tha this is the only output data on this frame.
+        bounds = Bounds(resp[0])
+        # We know that the table is the only object in the output data.
+        table_center = np.array(bounds.get_center(0))
+        chair_positions = [self.get_chair_position(table_center=table_center,
+                                                   table_bound_point=np.array(bounds.get_left(0))),
+                           self.get_chair_position(table_center=table_center,
+                                                   table_bound_point=np.array(bounds.get_right(0))),
+                           self.get_chair_position(table_center=table_center,
+                                                   table_bound_point=np.array(bounds.get_front(0))),
+                           self.get_chair_position(table_center=table_center,
+                                                   table_bound_point=np.array(bounds.get_back(0)))]
+        table_top = bounds.get_top(0)
+        camera = ThirdPersonCamera(position={"x": table_top[0] + 1.5, "y": table_top[1] + 0.7, "z": table_top[2] - 2},
+                                   look_at=TDWUtils.array_to_vector3(table_top))
+        path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("proc_gen_objects")
+        print(f"Images will be saved to: {path}")
+        capture = ImageCapture(avatar_ids=[camera.avatar_id], pass_masks=["_img"], path=path)
+        self.add_ons.extend([camera, capture])
+        table_bottom = TDWUtils.array_to_vector3(bounds.get_bottom(0))
+        commands = []
+        for chair_position in chair_positions:
+            object_id = self.get_unique_id()
+            commands.extend([self.get_add_object(model_name=chair.name,
+                                                 position=TDWUtils.array_to_vector3(chair_position),
+                                                 object_id=object_id),
+                             {"$type": "object_look_at_position",
+                              "position": table_bottom,
+                              "id": object_id},
+                             {"$type": "rotate_object_by",
+                              "angle": float(self.rng.uniform(-20, 20)),
+                              "id": object_id,
+                              "axis": "yaw"}])
+        self.communicate(commands)
+        self.communicate({"$type": "terminate"})
+
+if __name__ == "__main__":
+    c = ProcGen()
+    c.run()
diff --git a/Python/example_controllers/objects_and_scenes/proc_gen_room.py b/Python/example_controllers/objects_and_scenes/proc_gen_room.py
new file mode 100644
index 000000000..1da6f47c4
--- /dev/null
+++ b/Python/example_controllers/objects_and_scenes/proc_gen_room.py
@@ -0,0 +1,132 @@
+from typing import List
+import numpy as np
+from tdw.controller import Controller
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+
+class ProcGenRoom(Controller):
+    """
+    Procedurally create an interior scene.
+    """
+
+    def __init__(self, port: int = 1071, launch_build: bool = True, seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(seed)
+
+    def create_scene(self) -> List[dict]:
+        width: int = self.rng.randint(12, 18)
+        length: int = self.rng.randint(14, 20)
+        room_arr: np.array = np.zeros(shape=(width, length), dtype=int)
+        # Define the uppermost width-wise wall.
+        turn_south_at = int(length * 0.75) + self.rng.randint(1, 3)
+        for i in range(turn_south_at + 1):
+            room_arr[0, i] = 1
+        turn_west_at = int(width * 0.75) + self.rng.randint(0, 2)
+        for i in range(turn_west_at + 1):
+            room_arr[i, turn_south_at] = 1
+        turn_north_at = turn_south_at - self.rng.randint(4, 6)
+        for i in range(turn_north_at, turn_south_at):
+            room_arr[turn_west_at, i] = 1
+        turn_west_at_2 = self.rng.randint(4, 6)
+        for i in range(turn_west_at_2, turn_west_at):
+            room_arr[i, turn_north_at] = 1
+        for i in range(turn_north_at):
+            room_arr[turn_west_at_2, i] = 1
+        for i in range(turn_west_at_2):
+            room_arr[i, 0] = 1
+        # Create interior walls.
+        if self.rng.random() < 0.5:
+            interior_wall_0 = range(turn_north_at + 1, turn_south_at - 1)
+            interior_wall_1 = range(1, turn_west_at_2 - 1)
+        else:
+            interior_wall_0 = range(turn_north_at + 2, turn_south_at)
+            interior_wall_1 = range(2, turn_west_at_2)
+        for i in interior_wall_0:
+            room_arr[turn_west_at_2, i] = 2
+        for i in interior_wall_1:
+            room_arr[i, turn_north_at] = 2
+        # Convert the array to commands.
+        exterior_walls: List[dict] = list()
+        interior_walls: List[dict] = list()
+        for ix, iy in np.ndindex(room_arr.shape):
+            if room_arr[ix, iy] == 1:
+                exterior_walls.append({"x": ix, "y": iy})
+            elif room_arr[ix, iy] == 2:
+                interior_walls.append({"x": ix, "y": iy})
+        return [{"$type": "load_scene",
+                 "scene_name": "ProcGenScene"},
+                {"$type": "create_exterior_walls",
+                 "walls": exterior_walls},
+                {"$type": "create_interior_walls",
+                 "walls": interior_walls}]
+
+    def set_floor(self) -> List[dict]:
+        materials = ["parquet_wood_mahogany", "parquet_long_horizontal_clean", "parquet_wood_red_cedar"]
+        material_name = materials[self.rng.randint(0, len(materials))]
+        texture_scale: float = float(self.rng.uniform(4, 4.5))
+        return [self.get_add_material(material_name=material_name),
+                {"$type": "set_proc_gen_floor_material",
+                 "name": material_name},
+                {"$type": "set_proc_gen_floor_texture_scale",
+                 "scale": {"x": texture_scale, "y": texture_scale}},
+                {"$type": "set_proc_gen_floor_color",
+                 "color": {"r": float(self.rng.uniform(0.7, 1)),
+                           "g": float(self.rng.uniform(0.7, 1)),
+                           "b": float(self.rng.uniform(0.7, 1)),
+                           "a": 1.0}}]
+
+    def set_walls(self) -> List[dict]:
+        materials = ["cinderblock_wall", "concrete_tiles_linear_grey", "old_limestone_wall_reinforced"]
+        material_name = materials[self.rng.randint(0, len(materials))]
+        texture_scale: float = float(self.rng.uniform(0.2, 0.3))
+        return [self.get_add_material(material_name=material_name),
+                {"$type": "set_proc_gen_walls_material",
+                 "name": material_name},
+                {"$type": "set_proc_gen_walls_texture_scale",
+                 "scale": {"x": texture_scale, "y": texture_scale}},
+                {"$type": "set_proc_gen_walls_color",
+                 "color": {"r": float(self.rng.uniform(0.7, 1)),
+                           "g": float(self.rng.uniform(0.7, 1)),
+                           "b": float(self.rng.uniform(0.7, 1)),
+                           "a": 1.0}}]
+
+    def set_ceiling(self) -> List[dict]:
+        materials = ["bricks_red_regular", "bricks_chatham_gray_used", "bricks_salem_matt_used"]
+        material_name = materials[self.rng.randint(0, len(materials))]
+        texture_scale: float = float(self.rng.uniform(0.1, 0.2))
+        return [{"$type": "create_proc_gen_ceiling"},
+                self.get_add_material(material_name=material_name),
+                {"$type": "set_proc_gen_ceiling_material",
+                 "name": material_name},
+                {"$type": "set_proc_gen_ceiling_texture_scale",
+                 "scale": {"x": texture_scale, "y": texture_scale}},
+                {"$type": "set_proc_gen_ceiling_color",
+                 "color": {"r": float(self.rng.uniform(0.7, 1)),
+                           "g": float(self.rng.uniform(0.7, 1)),
+                           "b": float(self.rng.uniform(0.7, 1)),
+                           "a": 1.0}}]
+
+    def run(self) -> None:
+        path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("proc_gen_room")
+        print(f"Images will be saved to {path}")
+        camera = ThirdPersonCamera(avatar_id="a", position={"x": 0, "y": 20, "z": 0}, look_at={"x": 0, "y": 0, "z": 0})
+        capture = ImageCapture(avatar_ids=["a"], pass_masks=["_img"], path=path)
+        self.add_ons.extend([camera, capture])
+
+        # Create the scene.
+        self.communicate(self.create_scene())
+        # Set the floor.
+        self.communicate(self.set_floor())
+        # Set the walls.
+        self.communicate(self.set_walls())
+        # Set the ceiling.
+        self.communicate(self.set_ceiling())
+        # End the simulation.
+        self.communicate({"$type": "terminate"})
+
+
+if __name__ == "__main__":
+    c = ProcGenRoom()
+    c.run()
diff --git a/Python/example_controllers/objects_and_scenes/proc_gen_room_from_image.py b/Python/example_controllers/objects_and_scenes/proc_gen_room_from_image.py
new file mode 100644
index 000000000..e42ebc304
--- /dev/null
+++ b/Python/example_controllers/objects_and_scenes/proc_gen_room_from_image.py
@@ -0,0 +1,30 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+from tdw.add_ons.image_capture import ImageCapture
+
+
+class ProcGenRoomFromImage(Controller):
+    """
+    Generate a proc-gen room from this image: ![](../../Python/example_controllers/room.png)
+    Each pixel corresponds to a grid point.
+    For more information, see TDWUtils documentation.
+    """
+
+    def run(self) -> None:
+        commands = TDWUtils.create_room_from_image("room.png")
+        commands.append({"$type": "set_post_process",
+                         "value": False})
+        commands.extend(TDWUtils.create_avatar(position={"x": 0, "y": 40, "z": 0},
+                                               look_at=TDWUtils.VECTOR3_ZERO,
+                                               avatar_id="a"))
+        path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("proc_gen_room_from_image")
+        print(f"Images will be saved to: {path}")
+        capture = ImageCapture(avatar_ids=["a"], path=path)
+        self.add_ons.append(capture)
+        self.communicate(commands)
+        self.communicate({"$type": "terminate"})
+
+
+if __name__ == "__main__":
+    ProcGenRoomFromImage().run()
diff --git a/Python/example_controllers/objects_and_scenes/raycast.py b/Python/example_controllers/objects_and_scenes/raycast.py
new file mode 100644
index 000000000..9601a672f
--- /dev/null
+++ b/Python/example_controllers/objects_and_scenes/raycast.py
@@ -0,0 +1,53 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+from tdw.output_data import OutputData, Raycast
+
+"""
+Use raycast data to place an object in a bowl.
+"""
+
+c = Controller()
+bowl_id = c.get_unique_id()
+
+camera = ThirdPersonCamera(position={"x": 0.5, "y": 1.6, "z": -1},
+                           look_at=bowl_id,
+                           avatar_id="a")
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("raycast")
+print(f"Images will be saved to: {path}")
+capture = ImageCapture(avatar_ids=[camera.avatar_id], path=path, png=True)
+c.add_ons.extend([camera, capture])
+
+bowl_position = {"x": -1, "y": 0, "z": 0.5}
+
+raycast_id = c.get_unique_id()
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(position=bowl_position,
+                                       object_id=bowl_id,
+                                       model_name="round_bowl_small_walnut"),
+                      {"$type": "send_raycast",
+                       "origin": {"x": bowl_position["x"], "y": 100, "z": bowl_position["z"]},
+                       "destination": {"x": bowl_position["x"], "y": -100, "z": bowl_position["z"]},
+                       "id": raycast_id}])
+commands = []
+for i in range(len(resp) - 1):
+    # Get raycast output data.
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "rayc":
+        raycast = Raycast(resp[i])
+        # Is this the same raycast that we requested?
+        if raycast.get_raycast_id() == raycast_id:
+            # Did the raycast hit anything (including the floor)?
+            # Did the raycast hit an object?
+            # Did the raycast hit the bowl?
+            if raycast.get_hit() and raycast.get_hit_object() and raycast.get_object_id() == bowl_id:
+                # Get the point that the raycast hit.
+                position = raycast.get_point()
+                # Add an object at that position.
+                commands.append(c.get_add_object(model_name="jug03",
+                                                 position=TDWUtils.array_to_vector3(position),
+                                                 object_id=c.get_unique_id()))
+c.communicate(commands)
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/objects_and_scenes/reset_scene.py b/Python/example_controllers/objects_and_scenes/reset_scene.py
new file mode 100644
index 000000000..a126ff5dd
--- /dev/null
+++ b/Python/example_controllers/objects_and_scenes/reset_scene.py
@@ -0,0 +1,61 @@
+import numpy as np
+from tdw.controller import Controller
+from tdw.output_data import OutputData, Rigidbodies
+
+
+class ResetScene(Controller):
+    """
+    Create multiple trials of an object falling and reset the scene between trials.
+    """
+
+    def __init__(self, port: int = 1071, launch_build: bool = True, random_seed: int = 0):
+        super().__init__(port=port, launch_build=launch_build)
+        self.rng: np.random.RandomState = np.random.RandomState(random_seed)
+
+    def do_trials(self, num_trials: int):
+        scenes = ["tdw_room", "iceland_beach", "lava_field", "abandoned_factory"]
+        # Divided the total number of trials by the number of scenes.
+        num_trials_per_scene = int(num_trials / len(scenes))
+        # Initialize a scene for the next batch of trials.
+        for scene in scenes:
+            self.communicate(self.get_add_scene(scene_name=scene))
+            # Do trials for this scene.
+            self.do_trials_in_scene(num_trials=num_trials_per_scene)
+        self.communicate({"$type": "terminate"})
+
+    def do_trials_in_scene(self, num_trials: int):
+        for i in range(num_trials):
+            # Add an object with a random rotation and starting height.
+            object_id = self.get_unique_id()
+            resp = self.communicate([self.get_add_object(model_name="rh10",
+                                                         position={"x": 0, "y": self.rng.uniform(1.6, 3), "z": 0},
+                                                         rotation={"x": self.rng.uniform(-360, 360),
+                                                                   "y": self.rng.uniform(-360, 360),
+                                                                   "z": self.rng.uniform(-360, 360)},
+                                                         object_id=object_id),
+                                     {"$type": "send_rigidbodies",
+                                      "frequency": "always"}])
+
+            done = False
+            while not done:
+                # Check if the object stopped moving.
+                for j in range(len(resp) - 1):
+                    r_id = OutputData.get_data_type_id(resp[j])
+                    if r_id == "rigi":
+                        rigidbodies = Rigidbodies(resp[j])
+                        for k in range(rigidbodies.get_num()):
+                            if rigidbodies.get_id(k) == object_id:
+                                done = rigidbodies.get_sleeping(k)
+                # Advance another frame.
+                resp = self.communicate([])
+
+            # Reset the scene by destroying the object.
+            self.communicate([{"$type": "destroy_object",
+                               "id": object_id},
+                              {"$type": "send_rigidbodies",
+                               "frequency": "never"}])
+
+
+if __name__ == "__main__":
+    c = ResetScene()
+    c.do_trials(num_trials=10000)
\ No newline at end of file
diff --git a/Python/example_controllers/room.png b/Python/example_controllers/objects_and_scenes/room.png
similarity index 100%
rename from Python/example_controllers/room.png
rename to Python/example_controllers/objects_and_scenes/room.png
diff --git a/Python/example_controllers/objects_and_scenes/set_visual_material.py b/Python/example_controllers/objects_and_scenes/set_visual_material.py
new file mode 100644
index 000000000..66ea28348
--- /dev/null
+++ b/Python/example_controllers/objects_and_scenes/set_visual_material.py
@@ -0,0 +1,30 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.librarian import ModelLibrarian
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+"""
+Set an object's visual material.
+"""
+
+c = Controller()
+object_id = c.get_unique_id()
+
+model_record = ModelLibrarian().get_record("white_lounger_chair")
+material_name = "parquet_long_horizontal_clean"
+cam = ThirdPersonCamera(position={"x": 2, "y": 1.6, "z": -0.6},
+                        look_at=object_id)
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("set_visual_material")
+print(f"Images will be saved to: {path}")
+cap = ImageCapture(avatar_ids=[cam.avatar_id], pass_masks=["_img"], path=path)
+c.add_ons.extend([cam, cap])
+
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(model_name=model_record.name,
+                             object_id=object_id),
+            c.get_add_material(material_name=material_name)]
+commands.extend(TDWUtils.set_visual_material(c=c, substructure=model_record.substructure, material=material_name, object_id=object_id))
+c.communicate(commands)
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/objects_and_scenes/spherecast.py b/Python/example_controllers/objects_and_scenes/spherecast.py
new file mode 100644
index 000000000..9bcfda6b4
--- /dev/null
+++ b/Python/example_controllers/objects_and_scenes/spherecast.py
@@ -0,0 +1,49 @@
+import random
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+from tdw.output_data import OutputData, Raycast
+
+"""
+Use spherecast data to place an object in a bowl.
+"""
+
+c = Controller()
+bowl_id = c.get_unique_id()
+
+camera = ThirdPersonCamera(position={"x": 0.5, "y": 1.6, "z": -1},
+                           look_at=bowl_id,
+                           avatar_id="a")
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("spherecast")
+print(f"Images will be saved to: {path}")
+capture = ImageCapture(avatar_ids=[camera.avatar_id], path=path, png=True)
+c.add_ons.extend([camera, capture])
+
+bowl_position = {"x": -1, "y": 0, "z": 0.5}
+
+raycast_id = c.get_unique_id()
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(position=bowl_position,
+                                       object_id=bowl_id,
+                                       model_name="round_bowl_small_walnut"),
+                      {"$type": "send_spherecast",
+                       "radius": 0.3,
+                       "origin": {"x": bowl_position["x"], "y": 100, "z": bowl_position["z"]},
+                       "destination": {"x": bowl_position["x"], "y": -100, "z": bowl_position["z"]},
+                       "id": raycast_id}])
+hits = []
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "rayc":
+        raycast = Raycast(resp[i])
+        if raycast.get_raycast_id() == raycast_id:
+            if raycast.get_hit() and raycast.get_hit_object() and raycast.get_object_id() == bowl_id:
+                hits.append(raycast.get_point())
+jug_position = random.choice(hits)
+# Add an object at a random position on the bowl.
+c.communicate(c.get_add_object(model_name="jug03",
+                               position=TDWUtils.array_to_vector3(jug_position),
+                               object_id=c.get_unique_id()))
+c.communicate({"$type": "terminate"})
\ No newline at end of file
diff --git a/Python/example_controllers/panorama.py b/Python/example_controllers/panorama.py
deleted file mode 100644
index 5a9cda326..000000000
--- a/Python/example_controllers/panorama.py
+++ /dev/null
@@ -1,96 +0,0 @@
-from pathlib import Path
-from math import radians, sin, cos
-from tdw.controller import Controller
-from tdw.output_data import Images
-from tdw.tdw_utils import TDWUtils
-
-
-"""
-Capture a series of images around a model to form a 360-degree panorama.
-"""
-
-
-class Panorama(Controller):
-    def __init__(self):
-        # Create the output directory.
-        self.output_directory = Path("dist/panorama")
-        if not self.output_directory.exists():
-            self.output_directory.mkdir(parents=True)
-
-        super().__init__()
-
-    def do_panorama(self, model_name):
-        # Create the model.
-        object_id = self.add_object(model_name)
-
-        # Teleport the avatar.
-        self.communicate({"$type": "teleport_avatar_to",
-                          "avatar_id": "a",
-                          "position": {"x": 5, "y": 2.5, "z": 0}})
-
-        # Set the starting parameters of the rotation.
-        d_theta = 15
-        rotations = int(360 / d_theta)
-        theta = 0
-        x = 3
-        y = 2.5
-        z = 0
-        c_x = 0
-        c_z = 0
-
-        # Capture images.
-        self.communicate({"$type": "send_images",
-                          "frequency": "always"})
-
-        for i in range(rotations):
-            # Get the new position.
-            rad = radians(theta)
-            x_rot = cos(rad) * (x - c_x) - sin(rad) * (z - c_z) + c_x
-            z_rot = sin(rad) * (x - c_x) + cos(rad) * (z - c_z) + c_z
-
-            # Teleport the avatar to the next position.
-            # Rotate the avatar's camera to look at the object.
-            # Receive an image.
-            resp = self.communicate([{"$type": "teleport_avatar_to",
-                                      "avatar_id": "a",
-                                      "position": {"x": x_rot, "y": y, "z": z_rot}},
-                                     {"$type": "look_at",
-                                      "avatar_id": "a",
-                                      "object_id": object_id}
-                                     ])
-            # Save the images.
-            for r in resp[:-1]:
-                TDWUtils.save_images(Images(r), str(TDWUtils.zero_padding(i)), str(self.output_directory.resolve()))
-
-            # Increment the angle.
-            theta += d_theta
-
-        # Stop image capture.
-        # Destroy the target object.
-        self.communicate([{"$type": "send_images",
-                          "frequency": "never"},
-                          {"$type": "destroy_object",
-                           "id": object_id}
-                          ])
-
-    def run(self):
-        # Load the suburb scene.
-        self.load_streamed_scene(scene="suburb_scene_2018")
-
-        # Create the avatar.
-        self.communicate({"$type": "create_avatar",
-                          "type": "A_Img_Caps_Kinematic",
-                          "id": "a"})
-
-        # Set the pass masks.
-        self.communicate({"$type": "set_pass_masks",
-                          "avatar_id": "a",
-                          "pass_masks": ["_img"]})
-
-        # Capture panorama images.
-        for model_name in ["chair_billiani_doll", "small_table_green_marble"]:
-            self.do_panorama(model_name)
-
-
-if __name__ == "__main__":
-    Panorama().run()
diff --git a/Python/example_controllers/pass_masks.py b/Python/example_controllers/pass_masks.py
deleted file mode 100644
index 346717f8f..000000000
--- a/Python/example_controllers/pass_masks.py
+++ /dev/null
@@ -1,66 +0,0 @@
-from pathlib import Path
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.output_data import OutputData, Images
-
-
-"""
-Create one image per pass of a scene.
-"""
-
-
-class PassMasks(Controller):
-    def run(self):
-        # Create the scene.
-        # Set image encoding globals.
-        self.start()
-        commands = [TDWUtils.create_empty_room(12, 12),
-                    {"$type": "set_screen_size",
-                     "width": 128,
-                     "height": 128},
-                    {"$type": "set_img_pass_encoding",
-                     "value": False}]
-        # Create the avatar.
-        commands.extend(TDWUtils.create_avatar(position={"x": 2.478, "y": 1.602, "z": 1.412},
-                                               look_at=TDWUtils.VECTOR3_ZERO,
-                                               avatar_id="a"))
-        # Enable all pass masks. Request an image for this frame only.
-        commands.extend([{"$type": "set_pass_masks",
-                          "pass_masks": ["_img", "_id", "_category", "_mask", "_depth", "_normals", "_flow",
-                                         "_depth_simple", "_albedo"],
-                          "avatar_id": "a"},
-                         {"$type": "send_images",
-                          "ids": ["a"],
-                          "frequency": "once"}])
-        # Add objects.
-        commands.append(self.get_add_object("small_table_green_marble",
-                                            position=TDWUtils.VECTOR3_ZERO,
-                                            rotation=TDWUtils.VECTOR3_ZERO,
-                                            object_id=0))
-        commands.append(self.get_add_object("rh10",
-                                            position={"x": 0.7, "y": 0, "z": 0.4},
-                                            rotation={"x": 0, "y": 30, "z": 0},
-                                            object_id=1))
-        commands.append(self.get_add_object("jug01",
-                                            position={"x": -0.3, "y": 0.9, "z": 0.2},
-                                            rotation=TDWUtils.VECTOR3_ZERO,
-                                            object_id=3))
-        commands.append(self.get_add_object("jug05",
-                                            position={"x": 0.3, "y": 0.9, "z": -0.2},
-                                            rotation=TDWUtils.VECTOR3_ZERO,
-                                            object_id=4))
-        # Send the commands.
-        resp = self.communicate(commands)
-        # Save the images.
-        for r in resp[:-1]:
-            r_id = OutputData.get_data_type_id(r)
-            if r_id == "imag":
-                # Save the images.
-                TDWUtils.save_images(output_directory="dist", images=Images(r), filename="0")
-                print(f"Images saved to: {Path('dist').resolve()}")
-        # Stop the build.
-        self.communicate({"$type": "terminate"})
-
-
-if __name__ == "__main__":
-    PassMasks(launch_build=False).run()
diff --git a/Python/example_controllers/physx/ball_bounce.py b/Python/example_controllers/physx/ball_bounce.py
new file mode 100644
index 000000000..38a9a5ce8
--- /dev/null
+++ b/Python/example_controllers/physx/ball_bounce.py
@@ -0,0 +1,50 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.object_manager import ObjectManager
+
+"""
+Bounce a ball on a table.
+"""
+
+c = Controller()
+camera = ThirdPersonCamera(position={"x": -4.5, "y": 2.1, "z": 0.5},
+                           look_at={"x": 0, "y": 0, "z": 0})
+object_manager = ObjectManager(transforms=False, bounds=True, rigidbodies=True)
+c.add_ons.extend([camera, object_manager])
+table_id = c.get_unique_id()
+commands = [TDWUtils.create_empty_room(12, 12)]
+commands.extend(c.get_add_physics_object(model_name="small_table_green_marble",
+                                         object_id=table_id))
+c.communicate(commands)
+
+# Get the top of the table.
+table_top = object_manager.bounds[table_id].top
+
+ball_id = c.get_unique_id()
+# Add a ball. Note that this is from the models_special.json model library.
+commands = c.get_add_physics_object(model_name="prim_sphere",
+                                    library="models_special.json",
+                                    position={"x": 0.5, "y": 4, "z": -1.3},
+                                    scale_factor={"x": 0.2, "y": 0.2, "z": 0.2},
+                                    default_physics_values=False,
+                                    mass=10,
+                                    dynamic_friction=0.3,
+                                    static_friction=0.3,
+                                    bounciness=0.7,
+                                    object_id=ball_id)
+# Orient the ball to look at the top of the table. Apply a force.
+commands.extend([{"$type": "object_look_at_position",
+                 "position": TDWUtils.array_to_vector3(table_top),
+                 "id": ball_id},
+                 {"$type": "apply_force_magnitude_to_object",
+                  "magnitude": 60,
+                  "id": ball_id}])
+# Re-initialize the object manager.
+object_manager.initialized = False
+c.communicate(commands)
+
+# Wait until the ball stops moving.
+while not object_manager.rigidbodies[ball_id].sleeping:
+    c.communicate([])
+c.communicate({"$type": "terminate"})
\ No newline at end of file
diff --git a/Python/example_controllers/physx/collisions.py b/Python/example_controllers/physx/collisions.py
new file mode 100644
index 000000000..2e7317b59
--- /dev/null
+++ b/Python/example_controllers/physx/collisions.py
@@ -0,0 +1,41 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.collision_manager import CollisionManager
+
+
+"""
+Listen for collision output data with a CollisionManager.
+"""
+
+c = Controller()
+
+# Add a collision manager
+collision_manager = CollisionManager(enter=True, stay=False, exit=False, objects=True, environment=True)
+c.add_ons.append(collision_manager)
+
+commands = [TDWUtils.create_empty_room(12, 12)]
+dropped_object_id = c.get_unique_id()
+ground_object_id = c.get_unique_id()
+# Add two objects.
+commands.extend(c.get_add_physics_object(model_name="iron_box",
+                                         object_id=dropped_object_id,
+                                         position={"x": 0, "y": 3, "z": 0}))
+commands.extend(c.get_add_physics_object(model_name="rh10",
+                                         object_id=ground_object_id,
+                                         position={"x": 0, "y": 0, "z": 0}))
+c.communicate(commands)
+for i in range(100):
+    for object_ids in collision_manager.obj_collisions:
+        print(i, object_ids.int1, object_ids.int2, collision_manager.obj_collisions[object_ids].state,
+              collision_manager.obj_collisions[object_ids].relative_velocity)
+        for normal, point in zip(collision_manager.obj_collisions[object_ids].normals,
+                                 collision_manager.obj_collisions[object_ids].points):
+            print("\t", normal, point)
+    for object_id in collision_manager.env_collisions:
+        print(i, object_id, collision_manager.env_collisions[object_id].state,
+              collision_manager.env_collisions[object_id].floor)
+        for normal, point in zip(collision_manager.env_collisions[object_id].normals,
+                                 collision_manager.env_collisions[object_id].points):
+            print("\t", normal, point)
+    resp = c.communicate([])
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/physx/collisions_and_friction.py b/Python/example_controllers/physx/collisions_and_friction.py
new file mode 100644
index 000000000..7c7c0cadf
--- /dev/null
+++ b/Python/example_controllers/physx/collisions_and_friction.py
@@ -0,0 +1,113 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.collision_manager import CollisionManager
+from tdw.add_ons.object_manager import ObjectManager
+
+
+class CollisionsAndFriction(Controller):
+    """
+    - Listen for collisions between objects.
+    - Adjust the friction values of objects.
+    """
+
+    def __init__(self, port: int = 1071, check_version: bool = True, launch_build: bool = True):
+        super().__init__(port=port, check_version=check_version, launch_build=launch_build)
+        # Add a collision manager and an object manager.
+        self.collision_manager: CollisionManager = CollisionManager(enter=True, exit=True, stay=True, objects=True, environment=True)
+        self.object_manager: ObjectManager = ObjectManager(transforms=False, rigidbodies=True, bounds=False)
+        self.add_ons.extend([self.collision_manager, self.object_manager])
+
+    def trial(self, fridge_dynamic_friction: float, fridge_static_friction: float, fridge_bounciness: float,
+              chair_dynamic_friction: float, chair_static_friction: float, chair_bounciness: float) -> None:
+        """
+        Collide a chair with a fridge.
+
+        :param fridge_dynamic_friction: The dynamic friction of the fridge.
+        :param fridge_static_friction: The static friction of the fridge.
+        :param fridge_bounciness: The bounciness of the fridge.
+        :param chair_dynamic_friction: The dynamic friction of the chair.
+        :param chair_static_friction: The static friction of the chair.
+        :param chair_bounciness: The bounciness of the chair.
+        """
+
+        # Re-initialize the managers.
+        self.collision_manager.initialized = False
+        self.object_manager.initialized = False
+
+        print("###\n\nNew Trial\n\n###\n")
+
+        fridge_id = 0
+        chair_id = 1
+
+        # Destroy all objects currently in the scene.
+        init_commands = [{"$type": "destroy_all_objects"}]
+        # Create the avatar.
+        init_commands.extend(TDWUtils.create_avatar(position={"x": 1, "y": 2.5, "z": 5},
+                                                    look_at=TDWUtils.VECTOR3_ZERO))
+        # Add the objects. Set the masses and physic materials.
+        # Apply a force to the chair.
+        init_commands.extend(self.get_add_physics_object(model_name="fridge_large",
+                                                         object_id=fridge_id,
+                                                         default_physics_values=False,
+                                                         mass=40,
+                                                         dynamic_friction=fridge_dynamic_friction,
+                                                         static_friction=fridge_static_friction,
+                                                         bounciness=fridge_bounciness))
+        init_commands.extend(self.get_add_physics_object(model_name="chair_billiani_doll",
+                                                         object_id=chair_id,
+                                                         position={"x": 4, "y": 0, "z": 0},
+                                                         default_physics_values=False,
+                                                         mass=20,
+                                                         dynamic_friction=chair_dynamic_friction,
+                                                         static_friction=chair_static_friction,
+                                                         bounciness=chair_bounciness))
+        # Apply a force to the chair.
+        init_commands.append({"$type": "apply_force_to_object",
+                              "force": {"x": -200, "y": 0, "z": 0},
+                              "id": chair_id})
+        self.communicate(init_commands)
+
+        # Iterate until the objects stop moving.
+        done = False
+        while not done:
+            done = True
+            # Check if all objects stopped moving.
+            for object_id in self.object_manager.rigidbodies:
+                if not self.object_manager.rigidbodies[object_id].sleeping:
+                    done = False
+                    break
+            # Print collision data.
+            for object_ids in self.collision_manager.obj_collisions:
+                print(object_ids.int1, object_ids.int2, self.collision_manager.obj_collisions[object_ids].state,
+                      self.collision_manager.obj_collisions[object_ids].relative_velocity)
+                for normal, point in zip(self.collision_manager.obj_collisions[object_ids].normals,
+                                         self.collision_manager.obj_collisions[object_ids].points):
+                    print("\t", normal, point)
+            for object_id in self.collision_manager.env_collisions:
+                print(object_id, self.collision_manager.env_collisions[object_id].state,
+                      self.collision_manager.env_collisions[object_id].floor)
+                for normal, point in zip(self.collision_manager.env_collisions[object_id].normals,
+                                         self.collision_manager.env_collisions[object_id].points):
+                    print("\t", normal, point)
+            # Advance 1 frame.
+            self.communicate([])
+
+    def run(self) -> None:
+        """
+        Do multiple trials and then quit.
+        """
+
+        # Create the room.
+        self.communicate(TDWUtils.create_empty_room(12, 12))
+
+        # Run a trial.
+        self.trial(0.1, 0.12, 0.6, 0.8, 0.75, 0.1)
+
+        # Run a trial with different friction and bounciness parameters.
+        self.trial(0.9, 0.85, 1.0, 0.2, 0.75, 1.0)
+
+        self.communicate({"$type": "terminate"})
+
+
+if __name__ == "__main__":
+    CollisionsAndFriction().run()
diff --git a/Python/example_controllers/physx/composite_object.py b/Python/example_controllers/physx/composite_object.py
new file mode 100644
index 000000000..b1c561e3c
--- /dev/null
+++ b/Python/example_controllers/physx/composite_object.py
@@ -0,0 +1,97 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, CompositeObjects, IdPassSegmentationColors, SegmentationColors
+from pathlib import Path
+import platform
+
+
+"""
+Create a composite object from a local asset bundle.
+Test that the object loaded correctly.
+Apply sub-object commands to the sub-objects.
+"""
+
+
+class CompositeObject(Controller):
+    def run(self):
+        commands = [TDWUtils.create_empty_room(12, 12)]
+
+        # Find the local asset bundle for this platform.
+        url = "file:///" + str(Path("composite_objects/" + platform.system() + "/test_composite_object").resolve())
+
+        # Add the local object.
+        o_id = self.get_unique_id()
+        commands.extend([{"$type": "add_object",
+                          "name": "test_composite_object",
+                          "url": url,
+                          "scale_factor": 1,
+                          "id": o_id},
+                         {"$type": "set_mass",
+                          "id": o_id,
+                          "mass": 100},
+                         {"$type": "send_segmentation_colors"},
+                         {"$type": "send_id_pass_segmentation_colors"},
+                         {"$type": "send_composite_objects"},
+                         {"$type": "send_rigidbodies",
+                          "frequency": "always"}])
+        commands.extend(TDWUtils.create_avatar(position={"x": 0, "y": 1.49, "z": -2.77}))
+        resp = self.communicate(commands)
+
+        visible_objects = dict()
+        segmentation_colors = dict()
+        # Get all objects.
+        for i in range(len(resp) - 1):
+            r_id = OutputData.get_data_type_id(resp[i])
+            if r_id == "segm":
+                segm = SegmentationColors(resp[i])
+                for j in range(segm.get_num()):
+                    object_id = segm.get_object_id(j)
+                    visible_objects[object_id] = False
+                    segmentation_colors[segm.get_object_color(j)] = object_id
+        # Get all visible objects. Also, get the ID of the light and motor sub-objects.
+        light_id = -1
+        motor_id = -1
+        for i in range(len(resp) - 1):
+            r_id = OutputData.get_data_type_id(resp[i])
+            if r_id == "ipsc":
+                ipsc = IdPassSegmentationColors(resp[i])
+                for j in range(ipsc.get_num_segmentation_colors()):
+                    object_color = ipsc.get_segmentation_color(j)
+                    object_id = segmentation_colors[object_color]
+                    visible_objects[object_id] = True
+            elif r_id == "comp":
+                comp = CompositeObjects(resp[i])
+                for j in range(comp.get_num()):
+                    object_id = comp.get_object_id(j)
+                    if object_id == o_id:
+                        for k in range(comp.get_num_sub_objects(j)):
+                            sub_object_id = comp.get_sub_object_id(j, k)
+                            sub_object_machine_type = comp.get_sub_object_machine_type(j, k)
+                            if sub_object_machine_type == "light":
+                                light_id = sub_object_id
+                            elif sub_object_machine_type == "motor":
+                                motor_id = sub_object_id
+        print(visible_objects)
+        # Start the motor and turn the light on.
+        is_on = True
+        self.communicate([{"$type": "set_motor",
+                           "target_velocity": 500,
+                           "force": 500,
+                           "id": motor_id},
+                          {"$type": "set_sub_object_light",
+                           "is_on": is_on,
+                           "id": light_id}])
+        for i in range(1000):
+            # Every 50 frames, blink the lights on and off.
+            if i % 50 == 0:
+                is_on = not is_on
+                self.communicate({"$type": "set_sub_object_light",
+                                  "is_on": is_on,
+                                  "id": light_id})
+            else:
+                self.communicate([])
+        self.communicate({"$type": "terminate"})
+
+
+if __name__ == "__main__":
+    CompositeObject().run()
diff --git a/Python/example_controllers/physx/forcefield.py b/Python/example_controllers/physx/forcefield.py
new file mode 100644
index 000000000..5cd5b843d
--- /dev/null
+++ b/Python/example_controllers/physx/forcefield.py
@@ -0,0 +1,83 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.object_manager import ObjectManager
+import numpy as np
+
+
+class ForceField(Controller):
+    """
+    Simulate a "forcefield" that objects will bounce off of.
+    """
+
+    def run(self) -> None:
+        rng = np.random.RandomState(0)
+        commands = [TDWUtils.create_empty_room(100, 100)]
+
+        # The starting height of the objects.
+        y = 10
+        # The radius of the circle of objects.
+        r = 7.0
+        origin = np.array([0, 0, 0])
+        # Get all points within the circle defined by the radius.
+        p0 = np.array((0, 0))
+        o_id = 0
+        for x in np.arange(-r, r, 1):
+            for z in np.arange(-r, r, 1):
+                p1 = np.array((x, z))
+                dist = np.linalg.norm(p0 - p1)
+                if dist < r:
+                    # Add an object. Set its physics properties.
+                    commands.extend(self.get_add_physics_object(model_name="prim_cone",
+                                                                library="models_special.json",
+                                                                object_id=o_id,
+                                                                position={"x": x, "y": y, "z": z},
+                                                                rotation={"x": 0, "y": 0, "z": 180},
+                                                                default_physics_values=False,
+                                                                mass=5,
+                                                                dynamic_friction=0.8,
+                                                                static_friction=0.7,
+                                                                bounciness=0.5))
+                    # Set a random color.
+                    commands.append({"$type": "set_color",
+                                     "color": {"r": rng.random_sample(),
+                                               "g": rng.random_sample(),
+                                               "b": rng.random_sample(),
+                                               "a": 1.0},
+                                     "id": o_id})
+                    o_id += 1
+                    print(o_id)
+        # Add an object manager.
+        object_manager = ObjectManager(transforms=True, rigidbodies=False, bounds=False)
+        # Create a camera to observe the grisly spectacle.
+        avatar_position = {"x": -20, "y": 8, "z": 18}
+        camera = ThirdPersonCamera(position=avatar_position,
+                                   avatar_id="a",
+                                   look_at=TDWUtils.VECTOR3_ZERO)
+        self.add_ons.extend([object_manager, camera])
+        commands.append({"$type": "set_focus_distance",
+                         "focus_distance": np.linalg.norm(TDWUtils.vector3_to_array(avatar_position) - origin)})
+        self.communicate(commands)
+        # If an objects are this far away from (0, 0, 0) the forcefield "activates".
+        forcefield_radius = 5
+        # The forcefield will bounce objects away at this force.
+        forcefield_force = -10
+        zeros = np.array((0, 0, 0))
+        for i in range(1000):
+            commands = []
+            for object_id in object_manager.transforms:
+                # If the object is in the forcefield, apply a force.
+                if np.linalg.norm(object_manager.transforms[object_id].position - origin) <= forcefield_radius:
+                    # Get the normalized directional vector and multiply it by the force magnitude.
+                    d = zeros - object_manager.transforms[object_id].position
+                    d = d / np.linalg.norm(d)
+                    d = d * forcefield_force
+                    commands.append({"$type": "apply_force_to_object",
+                                     "id": object_id,
+                                     "force": TDWUtils.array_to_vector3(d)})
+            self.communicate(commands)
+        self.communicate({"$type": "terminate"})
+
+
+if __name__ == "__main__":
+    ForceField().run()
diff --git a/Python/example_controllers/physx/physics_values.py b/Python/example_controllers/physx/physics_values.py
new file mode 100644
index 000000000..bd339bfa9
--- /dev/null
+++ b/Python/example_controllers/physx/physics_values.py
@@ -0,0 +1,47 @@
+import numpy as np
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.object_manager import ObjectManager
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+
+"""
+Drop an object with varying physics values and observe its behavior.
+"""
+
+c = Controller()
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+# Do a number of trials.
+num_trials = 10
+
+# Set the 0 to another number to use a different random seed.
+rng = np.random.RandomState(0)
+
+# Add a camera and an object manager.
+camera = ThirdPersonCamera(position={"x": 3, "y": 2.5, "z": -1},
+                           look_at={"x": 0, "y": 0, "z": 0})
+object_manager = ObjectManager(transforms=True, rigidbodies=True)
+c.add_ons.extend([camera, object_manager])
+
+# Run the trials.
+for i in range(10):
+    object_id = c.get_unique_id()
+    # Add the object.
+    c.communicate(c.get_add_physics_object(model_name="iron_box",
+                                           object_id=object_id,
+                                           position={"x": 0, "y": 7, "z": 0},
+                                           default_physics_values=False,
+                                           mass=float(rng.uniform(0.5, 6)),
+                                           dynamic_friction=float(rng.uniform(0, 1)),
+                                           static_friction=float(rng.uniform(0, 1)),
+                                           bounciness=float(rng.uniform(0, 1))))
+    done = False
+    while not done:
+        c.communicate([])
+        done = object_manager.rigidbodies[object_id].sleeping
+    # Destroy the object.
+    c.communicate({"$type": "destroy_object",
+                   "id": object_id})
+    # Mark the object manager as requiring re-initialization.
+    object_manager.initialized = False
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/physx/step_physics_speed.py b/Python/example_controllers/physx/step_physics_speed.py
new file mode 100644
index 000000000..67badc9a2
--- /dev/null
+++ b/Python/example_controllers/physx/step_physics_speed.py
@@ -0,0 +1,58 @@
+from time import time
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.object_manager import ObjectManager
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.step_physics import StepPhysics
+
+
+class StepPhysicsSpeed(Controller):
+    """
+    Compare the speed of a simulation without skipping physics frames vs. skipping physics frames.
+    """
+
+    def __init__(self, port: int = 1071, check_version: bool = True, launch_build: bool = True):
+        super().__init__(port=port, check_version=check_version, launch_build=launch_build)
+        # Add a camera and an object manager.
+        self.object_manager: ObjectManager = ObjectManager(transforms=False, rigidbodies=True)
+        camera = ThirdPersonCamera(position={"x": 3, "y": 2.5, "z": -1},
+                                   look_at={"x": 0, "y": 0, "z": 0})
+        self.add_ons.extend([self.object_manager, camera])
+        self.communicate(TDWUtils.create_empty_room(12, 12))
+
+    def trial(self) -> float:
+        """
+        Wait for an object to fall.
+
+        :return: The elapsed time.
+        """
+
+        object_id = self.get_unique_id()
+        # Reset the object manager.
+        self.object_manager.initialized = False
+        # Add the object.
+        self.communicate(self.get_add_physics_object(model_name="iron_box",
+                                                     object_id=object_id,
+                                                     position={"x": 0, "y": 30, "z": 0}))
+        t0 = time()
+        # Wait for the object to fall.
+        while not self.object_manager.rigidbodies[object_id].sleeping:
+            self.communicate([])
+        t1 = time() - t0
+        # Destroy the object.
+        self.communicate({"$type": "destroy_object",
+                          "id": object_id})
+        return t1
+
+    def run(self) -> None:
+        t_no_skip = self.trial()
+        # Add a StepPhysics add-on.
+        self.add_ons.append(StepPhysics(num_frames=10))
+        t_skip_frames = self.trial()
+        print(t_no_skip, t_skip_frames)
+        self.communicate({"$type": "terminate"})
+
+
+if __name__ == "__main__":
+    c = StepPhysicsSpeed()
+    c.run()
diff --git a/Python/example_controllers/proc_gen_interior_design.py b/Python/example_controllers/proc_gen_interior_design.py
deleted file mode 100644
index c8ce0c6e9..000000000
--- a/Python/example_controllers/proc_gen_interior_design.py
+++ /dev/null
@@ -1,312 +0,0 @@
-import numpy as np
-import random
-from tdw.tdw_utils import TDWUtils
-from tdw.controller import Controller
-from tdw.output_data import Bounds, Transforms
-
-
-"""
-Procedurally furnish a room with basic relational semantic rules.
-"""
-
-
-class ProcGenInteriorDesign(Controller):
-    SIDE_TABLE = ["coffee_table_glass_round", "de_castelli_placas_table", "lg_table_white", "lg_table_marble_green",
-                  "sm_table_white", "glass_table_round", "side_table_wood", "trapezoidal_table"]
-    SIDE_TABLE_ON = ["candle", "f10_apple_iphone_4", "vase_01",
-                     "vase_02", "vase_03", "vase_05", "vase_06", "macbook_air", "zenblocks",
-                     "baton_table_lamp_dark", "black_lamp", "desk_lamp", "kevin_reilly_pattern_table_lamp",
-                     "lamp_02", "red_lamp", "rope_table_lamp", "spunlight_designermesh_lamp", "white_lamp"]
-    CIRCULAR_TABLE = ["coffee_table_glass_round", "de_castelli_placas_table", "de_castelli_placas_table_low",
-                      "glass_table_round"]
-
-    SOFA = ["alivar_tech_bench_sofa", "arflex_hollywood_sofa", "arflex_strips_sofa", "barcelona_bench_sofa",
-            "meridiani_belmondo_bench_sofa", "meridiani_freeman_sofa", "minotti_helion_3_seater_sofa",
-            "molteni_turner_sofa", "napoleon_iii_sofa", "on_the_rocks_sofa", "poliform_atollo_curved_sofa",
-            "sayonara_sofa"]
-    SOFA_FRONT_OF = ["luggage_bag_1", "macbook_air"]
-    SOFA_SIDE_OF = ["alma_floor_lamp", "arturoalvarez_v_floor_lamp", "bakerparisfloorlamp03", "bastone_floor_lamp"]
-    DINING_TABLE = ["willisau_varion_w3_table"]
-    DINING_CHAIR = ["brown_leather_dining_chair", "chair_billiani_doll", "chair_willisau_riale", "wood_chair"]
-    DINING_TABLE_SETTING = ["spoon2", "servingfork"]
-    DINING_TABLE_CENTERPIECE = ['candle', 'serving_bowl', 'vase_01',
-                                'vase_02', 'vase_03', 'vase_05', 'vase_06', 'jug01', 'jug02', 'jug03', 'jug04', 'jug05',
-                                'skillet_open', 'pot', 'kettle']
-
-    def run(self):
-        self.start()
-
-        width = 12
-        length = 12
-
-        # Create an empty room.
-        self.communicate(TDWUtils.create_empty_room(width, length))
-
-        # Disable physics.
-        self.communicate({"$type": "simulate_physics",
-                          "value": False})
-
-        # Add the avatar.
-        self.communicate(TDWUtils.create_avatar(position={"x": -5, "y": 10, "z": -2},
-                                                look_at=TDWUtils.VECTOR3_ZERO))
-
-        # Create the dining table.
-        self.dining_table()
-
-        # Create the sofa.
-        self.sofa(width, length)
-
-        # Bake the NavMesh.
-        self.communicate({"$type": "bake_nav_mesh",
-                          "carve_type": "none"})
-        # Get a random position on the NavMesh.
-        position = TDWUtils.get_random_position_on_nav_mesh(self, width, length)
-        position = TDWUtils.array_to_vector3(position)
-        # Add a side table.
-        self.side_table(position)
-
-    def all_objects_look_at(self, object_ids, target_position=TDWUtils.VECTOR3_ZERO):
-        commands = []
-        for object_id in object_ids:
-            commands.append({"$type": "object_look_at_position",
-                             "id": object_id,
-                             "position": target_position})
-        self.communicate(commands)
-
-    def get_transforms_data(self, object_ids):
-        resp = self.communicate({"$type": "send_transforms",
-                                 "frequency": "once",
-                                 "ids": object_ids})
-
-        transforms = Transforms(resp[0])
-        return transforms
-
-    def get_bounds_data(self, object_id):
-        resp = self.communicate({"$type": "send_bounds",
-                                 "frequency": "once",
-                                 "ids": [object_id]})
-        return Bounds(resp[0])
-
-    def move_objects_along_forward(self, transforms: Transforms, delta, y=0):
-        commands = []
-        for i in range(transforms.get_num()):
-            object_id = transforms.get_id(i)
-            position = transforms.get_position(i)
-            forward = transforms.get_forward(i)
-
-            deltas = [d * delta for d in forward]
-
-            position = {"x": position[0] + deltas[0],
-                        "y": y,
-                        "z": position[2] + deltas[2]}
-            commands.append({"$type": "teleport_object",
-                             "id": object_id,
-                             "position": position})
-        self.communicate(commands)
-
-    def random_rotation(self, object_ids, max_angle=45):
-        commands = []
-        for object_id in object_ids:
-            commands.append({"$type": "rotate_object_by",
-                             "angle": random.uniform(-max_angle, max_angle),
-                             "axis": "yaw",
-                             "id": object_id,
-                             "is_world": True})
-        self.communicate(commands)
-
-    def dining_table(self):
-        """
-        1. Create a dining table in the center of the room.
-        2. Create four chairs, one on each side of the table.
-        3. Create "table settings" in front of each chair on the table.
-        4. Create a centerpiece on the table.
-        """
-
-        # Create a dining table in the center of the room.
-        dining_table_id = self.add_object(random.choice(ProcGenInteriorDesign.DINING_TABLE))
-
-        # Get the table bounds.
-        dining_table_bounds = self.get_bounds_data(dining_table_id)
-
-        # Create the chairs.
-        # Get the model.
-        chair_model_name = random.choice(ProcGenInteriorDesign.DINING_CHAIR)
-
-        # Get the chair positions.
-        chair_positions = [dining_table_bounds.get_left(0),
-                           dining_table_bounds.get_right(0),
-                           dining_table_bounds.get_front(0),
-                           dining_table_bounds.get_back(0)]
-
-        top = dining_table_bounds.get_top(0)
-
-        chair_ids = []
-        for chair_pos in chair_positions:
-            # Create the chair.
-            chair_id = self.add_object(chair_model_name,
-                                       position={"x": chair_pos[0], "y": 0, "z": chair_pos[2]})
-            chair_ids.append(chair_id)
-
-        # Look at the table.
-        self.all_objects_look_at(chair_ids)
-
-        # Get transforms data.
-        transforms = self.get_transforms_data(chair_ids)
-
-        # Move the chairs away.
-        self.move_objects_along_forward(transforms, -0.5)
-
-        # Create table place settings.
-        setting_ids = []
-
-        for chair_pos in chair_positions:
-            # Add a random object.
-            setting_id = self.add_object(random.choice(ProcGenInteriorDesign.DINING_TABLE_SETTING),
-                                         position={"x": chair_pos[0], "y": top[1], "z": chair_pos[2]})
-            setting_ids.append(setting_id)
-
-        # Look at the table.
-        self.all_objects_look_at(setting_ids, target_position={"x": 0, "y": top[1], "z": 0})
-
-        # Get transforms data.
-        transforms = self.get_transforms_data(setting_ids)
-
-        # Move the settings onto the table.
-        self.move_objects_along_forward(transforms, 0.25, y=top[1])
-
-        # Rotate the settings randomly.
-        self.random_rotation(setting_ids)
-
-        # Create a centerpiece.
-        self.add_object(random.choice(ProcGenInteriorDesign.DINING_TABLE_CENTERPIECE),
-                        position={"x": 0, "y": top[1], "z": 0},
-                        rotation={"x": 0, "y": random.uniform(-45, 45), "z": 0})
-
-    def sofa(self, width, length):
-        """
-        1. Create a sofa in the corner in the room.
-        2. Create some objects in front of the sofa.
-        3. Create some objects the sides of the sofa.
-
-        :param width: The width of the room.
-        :param length: The length of the room.
-        """
-
-        # Add the sofa in the corner of the room.
-        sofa_position = {"x": width / 2 - 2, "y": 0, "z": length / 2 - 2}
-        sofa_id = self.add_object(random.choice(ProcGenInteriorDesign.SOFA),
-                                  position=sofa_position)
-
-        # Rotate the sofa to face the center of the room.
-        self.communicate({"$type": "object_look_at_position",
-                          "id": sofa_id,
-                          "position": TDWUtils.VECTOR3_ZERO})
-
-        sofa_bounds = self.get_bounds_data(sofa_id)
-
-        # Set the rotation to match the sofa.
-        sofa_transform = self.get_transforms_data([sofa_id])
-        sofa_rotation = sofa_transform.get_rotation(0)
-        sofa_rotation = {"x": sofa_rotation[0],
-                         "y": sofa_rotation[1],
-                         "z": sofa_rotation[2],
-                         "w": sofa_rotation[3]}
-
-        # Get a focal point for objects to place in front of the sofa.
-        centerpoint = TDWUtils.extend_line(np.array(sofa_bounds.get_center(0)), np.array(sofa_bounds.get_front(0)), 0.5)
-        centerpoint[1] = 0
-
-        # Place objects in front of the sofa at various positions near the centerpoint.
-        for i in range(random.randint(1, 3)):
-            pos = TDWUtils.get_random_point_in_circle(centerpoint, 0.125)
-            pos = TDWUtils.array_to_vector3(pos)
-            self.add_object(random.choice(ProcGenInteriorDesign.SOFA_FRONT_OF),
-                            position=pos,
-                            rotation={"x": 0, "y": random.uniform(-30, 30), "z": 0})
-
-        # Create 1-2 objects to the sides of the sofa.
-        positions = [sofa_bounds.get_left(0), sofa_bounds.get_right(0)]
-        random.shuffle(positions)
-        if random.random() > 0.5:
-            positions.pop(0)
-
-        for side_position in positions:
-            side_position = [side_position[0], 0, side_position[2]]
-            side_position = TDWUtils.array_to_vector3(side_position)
-            # Create a side table.
-            if random.random() > 0.5:
-                self.side_table(side_position, (sofa_position, sofa_rotation))
-            # Create some other object.
-            else:
-                # Create the object.
-                side_id = self.add_object(random.choice(ProcGenInteriorDesign.SOFA_SIDE_OF),
-                                          position=side_position)
-
-                self.side_of(side_id, sofa_position, sofa_rotation)
-
-    def side_of(self, object_id, parent_position, parent_rotation):
-        """
-        Create an object to the another object.
-
-        :param object_id: This object's ID.
-        :param parent_position: The position of the parent object (Vector3).
-        :param parent_rotation: The rotation of the parent object (Quaternion).
-        """
-
-        # Look at the parent.
-        self.communicate({"$type": "object_look_at_position",
-                          "id": object_id,
-                          "position": parent_position})
-        # Move away from the parent.
-        self.move_objects_along_forward(self.get_transforms_data([object_id]), -0.25)
-
-        # Set the rotation of this object to match the parent.
-        self.communicate({"$type": "rotate_object_to",
-                          "id": object_id,
-                          "rotation": parent_rotation})
-
-    def side_table(self, position, parent=None):
-        """
-        1. Create a side table.
-        2. Maybe create an object on the side table.
-
-        :param position: The position of the table (Vector3).
-        :param parent: THe parent parameters: (position, rotation).
-        """
-
-        # Create the table.
-        table_id = self.add_object(random.choice(ProcGenInteriorDesign.SIDE_TABLE),
-                                   position=position)
-
-        if parent:
-            # Place the object to the side of the parent and match its rotation.
-            parent_position = parent[0]
-            parent_rotation = parent[1]
-
-            self.side_of(table_id, parent_position, parent_rotation)
-        else:
-            # Apply a random rotation.
-            self.communicate({"$type": "rotate_object_by",
-                              "angle": random.uniform(-45, 45),
-                              "axis": "yaw",
-                              "id": table_id,
-                              "is_world": True})
-        # Maybe put an object on the table.
-        if random.random() > 0.25:
-            # Get the top position of the table.
-            table_bounds = self.get_bounds_data(table_id)
-            on_pos = table_bounds.get_top(0)
-            on_y = on_pos[1]
-            # Perturb the position slightly.
-            on_pos = TDWUtils.get_random_point_in_circle(np.array(on_pos), 0.125)
-            on_pos[1] = on_y
-            on_pos = TDWUtils.array_to_vector3(on_pos)
-            on_rot = {"x": 0, "y": random.uniform(-45, 45), "z": 0}
-            # Add the object.
-            self.add_object(random.choice(ProcGenInteriorDesign.SIDE_TABLE_ON),
-                            position=on_pos,
-                            rotation=on_rot)
-
-
-if __name__ == "__main__":
-    ProcGenInteriorDesign().run()
diff --git a/Python/example_controllers/proc_gen_room_from_image.py b/Python/example_controllers/proc_gen_room_from_image.py
deleted file mode 100644
index 75cecea9c..000000000
--- a/Python/example_controllers/proc_gen_room_from_image.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-
-
-"""
-Generate a proc-gen room from this image: ![](../../Python/example_controllers/room.png)
-Each pixel corresponds to a grid point.
-For more information, see TDWUtils documentation.
-"""
-
-
-class ProcGenRoomFromImage(Controller):
-    def run(self):
-        self.start()
-
-        # Create the room from an image.
-        self.communicate(TDWUtils.create_room_from_image("room.png"))
-        self.communicate({"$type": "set_post_process",
-                          "value": False})
-        self.communicate(TDWUtils.create_avatar(position={"x": 0, "y": 40, "z": 0},
-                                                look_at=TDWUtils.VECTOR3_ZERO))
-
-
-if __name__ == "__main__":
-    ProcGenRoomFromImage().run()
diff --git a/Python/example_controllers/robot_add_on.py b/Python/example_controllers/robot_add_on.py
deleted file mode 100644
index 97b1423c7..000000000
--- a/Python/example_controllers/robot_add_on.py
+++ /dev/null
@@ -1,54 +0,0 @@
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.add_ons.third_person_camera import ThirdPersonCamera
-from tdw.add_ons.robot import Robot
-
-
-class RobotAgents(Controller):
-    """
-    Add multiple robots to the scene.
-    """
-
-    def run(self) -> None:
-        # Add two robots.
-        ur5 = Robot(name="ur5",
-                    position={"x": -1, "y": 0, "z": 0.5},
-                    robot_id=0)
-        ur10 = Robot(name="ur10",
-                     position={"x": -3, "y": 0, "z": 0.5},
-                     robot_id=1)
-        # Add a third-person camera.
-        camera = ThirdPersonCamera(position={"x": -2.59, "y": 2, "z": -3.44},
-                                   look_at={"x": -2, "y": 0.5, "z": 0})
-        self.add_ons.extend([ur5, ur10, camera])
-
-        # Initialize the scene.
-        self.communicate([{"$type": "load_scene",
-                           "scene_name": "ProcGenScene"},
-                          TDWUtils.create_empty_room(12, 12)])
-
-        # Set joint targets for both robots.
-        shoulder: str = "shoulder_link"
-        upper_arm: str = "upper_arm_link"
-        forearm: str = "forearm_link"
-        ur5.set_joint_targets({ur5.static.joint_ids_by_name[shoulder]: 15,
-                               ur5.static.joint_ids_by_name[upper_arm]: -45,
-                               ur5.static.joint_ids_by_name[forearm]: 60})
-        ur10.set_joint_targets({ur10.static.joint_ids_by_name[shoulder]: 80,
-                                ur10.static.joint_ids_by_name[upper_arm]: -15,
-                                ur10.static.joint_ids_by_name[forearm]: -20})
-        # Wait for both robots to stop moving.
-        while ur5.joints_are_moving() and ur10.joints_are_moving():
-            self.communicate([])
-        # Set new joint targets.
-        ur5.set_joint_targets({ur5.static.joint_ids_by_name[forearm]: 0})
-        ur10.set_joint_targets({ur10.static.joint_ids_by_name[forearm]: 60})
-        # Wait for just the UR5 robot to stop moving.
-        while ur5.joints_are_moving():
-            self.communicate([])
-        self.communicate({"$type": "terminate"})
-
-
-if __name__ == "__main__":
-    c = RobotAgents()
-    c.run()
diff --git a/Python/example_controllers/robot_camera.py b/Python/example_controllers/robot_camera.py
deleted file mode 100644
index f1d914623..000000000
--- a/Python/example_controllers/robot_camera.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.output_data import OutputData, StaticRobot
-
-"""
-Add a camera to a Magnebot.
-"""
-
-
-if __name__ == "__main__":
-    c = Controller(launch_build=False)
-    c.start()
-    robot_id = 0
-    avatar_id = "a"
-    # Add a Magnebot to the scene. Request static robot data.
-    resp = c.communicate([TDWUtils.create_empty_room(12, 12),
-                          {"$type": "add_magnebot",
-                           "position": {"x": 0, "y": 0, "z": 0},
-                           "rotation": {"x": 0, "y": 0, "z": 0},
-                           "id": robot_id},
-                          {"$type": "send_static_robots",
-                           "frequency": "once"}])
-    # Find the static robot data and get the ID of the torso.
-    torso_id = -1
-    for i in range(len(resp) - 1):
-        r_id = OutputData.get_data_type_id(resp[i])
-        if r_id == "srob":
-            static_robot = StaticRobot(resp[i])
-            for j in range(static_robot.get_num_joints()):
-                if static_robot.get_joint_name(j) == "torso":
-                    torso_id = static_robot.get_joint_id(j)
-    # Create an avatar and parent it to the torso.
-    c.communicate([{"$type": "create_avatar",
-                    "type": "A_Img_Caps_Kinematic",
-                    "id": avatar_id},
-                   {"$type": "parent_avatar_to_robot",
-                    "position": {"x": 0, "y": 0.053, "z": 0.1838},
-                    "id": robot_id,
-                    "avatar_id": avatar_id,
-                    "body_part_id": torso_id}])
diff --git a/Python/example_controllers/robot_torque.py b/Python/example_controllers/robot_torque.py
deleted file mode 100644
index b77179a8a..000000000
--- a/Python/example_controllers/robot_torque.py
+++ /dev/null
@@ -1,97 +0,0 @@
-from typing import Optional, Dict, List
-import numpy as np
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.output_data import OutputData, StaticRobot, Robot
-
-
-class RobotTorque(Controller):
-    """
-    Add a robot to TDW and bend its arm.
-    """
-
-    def run(self) -> None:
-        """
-        Apply torques to the robot's shoulder and elbow.
-        """
-
-        robot_id = 0
-        self.start()
-        commands = [TDWUtils.create_empty_room(12, 12),
-                    self.get_add_robot(name="ur5", robot_id=robot_id),
-                    {"$type": "send_static_robots",
-                     "frequency": "once"},
-                    {"$type": "send_robots",
-                     "frequency": "always"}]
-        commands.extend(TDWUtils.create_avatar(look_at=TDWUtils.VECTOR3_ZERO,
-                                               position={"x": -0.881, "y": 0.836, "z": -1.396}))
-        resp = self.communicate(commands)
-        # Parse the output data for static robot data.
-        static_robot: Optional[StaticRobot] = None
-        for i in range(len(resp) - 1):
-            r_id = OutputData.get_data_type_id(resp[i])
-            if r_id == "srob":
-                r = StaticRobot(resp[i])
-                if r.get_id() == robot_id:
-                    static_robot = r
-                    break
-        assert static_robot is not None, f"No static robot data: {resp}"
-        shoulder_name = "shoulder_link"
-        elbow_name = "forearm_link"
-        # Get the IDs of the shoulder and the elbow.
-        body_part_ids: Dict[str, int] = dict()
-        for i in range(static_robot.get_num_joints()):
-            b_id = static_robot.get_joint_id(i)
-            b_name = static_robot.get_joint_name(i)
-            body_part_ids[b_name] = b_id
-        assert shoulder_name in body_part_ids
-        assert elbow_name in body_part_ids
-        # Apply torques.
-        joint_ids: List[int] = [body_part_ids[shoulder_name], body_part_ids[elbow_name]]
-        resp = self.communicate([{"$type": "add_torque_to_revolute",
-                                  "id": robot_id,
-                                  "joint_id": body_part_ids[shoulder_name],
-                                  "torque": 500},
-                                 {"$type": "add_torque_to_revolute",
-                                  "id": robot_id,
-                                  "joint_id": body_part_ids[elbow_name],
-                                  "torque": -1000}])
-        angles_0 = RobotTorque.get_angles(resp=resp, joint_ids=joint_ids)
-        # Wait until the joints stop moving.
-        moving = True
-        while moving:
-            moving = False
-            resp = self.communicate([])
-            angles_1 = RobotTorque.get_angles(resp=resp, joint_ids=joint_ids)
-            for angle_0, angle_1 in zip(angles_0, angles_1):
-                if np.abs(angle_0 - angle_1) > 0.001:
-                    moving = True
-                    break
-            angles_0 = angles_1
-        self.communicate({"$type": "terminate"})
-
-    @staticmethod
-    def get_angles(resp: List[bytes], joint_ids: List[int]) -> List[float]:
-        """
-        Get the current angles of the specified joints.
-
-        :param resp: The response from the build.
-        :param joint_ids: The IDs of the joints.
-
-        :return: The angles of the joints.
-        """
-
-        angles: List[float] = list()
-        for i in range(len(resp) - 1):
-            r_id = OutputData.get_data_type_id(resp[i])
-            if r_id == "robo":
-                robot = Robot(resp[i])
-                for j in range(robot.get_num_joints()):
-                    joint_id = robot.get_joint_id(j)
-                    if joint_id in joint_ids:
-                        angles.append(robot.get_joint_positions(j)[0])
-        return angles
-
-
-if __name__ == "__main__":
-    RobotTorque(launch_build=False).run()
diff --git a/Python/example_controllers/robots/collision_detection.py b/Python/example_controllers/robots/collision_detection.py
new file mode 100644
index 000000000..ad1caf0bc
--- /dev/null
+++ b/Python/example_controllers/robots/collision_detection.py
@@ -0,0 +1,39 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+from tdw.add_ons.collision_manager import CollisionManager
+from tdw.add_ons.object_manager import ObjectManager
+
+"""
+Enable collision detection for a UR5 robot.
+"""
+
+c = Controller()
+robot = Robot(name="ur5",
+              position={"x": 1, "y": 0, "z": -2},
+              rotation={"x": 0, "y": 0, "z": 0},
+              robot_id=c.get_unique_id())
+c.add_ons.append(robot)
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+while robot.joints_are_moving():
+    c.communicate([])
+
+collision_manager = CollisionManager(enter=True, exit=False, stay=False)
+object_manager = ObjectManager(transforms=True, rigidbodies=True)
+c.add_ons.extend([collision_manager, object_manager])
+
+object_id = c.get_unique_id()
+c.communicate([c.get_add_object(model_name="iron_box",
+                                position={"x": 1, "y": 10, "z": -2},
+                                object_id=object_id)])
+
+# Wait for the object to stop moving.
+while not object_manager.rigidbodies[object_id].sleeping:
+    c.communicate([])
+    for collision in collision_manager.obj_collisions:
+        if collision.int1 in robot.static.joints:
+            print("Collision between iron_box and " + robot.static.joints[collision.int1].name)
+        elif collision.int2 in robot.static.joints:
+            print("Collision between iron_box and " + robot.static.joints[collision.int2].name)
+c.communicate({"$type": "terminate"})
\ No newline at end of file
diff --git a/Python/example_controllers/robots/multi_robot.py b/Python/example_controllers/robots/multi_robot.py
new file mode 100644
index 000000000..14355a6db
--- /dev/null
+++ b/Python/example_controllers/robots/multi_robot.py
@@ -0,0 +1,98 @@
+from enum import Enum
+from typing import Dict, Union, List
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+from tdw.librarian import RobotLibrarian, RobotRecord
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+
+class State(Enum):
+    idle = 1  # The robot isn't moving.
+    swinging = 2  # The robot is rotating its shoulder.
+    resetting = 4  # The robot is resetting to its neutral position.
+
+
+class SwingRobot(Robot):
+    def __init__(self, name: str, target_id: int, swing_direction: int, robot_id: int = 0,
+                 position: Dict[str, float] = None, rotation: Dict[str, float] = None,
+                 source: Union[RobotLibrarian, RobotRecord, str] = None):
+        super().__init__(name=name, robot_id=robot_id, position=position, rotation=rotation, source=source)
+        self.state: State = State.idle
+        self.target_id: int = target_id
+        self.swing_direction: int = swing_direction
+
+    def on_send(self, resp: List[bytes]) -> None:
+        super().on_send(resp=resp)
+        # If the robot is idle and collides with the ball,
+        if self.state == State.idle:
+            # Are any of my joints currently colliding with the target object?
+            for collision in self.dynamic.collisions_with_objects:
+                # `collision` is a tuple. The first element is always a joint ID and the second element is always an object ID.
+                if collision[1] == self.target_id:
+                    self.start_to_swing()
+        elif self.state == State.swinging:
+            if not self.joints_are_moving():
+                self.start_to_reset()
+        elif self.state == State.resetting:
+            if not self.joints_are_moving():
+                self.state = State.idle
+
+    def start_to_swing(self) -> None:
+        self.state = State.swinging
+        self.set_joint_targets(targets={self.static.joint_ids_by_name["shoulder_link"]: 70 * self.swing_direction})
+
+    def start_to_reset(self) -> None:
+        self.state = State.resetting
+        self.set_joint_targets(targets={self.static.joint_ids_by_name["shoulder_link"]: 0})
+
+
+class MultiRobot(Controller):
+    def run(self) -> None:
+        object_id = self.get_unique_id()
+        # Add two robots, a camera, and image capture.
+        robot_0 = SwingRobot(name="ur5",
+                             target_id=object_id,
+                             swing_direction=-1,
+                             robot_id=self.get_unique_id(),
+                             position={"x": -1, "y": 0, "z": 0.8})
+        robot_1 = SwingRobot(name="ur10",
+                             robot_id=self.get_unique_id(),
+                             target_id=object_id,
+                             swing_direction=1,
+                             position={"x": 0.1, "y": 0, "z": -0.5},
+                             rotation={"x": 0, "y": 30, "z": 0})
+        camera = ThirdPersonCamera(avatar_id="a",
+                                   position={"x": 0, "y": 3.05, "z": 2.1},
+                                   look_at={"x": 0, "y": 0, "z": 0})
+        path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("multi_robot")
+        print(f"Images will be saved to: {path}")
+        capture = ImageCapture(avatar_ids=["a"], path=path)
+        self.add_ons.extend([robot_0, robot_1, camera, capture])
+        commands = [TDWUtils.create_empty_room(12, 12)]
+        commands.extend(self.get_add_physics_object(object_id=object_id,
+                                                    model_name="prim_sphere",
+                                                    library="models_special.json",
+                                                    position={"x": 0.36, "y": 0, "z": 0.67},
+                                                    scale_factor={"x": 0.2, "y": 0.2, "z": 0.2},
+                                                    default_physics_values=False,
+                                                    mass=5))
+        commands.append({"$type": "send_collisions",
+                         "enter": True,
+                         "stay": False,
+                         "exit": False,
+                         "collision_types": ["obj", "env"]})
+        self.communicate(commands)
+        # Start swinging.
+        robot_1.start_to_swing()
+
+        for i in range(500):
+            self.communicate([])
+        self.communicate({"$type": "terminate"})
+
+
+if __name__ == "__main__":
+    c = MultiRobot()
+    c.run()
diff --git a/Python/example_controllers/robot_arm.py b/Python/example_controllers/robots/robot_arm.py
similarity index 98%
rename from Python/example_controllers/robot_arm.py
rename to Python/example_controllers/robots/robot_arm.py
index a491347ac..222da1ef3 100644
--- a/Python/example_controllers/robot_arm.py
+++ b/Python/example_controllers/robots/robot_arm.py
@@ -8,7 +8,7 @@
 
 class RobotArm(Controller):
     """
-    Add a robot to TDW and bend its arm.
+    Control a UR5 robot with low-level commands.
     """
 
     def __init__(self, port: int = 1071, check_version: bool = True, launch_build: bool = True):
@@ -48,8 +48,6 @@ def get_joint_angles(robot: Robot) -> List[float]:
 
     def run(self) -> None:
         robot_id = 0
-        self.start()
-
         # Create the scene. Add a robot.
         # Request static robot data for this frame only.
         # Request dynamic robot data per frame.
diff --git a/Python/example_controllers/robots/robot_camera.py b/Python/example_controllers/robots/robot_camera.py
new file mode 100644
index 000000000..5d0339f84
--- /dev/null
+++ b/Python/example_controllers/robots/robot_camera.py
@@ -0,0 +1,34 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+"""
+Add a camera to a UR5 robot.
+"""
+
+c = Controller()
+robot_id = c.get_unique_id()
+robot = Robot(name="ur5", robot_id=robot_id)
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("parent_robot_to_avatar")
+print(f"Images will be saved to: {path}")
+capture = ImageCapture(avatar_ids=["avatar"], path=path)
+c.add_ons.extend([robot, capture])
+commands = [TDWUtils.create_empty_room(12, 12)]
+commands.extend(TDWUtils.create_avatar(avatar_id="avatar"))
+c.communicate(commands)
+
+joint_id = robot.static.joint_ids_by_name["wrist_3_link"]
+joint_position = robot.dynamic.joints[joint_id].position
+robot.set_joint_targets(targets={robot.static.joint_ids_by_name["shoulder_link"]: -70,
+                                 robot.static.joint_ids_by_name["forearm_link"]: -55,
+                                 robot.static.joint_ids_by_name["wrist_3_link"]: 60})
+c.communicate({"$type": "parent_avatar_to_robot",
+               "position": {"x": 0, "y": 0, "z": 0},
+               "body_part_id": joint_id,
+               "avatar_id": "avatar",
+               "id": robot_id})
+while robot.joints_are_moving():
+    c.communicate([])
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/robots/set_joint_targets.py b/Python/example_controllers/robots/set_joint_targets.py
new file mode 100644
index 000000000..da9e0e07c
--- /dev/null
+++ b/Python/example_controllers/robots/set_joint_targets.py
@@ -0,0 +1,40 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.robot import Robot
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+"""
+Add a UR5 robot and set its joint targets.
+"""
+
+c = Controller()
+robot_id = c.get_unique_id()
+robot = Robot(name="ur5",
+              position={"x": 1, "y": 0, "z": -2},
+              rotation={"x": 0, "y": 0, "z": 0},
+              robot_id=robot_id)
+# Add a camera and enable image capture.
+camera = ThirdPersonCamera(avatar_id="a",
+                           position={"x": -0.1, "y": 1.7, "z": 0.1},
+                           look_at=robot_id)
+path = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("set_joint_targets")
+print(f"Images will be saved to: {path}")
+capture = ImageCapture(avatar_ids=["a"], path=path)
+c.add_ons.extend([robot, camera, capture])
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+# Set the initial pose.
+while robot.joints_are_moving():
+    c.communicate([])
+
+# Strike a cool pose.
+robot.set_joint_targets(targets={robot.static.joint_ids_by_name["shoulder_link"]: 50,
+                                 robot.static.joint_ids_by_name["forearm_link"]: -60})
+
+# Wait for the joints to stop moving.
+while robot.joints_are_moving():
+    c.communicate([])
+
+c.communicate({"$type": "terminate"})
\ No newline at end of file
diff --git a/Python/example_controllers/minimal.py b/Python/example_controllers/setup/hello_world.py
similarity index 88%
rename from Python/example_controllers/minimal.py
rename to Python/example_controllers/setup/hello_world.py
index e6a7c7cc5..ab02ce27b 100644
--- a/Python/example_controllers/minimal.py
+++ b/Python/example_controllers/setup/hello_world.py
@@ -1,12 +1,12 @@
-from tdw.controller import Controller
-
-
-"""
-A minimal example of how to connect to the build and receive data.
-"""
-
-if __name__ == "__main__":
-    # If you're running the TDW build on a remote server, replace this with c = Controller(launch_build=False)
-    c = Controller()
-    print("Everything is OK!")
-    c.communicate({"$type": "terminate"})
+from tdw.controller import Controller
+
+
+"""
+A minimal example of how to connect to the build and receive data.
+"""
+
+if __name__ == "__main__":
+    # If you're running the TDW build on a remote server, replace this with c = Controller(launch_build=False)
+    c = Controller()
+    print("Hello world!")
+    c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/visual_material_quality.py b/Python/example_controllers/visual_material_quality.py
deleted file mode 100644
index a98333a1c..000000000
--- a/Python/example_controllers/visual_material_quality.py
+++ /dev/null
@@ -1,43 +0,0 @@
-from tdw.controller import Controller
-from tdw.tdw_utils import TDWUtils
-from tdw.librarian import ModelLibrarian
-from time import sleep
-
-
-"""
-Adjust the render quality of visual materials.
-"""
-
-
-class VisualMaterialQuality(Controller):
-    def run(self):
-        # Parse the substructure of fridge_large.
-        record = ModelLibrarian().get_record("fridge_large")
-
-        self.start()
-        self.communicate(TDWUtils.create_empty_room(12, 12))
-
-        # Create the object.
-        o_id = self.add_object("fridge_large")
-
-        # Add an avatar.
-        self.communicate(TDWUtils.create_avatar(position={"x": 0, "y": 1.886, "z": -0.15},
-                                                look_at=TDWUtils.VECTOR3_ZERO))
-
-        # Disable post-processing, so the changes to the material aren't blurry.
-        self.communicate({"$type": "set_post_process",
-                          "value": False})
-
-        for quality in ["low", "med", "high"]:
-            # Set the visual materials.
-            self.communicate(TDWUtils.set_visual_material(self, record.substructure, o_id,
-                                                          "parquet_long_horizontal_clean", quality=quality))
-
-            sleep(5)
-            self.communicate({"$type": "unload_asset_bundles",
-                              "bundle_type": "materials"})
-        self.communicate({"$type": "unload_asset_bundles",
-                          "bundle_type": "models"})
-
-
-VisualMaterialQuality().run()
diff --git a/Python/example_controllers/visual_perception/category.py b/Python/example_controllers/visual_perception/category.py
new file mode 100644
index 000000000..ba5892661
--- /dev/null
+++ b/Python/example_controllers/visual_perception/category.py
@@ -0,0 +1,72 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, SegmentationColors, Categories
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+"""
+Get the _category pass and category data per object.
+"""
+
+c = Controller()
+object_id_0 = c.get_unique_id()
+object_id_1 = c.get_unique_id()
+object_id_2 = c.get_unique_id()
+object_id_3 = c.get_unique_id()
+object_names = {object_id_0: "small_table_green_marble",
+                object_id_1: "rh10",
+                object_id_2: "jug01",
+                object_id_3: "jug05"}
+
+output_directory = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("category")
+
+# Enable image capture for the _category pass.
+print(f"Images will be saved to: {output_directory}")
+capture = ImageCapture(path=output_directory, pass_masks=["_category"], avatar_ids=["a"])
+c.add_ons.append(capture)
+
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(object_names[object_id_0],
+                             object_id=object_id_0),
+            c.get_add_object(object_names[object_id_1],
+                             position={"x": 0.7, "y": 0, "z": 0.4},
+                             rotation={"x": 0, "y": 30, "z": 0},
+                             object_id=object_id_1),
+            c.get_add_object(model_name=object_names[object_id_2],
+                             position={"x": -0.3, "y": 0.9, "z": 0.2},
+                             object_id=object_id_2),
+            c.get_add_object(object_names[object_id_3],
+                             position={"x": 0.3, "y": 0.9, "z": -0.2},
+                             object_id=object_id_3),
+            {"$type": "send_categories",
+             "frequency": "once"},
+            {"$type": "send_segmentation_colors",
+             "frequency": "once"}]
+
+commands.extend(TDWUtils.create_avatar(position={"x": 2.478, "y": 1.602, "z": 1.412},
+                                       look_at={"x": 0, "y": 0.2, "z": 0},
+                                       avatar_id="a"))
+resp = c.communicate(commands)
+# Get each object category and category color.
+object_categories = dict()
+category_colors = dict()
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "segm":
+        segm = SegmentationColors(resp[i])
+        for j in range(segm.get_num()):
+            object_id = segm.get_object_id(j)
+            object_category = segm.get_object_category(j)
+            object_categories[object_id] = object_category
+    elif r_id == "cate":
+        cate = Categories(resp[i])
+        for j in range(cate.get_num_categories()):
+            category_name = cate.get_category_name(j)
+            category_color = cate.get_category_color(j)
+            category_colors[category_name] = category_color
+# Print the category colors of each object.
+for object_id in object_categories:
+    category_name = object_categories[object_id]
+    object_category_color = category_colors[category_name]
+    print(object_id, category_name, object_category_color)
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/visual_perception/depth.py b/Python/example_controllers/visual_perception/depth.py
new file mode 100644
index 000000000..5e8de5bd8
--- /dev/null
+++ b/Python/example_controllers/visual_perception/depth.py
@@ -0,0 +1,66 @@
+import matplotlib.pyplot as plt
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.output_data import OutputData, CameraMatrices
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+"""
+Convert the _depth pass to depth values, plot them using matplotlib, and generate a point cloud
+"""
+
+c = Controller()
+object_id_0 = c.get_unique_id()
+object_id_1 = c.get_unique_id()
+object_id_2 = c.get_unique_id()
+object_id_3 = c.get_unique_id()
+object_names = {object_id_0: "small_table_green_marble",
+                object_id_1: "rh10",
+                object_id_2: "jug01",
+                object_id_3: "jug05"}
+
+cam = ThirdPersonCamera(position={"x": 2.478, "y": 1.602, "z": 1.412},
+                        look_at={"x": 0, "y": 0.2, "z": 0},
+                        avatar_id="a")
+output_directory = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("depth")
+print(f"Images will be saved to: {output_directory.resolve()}")
+c.add_ons.append(cam)
+cap = ImageCapture(path=output_directory, avatar_ids=["a"], pass_masks=["_depth"])
+c.add_ons.append(cap)
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(object_names[object_id_0],
+                                       object_id=object_id_0),
+                      c.get_add_object(object_names[object_id_1],
+                                       position={"x": 0.7, "y": 0, "z": 0.4},
+                                       rotation={"x": 0, "y": 30, "z": 0},
+                                       object_id=object_id_1),
+                      c.get_add_object(model_name=object_names[object_id_2],
+                                       position={"x": -0.3, "y": 0.9, "z": 0.2},
+                                       object_id=object_id_2),
+                      c.get_add_object(object_names[object_id_3],
+                                       position={"x": 0.3, "y": 0.9, "z": -0.2},
+                                       object_id=object_id_3),
+                      {"$type": "send_camera_matrices",
+                       "frequency": "always"}])
+# Get the camera matrix.
+camera_matrix = None
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "cama":
+        camera_matrix = CameraMatrices(resp[i]).get_camera_matrix()
+images = cap.images["a"]
+for i in range(images.get_num_passes()):
+    if images.get_pass_mask(i) == "_depth":
+        # Get the depth values.
+        depth_values = TDWUtils.get_depth_values(images.get_image(i), depth_pass="_depth",
+                                                 width=images.get_width(), height=images.get_height())
+
+        # Comment out these two lines on a Linux server.
+        plt.imshow(depth_values)
+        plt.show()
+
+        # Convert the depth values to a point cloud.
+        point_cloud_filename = str(output_directory.joinpath("point_cloud.txt").resolve())
+        TDWUtils.get_point_cloud(depth=depth_values, filename=point_cloud_filename, camera_matrix=camera_matrix)
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/visual_perception/flow.py b/Python/example_controllers/visual_perception/flow.py
new file mode 100644
index 000000000..82ab0f9e2
--- /dev/null
+++ b/Python/example_controllers/visual_perception/flow.py
@@ -0,0 +1,50 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+
+"""
+Get the _flow pass.
+"""
+
+c = Controller()
+object_id_0 = c.get_unique_id()
+object_id_1 = c.get_unique_id()
+object_id_2 = c.get_unique_id()
+object_id_3 = c.get_unique_id()
+object_names = {object_id_0: "small_table_green_marble",
+                object_id_1: "rh10",
+                object_id_2: "jug01",
+                object_id_3: "jug05"}
+
+output_directory = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("flow")
+
+# Enable image capture for the _flow pass.
+print(f"Images will be saved to: {output_directory}")
+capture = ImageCapture(path=output_directory, pass_masks=["_flow"], avatar_ids=["a"])
+c.add_ons.append(capture)
+
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(object_names[object_id_0],
+                             object_id=object_id_0),
+            c.get_add_object(object_names[object_id_1],
+                             position={"x": 0.7, "y": 0, "z": 0.4},
+                             rotation={"x": 0, "y": 30, "z": 0},
+                             object_id=object_id_1),
+            c.get_add_object(model_name=object_names[object_id_2],
+                             position={"x": -0.3, "y": 0.9, "z": 0.2},
+                             object_id=object_id_2),
+            c.get_add_object(object_names[object_id_3],
+                             position={"x": 0.3, "y": 0.9, "z": -0.2},
+                             object_id=object_id_3),
+            {"$type": "apply_force_to_object",
+             "id": object_id_1,
+             "force": {"x": 0, "y": 5, "z": -200}}]
+
+commands.extend(TDWUtils.create_avatar(position={"x": 2.478, "y": 1.602, "z": 1.412},
+                                       look_at={"x": 0, "y": 0.2, "z": 0},
+                                       avatar_id="a"))
+c.communicate(commands)
+for i in range(3):
+    c.communicate([])
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/visual_perception/id.py b/Python/example_controllers/visual_perception/id.py
new file mode 100644
index 000000000..13c1b32b5
--- /dev/null
+++ b/Python/example_controllers/visual_perception/id.py
@@ -0,0 +1,71 @@
+from collections import Counter
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.add_ons.third_person_camera import ThirdPersonCamera
+from tdw.add_ons.image_capture import ImageCapture
+from tdw.backend.paths import EXAMPLE_CONTROLLER_OUTPUT_PATH
+from tdw.output_data import OutputData, SegmentationColors
+
+"""
+Capture an _id image pass and read the segmentation colors in the image.
+"""
+
+c = Controller()
+object_id_0 = c.get_unique_id()
+object_id_1 = c.get_unique_id()
+object_id_2 = c.get_unique_id()
+object_id_3 = c.get_unique_id()
+object_names = {object_id_0: "small_table_green_marble",
+                object_id_1: "rh10",
+                object_id_2: "jug01",
+                object_id_3: "jug05"}
+
+cam = ThirdPersonCamera(position={"x": 2.478, "y": 1.602, "z": 1.412},
+                        look_at={"x": 0, "y": 0.2, "z": 0},
+                        avatar_id="a")
+output_directory = EXAMPLE_CONTROLLER_OUTPUT_PATH.joinpath("id_pass")
+print(f"Images will be saved to: {output_directory.resolve()}")
+c.add_ons.append(cam)
+cap = ImageCapture(path=output_directory, avatar_ids=["a"], pass_masks=["_id"])
+c.add_ons.append(cap)
+resp = c.communicate([TDWUtils.create_empty_room(12, 12),
+                      c.get_add_object(object_names[object_id_0],
+                                       object_id=object_id_0),
+                      c.get_add_object(object_names[object_id_1],
+                                       position={"x": 0.7, "y": 0, "z": 0.4},
+                                       rotation={"x": 0, "y": 30, "z": 0},
+                                       object_id=object_id_1),
+                      c.get_add_object(model_name=object_names[object_id_2],
+                                       position={"x": -0.3, "y": 0.9, "z": 0.2},
+                                       object_id=object_id_2),
+                      c.get_add_object(object_names[object_id_3],
+                                       position={"x": 0.3, "y": 0.9, "z": -0.2},
+                                       object_id=object_id_3),
+                      {"$type": "send_segmentation_colors",
+                       "frequency": "once"}])
+segmentation_colors = dict()
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    # Get segmentation color output data.
+    if r_id == "segm":
+        segm = SegmentationColors(resp[i])
+        for j in range(segm.get_num()):
+            object_id = segm.get_object_id(j)
+            object_name = object_names[object_id]
+            segmentation_color = segm.get_object_color(j)
+            segmentation_colors[object_id] = segmentation_color
+
+# The total number of pixels in the image.
+num_pixels = 256 * 256
+# Get PIL images from `ImageCapture`.
+pil_image = cap.get_pil_images()["a"]["_id"]
+colors = Counter(pil_image.getdata())
+# Get the percentage of the image occupied by each object.
+for object_id in segmentation_colors:
+    segmentation_color = segmentation_colors[object_id]
+    object_name = object_names[object_id]
+    if segmentation_color not in colors:
+        print(object_name + " is not in the image!")
+    else:
+        print(object_name + " is in the frame!", float(colors[segmentation_color]) / num_pixels)
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/visual_perception/id_pass_segmentation_colors.py b/Python/example_controllers/visual_perception/id_pass_segmentation_colors.py
new file mode 100644
index 000000000..b41a8ad33
--- /dev/null
+++ b/Python/example_controllers/visual_perception/id_pass_segmentation_colors.py
@@ -0,0 +1,65 @@
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, SegmentationColors, IdPassSegmentationColors
+
+"""
+Get the IDs of each object in the frame.
+"""
+
+c = Controller()
+object_id_0 = c.get_unique_id()
+object_id_1 = c.get_unique_id()
+object_id_2 = c.get_unique_id()
+object_id_3 = c.get_unique_id()
+object_names = {object_id_0: "small_table_green_marble",
+                object_id_1: "rh10",
+                object_id_2: "jug01",
+                object_id_3: "jug05"}
+
+commands = [TDWUtils.create_empty_room(12, 12),
+            c.get_add_object(object_names[object_id_0],
+                             object_id=object_id_0),
+            c.get_add_object(object_names[object_id_1],
+                             position={"x": 0.7, "y": 0, "z": 0.4},
+                             rotation={"x": 0, "y": 30, "z": 0},
+                             object_id=object_id_1),
+            c.get_add_object(model_name=object_names[object_id_2],
+                             position={"x": -0.3, "y": 0.9, "z": 0.2},
+                             object_id=object_id_2),
+            c.get_add_object(object_names[object_id_3],
+                             position={"x": 0.3, "y": 0.9, "z": -0.2},
+                             object_id=object_id_3)]
+
+commands.extend(TDWUtils.create_avatar(position={"x": 2.478, "y": 1.602, "z": 1.412},
+                                       look_at={"x": 0, "y": 0.2, "z": 0},
+                                       avatar_id="a"))
+commands.extend([{"$type": "set_pass_masks",
+                  "avatar_id": "a",
+                  "pass_masks": ["_id"]},
+                 {"$type": "send_segmentation_colors",
+                  "frequency": "once"},
+                 {"$type": "send_id_pass_segmentation_colors",
+                  "frequency": "always"}])
+resp = c.communicate(commands)
+# Get each segmentation color.
+segmentation_colors_per_object = dict()
+segmentation_colors_in_image = list()
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    # Get segmentation color output data.
+    if r_id == "segm":
+        segm = SegmentationColors(resp[i])
+        for j in range(segm.get_num()):
+            object_id = segm.get_object_id(j)
+            object_name = object_names[object_id]
+            segmentation_color = segm.get_object_color(j)
+            segmentation_colors_per_object[object_id] = segmentation_color
+    elif r_id == "ipsc":
+        ipsc = IdPassSegmentationColors(resp[i])
+        for j in range(ipsc.get_num_segmentation_colors()):
+            segmentation_colors_in_image.append(ipsc.get_segmentation_color(j))
+# Print the ID of each object in the image.
+for object_id in segmentation_colors_per_object:
+    if segmentation_colors_per_object[object_id] in segmentation_colors_in_image:
+        print(object_id)
+c.communicate({"$type": "terminate"})
diff --git a/Python/example_controllers/occlusion.py b/Python/example_controllers/visual_perception/occlusion.py
similarity index 86%
rename from Python/example_controllers/occlusion.py
rename to Python/example_controllers/visual_perception/occlusion.py
index 84bf34c43..9cd14a588 100644
--- a/Python/example_controllers/occlusion.py
+++ b/Python/example_controllers/visual_perception/occlusion.py
@@ -18,9 +18,7 @@ def run(self) -> None:
         """
 
         # Create the scene.
-        commands = [{"$type": "load_scene",
-                     "scene_name": "ProcGenScene"},
-                    TDWUtils.create_empty_room(12, 12)]
+        commands = [TDWUtils.create_empty_room(12, 12)]
         # Add some objects.
         x = -2
         for i in range(5):
@@ -38,10 +36,9 @@ def run(self) -> None:
         self.parse_resp(resp=resp)
 
         # Treat some of the objects as occluders.
-        commands.append({"$type": "send_occlusion",
-                         "frequency": "once",
-                         "object_ids": [2, 4, 5]})
-        resp = self.communicate(commands)
+        resp = self.communicate({"$type": "send_occlusion",
+                                 "frequency": "once",
+                                 "object_ids": [2, 4, 5]})
         self.parse_resp(resp=resp)
 
         # Place a wall in front of all of the objects.
diff --git a/Python/tdw/add_ons/cinematic_camera.py b/Python/tdw/add_ons/cinematic_camera.py
index 0d5b1509a..5ef4e3d15 100644
--- a/Python/tdw/add_ons/cinematic_camera.py
+++ b/Python/tdw/add_ons/cinematic_camera.py
@@ -35,7 +35,6 @@ class CinematicCamera(ThirdPersonCameraBase):
     from tdw.add_ons.cinematic_camera import CinematicCamera
 
     c = Controller(launch_build=False)
-    c.start()
     cam = CinematicCamera(position={"x": 0, "y": 1.5, "z": 0},
                           rotation={"x": 2, "y": 45, "z": 0},
                           move_speed=0.1,
@@ -55,7 +54,6 @@ class CinematicCamera(ThirdPersonCameraBase):
     from tdw.add_ons.cinematic_camera import CinematicCamera
 
     c = Controller(launch_build=False)
-    c.start()
     cam = CinematicCamera(position={"x": 0, "y": 1.5, "z": 0},
                           rotation={"x": 2, "y": 45, "z": 0},
                           move_speed=0.1,
@@ -80,7 +78,6 @@ class CinematicCamera(ThirdPersonCameraBase):
 
     object_id = 0
     c = Controller(launch_build=False)
-    c.start()
     c.communicate([TDWUtils.create_empty_room(12, 12),
                    c.get_add_object(model_name="iron_box", object_id=object_id)])
     cam = CinematicCamera(position={"x": 4, "y": 1.5, "z": 0},
@@ -114,7 +111,6 @@ class CinematicCamera(ThirdPersonCameraBase):
 
     object_id = 0
     c = Controller(launch_build=False)
-    c.start()
     c.communicate([TDWUtils.create_empty_room(12, 12),
                    c.get_add_object(model_name="iron_box", object_id=object_id)])
     cam = CinematicCamera(position={"x": 4, "y": 1.5, "z": 0},
@@ -145,7 +141,6 @@ class CinematicCamera(ThirdPersonCameraBase):
 
     object_id = 0
     c = Controller(launch_build=False)
-    c.start()
     c.communicate([TDWUtils.create_empty_room(12, 12),
                    c.get_add_object(model_name="iron_box", object_id=object_id)])
     cam = CinematicCamera(position={"x": 4, "y": 1.5, "z": 0},
diff --git a/Python/tdw/add_ons/image_capture.py b/Python/tdw/add_ons/image_capture.py
index f060fd562..e687f7cc3 100644
--- a/Python/tdw/add_ons/image_capture.py
+++ b/Python/tdw/add_ons/image_capture.py
@@ -20,7 +20,6 @@ class ImageCapture(AddOn):
     from tdw.add_ons.image_capture import ImageCapture
 
     c = Controller(launch_build=False)
-    c.start()
 
     # Add a third-person camera. It will look at object 0.
     object_id = 0
diff --git a/Python/tdw/add_ons/object_manager.py b/Python/tdw/add_ons/object_manager.py
index beea557f3..a37240218 100644
--- a/Python/tdw/add_ons/object_manager.py
+++ b/Python/tdw/add_ons/object_manager.py
@@ -71,7 +71,6 @@ class ObjectManager(AddOn):
     # Create the object manager.
     om = ObjectManager()
     c.add_ons.append(om)
-    c.start()
     commands = [TDWUtils.create_empty_room(100, 100)]
     # The starting height of the objects.
     y = 10
diff --git a/Python/tdw/add_ons/occupancy_map.py b/Python/tdw/add_ons/occupancy_map.py
index 0972031ee..b5dc95908 100644
--- a/Python/tdw/add_ons/occupancy_map.py
+++ b/Python/tdw/add_ons/occupancy_map.py
@@ -17,7 +17,6 @@ class OccupancyMap(AddOn):
     from tdw.add_ons.occupancy_map import OccupancyMap
 
     c = Controller(launch_build=False)
-    c.start()
     o = OccupancyMap(cell_size=0.5)
     c.add_ons.append(o)
     c.communicate(TDWUtils.create_empty_room(12, 12))
@@ -219,7 +218,6 @@ def get_occupancy_position(self, i: int, j: int) -> Tuple[float, float]:
         from tdw.add_ons.occupancy_map import OccupancyMap
 
         c = Controller(launch_build=False)
-        c.start()
         o = OccupancyMap(cell_size=0.5)
         c.add_ons.append(o)
         c.communicate(TDWUtils.create_empty_room(12, 12))
diff --git a/Python/tdw/add_ons/third_person_camera.py b/Python/tdw/add_ons/third_person_camera.py
index 579274e72..5a271aa74 100644
--- a/Python/tdw/add_ons/third_person_camera.py
+++ b/Python/tdw/add_ons/third_person_camera.py
@@ -12,7 +12,6 @@ class ThirdPersonCamera(ThirdPersonCameraBase):
     from tdw.add_ons.third_person_camera import ThirdPersonCamera
 
     c = Controller(launch_build=False)
-    c.start()
     cam = ThirdPersonCamera(avatar_id="c",
                             position={"x": 1, "y": 2.2, "z": -0.5},
                             rotation={"x": 0, "y": -45, "z": 0})
@@ -29,7 +28,6 @@ class ThirdPersonCamera(ThirdPersonCameraBase):
     from tdw.add_ons.image_capture import ImageCapture
 
     c = Controller(launch_build=False)
-    c.start()
     cam = ThirdPersonCamera(avatar_id="c",
                             position={"x": 1, "y": 2.2, "z": -0.5},
                             rotation={"x": 0, "y": -45, "z": 0})
@@ -56,7 +54,6 @@ class ThirdPersonCamera(ThirdPersonCameraBase):
     from tdw.add_ons.image_capture import ImageCapture
 
     c = Controller(launch_build=False)
-    c.start()
 
     # Add two cameras.
     cam_0 = ThirdPersonCamera(avatar_id="c0",
diff --git a/Python/tdw/controller.py b/Python/tdw/controller.py
index 10a30ecc3..0af601140 100644
--- a/Python/tdw/controller.py
+++ b/Python/tdw/controller.py
@@ -23,7 +23,6 @@ class Controller(object):
     ```python
     from tdw.controller import Controller
     c = Controller()
-    c.start()
     ```
     """
 
@@ -209,6 +208,8 @@ def get_add_physics_object(model_name: str, object_id: int, position: Dict[str,
             library = "models_core.json"
         if library not in Controller.MODEL_LIBRARIANS:
             Controller.MODEL_LIBRARIANS[library] = ModelLibrarian(library)
+        if position is None:
+            position = {"x": 0, "y": 0, "z": 0}
         record = Controller.MODEL_LIBRARIANS[library].get_record(model_name)
         commands = [{"$type": "add_object",
                      "name": record.name,
diff --git a/Python/tdw/py_impact.py b/Python/tdw/py_impact.py
index 61bdeca3b..f00de3f54 100644
--- a/Python/tdw/py_impact.py
+++ b/Python/tdw/py_impact.py
@@ -255,7 +255,6 @@ class CollisionTypesOnFrame:
 
     object_id = c.get_unique_id()
     c = Controller()
-    c.start()
 
     # Your code here.
 
diff --git a/Python/tdw/robot_creator.py b/Python/tdw/robot_creator.py
index db1aa18f4..816fa0ab7 100644
--- a/Python/tdw/robot_creator.py
+++ b/Python/tdw/robot_creator.py
@@ -16,45 +16,6 @@ class RobotCreator(AssetBundleCreatorBase):
     """
     Download a .urdf or .xacro file and convert it into an asset bundle that is usable by TDW.
 
-    # Requirements
-
-    - Windows 10, OS X, or Linux
-      - On a remote Linux server, you'll need a valid virtual display (see the `display` parameter of the constructor)
-    - Unity Editor 2020.2 (must be installed via Unity Hub)
-    - Python3 and the `tdw` module
-    - git
-
-    ### ROS and .xacro file requirements
-
-    If you want to use a .xacro file, `RobotCreator` can convert it to a usable .urdf file, provided that you first install ROS. If you already have a .urdf file, you don't need to install ROS.
-
-    [This is the source of the installation instructions listed below.](http://wiki.ros.org/Installation/Ubuntu)
-
-    On Linux:
-
-    - `sudo apt-key adv --keyserver 'hkp://keyserver.ubuntu.com:80' --recv-key C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654`
-    - `curl -sSL 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0xC1CF6E31E6BADE8868B172B4F42ED6FBAB17C654' | sudo apt-key add -`
-    - `sudo ros-melodic-ros-base`
-    - `sudo apt-get install rosbash`
-    - `sudo apt-get install ros-melodic-xacro`
-
-    On Windows:
-
-    - Install Ubuntu 18 on WSL 2
-    - `wsl sudo apt-key adv --keyserver 'hkp://keyserver.ubuntu.com:80' --recv-key C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654`
-    - `wsl curl -sSL 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0xC1CF6E31E6BADE8868B172B4F42ED6FBAB17C654' | sudo apt-key add -`
-    - `wsl sudo ros-melodic-ros-base`
-    - `wsl sudo apt-get install rosbash`
-    - `wsl sudo apt-get install ros-melodic-xacro`
-
-    On OS X:
-
-    ROS isn't well-supported on OS X. You can try following installation instructions [here](http://wiki.ros.org/Installation/).
-
-    # Usage
-
-    To create an asset bundle of the UR5 robot:
-
     ```python
     from tdw.robot_creator import RobotCreator
 
@@ -80,121 +41,6 @@ class RobotCreator(AssetBundleCreatorBase):
     print(record.urls)
     ```
 
-    The first time that this script is run, it clone [the robot_creator repo](https://github.com/alters-mit/robot_creator) (a Unity project used for creating robots) to your home directory.
-
-    ### Edit the prefab
-
-    `RobotCreator.create_asset_bundles()` creates a .prefab file in the `robot_creator` Unity project. This is an intermediate file that is required for building the asset bundle.
-
-    It's usually worth testing and editing the prefab before finalizing the asset bundle. To do this, first run `RobotCreator` as described above, and then do the following:
-
-    1. Create a prefab of the robot.
-    2. Open robot_creator Unity project in Unity 2020.2; the project is located at `~/robot_creator` (where `~` is your home directory).
-    3. In the Unity Editor project window, double-click `Scenes -> SampleScene`
-    4. In the Unity Editor project window, search for the name of the robot. Click the file and drag it into the scene view.
-    5. Press play.
-
-    Common problems and solutions during prefab creation:
-
-    | Problem | Solution |
-    | --- | --- |
-    | Prefab creation seems to hang. | This is because sometimes the physics hull collider meshes are very complicated and require more time to generate. Let the process run. |
-    | Got an error during prefab creation: `Root object of the robot doesn't have an ArticulationBody.` | Open the project, double-click the prefab, and add an ArticulationBody to the root object. Adjust the parenting hierarchy of the robot such that the ArticulationBodies beneath the root are direct children. This is bad: `root -> non-articulation -> articulation` and this is good: `root -> articulation` |
-
-    Common problems and solutions while testing a prefab in the Unity Editor project:
-
-    | Problem | Solution |
-    | --- | --- |
-    | Arms are flailing. | Usually this is because the colliders are parented to the wrong object. Double-click the prefab and make sure each `Collisions` object is parented to the matching ArticulationBody object. |
-    | Robot falls apart and there are `AABB` errors | You have too many ArticulationBodies. Unity supports a maximum of 65 (1 parent, 64 children). Double-click the prefab and delete any redundant ArticulationBodies. |
-    | The base of the robot is below (0, 0, 0) | Double-click the prefab and adjust the y position of the child objects. |
-    | Joints snap to a weird angle. | Usually this is because there are overlapping physics colliders. Double-click the prefab and in the Hierarchy panel click the root object. The green wireframe meshes in the Scene View are the physics colliders. Try deleting or disabling colliders near the glitching joint. |
-    | The robot tips over. | Set `immovable=True` in `create_asset_bundles()`. If that doesn't work, double-click the prefab. In the Hierarchy panel, click the ArticulationBody that you think is causing the robot to tilt. In the Inspector panel, click "Add Component". Add: `Center Of Mass`. Adjust the center of mass in the Inspector until the robot stops tipping. |
-
-    ### Create an asset bundle from a prefab
-
-    Once the robot prefab is working correctly, you can create asset bundles without overwriting the prefab:
-
-    ```python
-    from tdw.robot_creator import RobotCreator
-
-    r = RobotCreator()
-    urdf_url = "https://github.com/ros-industrial/robot_movement_interface/blob/master/dependencies/ur_description/urdf/ur5_robot.urdf"
-    record = r.create_asset_bundles(urdf_url=urdf_url)
-    print(record.name)
-    print(record.urls)
-
-    asset_bundles = rc.prefab_to_asset_bundles(name=name)
-    record = RobotCreator.get_record(name=record.name, urdf_url=urdf_url, immovable=True, asset_bundles=asset_bundles)
-    ```
-
-    ### Store metadata
-
-    `RobotCreator.create_asset_bundles()` returns a `RobotRecord` metadata object, which contains the files paths to the asset bundle.
-    You can store a `RobotRecord` object in a `RobotLibrarian`, which is saved as a JSON file.
-
-    To create a `RobotLibrarian`:
-
-    ```python
-    from tdw.librarian import RobotLibrarian
-
-    RobotLibrarian.create_library(path="my_robot_librarian.json", description="Custom Robot Librarian")
-    ```
-
-    To create asset bundles and save the `RobotRecord` metadata:
-
-    ```python
-    from tdw.robot_creator import RobotCreator
-    from tdw.librarian import RobotLibrarian
-
-    r = RobotCreator()
-
-    # Create the asset bundles and generate a metadata record.
-    record = r.create_asset_bundles(urdf_url="https://github.com/ros-industrial/robot_movement_interface/blob/master/dependencies/ur_description/urdf/ur5_robot.urdf")
-
-    # Add the record to the local library.
-    lib = RobotLibrarian("my_robot_librarian.json")
-    lib.add_or_update_record(record=record, overwrite=False, write=True)
-    ```
-
-    ### Load the robot into TDW
-
-    To add the robot to a TDW scene:
-
-    ```python
-    from pathlib import Path
-    from tdw.controller import Controller
-    from tdw.tdw_utils import TDWUtils
-    from tdw.librarian import RobotLibrarian
-    from tdw.robot_creator import RobotCreator
-
-    lib_path = "my_robot_librarian.json"
-    # Create your robot library if it doesn't already exist.
-    if not Path(lib_path).exists():
-        RobotLibrarian.create_library(path=lib_path, description="Custom Robot Librarian")
-    # Load your robot library.
-    lib = RobotLibrarian(lib_path)
-
-    robot_name = "ur5"
-    # If there isn't a metadata record yet for this robot, create asset bundles and add the record.
-    if lib.get_record(robot_name) is None:
-        r = RobotCreator()
-        record = r.create_asset_bundles(urdf_url="https://github.com/ros-industrial/robot_movement_interface/blob/master/dependencies/ur_description/urdf/ur5_robot.urdf")
-        # Add the record if it doesn't already exist.
-        lib.add_or_update_record(record=record, overwrite=False, write=True)
-
-    # Launch the controller.
-    c = Controller(launch_build=False)
-    c.start()
-    robot_id = 0
-    commands = [TDWUtils.create_empty_room(12, 12),
-                c.get_add_robot(name=robot_name, robot_id=robot_id, library=lib_path)]
-    commands.extend(TDWUtils.create_avatar(look_at=TDWUtils.VECTOR3_ZERO, position={"x": -0.881, "y": 0.836, "z": -1.396}))
-    c.communicate(commands)
-    ```
-
-    For a more complete example of how to use the robot in TDW (i.e. find all of its joints and set target angles or positions), see: `twd/Python/example_controllers/robot_arm.py` Note that in this example, there isn't any code to build the asset bundles or update the metadata. This is because UR5 is already in the default TDW robot library (on our remote S3 server) and the metadata record is already stored in the default metadata librarian.
-
     ***
 
     # Functions
diff --git a/Python/tdw/scene_data/scene_bounds.py b/Python/tdw/scene_data/scene_bounds.py
index 5cc09f0c3..40b22c53c 100644
--- a/Python/tdw/scene_data/scene_bounds.py
+++ b/Python/tdw/scene_data/scene_bounds.py
@@ -15,7 +15,6 @@ class SceneBounds:
     from tdw.scene_data.scene_bounds import SceneBounds
 
     c = Controller()
-    c.start()
     resp = c.communicate([TDWUtils.create_empty_room(12, 12),
                           {"$type": "send_scene_regions"}])
     scene_bounds = SceneBounds(resp=resp)
diff --git a/README.md b/README.md
index ea9a23e62..1946a63bf 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,203 @@
 # ThreeDWorld (TDW)
-### A Platform for Interactive Multi-Modal Physical Simulation
 
 **ThreeDWorld (TDW)** is a platform for interactive multi-modal physical simulation. With TDW, users can simulate high-fidelity sensory data and physical interactions between mobile agents and objects in a wide variety of rich 3D environments.
 
-### [Getting Started](https://github.com/threedworld-mit/tdw/blob/master/Documentation/getting_started.md) (Read this first!)
+TDW is a general-purpose tool that allows the user to communicate and manipulate a 3D environment. As such, there's no single "correct" procedure for using TDW. This guide will show you how to start using TDW and how to explore the available options.
 
-### [Code of Conduct](https://github.com/threedworld-mit/tdw/blob/master/code_of_conduct.md)
+- [Code of Conduct](https://github.com/threedworld-mit/tdw/blob/master/code_of_conduct.md)
+- [Changelog](https://github.com/threedworld-mit/tdw/blob/master/Documentation/Changelog.md)
+- [License](https://github.com/threedworld-mit/tdw/blob/master/LICENSE.txt)
+- [Website](http://threedworld.org/)
 
-### [Changelog](https://github.com/threedworld-mit/tdw/blob/master/Documentation/Changelog.md)
+# 1. General guide to TDW
 
-### [Website](http://threedworld.org/)
+## 1.1 Setup
 
-### [C# Code](https://github.com/threedworld-mit/tdw/blob/master/Documentation/contributions/c_sharp_sources.md)
+1. [Install TDW](Documentation/lessons/setup/install.md)
+3. [Upgrade TDW](Documentation/lessons/setup/upgrade.md)
+
+## 1.2 Core Concepts
+
+1. [The controller](Documentation/lessons/core_concepts/controller.md)
+2. [Auto-launching the TDW build](Documentation/lessons/core_concepts/launch_build.md)
+3. [Commands](Documentation/lessons/core_concepts/commands.md)
+4. [Design philosophy of TDW](Documentation/lessons/core_concepts/design_philosophy.md)
+5. [Scenes](Documentation/lessons/core_concepts/scenes.md)
+6. [Avatars and cameras](Documentation/lessons/core_concepts/avatars.md)
+7. [Add-ons and the `ThirdPersonCamera`](Documentation/lessons/core_concepts/add_ons.md)
+8. [Objects](Documentation/lessons/core_concepts/objects.md)
+9. [Output data](Documentation/lessons/core_concepts/output_data.md)
+10. [Images](Documentation/lessons/core_concepts/images.md)
+
+## 1.3 Troubleshooting and good coding practices :warning: TODO :warning:
+
+1. Debug logging
+2. Common errors
+3. Good coding practices
+4. Performance optimizations
+5. Performance benchmarks
+
+# 2. Tutorials :warning: TODO :warning:
+
+## 2.1 Objects and Scenes
+
+1. [Overview](Documentation/lessons/objects_and_scenes/overview.md)
+2. [Scripted object placement (floorplan layouts)](Documentation/lessons/objects_and_scenes/floorplans.md)
+3. [Visual materials, textures, and colors](Documentation/lessons/objects_and_scenes/materials_textures_colors.md)
+4. [Procedural generation (scenes)](Documentation/lessons/objects_and_scenes/proc_gen_room.md)
+5. [Units and data formats](Documentation/lessons/objects_and_scenes/units.md)
+6. [`Bounds` output data](Documentation/lessons/objects_and_scenes/bounds.md)
+7. [Procedural generation (objects)](Documentation/lessons/objects_and_scenes/proc_gen_objects.md)
+8. [`Raycast` output data](Documentation/lessons/objects_and_scenes/raycast.md)
+9. [`Overlap` output data](Documentation/lessons/objects_and_scenes/overlap.md)
+10. [Reset a scene](Documentation/lessons/objects_and_scenes/reset_scene.md)
+
+High-level API: [Floorplan](Documentation/python/floorplan.md)
+
+## 2.2 3D Model Libraries
+
+1. [Overview](Documentation/lessons/3d_models/overview.md)
+2. [Free models](Documentation/lessons/3d_models/free_models.md)
+3. [Non-free models](Documentation/lessons/3d_models/non_free_models.md)
+4. [Add your own models to TDW](Documentation/lessons/3d_models/custom_models.md)
+5. [Add ShapeNet models to TDW](Documentation/lessons/3d_models/shapenet.md)
+
+## 2.3 Visual Perception
+
+1. [Overview](Documentation/lessons/visual_perception/overview.md)
+2. [Instance ID segmentation colors (`_id` pass)](Documentation/lessons/visual_perception/id.md)
+3. [Semantic category segmentation colors (`_category` pass)](Documentation/lessons/visual_perception/category.md)
+4. [Depth maps (`_depth` and `_depth_simple` passes)](Documentation/lessons/visual_perception/depth.md)
+5. [Motion perception (`_flow` pass)](Documentation/lessons/visual_perception/flow.md)
+6. [Other image passes (`_mask`, `_normals`, and `_albedo` passes)](Documentation/lessons/visual_perception/other_passes.md)
+7. [`Occlusion` output data](Documentation/lessons/visual_perception/occlusion.md)
+
+## 2.4 Camera Controls
+
+1. [Overview](Documentation/lessons/camera/overview.md)
+2. [Move a camera](Documentation/lessons/camera/position.md)
+3. [Rotate a camera](Documentation/lessons/camera/rotation.md)
+4. [Follow an object](Documentation/lessons/camera/follow.md)
+
+## 2.5 Photorealistic Images
+
+1. Overview
+2. Lighting
+3. Post-processing
+4. Depth of field
+5. Anti-aliasing
+
+High-level API: [tdw_image_dataset](https://github.com/alters-mit/tdw_image_dataset)
+
+## 2.6 Physics
+
+1. [Overview](Documentation/lessons/physx/overview.md)
+
+### 2.6.1 Physics (PhysX)
+
+1. [PhysX](Documentation/lessons/physx/physx.md)
+2. [Object physics parameters](Documentation/lessons/physx/physics_objects.md)
+3. [`Rigidbodies` output data](Documentation/lessons/physx/rigidbodies.md)
+4. [`Collision` output data](Documentation/lessons/physx/collisions.md)
+5. [Apply forces to objects](Documentation/lessons/physx/forces.md)
+6. [Composite objects (objects with affordances)](Documentation/lessons/physx/composite_objects.md)
+7. [Skip physics frames](Documentation/lessons/physx/step_physics.md)
+8. [Disable physics](Documentation/lessons/physx/disable_physics.md)
+
+High-level API: [tdw_physics](https://github.com/alters-mit/tdw_physics)
+
+### 2.6.2 Physics (Flex)
+
+1. Flex
+2. Solid actors
+3. Soft actors
+4. Cloth actors
+5. Fluid actors
+6. Source actors
+7. `FlexParticles` output data
+8. Apply forces to Flex objects
+
+High-level API: [tdw_physics](https://github.com/alters-mit/tdw_physics)
 
-### [License](https://github.com/threedworld-mit/tdw/blob/master/LICENSE.txt)
+## 2.8 Audio
 
-### [How to upgrade from TDW v1.7 to v1.8](https://github.com/threedworld-mit/tdw/blob/master/Documentation/upgrade_guides/v1.7_to_v1.8.md)
+1. Overview
+2. PyImpact (dynamic impact sounds)
+3. Audio perception
+4. Recording audio
+5. Resonance Audio
+
+High-level API: [tdw_physics](https://github.com/alters-mit/multimodal_challenge)
+
+## 2.9 Video Recording
+
+1. Controlling the framerate
+2. Image-only videos
+3. Image+audio videos
+4. The `CinematicCamera` add-on
+
+## 2.10 Agents
+
+[**Overview**](Documentation/lessons/agents/overview.md)
+
+### 2.10.1 Robots
+
+1. [Overview](Documentation/lessons/robots/overview.md)
+2. [The `Robot` add-on](Documentation/lessons/robots/robot_add_on.md)
+3. [Robot collision detection](Documentation/lessons/robots/collision_detection.md)
+4. [Select a robot](Documentation/lessons/robots/select_robot.md)
+5. [Add your own robots to TDW](Documentation/lessons/robots/custom_robots.md)
+6. [Robotics API (low-level)](Documentation/lessons/robots/custom_robots.md)
+7. [Add a camera to a robot](Documentation/lessons/robots/add_camera.md)
+
+### 2.10.2 Magnebots
+
+[`magnebot` (external repo)](https://github.com/alters-mit/magnebot)
+
+### 2.10.3 Human user interaction
+
+1. Overview
+2. Virtual reality
+3. Keyboard controls
+
+### 2.13 Humanoid agents ("Replicants")
+
+1. Overview
+3. ???
+
+### 2.10.4 Embodied avatars
+
+1. The `EmbodiedAvatar`
+
+### 2.10.4 Navigation
+
+1. Overview
+2. NavMesh pathfinding
+3. Occupancy maps
+
+### 2.10.5 Multi-agent simulations
+
+1. Overview
+2. State machines
+3. World state objects
+
+## 2.14 Misc. topics
+
+1. C# source code
+2. Freezing your code
+3. BinaryManager
+4. xpra
+5. Creating composite objects
+6. Paintings
+7. Non-physics humanoids
+
+# 3. API Documentation
+
+TODO
+
+## :warning: old documentation :warning:
+
+### [C# Code](https://github.com/threedworld-mit/tdw/blob/master/Documentation/contributions/c_sharp_sources.md)
 
 # API
 


Test patch:
diff --git a/Python/example_controllers/composite_objects/Darwin/test_composite_object b/Python/example_controllers/physx/composite_objects/Darwin/test_composite_object
similarity index 100%
rename from Python/example_controllers/composite_objects/Darwin/test_composite_object
rename to Python/example_controllers/physx/composite_objects/Darwin/test_composite_object
diff --git a/Python/example_controllers/composite_objects/Linux/test_composite_object b/Python/example_controllers/physx/composite_objects/Linux/test_composite_object
similarity index 100%
rename from Python/example_controllers/composite_objects/Linux/test_composite_object
rename to Python/example_controllers/physx/composite_objects/Linux/test_composite_object
diff --git a/Python/example_controllers/composite_objects/Windows/test_composite_object b/Python/example_controllers/physx/composite_objects/Windows/test_composite_object
similarity index 100%
rename from Python/example_controllers/composite_objects/Windows/test_composite_object
rename to Python/example_controllers/physx/composite_objects/Windows/test_composite_object