Skip to content

Collision Examples

Collision checking below the high-level Robot API: contact-manager plugins, collision margins, contact validation, continuous checks and convex decomposition. Each example is a port of an upstream tesseract example or test, or takes its parameters from upstream source, cited in the module docstring. Every value printed is also asserted in tests/examples.

Bash
# Installed console scripts
tesseract_contact_managers_example
tesseract_trajectory_collision_example
tesseract_convex_decomposition_example

# Or via Python module invocation
pixi run python -m tesseract_robotics.examples.contact_managers_example

Contact Managers

A port of upstream's box_box_example.cpp, run on every discrete backend, then the rest of the contact-manager API on the same scene.

Load the plugin factory from contact_manager_plugins.yaml. It names three discrete managers (BulletDiscreteBVHManager is the default) and two continuous ones (BulletCastBVHManager is the default):

contact_managers_example.py
    locator = GeneralResourceLocator()
    factory = ContactManagersPluginFactory(locate(locator, PLUGINS_URL), locator)

    discrete_plugins = list(factory.getDiscreteContactManagerPlugins())
    continuous_plugins = list(factory.getContinuousContactManagerPlugins())
    defaults = (
        factory.getDefaultDiscreteContactManagerPlugin(),
        factory.getDefaultContinuousContactManagerPlugin(),
    )

The second box is upstream's box_2m.ply as a convex hull. Upstream reads the PLY with loadSimplePlyFile, which is not bound; createMeshFromPath loads the same mesh, and makeConvexMesh is the call upstream makes on it:

contact_managers_example.py
    mesh = createMeshFromPath(str(locate(locator, BOX_2M_URL)))[0]
    hull = makeConvexMesh(mesh)

The example itself: a unit box inside the 2 m hull, then moved to x = 1.6, a 0.1 m gap, and checked at margins 0.1 and 0.25.

contact_managers_example.py
    add_box_box_objects(manager, hull)
    manager.setCollisionMarginData(CollisionMarginData(MARGIN))

    manager.setCollisionObjectsTransform(
        {"box_link": translation(*PENETRATING_XY), "second_box_link": translation(0)}
    )
    penetrating = contact_test(manager)

    manager.setCollisionObjectsTransform({"box_link": translation(GAP_X)})
    at_margin = contact_test(manager)

    manager.setDefaultCollisionMargin(LARGER_MARGIN)
    at_larger_margin = contact_test(manager)
backend inside: pair inside: distance x = 1.6, margin 0.1 x = 1.6, margin 0.25
BulletDiscreteBVHManager box_link / second_box_link −1.2663 no contact 0.1000
BulletDiscreteSimpleManager box_link / second_box_link −1.2663 no contact 0.1000
FCLDiscreteBVHManager box_link / second_box_link −1.3000 no contact 0.1000

The backends agree on which pair collides, not on how deep: the analytic penetration is 1.3 m, which FCL reports. Upstream's own box-box unit test avoids box_2m.ply for Bullet for this reason (collision_box_box_unit.hpp:58). BulletDiscreteSimpleManager also reports link_names in the other order, so the example compares the ContactResultMap keys, which are always ordered.

The margin 0.1 case is a boundary

Both backends keep a contact whose distance is at most the margin (bullet_utils.cpp:1072, fcl_utils.cpp:307). In doubles the gap is (1.6 - 0.5) - 1.0 = 0.10000000000000009, one ulp above 0.1, so margin 0.1 reports nothing, as upstream's unit test expects (collision_box_box_unit.hpp:201). Any margin even 1e-12 m larger reports the pair.

A manager can also be created from a PluginInfo:

contact_managers_example.py
    discrete_info = factory.getDiscreteContactManagerPlugins()["BulletDiscreteSimpleManager"]
    continuous_info = factory.getContinuousContactManagerPlugins()["BulletCastSimpleManager"]
    created_by_plugin_info = (
        factory.createDiscreteContactManager(
            "BulletDiscreteSimpleManager", discrete_info
        ).getName(),
        factory.createContinuousContactManager(
            "BulletCastSimpleManager", continuous_info
        ).getName(),
    )

Collision margins

The pair-margin cases of upstream's box-box unit test, built with the pair-data constructors and checked by a contact test at the 0.1 m gap pose. A margin on a pair that isn't in the scene (1.7 m) raises getMaxCollisionMargin() but not getMaxCollisionMargin("box_link"), so the boxes stay out of contact. incrementMargins(0.15) and scaleMargins(2.5) take 0.1 to upstream's second margin, 0.25. apply with MODIFY merges pair margins and with REPLACE swaps them:

contact_managers_example.py
    unrelated = CollisionMarginPairData({UNRELATED_PAIR: UNRELATED_MARGIN})
    box_pair = CollisionMarginPairData({BOX_PAIR: LARGER_MARGIN})

    cases = {
        "unrelated_pair_1_7": CollisionMarginData(MARGIN, unrelated),
        "box_pair_0_25": CollisionMarginData(MARGIN, box_pair),
        "pair_only": CollisionMarginData(box_pair),
    }
    incremented = CollisionMarginData(MARGIN)
    incremented.incrementMargins(LARGER_MARGIN - MARGIN)
    scaled = CollisionMarginData(MARGIN)
    scaled.scaleMargins(LARGER_MARGIN / MARGIN)
    modify_applied = CollisionMarginData(MARGIN, unrelated)
    modify_applied.apply(box_pair, CollisionMarginPairOverrideType.MODIFY)
    replace_applied = CollisionMarginData(MARGIN, unrelated)
    replace_applied.apply(box_pair, CollisionMarginPairOverrideType.REPLACE)
case max max for box_link contact
default 0.1 + unrelated pair 1.7 1.7 0.1 no
default 0.1 + box pair 0.25 0.25 0.25 yes
box pair 0.25 only 0.25 0.25 yes
0.1, incrementMargins(0.15) 0.25 0.25 yes
0.1, scaleMargins(2.5) 0.25 0.25 yes
unrelated 1.7, apply(box pair 0.25, MODIFY) 1.7 0.25 yes
unrelated 1.7, apply(box pair 0.25, REPLACE) 0.25 0.25 yes

CollisionMarginPairData has the same arithmetic. Its getMaxCollisionMargin(link) returns None for a link no pair involves:

contact_managers_example.py
    pair_incremented = CollisionMarginPairData({BOX_PAIR: MARGIN})
    pair_incremented.incrementMargins(LARGER_MARGIN - MARGIN)
    pair_scaled = CollisionMarginPairData({BOX_PAIR: MARGIN})
    pair_scaled.scaleMargins(LARGER_MARGIN / MARGIN)
    pair_modified = CollisionMarginPairData({BOX_PAIR: MARGIN})
    pair_modified.apply(unrelated, CollisionMarginPairOverrideType.MODIFY)
    pair_replaced = CollisionMarginPairData({BOX_PAIR: MARGIN})
    pair_replaced.apply(unrelated, CollisionMarginPairOverrideType.REPLACE)

Editing the factory

Add a copy of a plugin, make it the default, create a manager from it and remove it again, as upstream's PluginFactorAPIUnit does. A manager created by plugin name carries that name. When the default is removed, the first remaining plugin by name becomes the default:

contact_managers_example.py
    simple = factory.getDiscreteContactManagerPlugins()["BulletDiscreteSimpleManager"]
    factory.addDiscreteContactManagerPlugin("TestDiscreteManager", copy_plugin_info(simple))
    factory.setDefaultDiscreteContactManagerPlugin("TestDiscreteManager")
    discrete_default = factory.getDefaultDiscreteContactManagerPlugin()
    discrete_created = factory.createDiscreteContactManager(discrete_default).getName()
    factory.removeDiscreteContactManagerPlugin("TestDiscreteManager")

    cast_simple = factory.getContinuousContactManagerPlugins()["BulletCastSimpleManager"]
    factory.addContinuousContactManagerPlugin(
        "TestContinuousManager", copy_plugin_info(cast_simple)
    )
    factory.setDefaultContinuousContactManagerPlugin("TestContinuousManager")
    continuous_default = factory.getDefaultContinuousContactManagerPlugin()
    continuous_created = factory.createContinuousContactManager(continuous_default).getName()
    factory.removeContinuousContactManagerPlugin("TestContinuousManager")

saveConfig writes the factory's current configuration, and a factory built from that file reports the same getConfig():

contact_managers_example.py
    with tempfile.TemporaryDirectory() as tmp:
        saved = Path(tmp) / "contact_manager_plugins.yaml"
        factory.saveConfig(saved)
        config_round_trip_equal = (
            ContactManagersPluginFactory(saved, locator).getConfig() == factory.getConfig()
        )

clearSearchPaths and clearSearchLibraries empty the plugin loader's search lists:

contact_managers_example.py
    factory.clearSearchPaths()
    factory.clearSearchLibraries()
    cleared_search = {"paths": factory.getSearchPaths(), "libraries": factory.getSearchLibraries()}

Allowed collision matrix

Upstream's TestGetAllowedCollisions entries, built from a dict keyed by makeOrderedLinkPair. The result equals an ACM built entry by entry with addAllowedCollision. getAllowedCollisions lists the links allowed to collide with any of link1, link3, cause_duplicate: 5 names, or 6 when duplicates are kept. removeAllowedCollision("link1") drops every entry involving link1:

contact_managers_example.py
    entries = {
        makeOrderedLinkPair(*link_pair): reason
        for link_pair, reason in zip(ACM_ENTRIES, ACM_REASONS)
    }
    acm = AllowedCollisionMatrix(entries)

    added = AllowedCollisionMatrix()
    for (link_1, link_2), reason in zip(ACM_ENTRIES, ACM_REASONS):
        added.addAllowedCollision(link_1, link_2, reason)

    from_dict_equal = acm == added

    allowed_deduplicated = getAllowedCollisions(ACM_QUERY, acm.getAllAllowedCollisions(), True)
    allowed_with_duplicates = getAllowedCollisions(ACM_QUERY, acm.getAllAllowedCollisions(), False)

    acm.removeAllowedCollision("link1")

Contact validation and result maps

A ContactResultValidator subclass approves or rejects each contact. A contact test calls it from its worker thread with the GIL taken again, and an exception it raises propagates to the caller:

contact_managers_example.py
class DropPairValidator(ContactResultValidator):
    """Rejects every contact between two given links; records what it was asked."""

    def __init__(self, link_pair: tuple[str, str]):
        super().__init__()
        self.link_pair = link_pair
        self.seen: list[tuple[str, str]] = []

    def __call__(self, result: ContactResult) -> bool:
        link_pair = makeOrderedLinkPair(*result.link_names)
        self.seen.append(link_pair)
        return link_pair != self.link_pair

With the thin box enabled, the three objects overlap pairwise. The validator is called once directly, then set on ContactRequest.is_valid, where it drops the box_link / thin_box_link pair:

contact_managers_example.py
    manager.enableCollisionObject("thin_box_link")
    manager.setActiveCollisionObjects(["box_link", "thin_box_link", "second_box_link"])

    dropped = makeOrderedLinkPair("thin_box_link", "box_link")
    validator = DropPairValidator(dropped)
    probe = ContactResult()
    probe.link_names = list(dropped)
    dropped_pair = validator(probe)
    probe.link_names = list(BOX_PAIR)
    kept_pair = validator(probe)
    validator.seen.clear()

    without = contact_test(manager, ContactTestType.ALL)
    request = ContactRequest(ContactTestType.ALL)
    request.is_valid = validator
    with_validator = contact_test(manager, request=request)

A ContactResultMap iterates as (key, ContactResultVector) pairs. filter edits the vectors in place, and addContactResult / setContactResult take either one result or a vector:

contact_managers_example.py
    iterated_keys = [key for key, _ in without]
    container = without.getContainer()
    at_matches_container = all(
        len(without.at(key)) == len(vector) for key, vector in container.items()
    )

    def keep_thin_box_with_box(key, vector):
        if key != dropped:
            vector.clear()

    filtered = contact_test(manager, ContactTestType.ALL)
    filtered.filter(keep_thin_box_with_box)

    edited = contact_test(manager, ContactTestType.ALL)
    counts = {"start": edited.count()}
    edited.addContactResult(BOX_PAIR, edited.at(BOX_PAIR)[0])
    counts["add_result"] = edited.count()
    two = ContactResultVector()
    two.append(edited.at(BOX_PAIR)[0])
    two.append(edited.at(BOX_PAIR)[0])
    edited.addContactResult(BOX_PAIR, two)
    counts["add_vector"] = edited.count()
    edited.clear()
    edited.setContactResult(BOX_PAIR, two[0])
    counts["set_result"] = edited.count()
    edited.setContactResult(BOX_PAIR, two)
    counts["set_vector"] = edited.count()

Continuous (cast) manager

Upstream's box-box cast unit test: a 0.25 m box swept from (−1.9, 0, 0) to (1.9, 3.8, 0) past a static unit box. Each of the three cast overloads of setCollisionObjectsTransform sets the same sweep, by name, by name list or by a pair of transform maps:

contact_managers_example.py
    manager.setCollisionObjectsTransform("moving_box_link", start, end)
contact_managers_example.py
    manager.setCollisionObjectsTransform(["moving_box_link"], [start], [end])
contact_managers_example.py
    manager.setCollisionObjectsTransform({"moving_box_link": start}, {"moving_box_link": end})

Each gives upstream's values: distance −0.2475, cc_time (−1, 0.25), and the contact point mapped through the sweep's start and end poses, transform[1] * nearest_points_local[1] = (−1.275, −0.625, 0) and cc_transform[1] * nearest_points_local[1] = (2.525, 3.175, 0).

Parking a cast object

A single-pose setCollisionObjectsTransform on a cast manager moves the object but keeps its previous sweep (bullet_cast_bvh_manager.cpp:240–256). To park an object, set a zero-length sweep: the same pose twice.

Environment contact managers

On the IIWA environment, EnvironmentContactAllowedValidator answers from the scene graph's ACM: base_link / link_1 is disabled in the SRDF ("Adjacent"), base_link / link_5 is not. Any configured manager can be fetched by name, and the SetActive*ContactManagerCommand pair switches the active ones:

contact_managers_example.py
    validator = EnvironmentContactAllowedValidator(scene_graph)
    links = [link.getName() for link in scene_graph.getLinks()]
    validator_matches_scene_graph = all(
        validator(a, b) == scene_graph.isCollisionAllowed(a, b) for a in links for b in links
    )

    by_name = (
        env.getDiscreteContactManager("FCLDiscreteBVHManager").getName(),
        env.getContinuousContactManager("BulletCastSimpleManager").getName(),
    )

    discrete_command = SetActiveDiscreteContactManagerCommand("BulletDiscreteSimpleManager")
    continuous_command = SetActiveContinuousContactManagerCommand("BulletCastSimpleManager")
    applied = (env.applyCommand(discrete_command), env.applyCommand(continuous_command))
    active_after_commands = (
        env.getDiscreteContactManager().getName(),
        env.getContinuousContactManager().getName(),
    )

Trajectory Collision

Collision checks of a single state, a swept segment and a whole trajectory, and a trajectory turned into a collision link.

States and segments

Upstream's boxbot test: two unit boxes, test_box_link 0.9 m along x, so they overlap by 0.1 m. checkTrajectoryState has a discrete and a continuous overload; both report the pair:

trajectory_collision_example.py
    state = {"boxbot_link": translation(0), "test_box_link": translation(*STATE_OFFSET)}

    discrete = env.getDiscreteContactManager()
    discrete.setActiveCollisionObjects(list(BOX_PAIR))
    discrete.applyContactManagerConfig(config)
    discrete_contacts = checkTrajectoryState(discrete, state, request)

    continuous = env.getContinuousContactManager()
    continuous.setActiveCollisionObjects(list(BOX_PAIR))
    continuous.applyContactManagerConfig(config)
    continuous_contacts = checkTrajectoryState(continuous, state, request)

checkTrajectorySegment sweeps test_box_link from y = 2 to y = −2 through boxbot_link:

trajectory_collision_example.py
    state0 = {"boxbot_link": translation(0), "test_box_link": translation(*SEGMENT_START)}
    state1 = {"boxbot_link": translation(0), "test_box_link": translation(*SEGMENT_END)}
    segment_contacts = checkTrajectorySegment(continuous, state0, state1, request)

The penetration depth depends on the backend. FCL, fetched by name from the boxbot's plugin configuration, reports the analytic 0.1 m:

trajectory_collision_example.py
    fcl = env.getDiscreteContactManager("FCLDiscreteBVHManager")
    fcl.setActiveCollisionObjects(list(BOX_PAIR))
    fcl.applyContactManagerConfig(config)
    fcl_contacts = checkTrajectoryState(fcl, state, request)
manager distance
BulletDiscreteBVHManager (default discrete) −0.0577
BulletCastBVHManager (default continuous) −0.0577
FCLDiscreteBVHManager −0.1000

Bullet's −0.0577 m is 0.1 / √3, along a corner direction. The boxes' y and z faces are coplanar, and Bullet's penetration solver does not find the x-axis minimum. Upstream's boxbot test asserts only that the pair is in contact. Disabling boxbot_link through ContactManagerConfig.modify_object_enabled clears every check.

checkTrajectory, rebuilt from its parts

Upstream's checkTrajectoryUnit (IIWA with a 0.15 m sphere attached to base_link, five states from joint_a1 = −0.4 to 0.4 rad) checked with LVS_CONTINUOUS. checkTrajectory splits each step into sub-segments of at most longest_valid_segment_length (0.005, the default) in joint space, checks each with checkTrajectorySegment and merges the results with addInterpolatedCollisionResults. The example does the same in Python:

trajectory_collision_example.py
    lvs = config.longest_valid_segment_length
    active_links = manager.getActiveCollisionObjects()
    # One map reused across steps, as utils.cpp:234 does: clear() keeps the keys
    # (with empty vectors), and == compares them
    step_results = ContactResultMap()
    steps_equal, rebuilt_pairs, rebuilt_counts = [], [], []
    for step in range(len(traj) - 1):
        step_results.clear()
        start, end = traj[step], traj[step + 1]
        # utils.cpp:236-243: sub-states every `lvs` of joint-space distance
        count = math.ceil(np.linalg.norm(end - start) / lvs) + 1
        substates = np.linspace(start, end, count)
        last_index = count - 1
        segment_dt = 1.0 / last_index

        for substep in range(last_index):
            state0 = state_solver.getState(IIWA_JOINTS, substates[substep]).link_transforms
            state1 = state_solver.getState(IIWA_JOINTS, substates[substep + 1]).link_transforms
            segment = checkTrajectorySegment(manager, state0, state1, config.contact_request)
            if not segment.empty():
                step_results.addInterpolatedCollisionResults(
                    segment, substep, last_index, active_links, segment_dt, False, None
                )
        steps_equal.append(step_results == step_contacts[step])

Each step's rebuilt result equals checkTrajectory's, and both give upstream's counts: link pairs per step 2, 2, 3, 2, and 45 + 80 + 118 + 45 = 288 contacts, with the most in step 2.

ContactResultMap.clear() keeps its keys

clear() empties every vector but keeps the key, and == compares keys. checkTrajectory reuses one map across steps (utils.cpp:234), so in step 3 the pair link_5 / sphere_attached, which touched only in step 2, is still there with no contacts. A rebuild with a new map per step has the same contacts but compares unequal. Reuse one map, as above.

JointTrajectory

Upstream's trajectory-link test trajectory, joint_a1 and joint_a2 from 0 to 1 rad, built with either constructor. Indexing returns a copy; assign it back with traj[i] = state:

trajectory_collision_example.py
    start = JointState(TRAJECTORY_JOINTS, np.zeros(2))
    end = JointState(TRAJECTORY_JOINTS, np.ones(2))

    pushed = JointTrajectory(TRAJECTORY_DESCRIPTION)
    pushed.push_back(start)
    pushed.push_back(end)
    trajectory = JointTrajectory([start, end], TRAJECTORY_DESCRIPTION)
    trajectory.uuid = pushed.uuid = str(uuid.uuid4())
    equal_by_constructor = trajectory == pushed

    positions = [state.position.tolist() for state in trajectory]
    midpoint = trajectory[1]
    midpoint.position = np.full(2, 0.5)
    getitem_is_copy = trajectory[1].position.tolist() == [1.0, 1.0]
    pushed[1] = midpoint
    after_setitem = pushed[1].position.tolist()
    pushed.clear()

AddTrajectoryLinkCommand adds a link, traj_link under base_link, that carries the collision geometry of every active link over the trajectory. The Method decides how that geometry is merged. The example applies each method in turn, replacing the link (replace_allowed=True after the first):

trajectory_collision_example.py
        replace_allowed = method != AddTrajectoryLinkCommand.Method.PER_STATE_OBJECTS
        command = AddTrajectoryLinkCommand(
            TRAJECTORY_LINK, TRAJECTORY_PARENT, trajectory, replace_allowed, method
        )
        applied = env.applyCommand(command)
        collisions[method.name] = len(env.getLink(TRAJECTORY_LINK).collision)

joint_a1 and joint_a2 move link_1 to link_7, one collision shape each, over 2 states (environment.cpp:1412–1471):

Method collision shapes rule
PER_STATE_OBJECTS 14 every active link's shapes, per state (7 × 2)
PER_STATE_CONVEX_HULL 2 one hull per state
GLOBAL_PER_LINK_CONVEX_HULL 7 one hull per active link
GLOBAL_CONVEX_HULL 1 one hull for everything

Convex Decomposition

A port of upstream's create_convex_decomposition command-line tool: split a mesh into convex hulls with V-HACD and write each hull to a PLY file. The input is the car-seat visual mesh seat.dae (10367 vertices, 20776 faces).

Options

Upstream's options, with its short flags and value ranges, as argparse. The defaults are VHACDParameters()'s, and every field is set from an option:

convex_decomposition_example.py
def parse_args(argv: list[str]) -> argparse.Namespace:
    """create_convex_decomposition.cpp's options; defaults from VHACDParameters()."""
    defaults = VHACDParameters()
    unsigned_max = 2**32 - 1
    parser = argparse.ArgumentParser(description="Create a convex decomposition of a mesh")
    parser.add_argument("-i", "--input", help="File path to mesh used to create a convex hull.")
    parser.add_argument(
        "-o",
        "--output",
        default=DEFAULT_OUTPUT,
        help="File path to save the generated convex hull as a ply.",
    )
    parser.add_argument(
        "-n",
        "--max_convex_hulls",
        type=in_range(int, 1, 32),
        default=defaults.max_convex_hulls,
        help="Maximum number of convex hulls",
    )
    parser.add_argument(
        "-r",
        "--resolution",
        type=in_range(int, 1, unsigned_max),
        default=defaults.resolution,
        help="Number of voxels to use to represent the shape",
    )
    parser.add_argument(
        "-e",
        "--min_volume_percent_error",
        type=in_range(float, 0.001, 10.0),
        default=defaults.minimum_volume_percent_error_allowed,
        help="If the voxels are within this threshold percentage of the volume of the hull, "
        "we consider this a close enough approximation",
    )
    parser.add_argument(
        "-d",
        "--max_recursion_depth",
        type=in_range(int, 0, unsigned_max),
        default=defaults.max_recursion_depth,
        help="Maximum recursion depth for convex decomposition improvement",
    )
    parser.add_argument(
        "-s",
        "--shrinkwrap",
        type=boolean,
        default=defaults.shrinkwrap,
        help="Shrinkwrap the voxel positions to the source mesh on output",
    )
    parser.add_argument(
        "-v",
        "--max_num_vertices",
        type=in_range(int, 4, unsigned_max),
        default=defaults.max_num_vertices_per_ch,
        help="Maximum number of vertices per convex hull",
    )
    parser.add_argument(
        "-l",
        "--min_edge_length",
        type=in_range(int, 1, unsigned_max),
        default=defaults.min_edge_length,
        help="Once a voxel patch has an edge length of less than this value in all 3 "
        "dimensions, stop recursing",
    )
    parser.add_argument(
        "-p",
        "--find_best_plane",
        type=boolean,
        default=defaults.find_best_plane,
        help="Flag for attempting to split planes along best location (experimental)",
    )
    # Not in upstream's tool: how the voxels inside the mesh are filled
    parser.add_argument(
        "--fill_mode",
        choices=[mode.name for mode in FillMode],
        default=defaults.fill_mode.name,
        help="FLOOD_FILL for closed meshes, RAYCAST_FILL for open ones, SURFACE_ONLY for hollow",
    )
    return parser.parse_args(argv)


def parameters(args: argparse.Namespace) -> VHACDParameters:
    """VHACDParameters from the parsed options."""
    params = VHACDParameters()
    params.max_convex_hulls = args.max_convex_hulls
    params.resolution = args.resolution
    params.minimum_volume_percent_error_allowed = args.min_volume_percent_error
    params.max_recursion_depth = args.max_recursion_depth
    params.shrinkwrap = args.shrinkwrap
    params.fill_mode = FillMode[args.fill_mode]
    params.max_num_vertices_per_ch = args.max_num_vertices
    params.min_edge_length = args.min_edge_length
    params.find_best_plane = args.find_best_plane
    return params

The example deviates from the tool in three places: --input and --output default to seat.dae and seat.ply, so it runs without arguments; any format Assimp reads is accepted, where upstream reads only PLY (loadSimplePlyFile, not bound); and --fill_mode is added, because the fill mode changes the result. As upstream, a range is checked only for a value given on the command line: the default max_convex_hulls of 64 lies outside -n's range [1, 32].

Bash
tesseract_convex_decomposition_example -n 16 --fill_mode RAYCAST_FILL -o /path/to/seat.ply

Decompose and write

convex_decomposition_example.py
    params = parameters(args)
    mesh = createMeshFromPath(str(seat_path))[0]
    hulls = ConvexDecompositionVHACD(params).compute(mesh.getVertices(), mesh.getFaces(), False)

At the defaults this gives 64 hulls in about a second. Each is written as <i>_<output>, as the tool does:

convex_decomposition_example.py
        written = []
        for i, hull in enumerate(hulls):
            path = directory / f"{i}_{Path(args.output).name}"
            writeMeshToFile(hull, str(path))
            written.append(path)

Read back, each file holds its hull's vertices to the PLY's 7 written decimals. The round trip works because the binding repairs the hulls' face lists, which upstream's createConvexHull corrupts (#233; ConvexHullFaceRepairError in the API reference).

Each hull is checked for convexity against its own faces: every face is planar and every vertex lies on the inner side of every face plane, within the rounding of Bullet's int * scaling + center output and of the plane test (Shewchuk's orient3d). V-HACD can also return a flat hull, all its vertices in one plane, with a front and a back face. That hull is convex when each face is a convex polygon in its plane (orient2d). On macOS arm64 the seat's default decomposition has no flat hull; box_2m.ply at resolution 10000 with SURFACE_ONLY has 28 of 64. A second createConvexHull of its vertices passes the same test and keeps only the hull's own vertices, up to one step of Bullet's integer grid.

createConvexHull is not idempotent

Bullet computes a hull on a grid of (max − min) / 10216 per axis (btConvexHullComputer.cpp:1992). A vertex within one grid step of a face is merged away, so hulling a hull's vertices again can drop one: one RAYCAST_FILL hull of the seat loses a vertex 4 µm above a face. Compare vertex sets within the grid step, not vertex counts.

Fill modes

FillMode decides which voxels count as inside the mesh: FLOOD_FILL for closed meshes, RAYCAST_FILL for open ones, SURFACE_ONLY for hollow ones. Each gives its own decomposition. The default-constructed ConvexDecompositionVHACD() gives the same hulls as explicit default parameters:

convex_decomposition_example.py
    fill_modes = {}
    for mode in FillMode:
        mode_params = parameters(args)
        mode_params.fill_mode = mode
        fill_modes[mode.name] = ConvexDecompositionVHACD(mode_params).compute(
            mesh.getVertices(), mesh.getFaces(), False
        )
    default_hulls = ConvexDecompositionVHACD().compute(mesh.getVertices(), mesh.getFaces(), False)
FillMode hulls bounding box (m)
FLOOD_FILL 64 (−0.3641, −0.2366, 0.0) to (0.2309, 0.2396, 1.0035)
SURFACE_ONLY 64 (−0.3668, −0.2366, 0.002) to (0.2309, 0.2396, 1.0049)
RAYCAST_FILL 64 (−0.3668, −0.2366, 0.0) to (0.2306, 0.2395, 1.0049)
mesh seat.dae (−0.3669, −0.2403, 0.0) to (0.2365, 0.2403, 1.0094)
shipped seat_*.stl 10 (−0.367, −0.2404, 0.0) to (0.2367, 0.2404, 1.0096)

The decomposition shipped with tesseract support, car_seat/collision/seat_*.stl, was made with other, unrecorded parameters. It is reported, not compared.