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

Issue:
Verifier add on
### Build

 - Fixed: Warnings when repeatedly sending `send_model_report` without first unloading the scene.

### `tdw` module

 - (Backend) Added `ModelVerifier` add-on plus the following `ModelTest` classes:
  - `ModelReport`
  - `PhysicsQuality`
  - `MissingMaterials`

#### TDWUtils

 - Fixed: Unhandled ZeroDivisionError  in `get_unit_scale(record)` if bounds are all 0 (if so, returns 1).

#### `paths` (backend)

- Added: `EXAMPLE_CONTROLLER_OUTPUT_PATH`
- Removed: `VALIDATOR_REPORT_PATH`

#### Model Pipeline (backend)

- Removed: `model_pipeline/missing_materials.py`, `model_pipeline/validator.py`, `model_pipeline/write_physics_quality.py` (replaced with the `ModelVerifier` add-on)
  - Improved the accuracy of the physics quality test.


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 095d3dcd7..99bb36c2e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,7 @@ __pycache__/
 Python/dist/
 Python/tdw/tdw.egg-info/
 Python/build/
-Python/example_controllers/point_cloud.txt
\ No newline at end of file
+Python/example_controllers/point_cloud.txt
+Python/tdw/binaries/Windows/vhacd/log.txt
+Python/tdw/binaries/Darwin/vhacd/log.txt
+Python/tdw/binaries/Linux/vhacd/log.txt
\ No newline at end of file
diff --git a/Documentation/Changelog.md b/Documentation/Changelog.md
index 99c9e9a0b..551396d4d 100644
--- a/Documentation/Changelog.md
+++ b/Documentation/Changelog.md
@@ -54,6 +54,7 @@ To upgrade from TDW v1.8 to v1.9, read [this guide](Documentation/upgrade_guides
 - Adjusted avatar type `A_Simple_Body`:
   - Fixed: Avatar bodies are centered on the avatar's pivot as opposed to halfway above it (i.e. making the pivot of the avatar the bottom-center), thus causing the avatar to "pop" out of the ground when it is first created.
   - Fixed: The cube avatar requires much more torque to turn. Its box collider has been replaced with a cube collider.
+- Fixed: Warnings when repeatedly sending `send_model_report` without first unloading the scene.
 
 ### `tdw` module
 
@@ -90,6 +91,11 @@ To upgrade from TDW v1.8 to v1.9, read [this guide](Documentation/upgrade_guides
 - Removed `FloorplanController` (replaced with `Floorplan` add-on)
 - Moved `CollisionObjObj` and `CollisionObjEnv` from `tdw.collision` to `tdw.collision_data`
   - Removed `collisons.py`
+- Made more objects in the floorplan layouts kinematic.
+- (Backend) Added `ModelVerifier` add-on plus the following `ModelTest` classes:
+  - `ModelReport`
+  - `PhysicsQuality`
+  - `MissingMaterials`
 
 #### `Controller`
 
@@ -103,6 +109,10 @@ To upgrade from TDW v1.8 to v1.9, read [this guide](Documentation/upgrade_guides
 - Removed all cached librarian fields (`self.model_librarian`, `self.scene_librarian`, etc.) and replaced them with class variable dictionaries that automatically cache librarians (`Controller.MODEL_LIBRARIANS`, `Controller.SCENE_LIBRARIANS`, etc.) This allows multiple librarian objects to be cached at the same time and allows other classes to access them.
 - All asset bundle wrapper functions (`get_add_object()`, `get_add_scene()`, etc.) are now static.
 
+#### `TDWUtils`
+
+- Fixed: Unhandled ZeroDivisionError  in `get_unit_scale(record)` if bounds are all 0 (if so, returns 1).
+
 #### `PyImpact`
 
 - Added: `STATIC_FRICTION` and `DYNAMIC_FRICTION`. Dictionaries of friction coefficients per audio material.
@@ -110,6 +120,12 @@ To upgrade from TDW v1.8 to v1.9, read [this guide](Documentation/upgrade_guides
 #### `paths` (backend)
 
 - Added: `EXAMPLE_CONTROLLER_OUTPUT_PATH`
+- Removed: `VALIDATOR_REPORT_PATH`
+
+#### Model Pipeline (backend)
+
+- Removed: `model_pipeline/missing_materials.py`, `model_pipeline/validator.py`, `model_pipeline/write_physics_quality.py` (replaced with the `ModelVerifier` add-on)
+  - Improved the accuracy of the physics quality test.
 
 #### `SceneBounds` and `RoomBounds`
 
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/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..4b9b20955
--- /dev/null
+++ b/Documentation/lessons/physx/rigidbodies.md
@@ -0,0 +1,107 @@
+##### Physics (PhysX)
+
+# `Rigidbodies` output data
+
+Send the command [`send_rigidbodies`](../../api/command_api.md#send_rigidbodies) to receive [`Rigidbodies` output data](../../api/output_data.md#Rigidbodies):
+
+```python
+from tdw.controller import Controller
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, Rigidbodies
+
+c = Controller()
+c.communicate(TDWUtils.create_empty_room(12, 12))
+
+object_id = c.get_unique_id()
+commands = c.get_add_physics_object(model_name="iron_box",
+                                    object_id=object_id,
+                                    position={"x": 0, "y": 7, "z": 0})
+commands.append({"$type": "send_rigidbodies",
+                 "frequency": "once"})
+resp = c.communicate(commands)
+
+for i in range(len(resp) - 1):
+    r_id = OutputData.get_data_type_id(resp[i])
+    if r_id == "rigi":
+        rigidbodies = Rigidbodies(resp[i])
+        for j in range(rigidbodies.get_num()):
+            if rigidbodies.get_id(j) == object_id:
+                print(object_id)
+                print(rigidbodies.get_mass(j))
+                print(rigidbodies.get_kinematic(j))
+                print(rigidbodies.get_sleeping(j))
+                print(rigidbodies.get_velocity(j))
+                print(rigidbodies.get_angular_velocity(j))
+c.communicate({"$type": "terminate"})
+```
+
+The `send_rigidbodies` command and `Rigidbodies` output data are very similar to [`send_transforms` and `Transforms`](../core_concepts/output_data.md) and [`send_bounds` and `Bounds`](../objects_and_scenes/bounds.md):
+
+- All three commands have a `"frequency"` parameter that can be set to `"once"`, `"always"`, or `"never"`.
+- All three output data types are structured around indexed lists that must be iterated through like `for j in range(rigidbodies.get_num()):`
+
+`Rigidbodies` output data contains some data that is static:
+
+- Mass
+
+- Kinematic state ([whether the object responds to physics](physics_objects.md))
+
+`Rigidbodies` output data also contains data that is dynamic (changes per frame):
+
+- 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".
+
+## 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.
+
+```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}))
+# 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)
+
+Output Data API:
+
+- [`Rigidbodies`](../../api/output_data.md#Rigidbodies)
+
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/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/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/python/add_ons/add_on.md b/Documentation/python/add_ons/add_on.md
index dd422a185..701e2c26d 100644
--- a/Documentation/python/add_ons/add_on.md
+++ b/Documentation/python/add_ons/add_on.md
@@ -43,6 +43,10 @@ c.communicate({"$type": "terminate"})
 - [StepPhysics](step_physics.md)
 - [ThirdPersonCamera](third_person_camera.md)
 
+Backend:
+
+- [ModelVerifier](model_verifier.md)
+
 ## Example controllers
 
 - `tdw/Python/example_controllers/add_ons.py` How to add multiple add-ons to the controller.
diff --git a/Documentation/python/add_ons/cinematic_camera.md b/Documentation/python/add_ons/cinematic_camera.md
index a8cd1b23b..6c50481a8 100644
--- a/Documentation/python/add_ons/cinematic_camera.md
+++ b/Documentation/python/add_ons/cinematic_camera.md
@@ -204,9 +204,9 @@ c.communicate({"$type": "terminate"})
 
 #### \_\_init\_\_
 
-**`CinematicCamera()`**
+**`CinematicCamera(look_at)`**
 
-**`CinematicCamera(avatar_id=None, position=None, rotation=None, fov=None, framerate=None, move_speed=0.1, rotate_speed=3, focus_speed=0.3)`**
+**`CinematicCamera(avatar_id=None, position=None, rotation=None, fov=None, framerate=None, move_speed=0.1, rotate_speed=3, focus_speed=0.3, look_at)`**
 
 | Parameter | Type | Default | Description |
 | --- | --- | --- | --- |
@@ -218,6 +218,7 @@ c.communicate({"$type": "terminate"})
 | move_speed |  float  | 0.1 | The directional speed of the camera. This can later be adjusted by setting `self.move_speed`. |
 | rotate_speed |  float  | 3 | The angular speed of the camera. This can later be adjusted by setting `self.rotate_speed`. |
 | focus_speed |  float  | 0.3 | The speed of the focus of the camera. This can later be adjusted by setting `self.focus_speed`. |
+| look_at |  Union[int, Dict[str, float] |  | If not None, the cinematic camera will look at this object (if int) or position (if dictionary). |
 
 #### on_send
 
@@ -275,6 +276,16 @@ Start to rotate towards an object (to look at the object).
 | --- | --- | --- | --- |
 | target |  int |  | The ID of the target object. |
 
+#### rotate_to_position
+
+**`self.rotate_to_position(target)`**
+
+Start rotating the camera by the `[roll, pitch, yaw]` angles expressed as an `[x, y, z]` dictionary.
+
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| target |  Dict[str, float] |  | The target `[roll, pitch, yaw]` angles from when this function was first called, in degrees. |
+
 #### rotate_by_rpy
 
 **`self.rotate_by_rpy(target)`**
diff --git a/Documentation/python/add_ons/model_verifier.md b/Documentation/python/add_ons/model_verifier.md
new file mode 100644
index 000000000..f9d9cc381
--- /dev/null
+++ b/Documentation/python/add_ons/model_verifier.md
@@ -0,0 +1,77 @@
+# ModelVerifier
+
+`from tdw.add_ons.model_verifier import ModelVerifier`
+
+Run tests on an object model.
+
+***
+
+## Fields
+
+- `commands` These commands will be appended to the commands of the next `communicate()` call.
+
+- `initialized` If True, this module has been initialized.
+
+- `reports` A list of reports from the test.
+
+- `done` If True, the tests are done.
+
+***
+
+## Functions
+
+#### \_\_init\_\_
+
+**`ModelVerifier()`**
+
+A list of reports from the test.
+
+#### get_initialization_commands
+
+**`self.get_initialization_commands()`**
+
+This function gets called exactly once per add-on. To re-initialize, set `self.initialized = False`.
+
+_Returns:_  A list of commands that will initialize this add-on.
+
+#### set_tests
+
+**`self.set_tests(name, model_report, missing_materials, physics_quality)`**
+
+**`self.set_tests(name, source=None, model_report, missing_materials, physics_quality)`**
+
+Start new tests for the model. Only call this if there isn't currently a test running.
+
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| name |  str |  | The name of the model. |
+| source |  Union[ModelLibrarian, ModelRecord, str] | None | The source of the model. If None: Get the record corresponding to `name` in `models_core.json`. If `ModelLibrarian`: Get the record corresponding to `name` in this library. If `ModelRecord`: Use this record. If `str`: Create a dummy record with name `name` and URL `source`. |
+| model_report |  bool |  | If True, run a basic test on the model. |
+| missing_materials |  bool |  | If True, test the model for any missing materials. |
+| physics_quality |  bool |  | If True, test the extent to which the colliders geometry matches the rendered geometry. |
+
+#### on_send
+
+**`self.on_send(resp)`**
+
+This is called after commands are sent to the build and a response is received.
+
+Use this function to send commands to the build on the next frame, given the `resp` response.
+Any commands in the `self.commands` list will be sent on the next frame.
+
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| resp |  List[bytes] |  | The response from the build. |
+
+#### before_send
+
+**`self.before_send(commands)`**
+
+This is called before sending commands to the build. By default, this function doesn't do anything.
+
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| commands |  List[dict] |  | The commands that are about to be sent to the build. |
+
+
+
diff --git a/Documentation/python/add_ons/robot.md b/Documentation/python/add_ons/robot.md
index 5e045b884..dd5562d9e 100644
--- a/Documentation/python/add_ons/robot.md
+++ b/Documentation/python/add_ons/robot.md
@@ -36,7 +36,6 @@ c.communicate({"$type": "terminate"})
 | Variable | Type | Description |
 | --- | --- | --- |
 | `NON_MOVING` | float | If a joint has moved less than this many degrees (revolute or spherical) or meters (prismatic) since the previous frame, it is considered to be not moving for the purposes of determining which joints are moving. |
-| `ROBOT_LIBRARIAN` | RobotLibrarian | TDW's built-in [`RobotLibrarian`](../librarian/robot_librarian.md). |
 
 ***
 
diff --git a/Documentation/python/floorplan_controller.md b/Documentation/python/floorplan_controller.md
deleted file mode 100644
index f78ece912..000000000
--- a/Documentation/python/floorplan_controller.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# `floorplan_controller.py`
-
-## `FloorplanController(Controller)`
-
-`from tdw.floorplan_controller import FloorplanController`
-
-A controller that can create an interior scene populated with objects.
-
-```python
-from tdw.floorplan_controller import FloorplanController
-
-c = FloorplanController()
-init_commands = c.get_scene_init_commands(scene="2a", layout=0, audio=True)
-c.communicate(init_commands)
-```
-
-***
-
-#### `get_scene_init_commands(self, scene: str, layout: int) -> List[dict]`
-
-Get commands to create a scene and populate it with objects.
-Valid scenes and layouts:
-| `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 |
-
-| Parameter | Description |
-| --- | --- |
-| scene | The name of the scene. Corresponds to a record named: `floorplan_[scene]`. |
-| layout | The layout index. |
-
-_Returns:_  A list of commands to initialize the scene and populate it with objects.
-
-***
-
diff --git a/Documentation/python/object_init_data.md b/Documentation/python/object_init_data.md
deleted file mode 100644
index b6ada8870..000000000
--- a/Documentation/python/object_init_data.md
+++ /dev/null
@@ -1,95 +0,0 @@
-# `object_init_data.py`
-
-## `TransformInitData`
-
-`from tdw.object_init_data import TransformInitData`
-
-Basic initialization parameters for an object. Can be converted to and from a list of commands.
-
-This is similar to [`Controller.get_add_object()`](controller.md) except that it includes more parameters.
-
-***
-
-#### `__init__(self, name: str, library: str = "models_core.json", scale_factor: Dict[str, float] = None, position: Dict[str, float] = None, rotation: Dict[str, float] = None, kinematic: bool = False, gravity: bool = True)`
-
-
-| Parameter | Description |
-| --- | --- |
-| name | The name of the model. |
-| library | The filename of the library containing the model's record. |
-| scale_factor | The [scale factor](../api/command_api.md#scale_object). |
-| position | The initial position. If None, defaults to: `{"x": 0, "y": 0, "z": 0`}. |
-| rotation | The initial rotation as Euler angles or a quaternion. If None, defaults to: `{"w": 1, "x": 0, "y": 0, "z": 0}` |
-| kinematic | If True, the object will be [kinematic](../api/command_api.md#set_kinematic_state). |
-| gravity | If True, the object won't respond to [gravity](../api/command_api.md#set_kinematic_state). |
-
-***
-
-#### `get_commands(self) -> Tuple[int, List[dict]]`
-
-_Returns:_  Tuple: The ID of the object; a list of commands to create the object: `[add_object, rotate_object_to, scale_object, set_kinematic_state, set_object_collision_detection_mode]`
-
-***
-
-## `RigidbodyInitData(TransformInitData)`
-
-`from tdw.object_init_data import RigidbodyInitData`
-
-A subclass of `TransformInitData`. Includes data and commands to set the mass and physic material of the object.
-
-***
-
-#### `__init__(self, name: str, mass: float, dynamic_friction: float, static_friction: float, bounciness: float, library: str = "models_core.json", scale_factor: Dict[str, float] = None, position: Dict[str, float] = None, rotation: Dict[str, float] = None, kinematic: bool = False, gravity: bool = True)`
-
-
-| Parameter | Description |
-| --- | --- |
-| name | The name of the model. |
-| library | The filename of the library containing the model's record. |
-| scale_factor | The [scale factor](../api/command_api.md#scale_object). |
-| position | The initial position. If None, defaults to: `{"x": 0, "y": 0, "z": 0`}. |
-| rotation | The initial rotation as Euler angles or a quaternion. If None, defaults to: `{"w": 1, "x": 0, "y": 0, "z": 0}` |
-| kinematic | If True, the object will be [kinematic](../api/command_api.md#set_kinematic_state). |
-| gravity | If True, the object won't respond to [gravity](../api/command_api.md#set_kinematic_state). |
-| mass | The mass of the object. |
-| dynamic_friction | The [dynamic friction](../api/command_api.md#set_physic_material) of the object. |
-
-***
-
-#### `get_commands(self) -> Tuple[int, List[dict]]`
-
-_Returns:_  Tuple: The ID of the object; a list of commands to create the object: `[add_object, rotate_object_to, scale_object, set_kinematic_state, set_object_collision_detection_mode, set_mass, set_physic_material]`
-
-***
-
-## `AudioInitData(RigidbodyInitData)`
-
-`from tdw.object_init_data import AudioInitData`
-
-A subclass of `RigidbodyInitData` that includes [audio values](py_impact.md#objectinfo).
-Physics values are derived from these audio values.
-
-***
-
-#### `__init__(self, name: str, library: str = "models_core.json", scale_factor: Dict[str, float] = None, position: Dict[str, float] = None, rotation: Dict[str, float] = None, kinematic: bool = False, gravity: bool = True, audio: ObjectInfo = None)`
-
-
-| Parameter | Description |
-| --- | --- |
-| name | The name of the model. |
-| library | The filename of the library containing the model's record. |
-| scale_factor | The [scale factor](../api/command_api.md#scale_object). |
-| position | The initial position. If None, defaults to: `{"x": 0, "y": 0, "z": 0`}. |
-| rotation | The initial rotation as Euler angles or a quaternion. If None, defaults to: `{"w": 1, "x": 0, "y": 0, "z": 0}` |
-| kinematic | If True, the object will be [kinematic](../api/command_api.md#set_kinematic_state). |
-| gravity | If True, the object won't respond to [gravity](../api/command_api.md#set_kinematic_state). |
-| audio | If None, derive physics data from the audio data in `PyImpact.get_object_info()` (if the object isn't in this dictionary, this constructor will throw an error). If not None, use these values instead of the default audio values. |
-
-***
-
-#### `get_commands(self) -> Tuple[int, List[dict]]`
-
-_Returns:_  Tuple: The ID of the object; a list of commands to create the object: `[add_object, rotate_object_to, scale_object, set_kinematic_state, set_object_collision_detection_mode, set_mass, set_physic_material]`
-
-***
-
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/forcefield.py b/Python/example_controllers/forcefield.py
deleted file mode 100644
index 27d7de275..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/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/tdw/add_ons/add_on.py b/Python/tdw/add_ons/add_on.py
index b3810b335..c127b0f5c 100644
--- a/Python/tdw/add_ons/add_on.py
+++ b/Python/tdw/add_ons/add_on.py
@@ -45,6 +45,10 @@ class AddOn(ABC):
     - [StepPhysics](step_physics.md)
     - [ThirdPersonCamera](third_person_camera.md)
 
+    Backend:
+
+    - [ModelVerifier](model_verifier.md)
+
     ## Example controllers
 
     - `tdw/Python/example_controllers/add_ons.py` How to add multiple add-ons to the controller.
diff --git a/Python/tdw/add_ons/cinematic_camera.py b/Python/tdw/add_ons/cinematic_camera.py
index 62fca8591..5ef4e3d15 100644
--- a/Python/tdw/add_ons/cinematic_camera.py
+++ b/Python/tdw/add_ons/cinematic_camera.py
@@ -207,7 +207,7 @@ class CinematicCamera(ThirdPersonCameraBase):
 
     def __init__(self, avatar_id: str = None, position: Dict[str, float] = None, rotation: Dict[str, float] = None,
                  fov: int = None, framerate: int = None, move_speed: float = 0.1, rotate_speed: float = 3,
-                 focus_speed: float = 0.3):
+                 focus_speed: float = 0.3, look_at: Union[int, Dict[str, float]] = None):
         """
         :param avatar_id: The ID of the avatar (camera). If None, a random ID is generated.
         :param position: The initial position of the object.If None, defaults to `{"x": 0, "y": 0, "z": 0}`.
@@ -217,6 +217,7 @@ def __init__(self, avatar_id: str = None, position: Dict[str, float] = None, rot
         :param move_speed: The directional speed of the camera. This can later be adjusted by setting `self.move_speed`.
         :param rotate_speed: The angular speed of the camera. This can later be adjusted by setting `self.rotate_speed`.
         :param focus_speed: The speed of the focus of the camera. This can later be adjusted by setting `self.focus_speed`.
+        :param look_at: If not None, the cinematic camera will look at this object (if int) or position (if dictionary).
         """
 
         super().__init__(avatar_id=avatar_id, position=position, rotation=rotation, fov=fov, framerate=framerate)
@@ -267,10 +268,36 @@ def __init__(self, avatar_id: str = None, position: Dict[str, float] = None, rot
         self._rotated: bool = True
 
         # The object ID of the focus target.
-        self._focus_target: Optional[int] = None
+        self._focus_target: Optional[Union[int, Dict[str, float]]] = None
         # If True, the camera is done focusing.
         self._focused: bool = True
 
+        if look_at is not None:
+            # Look at and focus on the object.
+            if isinstance(look_at, int):
+                self._init_commands.extend([{"$type": "look_at",
+                                             "object_id": look_at,
+                                             "use_centroid": True,
+                                             "avatar_id": self.avatar_id},
+                                            {"$type": "focus_on_object",
+                                             "object_id": look_at,
+                                             "use_centroid": True,
+                                             "avatar_id": self.avatar_id}])
+                # Continue to look at the object.
+                self.rotate_to_object(target=look_at)
+            # Look at and focus on the position.
+            elif isinstance(look_at, dict):
+                distance = np.linalg.norm(TDWUtils.vector3_to_array(position) - TDWUtils.vector3_to_array(look_at))
+                self._init_commands.extend([{"$type": "look_at_position",
+                                             "position": look_at,
+                                             "avatar_id": self.avatar_id},
+                                            {"$type": "set_focus_distance",
+                                             "focus_distance": distance}])
+                # Continue to look at the position.
+                self.rotate_to_position(target=look_at)
+            else:
+                raise TypeError(f"Invalid look-at target: {look_at}")
+
     def on_send(self, resp: List[bytes]) -> None:
         # Set a relative target.
         if self._has_relative_translation:
@@ -282,6 +309,7 @@ def on_send(self, resp: List[bytes]) -> None:
             if self._move_target_type == _MoveTargetType.position:
                 self.commands.append({"$type": "move_avatar_towards_position",
                                       "position": self._move_target,
+                                      "speed": self.move_speed,
                                       "avatar_id": self.avatar_id})
             elif self._move_target_type == _MoveTargetType.object:
                 self.commands.append({"$type": "move_avatar_towards_object",
@@ -289,6 +317,7 @@ def on_send(self, resp: List[bytes]) -> None:
                                       "offset_distance": self._move_distance_offset,
                                       "min_y": self._move_min_y,
                                       "use_centroid": True,
+                                      "speed": self.move_speed,
                                       "avatar_id": self.avatar_id})
             else:
                 raise Exception(f"Invalid move target type: {self._move_target_type}")
@@ -313,20 +342,28 @@ def on_send(self, resp: List[bytes]) -> None:
                                       "rotation": self._rotate_target,
                                       "speed": self.rotate_speed})
             elif self._rotate_target_type == _RotateTargetType.object:
-                self.commands.append({"$type": "rotate_sensor_container_towards_object",
-                                      "avatar_id": self.avatar_id,
-                                      "speed": self.rotate_speed,
-                                      "object_id": self._rotate_target,
-                                      "use_centroid": True})
+                self.commands.extend([{"$type": "rotate_sensor_container_towards_object",
+                                       "avatar_id": self.avatar_id,
+                                       "speed": self.rotate_speed,
+                                       "object_id": self._rotate_target,
+                                       "use_centroid": True},
+                                      {"$type": "focus_towards_object",
+                                       "avatar_id": self.avatar_id,
+                                       "speed": self.focus_speed,
+                                       "object_id": self._focus_target,
+                                       "use_centroid": True}])
+            elif self._rotate_target_type == _RotateTargetType.position:
+                distance = np.linalg.norm(self._get_avatar_position(resp=resp) -
+                                          TDWUtils.vector3_to_array(self._rotate_target))
+                self.commands.extend([{"$type": "rotate_sensor_container_towards_position",
+                                       "avatar_id": self.avatar_id,
+                                       "position": self._rotate_target,
+                                       "speed": self.rotate_speed},
+                                      {"$type": "set_focus_distance",
+                                       "focus_distance": distance}])
+
             else:
                 raise Exception(f"Invalid rotate target type: {self._move_target_type}")
-        # Focus towards a target.
-        if self._focus_target is not None:
-            self.commands.append({"$type": "focus_towards_object",
-                                  "avatar_id": self.avatar_id,
-                                  "speed": self.focus_speed,
-                                  "object_id": self._focus_target,
-                                  "use_centroid": True})
 
     def move_to_position(self, target: Dict[str, float], relative: bool = False) -> None:
         """
@@ -380,6 +417,13 @@ def rotate_to_object(self, target: int) -> None:
         self._focused = False
         self._focus_target = target
 
+    def rotate_to_position(self, target: Dict[str, float]) -> None:
+        self._rotate_target = target
+        self._rotate_target_type = _RotateTargetType.position
+        self._rotated = False
+        self._focused = False
+        self._focus_target = target
+
     def rotate_by_rpy(self, target: Dict[str, float]) -> None:
         """
         Start rotating the camera by the `[roll, pitch, yaw]` angles expressed as an `[x, y, z]` dictionary.
diff --git a/Python/tdw/add_ons/floorplan_layouts.json b/Python/tdw/add_ons/floorplan_layouts.json
index 1f4322600..b1e638c90 100644
--- a/Python/tdw/add_ons/floorplan_layouts.json
+++ b/Python/tdw/add_ons/floorplan_layouts.json
@@ -332,8 +332,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -376,8 +376,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bakerparisfloorlamp03",
         "position": {
@@ -398,8 +398,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -442,8 +442,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "trunck",
         "position": {
@@ -706,8 +706,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "fridge_large",
         "position": {
@@ -728,8 +728,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "dishwasher_4",
         "position": {
@@ -772,8 +772,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bakerparisfloorlamp03",
         "position": {
@@ -2094,8 +2094,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -2138,8 +2138,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -2160,8 +2160,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -2292,8 +2292,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "trunck",
         "position": {
@@ -2534,8 +2534,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "fridge_large",
         "position": {
@@ -2556,8 +2556,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "dishwasher_4",
         "position": {
@@ -2600,8 +2600,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -4186,8 +4186,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -4208,8 +4208,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -4318,8 +4318,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "trunck",
         "position": {
@@ -4538,8 +4538,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "fridge_large",
         "position": {
@@ -4560,8 +4560,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "dishwasher_4",
         "position": {
@@ -4604,8 +4604,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -5506,8 +5506,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bakerparisfloorlamp03",
         "position": {
@@ -5550,8 +5550,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "appliance-ge-profile-microwave3",
         "position": {
@@ -6038,8 +6038,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -6060,8 +6060,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -6214,8 +6214,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "trunck",
         "position": {
@@ -6456,8 +6456,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "fridge_large",
         "position": {
@@ -6478,8 +6478,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "dishwasher_4",
         "position": {
@@ -6522,8 +6522,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -7534,8 +7534,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -7734,8 +7734,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "trunck",
         "position": {
@@ -7866,8 +7866,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -7910,8 +7910,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -7932,8 +7932,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -8042,8 +8042,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "trunck",
         "position": {
@@ -8284,8 +8284,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "fridge_large",
         "position": {
@@ -8306,8 +8306,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "dishwasher_4",
         "position": {
@@ -8350,8 +8350,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -9936,8 +9936,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -9958,8 +9958,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -10112,8 +10112,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "trunck",
         "position": {
@@ -10376,8 +10376,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "fridge_large",
         "position": {
@@ -10398,8 +10398,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "dishwasher_4",
         "position": {
@@ -10442,8 +10442,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -11964,8 +11964,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -12008,8 +12008,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -12030,8 +12030,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -12118,8 +12118,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "trunck",
         "position": {
@@ -12338,8 +12338,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "fridge_large",
         "position": {
@@ -12360,8 +12360,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "dishwasher_4",
         "position": {
@@ -12404,8 +12404,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -13528,8 +13528,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -13572,8 +13572,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -13594,8 +13594,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -13682,8 +13682,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "trunck",
         "position": {
@@ -13858,8 +13858,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "dishwasher_4",
         "position": {
@@ -13902,8 +13902,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -14804,8 +14804,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "fridge_large",
         "position": {
@@ -15356,8 +15356,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -15378,8 +15378,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -15444,8 +15444,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "trunck",
         "position": {
@@ -15708,8 +15708,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "fridge_large",
         "position": {
@@ -15730,8 +15730,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "dishwasher_4",
         "position": {
@@ -15774,8 +15774,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -17142,8 +17142,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -17164,8 +17164,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -17230,8 +17230,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "trunck",
         "position": {
@@ -17472,8 +17472,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "fridge_large",
         "position": {
@@ -17494,8 +17494,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "dishwasher_4",
         "position": {
@@ -17538,8 +17538,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -19058,8 +19058,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -19102,8 +19102,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -19124,8 +19124,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -19234,8 +19234,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "trunck",
         "position": {
@@ -19476,8 +19476,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "fridge_large",
         "position": {
@@ -19498,8 +19498,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "dishwasher_4",
         "position": {
@@ -19542,8 +19542,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bastone_floor_lamp",
         "position": {
@@ -20488,8 +20488,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "bakerparisfloorlamp03",
         "position": {
@@ -20532,8 +20532,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "appliance-ge-profile-microwave3",
         "position": {
@@ -20622,8 +20622,8 @@
     ],
     "2": [
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "trunck",
         "position": {
@@ -20952,8 +20952,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -20996,8 +20996,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "alma_floor_lamp",
         "position": {
@@ -21084,8 +21084,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "trunck",
         "position": {
@@ -21260,8 +21260,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "dishwasher_4",
         "position": {
@@ -22228,8 +22228,8 @@
         }
       },
       {
-        "gravity": true,
-        "kinematic": false,
+        "gravity": false,
+        "kinematic": true,
         "library": "models_core.json",
         "name": "fridge_large",
         "position": {
diff --git a/Python/tdw/add_ons/model_verifier/model_verifier.py b/Python/tdw/add_ons/model_verifier/model_verifier.py
new file mode 100644
index 000000000..afd318c02
--- /dev/null
+++ b/Python/tdw/add_ons/model_verifier/model_verifier.py
@@ -0,0 +1,132 @@
+from enum import Enum
+from platform import system
+from typing import List, Union, Optional
+from tdw.controller import Controller
+from tdw.librarian import ModelLibrarian, ModelRecord
+from tdw.add_ons.add_on import AddOn
+from tdw.add_ons.model_verifier.model_tests.model_test import ModelTest
+from tdw.add_ons.model_verifier.model_tests.model_report import ModelReport
+from tdw.add_ons.model_verifier.model_tests.missing_materials import MissingMaterials
+from tdw.add_ons.model_verifier.model_tests.physics_quality import PhysicsQuality
+
+
+class _Test(Enum):
+    """
+    A type of test.
+    """
+
+    model_report = 1
+    missing_materials = 2
+    physics_quality = 4
+
+
+class ModelVerifier(AddOn):
+    """
+    Run tests on an object model.
+    """
+
+    def __init__(self):
+        super().__init__()
+        self._tests: List[_Test] = list()
+        self._record: Optional[ModelRecord] = None
+        self._current_test: Optional[ModelTest] = None
+        self._object_id: int = -1
+        """:field
+        A list of reports from the test.
+        """
+        self.reports: List[str] = list()
+        """:field
+        If True, the tests are done.
+        """
+        self.done: bool = False
+
+    def get_initialization_commands(self) -> List[dict]:
+        return [{"$type": "create_empty_environment"},
+                {"$type": "set_render_quality",
+                 "render_quality": 0},
+                {"$type": "set_post_process",
+                 "value": False},
+                {"$type": "simulate_physics",
+                 "value": False},
+                {"$type": "create_avatar",
+                 "id": "a",
+                 "type": "A_Img_Caps_Kinematic"},
+                {"$type": "teleport_avatar_to",
+                 "position": {"x": 1.75, "y": 0.5, "z": 0}},
+                {"$type": "look_at_position",
+                 "position": {"x": 0, "y": 0.5, "z": 0}}]
+
+    def set_tests(self, name: str, model_report: bool, missing_materials: bool, physics_quality: bool,
+                  source: Union[ModelLibrarian, ModelRecord, str] = None) -> None:
+        """
+        Start new tests for the model. Only call this if there isn't currently a test running.
+
+        :param name: The name of the model.
+        :param source: The source of the model. If None: Get the record corresponding to `name` in `models_core.json`. If `ModelLibrarian`: Get the record corresponding to `name` in this library. If `ModelRecord`: Use this record. If `str`: Create a dummy record with name `name` and URL `source`.
+        :param model_report: If True, run a basic test on the model.
+        :param missing_materials: If True, test the model for any missing materials.
+        :param physics_quality: If True, test the extent to which the colliders geometry matches the rendered geometry.
+        """
+
+        if self._current_test is not None:
+            raise Exception(f"Cannot set new tests because we're still testing {self._record.name}")
+        self._tests = list()
+        self.done = False
+        self.reports.clear()
+        if model_report:
+            self._tests.append(_Test.model_report)
+        if missing_materials:
+            self._tests.append(_Test.missing_materials)
+        if physics_quality:
+            self._tests.append(_Test.physics_quality)
+        # This is the name of a model in models_core.json
+        if source is None:
+            if "models_core.json" not in Controller.MODEL_LIBRARIANS:
+                Controller.MODEL_LIBRARIANS["models_core.json"] = ModelLibrarian()
+            self._record = Controller.MODEL_LIBRARIANS["models_core.json"].get_record(name)
+        # This is the name of a record in a specified library.
+        elif isinstance(source, ModelLibrarian):
+            self._record = source.get_record(name)
+        # This is a model record.
+        elif isinstance(source, ModelRecord):
+            self._record = source
+        # This is a URL.
+        elif isinstance(source, str):
+            self._record = ModelRecord()
+            self._record.name = name
+            self._record.urls[system()] = source
+        else:
+            raise TypeError("Invalid type for source: " + source)
+        self._next_test()
+
+    def _next_test(self) -> None:
+        """
+        Start the next test, if any.
+        """
+
+        if len(self._tests) == 0:
+            self._current_test = None
+            self._record = None
+            return
+        t: _Test = self._tests.pop(0)
+        if t == _Test.model_report:
+            self._current_test = ModelReport(record=self._record)
+        elif t == _Test.physics_quality:
+            self._current_test = PhysicsQuality(record=self._record)
+        elif t == _Test.missing_materials:
+            self._current_test = MissingMaterials(record=self._record)
+        else:
+            raise Exception(f"Test not defined: {t}")
+        self.commands.append({"$type": "unload_asset_bundles"})
+        self.commands.extend(self._current_test.start())
+
+    def on_send(self, resp: List[bytes]) -> None:
+        if self._current_test is None:
+            self.done = True
+            return
+        # Get test commands.
+        self.commands = self._current_test.on_send(resp=resp)
+        # If this test is done, append the reports and try to start a new test.
+        if self._current_test.done:
+            self.reports.extend(self._current_test.reports)
+            self._next_test()
diff --git a/Python/tdw/add_ons/robot.py b/Python/tdw/add_ons/robot.py
index b78c09b7f..761625750 100644
--- a/Python/tdw/add_ons/robot.py
+++ b/Python/tdw/add_ons/robot.py
@@ -1,6 +1,7 @@
 from typing import List, Dict, Union, Optional
 from overrides import final
 import numpy as np
+from tdw.controller import Controller
 from tdw.librarian import RobotLibrarian, RobotRecord
 from tdw.tdw_utils import TDWUtils
 from tdw.robot_data.robot_static import RobotStatic
@@ -39,11 +40,6 @@ class Robot(RobotBase):
     ```
     """
 
-    """:class_var
-    TDW's built-in [`RobotLibrarian`](../librarian/robot_librarian.md).
-    """
-    ROBOT_LIBRARIAN: RobotLibrarian = RobotLibrarian()
-
     def __init__(self, name: str, robot_id: int = 0, position: Dict[str, float] = None, rotation: Dict[str, float] = None,
                  source: Union[RobotLibrarian, RobotRecord, str] = None):
         """
@@ -71,13 +67,23 @@ def __init__(self, name: str, robot_id: int = 0, position: Dict[str, float] = No
         The URL or filepath of the robot asset bundle.
         """
         self.url: str = ""
+        # The initial target positons for the joints.
+        self._initial_targets: dict = dict()
+        # If True, we've already started to move the joints to their initial position.
+        self._set_initial_targets: bool = False
         if source is None:
-            record: RobotRecord = Robot.ROBOT_LIBRARIAN.get_record(name)
+            if "robots.json" not in Controller.ROBOT_LIBRARIANS:
+                Controller.ROBOT_LIBRARIANS["robots.json"] = RobotLibrarian()
+            record: RobotRecord = Controller.ROBOT_LIBRARIANS["robots.json"].get_record(name)
             self.url = record.get_url()
+            self._initial_targets = record.targets
         elif isinstance(source, RobotLibrarian):
-            self.url = source.get_record(name).get_url()
+            record = source.get_record(name)
+            self.url = record.get_url()
+            self._initial_targets = record.targets
         elif isinstance(source, RobotRecord):
             self.url = source.get_url()
+            self._initial_targets = source.targets
         elif isinstance(source, str):
             self.url = source
         else:
@@ -191,3 +197,10 @@ def _set_dynamic_data(self, resp: List[bytes]) -> None:
         dynamic = RobotDynamic(resp=resp, robot_id=self.robot_id, body_parts=self.static.body_parts,
                                previous=self.dynamic)
         self.dynamic = self._set_joints_moving(dynamic)
+        # Set the joints to their initial targets.
+        if not self._set_initial_targets:
+            self._set_initial_targets = True
+            targets = dict()
+            for joint_name in self._initial_targets:
+                targets[self.static.joint_ids_by_name[joint_name]] = self._initial_targets[joint_name]["target"]
+            self.set_joint_targets(targets=targets)
diff --git a/Python/tdw/asset_bundle_creator.py b/Python/tdw/asset_bundle_creator.py
index 90740adba..e5f627994 100644
--- a/Python/tdw/asset_bundle_creator.py
+++ b/Python/tdw/asset_bundle_creator.py
@@ -5,13 +5,14 @@
 import os
 import shutil
 from tdw.librarian import ModelRecord
-from tdw.backend import paths
 import json
 import pkg_resources
 import distutils.dir_util
 import distutils.file_util
 from tdw.backend.platforms import S3_TO_UNITY, SYSTEM_TO_UNITY, UNITY_TO_SYSTEM
 from tdw.asset_bundle_creator_base import AssetBundleCreatorBase
+from tdw.add_ons.model_verifier.model_verifier import ModelVerifier
+from tdw.controller import Controller
 
 
 class AssetBundleCreator(AssetBundleCreatorBase):
@@ -574,7 +575,7 @@ def create_record(self, model_name: str, wnid: int, wcategory: str, scale: float
 
         if write_physics:
             self.write_physics_quality(record_path=record_path,
-                                       asset_bundle_path=self.get_local_asset_bundle_path(model_name))
+                                       asset_bundle_path=self.get_local_asset_bundle_path(model_name).resolve())
 
         return record_path
 
@@ -609,13 +610,20 @@ def write_physics_quality(record_path: Path, asset_bundle_path: Path) -> None:
         :param asset_bundle_path: The URL to the local asset bundle.
         """
 
-        # Get the path to the writer controller.
-        writer_path = pkg_resources.resource_filename(__name__, "model_pipeline/write_physics_quality.py")
-        writer_call = ["py", "-3", writer_path,
-                       "--record_path", str(record_path.resolve()),
-                       "--asset_bundle_path", str(asset_bundle_path.resolve())]
-
-        call(writer_call)
+        c = Controller()
+        v = ModelVerifier()
+        r: ModelRecord = ModelRecord(json.loads(Path(record_path).read_text(encoding="utf-8")))
+        r.urls[platform.system()] = f"file:///{str(asset_bundle_path)}"
+        v.set_tests(name=r.name, source=r, model_report=False, missing_materials=False, physics_quality=True)
+        c.add_ons.append(v)
+        c.communicate([])
+        while not v.done:
+            c.communicate([])
+        c.communicate({"$type": "terminate"})
+        c.socket.close()
+        # Write the physics quality.
+        r.physics_quality = float(v.reports[0])
+        record_path.write_text(json.dumps(r.__dict__), encoding="utf-8")
 
     def validate(self, record_path: Path, asset_bundle_path: Path) -> Tuple[bool, str]:
         """
@@ -630,16 +638,20 @@ def validate(self, record_path: Path, asset_bundle_path: Path) -> Tuple[bool, st
         if not self.quiet:
             print("Validating asset bundle...")
 
-        validator_path = pkg_resources.resource_filename(__name__, "model_pipeline/validator.py")
-        validator_call = ["py", "-3", validator_path,
-                          "--record_path", str(record_path.resolve()),
-                          "--asset_bundle_path", str(asset_bundle_path.resolve())]
-        call(validator_call)
-
-        report = json.loads(paths.VALIDATOR_REPORT_PATH.read_text(encoding="utf-8"))
-        if not report["ok"]:
+        c = Controller()
+        v = ModelVerifier()
+        r: ModelRecord = ModelRecord(json.loads(Path(record_path).read_text(encoding="utf-8")))
+        r.urls[platform.system()] = f"file:///{str(asset_bundle_path)}"
+        v.set_tests(name=r.name, source=r, model_report=True, missing_materials=True, physics_quality=False)
+        c.add_ons.append(v)
+        c.communicate([])
+        while not v.done:
+            c.communicate([])
+        c.communicate({"$type": "terminate"})
+        c.socket.close()
+        if len(v.reports) != 0:
             output = "There are problems with the asset bundle!"
-            for problem in report["reports"]:
+            for problem in v.reports:
                 output += "\n\t" + problem
             return False, output
         if not self.quiet:
diff --git a/Python/tdw/backend/paths.py b/Python/tdw/backend/paths.py
index fd28eaf38..785e83ea6 100644
--- a/Python/tdw/backend/paths.py
+++ b/Python/tdw/backend/paths.py
@@ -3,7 +3,6 @@
 
 
 ASSET_BUNDLE_VERIFIER_OUTPUT_DIR = Path.home().joinpath("tdw_asset_bundle_verifier")
-VALIDATOR_REPORT_PATH = ASSET_BUNDLE_VERIFIER_OUTPUT_DIR.joinpath("validator_report.json")
 EXAMPLE_CONTROLLER_OUTPUT_PATH = Path.home().joinpath("tdw_example_controller_output")
 
 if system() == "Windows":
diff --git a/Python/tdw/binaries/Windows/vhacd/log.txt b/Python/tdw/binaries/Windows/vhacd/log.txt
deleted file mode 100644
index ddef1dea7..000000000
--- a/Python/tdw/binaries/Windows/vhacd/log.txt
+++ /dev/null
@@ -1,113 +0,0 @@
-V-HACD V2.2
-Syntax: testVHACD [options] --input infile.obj --output outfile.wrl --log logfile.txt
-
-Options:
-       --input                     Wavefront .obj input file name
-       --output                    VRML 2.0 output file name
-       --log                       Log file name
-       --resolution                Maximum number of voxels generated during the voxelization stage (default=100,000, range=10,000-16,000,000)
-       --depth                     Maximum number of clipping stages. During each split stage, parts with a concavity higher than the user defined threshold are clipped according the "best" clipping plane (default=20, range=1-32)
-       --concavity                 Maximum allowed concavity (default=0.0025, range=0.0-1.0)
-       --planeDownsampling         Controls the granularity of the search for the "best" clipping plane (default=4, range=1-16)
-       --convexhullDownsampling    Controls the precision of the convex-hull generation process during the clipping plane selection stage (default=4, range=1-16)
-       --alpha                     Controls the bias toward clipping along symmetry planes (default=0.05, range=0.0-1.0)
-       --beta                      Controls the bias toward clipping along revolution axes (default=0.05, range=0.0-1.0)
-       --gamma                     Controls the maximum allowed concavity during the merge stage (default=0.00125, range=0.0-1.0)
-       --delta                     Controls the bias toward maximaxing local concavity (default=0.05, range=0.0-1.0)
-       --pca                       Enable/disable normalizing the mesh before applying the convex decomposition (default=0, range={0,1})
-       --mode                      0: voxel-based approximate convex decomposition, 1: tetrahedron-based approximate convex decomposition (default=0, range={0,1})
-       --maxNumVerticesPerCH       Controls the maximum number of triangles per convex-hull (default=64, range=4-1024)
-       --minVolumePerCH            Controls the adaptive sampling of the generated convex-hulls (default=0.0001, range=0.0-0.01)
-       --convexhullApproximation   Enable/disable approximation when computing convex-hulls (default=1, range={0,1})
-       --oclAcceleration           Enable/disable OpenCL acceleration (default=0, range={0,1})
-       --oclPlatformID             OpenCL platform id (default=0, range=0-# OCL platforms)
-       --oclDeviceID               OpenCL device id (default=0, range=0-# OCL devices)
-       --help                      Print usage
-
-Examples:
-       testVHACD.exe --input bunny.obj --output bunny_acd.wrl --log log.txt
-
-+ OpenCL (ON)
-	 Number of OpenCL platforms: 1
-	 OpenCL platform [0]
-		CL_PLATFORM_NAME: FULL_PROFILE
-		CL_PLATFORM_VENDOR: OpenCL 1.2 CUDA 9.1.128
-		CL_PLATFORM_VERSION: NVIDIA CUDA
-		CL_PLATFORM_PROFILE: NVIDIA Corporation
-		CL_PLATFORM_EXTENSIONS: cl_khr_global_int32_base_atomics cl_khr_global_int32_extended_atomics cl_khr_local_int32_base_atomics cl_khr_local_int32_extended_atomics cl_khr_fp64 cl_khr_byte_addressable_store cl_khr_icd cl_khr_gl_sharing cl_nv_compiler_options cl_nv_device_attribute_query cl_nv_pragma_unroll cl_nv_d3d10_sharing cl_khr_d3d10_sharing cl_nv_d3d11_sharing cl_nv_copy_opts cl_nv_create_buffer
-	 Using OpenCL platform [0]
-	 Number of OpenCL devices: 1
-	 OpenCL device [0]
-		CL_DEVICE_NAME: GeForce GTX 1080
-		CL_DEVICE_PROFILE: FULL_PROFILE
-		CL_DEVICE_VENDOR: NVIDIA Corporation
-		CL_DEVICE_VERSION: OpenCL 1.2 CUDA
-		CL_DRIVER_VERSION: 388.75
-		CL_DEVICE_EXTENSIONS: cl_khr_global_int32_base_atomics cl_khr_global_int32_extended_atomics cl_khr_local_int32_base_atomics cl_khr_local_int32_extended_atomics cl_khr_fp64 cl_khr_byte_addressable_store cl_khr_icd cl_khr_gl_sharing cl_nv_compiler_options cl_nv_device_attribute_query cl_nv_pragma_unroll cl_nv_d3d10_sharing cl_khr_d3d10_sharing cl_nv_d3d11_sharing cl_nv_copy_opts cl_nv_create_buffer
-		CL_DEVICE_VERSION: OpenCL 1.2 CUDA
-		CL_DEVICE_ADDRESS_BITS: 64
-		CL_DEVICE_GLOBAL_MEM_CACHELINE_SIZE: 128
-		CL_DEVICE_MAX_CLOCK_FREQUENCY: 1733
-		CL_DEVICE_MAX_COMPUTE_UNITS: 20
-		CL_DEVICE_MAX_CONSTANT_ARGS: 9
-		CL_DEVICE_MAX_READ_IMAGE_ARGS: 256
-		CL_DEVICE_MAX_SAMPLERS: 32
-		CL_DEVICE_MAX_WORK_ITEM_DIMENSIONS: 3
-		CL_DEVICE_MAX_WRITE_IMAGE_ARGS: 16
-		CL_DEVICE_MEM_BASE_ADDR_ALIGN: 4096
-		CL_DEVICE_MIN_DATA_TYPE_ALIGN_SIZE: 128
-		CL_DEVICE_PREFERRED_VECTOR_WIDTH_CHAR: 1
-		CL_DEVICE_PREFERRED_VECTOR_WIDTH_SHORT: 1
-		CL_DEVICE_PREFERRED_VECTOR_WIDTH_INT: 1
-		CL_DEVICE_PREFERRED_VECTOR_WIDTH_LONG: 1
-		CL_DEVICE_PREFERRED_VECTOR_WIDTH_FLOAT: 1
-		CL_DEVICE_PREFERRED_VECTOR_WIDTH_DOUBLE: 1
-		CL_DEVICE_VENDOR_ID: 4318
-		CL_DEVICE_AVAILABLE: 1
-		CL_DEVICE_COMPILER_AVAILABLE: 1
-		CL_DEVICE_ENDIAN_LITTLE: 1
-		CL_DEVICE_ERROR_CORRECTION_SUPPORT: 0
-		CL_DEVICE_IMAGE_SUPPORT: 1
-		CL_DEVICE_EXECUTION_CAPABILITIES: 1
-		CL_DEVICE_GLOBAL_MEM_CACHE_SIZE: 327680
-		CL_DEVICE_GLOBAL_MEM_SIZE: 8589934592
-		CL_DEVICE_LOCAL_MEM_SIZE: 49152
-		CL_DEVICE_MAX_CONSTANT_BUFFER_SIZE: 65536
-		CL_DEVICE_MAX_MEM_ALLOC_SIZE: 2147483648
-		CL_DEVICE_GLOBAL_MEM_CACHE_TYPE: 2
-		CL_DEVICE_IMAGE2D_MAX_HEIGHT: 32768
-		CL_DEVICE_IMAGE2D_MAX_WIDTH: 16384
-		CL_DEVICE_IMAGE3D_MAX_DEPTH: 16384
-		CL_DEVICE_IMAGE3D_MAX_HEIGHT: 16384
-		CL_DEVICE_IMAGE3D_MAX_WIDTH: 16384
-		CL_DEVICE_MAX_PARAMETER_SIZE: 4352
-		CL_DEVICE_MAX_WORK_GROUP_SIZE: 1024
-		CL_DEVICE_PROFILING_TIMER_RESOLUTION: 1000
-		CL_DEVICE_QUEUE_PROPERTIES: 3
-		CL_DEVICE_TYPE: 4
-		CL_DEVICE_LOCAL_MEM_TYPE: 1
-		CL_DEVICE_MAX_WORK_ITEM_SIZES: (1024, 1024, 64)
-	 Using OpenCL device [0]
-+ OpenMP (ON)
-+ Parameters
-	 input                                       
-	 resolution                                  100000
-	 max. depth                                  20
-	 max. concavity                              0.001
-	 plane down-sampling                         4
-	 convex-hull down-sampling                   4
-	 alpha                                       0.05
-	 beta                                        0.05
-	 gamma                                       0.0005
-	 pca                                         0
-	 mode                                        0
-	 max. vertices per convex-hull               64
-	 min. volume to add vertices to convex-hulls 0.0001
-	 convex-hull approximation                   1
-	 OpenCL acceleration                         1
-	 OpenCL platform ID                          0
-	 OpenCL device ID                            0
-	 output                                      output.wrl
-	 log                                         log.txt
-+ Load mesh
-Format not supported!
diff --git a/Python/tdw/controller.py b/Python/tdw/controller.py
index 2376650c8..0af601140 100644
--- a/Python/tdw/controller.py
+++ b/Python/tdw/controller.py
@@ -208,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/model_pipeline/__init__.py b/Python/tdw/model_pipeline/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/Python/tdw/model_pipeline/missing_materials.py b/Python/tdw/model_pipeline/missing_materials.py
deleted file mode 100644
index 503cbb91d..000000000
--- a/Python/tdw/model_pipeline/missing_materials.py
+++ /dev/null
@@ -1,182 +0,0 @@
-from tdw.controller import Controller
-from tdw.librarian import ModelLibrarian, ModelRecord
-from tdw.output_data import Images
-from tdw.backend.paths import ASSET_BUNDLE_VERIFIER_OUTPUT_DIR
-from PIL import Image
-from io import BytesIO
-from tdw.tdw_utils import TDWUtils
-from math import radians, sin, cos
-from tqdm import tqdm
-import io
-
-
-class MissingMaterials:
-    """
-    Check for missing materials.
-    """
-
-    HELL_PINK = (255, 0, 255)
-    DELTA_THETA = 15
-    OUTPUT_FILE = ASSET_BUNDLE_VERIFIER_OUTPUT_DIR.joinpath("missing_materials.txt")
-    if not ASSET_BUNDLE_VERIFIER_OUTPUT_DIR.exists():
-        ASSET_BUNDLE_VERIFIER_OUTPUT_DIR.mkdir(parents=True)
-    OBJECT_ID = 0
-
-    @staticmethod
-    def start(c: Controller) -> None:
-        """
-        Start the controller.
-
-        :param c: The controller.
-        """
-
-        print(f"Results will be saved to: {MissingMaterials.OUTPUT_FILE}")
-
-        c.start()
-
-        c.communicate([{"$type": "simulate_physics",
-                        "value": False},
-                       {"$type": "create_empty_environment"},
-                       {"$type": "set_render_quality",
-                        "render_quality": 0},
-                       {"$type": "set_post_process",
-                        "value": False},
-                       {"$type": "create_avatar",
-                        "id": "a",
-                        "type": "A_Img_Caps_Kinematic"},
-                       {"$type": "set_pass_masks",
-                        "pass_masks": ["_img"]}])
-
-    @staticmethod
-    def materials_are_missing(c: Controller, record: ModelRecord, url: str) -> bool:
-        """
-        Check for missing materials.
-
-        :param c: The controller.
-        :param record: The model metadata record.
-        :param url: The path to the model.
-
-        :return: True if the model is missing materials.
-        """
-
-        # Disable post-processing.
-        # Disable shadows.
-        # Create the avatar.
-        # Set pass masks.
-        # Send images.
-        # Add the object.
-        # Add the object.
-        # Load the bounds of the model.
-        scale = TDWUtils.get_unit_scale(record)
-        c.communicate([{"$type": "send_images",
-                        "frequency": "always"},
-                       {"$type": "add_object",
-                        "name": record.name,
-                        "url": url,
-                        "scale_factor": record.scale_factor,
-                        "id": MissingMaterials.OBJECT_ID},
-                       {"$type": "scale_object",
-                        "id": MissingMaterials.OBJECT_ID,
-                        "scale_factor": {"x": scale, "y": scale, "z": scale}}])
-        x = 1.75
-        y = 0.5
-        z = 0
-
-        # Equatorial orbit.
-        theta = 0
-        while theta < 360:
-            # Get the new position.
-            rad = radians(theta)
-            x1 = cos(rad) * x - sin(rad) * z
-            z1 = sin(rad) * x + cos(rad) * z
-
-            if MissingMaterials._orbit_and_capture_image(c, x1, y, z1):
-                return True
-            theta += MissingMaterials.DELTA_THETA
-
-        # Polar orbit.
-        theta = 0
-        while theta < 360:
-            # Get the new position.
-            rad = radians(theta)
-            x1 = cos(rad) * x - sin(rad) * z
-            y1 = (sin(rad) * x + cos(rad) * z) + y
-
-            if MissingMaterials._orbit_and_capture_image(c, x1, y1, z):
-                return True
-            theta += MissingMaterials.DELTA_THETA
-
-        return False
-
-    @staticmethod
-    def _orbit_and_capture_image(c: Controller, x: float, y: float, z: float) -> bool:
-        """
-        Orbit around an object and check for missing materials.
-
-        :param c: The controller.
-        :param x: The next x positional coordinate.
-        :param y: The next y positional coordinate.
-        :param z: The next z positional coordinate.
-
-        :return: True if there is a missing material in the image, False otherwise.
-        """
-
-        # Teleport the avatar.
-        # Look at the model.
-        # Receive an image.
-        resp = c.communicate([{"$type": "teleport_avatar_to",
-                               "position": {"x": x, "y": y, "z": z}},
-                              {"$type": "look_at",
-                               "object_id": MissingMaterials.OBJECT_ID,
-                               "use_centroid": True}])
-
-        # Check if there are any pink pixels.
-        images = Images(resp[0])
-
-        img = Image.open(BytesIO(images.get_image(0)))
-
-        for c in img.getcolors(maxcolors=100000):
-            if c[1] == MissingMaterials.HELL_PINK:
-                return True
-        return False
-
-    @staticmethod
-    def run(c: Controller) -> None:
-        """
-        Check every model for missing materials.
-
-        :param c: The controller.
-        """
-
-        # Create a new output file.
-        if MissingMaterials.OUTPUT_FILE.exists():
-            MissingMaterials.OUTPUT_FILE.unlink()
-        MissingMaterials.OUTPUT_FILE = str(MissingMaterials.OUTPUT_FILE.resolve())
-
-        MissingMaterials.start(c)
-
-        print(f"The names of models with missing materials will be saved to: {MissingMaterials.OUTPUT_FILE}")
-        for library_path in ModelLibrarian.get_library_filenames():
-            print(library_path)
-            lib = ModelLibrarian(library=library_path)
-            pbar = tqdm(total=len(lib.records))
-            for record in lib.records:
-                if record.do_not_use:
-                    pbar.update(1)
-                    continue
-                pbar.set_description(record.name)
-                # Check for missing materials.
-                if MissingMaterials.materials_are_missing(c, record, record.get_url()):
-                    with io.open(MissingMaterials.OUTPUT_FILE, "at") as f:
-                        f.write("\n" + record.name)
-                # Cleanup.
-                c.communicate([{"$type": "destroy_object",
-                                "id": MissingMaterials.OBJECT_ID},
-                               {"$type": "unload_asset_bundles"}])
-                pbar.update(1)
-            pbar.close()
-        c.communicate({"$type": "terminate"})
-
-
-if __name__ == "__main__":
-    MissingMaterials.run(Controller(launch_build=False))
diff --git a/Python/tdw/model_pipeline/validator.py b/Python/tdw/model_pipeline/validator.py
deleted file mode 100644
index 477cd1c5d..000000000
--- a/Python/tdw/model_pipeline/validator.py
+++ /dev/null
@@ -1,98 +0,0 @@
-from tdw.controller import Controller
-from tdw.librarian import ModelRecord
-from typing import Tuple, List
-from tdw.output_data import LogMessage
-from tdw.backend import paths
-from subprocess import Popen
-import json
-from pathlib import Path
-from argparse import ArgumentParser
-from tdw.model_pipeline.missing_materials import MissingMaterials
-
-
-class Validator(Controller):
-    """
-    Check a model asset bundle for problems.
-    First, load the model into the build and report back any errors.
-    Then, scan the model for missing materials.
-
-    This controller will attempt to launch a build.
-    If one doesn't exist at the expected location, you'll need to launch one manually.
-    """
-
-    PINK_MATERIALS_REPORT: List[str] = ["This model has missing (pink) materials."]
-
-    def __init__(self, record_path: str, asset_bundle_path: str, port=1071):
-        """
-        :param record_path: The path to the temporary record file.
-        :param asset_bundle_path: The path to the local asset bundle.
-        :param port: The port.
-        """
-
-        self.record_path: str = record_path
-        self.record: ModelRecord = ModelRecord(json.loads(Path(record_path).read_text(encoding="utf-8")))
-        self.asset_bundle_path: str = asset_bundle_path
-        if not self.asset_bundle_path.startswith("file:///"):
-            self.asset_bundle_path = "file:///" + self.asset_bundle_path
-
-        super().__init__(port)
-
-    def run(self, quiet=True) -> Tuple[bool, List[str]]:
-        """
-        Run verification. First, test for general errors with the model. Next, test for missing materials.
-
-        :param quiet: If true, suppress messages.
-        """
-
-        MissingMaterials.start(self)
-
-        # Run the verifier.
-        if not quiet:
-            print("Running verifier...")
-
-        resp = self.communicate({"$type": "send_model_report",
-                                 "name": self.record.name,
-                                 "url": self.asset_bundle_path,
-                                 "id": self.get_unique_id(),
-                                 "scale_factor": self.record.scale_factor})
-        reports = []
-        for r in resp[:-1]:
-            msg = LogMessage(r)
-            reports.append(msg.get_message_type() + ": " + msg.get_message())
-
-        # Check for missing materials.
-        if not quiet:
-            print("Checking for missing materials...")
-
-        pink_materials = MissingMaterials.materials_are_missing(self, self.record, self.asset_bundle_path)
-        if pink_materials:
-            reports.append(self.PINK_MATERIALS_REPORT)
-
-        self.kill_build()
-        return len(reports) == 0, reports
-
-    def kill_build(self):
-        """
-        Kill the build process.
-        """
-
-        self.communicate({"$type": "terminate"})
-
-
-if __name__ == "__main__":
-    parser = ArgumentParser()
-    parser.add_argument("--record_path", type=str, help="The path to the temporary record file.")
-    parser.add_argument("--asset_bundle_path", type=str, help="The path to the local asset bundle.")
-    args = parser.parse_args()
-
-    # Get the physics quality.
-    c = Validator(record_path=args.record_path,
-                  asset_bundle_path=args.asset_bundle_path)
-    ok, reports = c.run()
-    report = {"ok": ok,
-              "reports": reports}
-
-    root_dir = paths.ASSET_BUNDLE_VERIFIER_OUTPUT_DIR
-    if not root_dir.exists():
-        root_dir.mkdir(parents=True)
-    paths.VALIDATOR_REPORT_PATH.write_text(json.dumps(report), encoding="utf-8")
diff --git a/Python/tdw/model_pipeline/write_physics_quality.py b/Python/tdw/model_pipeline/write_physics_quality.py
deleted file mode 100644
index 6dfdd59c3..000000000
--- a/Python/tdw/model_pipeline/write_physics_quality.py
+++ /dev/null
@@ -1,180 +0,0 @@
-from math import cos, sin, radians
-from tdw.librarian import ModelRecord
-from tdw.tdw_utils import TDWUtils
-from tdw.model_pipeline.validator import Validator
-from tdw.output_data import Images
-from PIL import Image
-from io import BytesIO
-import numpy as np
-from argparse import ArgumentParser
-import json
-from pathlib import Path
-
-
-class PhysicsQualityWriter(Validator):
-    """
-    Assess the percentage of coverage by colliders on a model.
-    Write the "physics quality" to the record.
-    """
-
-    def get_physics_quality(self) -> float:
-        """
-        Get the physics quality of the model.
-        First, capture multiple images at different angles.
-        Then, obscure the object with its own colliders and capture the same images.
-        Compare the _id capture pass pixel count between each set of images.
-        If the hull colliders obscure the entirety of the object in every image, physics_quality = 1.0
-        """
-
-        self.start()
-        self.communicate([{"$type": "simulate_physics",
-                           "value": False},
-                          {"$type": "create_empty_environment"}])
-
-        avatar_id = "A"
-        delta_theta = 15
-
-        print("Writing the physics quality to the record.")
-
-        scale = TDWUtils.get_unit_scale(self.record)
-
-        # Add the object.
-        object_id = self.get_unique_id()
-        self.communicate({"$type": "add_object",
-                          "name": self.record.name,
-                          "url": self.asset_bundle_path,
-                          "scale_factor": self.record.scale_factor,
-                          "id": object_id})
-
-        # Scale the object to unit size.
-        self.communicate({"$type": "scale_object",
-                          "id": object_id,
-                          "scale_factor": {"x": scale, "y": scale, "z": scale}})
-
-        # Create the avatar.
-        # Set the pass masks.
-        self.communicate(TDWUtils.create_avatar(avatar_id=avatar_id))
-
-        self.communicate({"$type": "set_pass_masks",
-                          "avatar_id": avatar_id,
-                          "pass_masks": ["_id"]})
-
-        id_pass = []
-        pink_pass = []
-        for show_collider_hulls in [False, True]:
-            x = 1.75
-            y = 0.5
-            z = 0
-
-            # Show collider hulls.
-            if show_collider_hulls:
-                self.communicate({"$type": "show_collider_hulls",
-                                  "id": object_id})
-
-            # Reset the avatar.
-            resp = self.communicate([{"$type": "teleport_avatar_to",
-                                      "avatar_id": avatar_id,
-                                      "position": {"x": x, "y": y, "z": z}},
-                                     {"$type": "look_at",
-                                      "avatar_id": avatar_id,
-                                      "object_id": object_id,
-                                      "use_centroid": True},
-                                     {"$type": "send_images",
-                                      "frequency": "always"}])
-
-            # Equatorial orbit.
-            theta = 0
-            while theta < 360:
-                # Get the number of pixels that aren't black.
-                img = np.array(Image.open(BytesIO(Images(resp[0]).get_image(0))))
-                grade = (256 * 256) - np.sum(np.all(img == np.array([0, 0, 0]), axis=2))
-
-                if show_collider_hulls:
-                    pink_pass.append(grade)
-                else:
-                    id_pass.append(grade)
-
-                theta += delta_theta
-
-                # Get the new position.
-                rad = radians(theta)
-                x1 = cos(rad) * x - sin(rad) * z
-                z1 = sin(rad) * x + cos(rad) * z
-
-                # Teleport the avatar.
-                # Look at the object.
-                resp = self.communicate([{"$type": "teleport_avatar_to",
-                                          "avatar_id": avatar_id,
-                                          "position": {"x": x1, "y": y, "z": z1}},
-                                         {"$type": "look_at",
-                                          "avatar_id": avatar_id,
-                                          "object_id": object_id,
-                                          "use_centroid": True}
-                                         ])
-
-            # Reset the avatar.
-            resp = self.communicate([{"$type": "teleport_avatar_to",
-                                      "avatar_id": avatar_id,
-                                      "position": {"x": x, "y": y, "z": z}},
-                                     {"$type": "look_at",
-                                      "avatar_id": avatar_id,
-                                      "object_id": object_id,
-                                      "use_centroid": True},
-                                     {"$type": "send_images",
-                                      "frequency": "always"}])
-            # Polar orbit.
-            theta = 0
-            while theta < 360:
-                # Get the number of pixels that aren't black.
-                img = np.array(Image.open(BytesIO(Images(resp[0]).get_image(0))))
-                grade = (256 * 256) - np.sum(np.all(img == np.array([0, 0, 0]), axis=2))
-
-                if show_collider_hulls:
-                    pink_pass.append(grade)
-                else:
-                    id_pass.append(grade)
-
-                theta += delta_theta
-
-                # Get the new position.
-                rad = radians(theta)
-                x1 = cos(rad) * x - sin(rad) * z
-                y1 = (sin(rad) * x + cos(rad) * z) + y
-
-                # Teleport the avatar.
-                # Look at the object.
-                resp = self.communicate([{"$type": "teleport_avatar_to",
-                                          "avatar_id": avatar_id,
-                                          "position": {"x": x1, "y": y1, "z": 0}},
-                                         {"$type": "look_at",
-                                          "avatar_id": avatar_id,
-                                          "object_id": object_id,
-                                          "use_centroid": True}
-                                         ])
-
-        grades = [1 - (float(h) / float(i)) for h, i in zip(pink_pass, id_pass)]
-
-        physics_quality = float(sum(grades)) / len(grades)
-        print("Physics quality: " + str(physics_quality))
-
-        # Kill the build.
-        self.kill_build()
-        return physics_quality
-
-
-if __name__ == "__main__":
-    parser = ArgumentParser()
-    parser.add_argument("--record_path", type=str, help="The path to the temporary record file")
-    parser.add_argument("--asset_bundle_path", type=str, help="The path to the local asset bundle.")
-    args = parser.parse_args()
-
-    # Get the physics quality.
-    c = PhysicsQualityWriter(record_path=args.record_path,
-                             asset_bundle_path=args.asset_bundle_path)
-    physics_quality = c.get_physics_quality()
-
-    # Update the record.
-    record_path = Path(args.record_path)
-    record = ModelRecord(json.loads(record_path.read_text(encoding="utf-8")))
-    record.physics_quality = physics_quality
-    record_path.write_text(json.dumps(record.__dict__), encoding="utf-8")
diff --git a/Python/tdw/tdw_utils.py b/Python/tdw/tdw_utils.py
index 4fd269e68..26d3c5f19 100644
--- a/Python/tdw/tdw_utils.py
+++ b/Python/tdw/tdw_utils.py
@@ -647,11 +647,14 @@ def get_unit_scale(record: ModelRecord) -> float:
         bounds = record.bounds
 
         # Get the "unit scale" of the object.
-        s = 1 / max(
-            bounds['top']['y'] - bounds['bottom']['y'],
-            bounds['front']['z'] - bounds['back']['z'],
-            bounds['right']['x'] - bounds['left']['x'])
-        return s
+        try:
+            s = 1 / max(
+                bounds['top']['y'] - bounds['bottom']['y'],
+                bounds['front']['z'] - bounds['back']['z'],
+                bounds['right']['x'] - bounds['left']['x'])
+            return s
+        except ZeroDivisionError:
+            return 1
 
     @staticmethod
     def validate_amazon_s3() -> bool:
diff --git a/Python/use_cases/rube_goldberg/rube_goldberg.py b/Python/use_cases/rube_goldberg/rube_goldberg.py
index 60b629c49..a0e9c5284 100644
--- a/Python/use_cases/rube_goldberg/rube_goldberg.py
+++ b/Python/use_cases/rube_goldberg/rube_goldberg.py
@@ -90,7 +90,7 @@ def run(self, num_trials: int):
         """
 
         # Load the photorealistic "archviz_house" environment.
-        self.load_streamed_scene(scene="archviz_house")
+        self.communicate(self.get_add_scene(scene_name="archviz_house"))
 
         # Organize all initialization commands into a single list.
         # Set global values, including the desired screen size and aspect ratio (720P).
@@ -146,6 +146,7 @@ def run(self, num_trials: int):
 
         for i in range(num_trials):
             self.do_trial()
+        self.communicate({"$type": "terminate"})
             
 
     def do_trial(self):
diff --git a/README.md b/README.md
index 7878677f6..786d9ffda 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ TDW is a general-purpose tool that allows the user to communicate and manipulate
 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: [FloorplanController](Documentation/python/floorplan_controller.md)
+High-level API: [Floorplan](Documentation/python/floorplan.md)
 
 ## 2.2 3D Model Libraries
 
@@ -91,18 +91,35 @@ High-level API: [tdw_image_dataset](https://github.com/alters-mit/tdw_image_data
 
 ## 2.6 Physics
 
-1. Overview
-2. Object physics parameters
-3. `Rigidbody` output data
-4. `Collision` output data
-5. Apply forces to objects
-6. Kinematic objects
-7. NVIDIA Flex
-8. Non-physics simulations
+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)
 
-## 2.7 Audio
+## 2.8 Audio
 
 1. Overview
 2. PyImpact (dynamic impact sounds)
@@ -112,14 +129,14 @@ High-level API: [tdw_physics](https://github.com/alters-mit/tdw_physics)
 
 High-level API: [tdw_physics](https://github.com/alters-mit/multimodal_challenge)
 
-## 2.8 Video Recording
+## 2.9 Video Recording
 
 1. Controlling the framerate
 2. Image-only videos
 3. Image+audio videos
 4. The `CinematicCamera` add-on
 
-## 2.9 Robots and Embodied Agents
+## 2.10 Robots and Embodied Agents
 
 1. Robots
 2. Humanoid agents
@@ -130,13 +147,14 @@ High-level API: [tdw_physics](https://github.com/alters-mit/multimodal_challenge
 
 High-level API: [magnebot](https://github.com/alters-mit/magnebot)
 
-## 2.10 Misc. topics
+## 2.11 Misc. topics
 
 1. C# source code
 2. Freezing your code
 3. BinaryManager
 4. xpra
-5. Composite objects
+5. Creating composite objects
+6. Paintings
 
 # 3. API Documentation
 


Test patch:
diff --git a/Documentation/python/model_tests/missing_materials.md b/Documentation/python/model_tests/missing_materials.md
new file mode 100644
index 000000000..0b21de6bd
--- /dev/null
+++ b/Documentation/python/model_tests/missing_materials.md
@@ -0,0 +1,25 @@
+# MissingMaterials
+
+`from model_tests.missing_materials import MissingMaterials`
+
+Check if any materials are missing.
+
+***
+
+#### start
+
+**`self.start()`**
+
+_Returns:_  A list of commands to start the test.
+
+#### on_send
+
+**`self.on_send(resp)`**
+
+
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| resp |  List[bytes] |  | The response from the build. |
+
+_Returns:_  A list of commands to continue or end the test.
+
diff --git a/Documentation/python/model_tests/model_report.md b/Documentation/python/model_tests/model_report.md
new file mode 100644
index 000000000..7f1d2d98b
--- /dev/null
+++ b/Documentation/python/model_tests/model_report.md
@@ -0,0 +1,25 @@
+# ModelReport
+
+`from model_tests.model_report import ModelReport`
+
+Send `send_model_report` and get a basic report on the model.
+
+***
+
+#### start
+
+**`self.start()`**
+
+_Returns:_  A list of commands to start the test.
+
+#### on_send
+
+**`self.on_send(resp)`**
+
+
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| resp |  List[bytes] |  | The response from the build. |
+
+_Returns:_  A list of commands to continue or end the test.
+
diff --git a/Documentation/python/model_tests/model_test.md b/Documentation/python/model_tests/model_test.md
new file mode 100644
index 000000000..6c8153977
--- /dev/null
+++ b/Documentation/python/model_tests/model_test.md
@@ -0,0 +1,43 @@
+# ModelTest
+
+`from model_tests.model_test import ModelTest`
+
+Run a test on a model.
+
+***
+
+## Fields
+
+- `reports` A list of report strings after running the test.
+
+- `done` If True, the test is done.
+
+***
+
+## Functions
+
+#### \_\_init\_\_
+
+**`ModelTest(record)`**
+
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| record |  ModelRecord |  | The model record. |
+
+#### start
+
+**`self.start()`**
+
+_Returns:_  A list of commands to start the test.
+
+#### on_send
+
+**`self.on_send(resp)`**
+
+
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| resp |  List[bytes] |  | The response from the build. |
+
+_Returns:_  A list of commands to continue or end the test.
+
diff --git a/Documentation/python/model_tests/physics_quality.md b/Documentation/python/model_tests/physics_quality.md
new file mode 100644
index 000000000..40ecc873a
--- /dev/null
+++ b/Documentation/python/model_tests/physics_quality.md
@@ -0,0 +1,33 @@
+# PhysicsQuality
+
+`from model_tests.physics_quality import PhysicsQuality`
+
+Test the "physics quality" i.e. the disparity between the colliders volume and the rendered volume.
+
+***
+
+## Functions
+
+#### \_\_init\_\_
+
+**`PhysicsQuality()`**
+
+_Returns:_  A list of commands to start the test.
+
+#### start
+
+**`self.start()`**
+
+_Returns:_  A list of commands to start the test.
+
+#### on_send
+
+**`self.on_send(resp)`**
+
+
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| resp |  List[bytes] |  | The response from the build. |
+
+_Returns:_  A list of commands to continue or end the test.
+
diff --git a/Documentation/python/model_tests/rotate_object_test.md b/Documentation/python/model_tests/rotate_object_test.md
new file mode 100644
index 000000000..8bc96ba82
--- /dev/null
+++ b/Documentation/python/model_tests/rotate_object_test.md
@@ -0,0 +1,45 @@
+# RotateObjectTest
+
+`from model_tests.rotate_object_test import RotateObjectTest`
+
+These tests add an object and then rotate it.
+
+***
+
+## Class Variables
+
+| Variable | Type | Description |
+| --- | --- | --- |
+| `OBJECT_ID` | int | The ID of the object. |
+| `DELTA_THETA` | int | Rotate by this many degrees per frame. |
+| `PINK ` |  | The Unity pink color. |
+
+***
+
+## Functions
+
+#### \_\_init\_\_
+
+**`RotateObjectTest(record)`**
+
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| record |  ModelRecord |  | The model record. |
+
+#### start
+
+**`self.start()`**
+
+_Returns:_  A list of commands to start the test.
+
+#### on_send
+
+**`self.on_send(resp)`**
+
+
+| Parameter | Type | Default | Description |
+| --- | --- | --- | --- |
+| resp |  List[bytes] |  | The response from the build. |
+
+_Returns:_  A list of commands to continue or end the test.
+
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
diff --git a/Python/tdw/add_ons/model_verifier/model_tests/missing_materials.py b/Python/tdw/add_ons/model_verifier/model_tests/missing_materials.py
new file mode 100644
index 000000000..c50bd9258
--- /dev/null
+++ b/Python/tdw/add_ons/model_verifier/model_tests/missing_materials.py
@@ -0,0 +1,43 @@
+from collections import Counter
+from typing import List
+from io import BytesIO
+from PIL import Image
+from tdw.add_ons.model_verifier.model_tests.rotate_object_test import RotateObjectTest
+from tdw.output_data import Images
+
+
+class MissingMaterials(RotateObjectTest):
+    """
+    Check if any materials are missing.
+    """
+
+    def start(self) -> List[dict]:
+        """
+        :return: A list of commands to start the test.
+        """
+
+        commands = super().start()
+        commands.insert(0, {"$type": "set_pass_masks",
+                            "pass_masks": ["_img"]})
+        return commands
+
+    def _read_images(self, images: Images) -> None:
+        image = Image.open(BytesIO(images.get_image(0)))
+        # Source: https://stackoverflow.com/a/59709420
+        colors = Counter(image.getdata())
+        unique_colors = set(colors)
+        if MissingMaterials.PINK in unique_colors:
+            self.reports.append("Missing materials")
+            self.done = True
+
+    def on_send(self, resp: List[bytes]) -> List[dict]:
+        """
+        :param resp: The response from the build.
+
+        :return: A list of commands to continue or end the test.
+        """
+
+        commands = super().on_send(resp=resp)
+        if self.done:
+            commands.extend(RotateObjectTest._get_end_commands())
+        return commands
diff --git a/Python/tdw/add_ons/model_verifier/model_tests/model_report.py b/Python/tdw/add_ons/model_verifier/model_tests/model_report.py
new file mode 100644
index 000000000..a8d3fd85b
--- /dev/null
+++ b/Python/tdw/add_ons/model_verifier/model_tests/model_report.py
@@ -0,0 +1,36 @@
+from typing import List
+from tdw.add_ons.model_verifier.model_tests.model_test import ModelTest
+from tdw.output_data import OutputData, LogMessage
+
+
+class ModelReport(ModelTest):
+    """
+    Send `send_model_report` and get a basic report on the model.
+    """
+
+    def start(self) -> List[dict]:
+        """
+        :return: A list of commands to start the test.
+        """
+
+        return [{"$type": "send_model_report",
+                 "name": self._record.name,
+                 "url": self._record.get_url(),
+                 "id": 0,
+                 "scale_factor": self._record.scale_factor}]
+
+    def on_send(self, resp: List[bytes]) -> List[dict]:
+        """
+        :param resp: The response from the build.
+
+        :return: A list of commands to continue or end the test.
+        """
+
+        self.done = True
+        for i in range(len(resp) - 1):
+            r_id = OutputData.get_data_type_id(resp[i])
+            if r_id == "logm":
+                log_message = LogMessage(resp[i])
+                self.reports.append(log_message.get_message_type() + ": " + log_message.get_message())
+        return []
+
diff --git a/Python/tdw/add_ons/model_verifier/model_tests/model_test.py b/Python/tdw/add_ons/model_verifier/model_tests/model_test.py
new file mode 100644
index 000000000..4ad60b072
--- /dev/null
+++ b/Python/tdw/add_ons/model_verifier/model_tests/model_test.py
@@ -0,0 +1,43 @@
+from abc import ABC, abstractmethod
+from typing import List
+from tdw.librarian import ModelRecord
+
+
+class ModelTest(ABC):
+    """
+    Run a test on a model.
+    """
+
+    def __init__(self, record: ModelRecord):
+        """
+        :param record: The model record.
+        """
+
+        # The model record.
+        self._record: ModelRecord = record
+        """:field
+        A list of report strings after running the test.
+        """
+        self.reports: List[str] = list()
+        """:field
+        If True, the test is done.
+        """
+        self.done: bool = False
+
+    @abstractmethod
+    def start(self) -> List[dict]:
+        """
+        :return: A list of commands to start the test.
+        """
+
+        raise Exception()
+
+    @abstractmethod
+    def on_send(self, resp: List[bytes]) -> List[dict]:
+        """
+        :param resp: The response from the build.
+
+        :return: A list of commands to continue or end the test.
+        """
+
+        raise Exception()
diff --git a/Python/tdw/add_ons/model_verifier/model_tests/physics_quality.py b/Python/tdw/add_ons/model_verifier/model_tests/physics_quality.py
new file mode 100644
index 000000000..f6077f27f
--- /dev/null
+++ b/Python/tdw/add_ons/model_verifier/model_tests/physics_quality.py
@@ -0,0 +1,82 @@
+from typing import List
+from io import BytesIO
+from PIL import Image
+from collections import Counter
+from tdw.add_ons.model_verifier.model_tests.rotate_object_test import RotateObjectTest
+from tdw.output_data import Images
+from tdw.librarian import ModelRecord
+
+
+class PhysicsQuality(RotateObjectTest):
+    """
+    Test the "physics quality" i.e. the disparity between the colliders volume and the rendered volume.
+    """
+
+    MASK_RED = (255, 0, 0)
+
+    def __init__(self, record: ModelRecord):
+        super().__init__(record=record)
+        self._showing_collider_hulls: bool = False
+        self._without_collider_hulls: List[int] = list()
+        self._with_collider_hulls: List[int] = list()
+
+    def start(self) -> List[dict]:
+        """
+        :return: A list of commands to start the test.
+        """
+
+        commands = super().start()
+        commands.insert(0, {"$type": "set_pass_masks",
+                            "pass_masks": ["_mask"]})
+        return commands
+
+    def on_send(self, resp: List[bytes]) -> List[dict]:
+        """
+        :param resp: The response from the build.
+
+        :return: A list of commands to continue or end the test.
+        """
+
+        commands = super().on_send(resp=resp)
+        if self.done:
+            # Done with both passes. Get the physics quality.
+            if self._showing_collider_hulls:
+                qualities: List[float] = list()
+                for with_colliders, without_colliders in zip(self._with_collider_hulls, self._without_collider_hulls):
+                    if without_colliders == 0:
+                        physics_quality = 0
+                    else:
+                        physics_quality = 1 - float(without_colliders - with_colliders) / without_colliders
+                    qualities.append(physics_quality)
+                if len(qualities) == 0:
+                    physics_quality = -1
+                else:
+                    physics_quality = float(sum(qualities)) / len(qualities)
+                self.reports.append(str(physics_quality))
+                commands.extend(RotateObjectTest._get_end_commands())
+                return commands
+            # Show the colliders and start rotating again.
+            else:
+                self._showing_collider_hulls = True
+                self.done = False
+                self._angle = 0
+                self._axis = "yaw"
+                return [{"$type": "show_collider_hulls",
+                         "id": RotateObjectTest.OBJECT_ID},
+                        {"$type": "set_pass_masks",
+                         "pass_masks": ["_img"]},
+                        {"$type": "rotate_object_to",
+                         "rotation": {"w": 1, "x": 0, "y": 0, "z": 0},
+                         "id": RotateObjectTest.OBJECT_ID},
+                        {"$type": "teleport_object",
+                         "id": RotateObjectTest.OBJECT_ID,
+                         "position": {"x": 0, "y": 0.5, "z": 0}}]
+        else:
+            return commands
+
+    def _read_images(self, images: Images) -> None:
+        image = Image.open(BytesIO(images.get_image(0)))
+        if self._showing_collider_hulls:
+            self._with_collider_hulls.append(Counter(image.getdata())[RotateObjectTest.PINK])
+        else:
+            self._without_collider_hulls.append(Counter(image.getdata())[PhysicsQuality.MASK_RED])
diff --git a/Python/tdw/add_ons/model_verifier/model_tests/rotate_object_test.py b/Python/tdw/add_ons/model_verifier/model_tests/rotate_object_test.py
new file mode 100644
index 000000000..81cab0ca9
--- /dev/null
+++ b/Python/tdw/add_ons/model_verifier/model_tests/rotate_object_test.py
@@ -0,0 +1,126 @@
+from typing import List, Dict
+from abc import ABC, abstractmethod
+import numpy as np
+from tdw.add_ons.model_verifier.model_tests.model_test import ModelTest
+from tdw.librarian import ModelRecord
+from tdw.tdw_utils import TDWUtils
+from tdw.output_data import OutputData, Images
+
+
+class RotateObjectTest(ModelTest, ABC):
+    """
+    These tests add an object and then rotate it.
+    """
+
+    """:class_var
+    The ID of the object.
+    """
+    OBJECT_ID: int = 0
+    """:class_var
+    Rotate by this many degrees per frame.
+    """
+    DELTA_THETA: int = 15
+    """:class_var
+    The Unity pink color.
+    """
+    PINK: tuple = (255, 0, 255)
+    """:class_var
+    Look at this position.
+    """
+    LOOK_AT: Dict[str, float] = {"x": 0, "y": 0.5, "z": 0}
+    """:class_var
+    The position of the avatar.
+    """
+    AVATAR_POSITION: Dict[str, float] = {"x": 1.75, "y": 0.5, "z": 0}
+
+    def __init__(self, record: ModelRecord):
+        """
+        :param record: The model record.
+        """
+
+        super().__init__(record=record)
+        self._axis: str = "yaw"
+        self._angle: int = 0
+
+    def start(self) -> List[dict]:
+        """
+        :return: A list of commands to start the test.
+        """
+
+        scale = TDWUtils.get_unit_scale(self._record)
+        # Create the scene. Add the avatar. Add the object.
+        return [{"$type": "send_images",
+                "frequency": "always"},
+                {"$type": "add_object",
+                 "name": self._record.name,
+                 "url": self._record.get_url(),
+                 "position": {"x": 0, "y": 0, "z": 0},
+                 "scale_factor": self._record.scale_factor,
+                 "id": RotateObjectTest.OBJECT_ID},
+                {"$type": "scale_object",
+                 "id": RotateObjectTest.OBJECT_ID,
+                 "scale_factor": {"x": scale, "y": scale, "z": scale}}]
+
+    def on_send(self, resp: List[bytes]) -> List[dict]:
+        """
+        :param resp: The response from the build.
+
+        :return: A list of commands to continue or end the test.
+        """
+
+        for i in range(len(resp) - 1):
+            r_id = OutputData.get_data_type_id(resp[i])
+            if r_id == "imag":
+                self._read_images(Images(resp[i]))
+                break
+        # Reading the images can cause the test to finish early.
+        if self.done:
+            return []
+        # Either end the test or reset the angle and start rotating around a new axis.
+        elif self._angle >= 360:
+            if self._axis == "yaw":
+                self._axis = "roll"
+                self._angle = 0
+                return [{"$type": "teleport_avatar_to",
+                         "position": RotateObjectTest.AVATAR_POSITION},
+                        {"$type": "look_at_position",
+                         "position": RotateObjectTest.LOOK_AT}]
+            else:
+                self.done = True
+                return []
+        # Continue to rotate.
+        else:
+            self._angle += RotateObjectTest.DELTA_THETA
+            rad = np.radians(self._angle)
+            if self._axis == "yaw":
+                x = np.cos(rad) * RotateObjectTest.AVATAR_POSITION["x"] - np.sin(rad) * RotateObjectTest.AVATAR_POSITION["z"]
+                y = RotateObjectTest.AVATAR_POSITION["y"]
+                z = np.sin(rad) * RotateObjectTest.AVATAR_POSITION["x"] + np.cos(rad) * RotateObjectTest.AVATAR_POSITION["z"]
+            else:
+                x = np.cos(rad) * RotateObjectTest.AVATAR_POSITION["x"] - np.sin(rad) * RotateObjectTest.AVATAR_POSITION["z"]
+                y = (np.sin(rad) * RotateObjectTest.AVATAR_POSITION["x"] + np.cos(rad) * RotateObjectTest.AVATAR_POSITION["z"]) + RotateObjectTest.AVATAR_POSITION["y"]
+                z = RotateObjectTest.AVATAR_POSITION["z"]
+
+            return [{"$type": "teleport_avatar_to",
+                     "position": {"x": x, "y": y, "z": z}},
+                    {"$type": "look_at_position",
+                     "position": RotateObjectTest.LOOK_AT}]
+
+    @abstractmethod
+    def _read_images(self, images: Images) -> None:
+        """
+        Read image data.
+
+        :param images: The image data.
+        """
+
+        raise Exception()
+
+    @staticmethod
+    def _get_end_commands() -> List[dict]:
+        """
+        :return: A list of commands to end to test.
+        """
+
+        return [{"$type": "destroy_object",
+                 "id": RotateObjectTest.OBJECT_ID}]