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.
# 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):
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:
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.
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:
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:
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:
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:
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():
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:
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:
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:
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:
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:
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:
manager.setCollisionObjectsTransform(["moving_box_link"], [start], [end])
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:
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:
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:
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:
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:
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:
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()
Trajectory links¶
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):
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:
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].
Decompose and write¶
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:
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:
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.