Motion Planning¶
Motion Planner¶
MotionPlanner(robot_name: str, config: PlannerConfig | None = None, pointcloud: np.ndarray | None = None, base_config: np.ndarray | None = None, constraints: list | None = None, costs: list | None = None)
¶
Motion planner using OMPL + VAMP C++ backend.
All internals are private. The public API accepts and returns only numpy arrays.
For subgroup planners the helper methods :meth:extract_config,
:meth:embed_config, and :meth:embed_path convert between the
reduced DOF space used by the planner and the full 10-DOF body
configuration.
Source code in wheelchair_planning/planning/motion_planner.py
bounds: tuple[np.ndarray, np.ndarray]
property
¶
(lower, upper) joint limits of the active DOF.
joint_limits: dict[str, tuple[float, float]]
property
¶
{joint name: (lower, upper)} the planner samples in, for every
joint of the full body. :attr:bounds gives the active joints'
ranges as arrays.
joint_names: list[str]
property
¶
Joint names controlled by this planner, in DOF order.
is_subgroup: bool
property
¶
True if this planner controls a subset of the full body.
subgroup_indices: np.ndarray | None
property
¶
Indices of this planner's joints in the full 10-DOF config.
base_config: np.ndarray
property
¶
The 10-DOF stance frozen for joints outside this subgroup.
has_pointcloud: bool
property
¶
True if a pointcloud is currently registered.
set_joint_limits(limits: Mapping[str, tuple[float, float]] | None = None) -> None
¶
Set the joint ranges the planner samples in, by joint name.
Joints left out of limits take the range compiled into the
robot model, so None restores every compiled range. The
ranges cover the full body and are kept across
:meth:set_subgroup. Collision checking does not read them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
limits
|
Mapping[str, tuple[float, float]] | None
|
|
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
For an unknown joint name, or bounds that are not
finite with |
Source code in wheelchair_planning/planning/motion_planner.py
clear_constraints() -> None
¶
set_constraints(constraints: list) -> None
¶
Replace all constraints: clear existing, then push new ones.
clear_costs() -> None
¶
set_costs(costs: list) -> None
¶
add_pointcloud(pointcloud: np.ndarray) -> None
¶
Set the scene pointcloud, replacing any previously-registered cloud.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pointcloud
|
ndarray
|
|
required |
Source code in wheelchair_planning/planning/motion_planner.py
remove_pointcloud() -> bool
¶
Drop the currently-registered pointcloud.
Returns False if there was no cloud to remove.
filter_pointcloud(pointcloud: np.ndarray, min_dist: float, max_range: float, origin: np.ndarray | list[float], workspace_min: np.ndarray | list[float], workspace_max: np.ndarray | list[float], cull: bool = True) -> np.ndarray
¶
Spatially downsample a point cloud via Morton-curve sorting.
Keeps one representative point per min_dist neighbourhood and
discards points farther than max_range from origin or
outside the [workspace_min, workspace_max] bounding box.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pointcloud
|
ndarray
|
|
required |
min_dist
|
float
|
Minimum distance between two retained points. |
required |
max_range
|
float
|
Maximum distance from |
required |
origin
|
ndarray | list[float]
|
|
required |
workspace_min
|
ndarray | list[float]
|
|
required |
workspace_max
|
ndarray | list[float]
|
|
required |
cull
|
bool
|
If |
True
|
Returns:
| Type | Description |
|---|---|
ndarray
|
|
Source code in wheelchair_planning/planning/motion_planner.py
filter_self_from_pointcloud(pointcloud: np.ndarray, point_radius: float, config: np.ndarray) -> np.ndarray
¶
Remove points that collide with the robot body or environment.
Computes forward kinematics at config, then drops every
point whose inflated sphere (radius point_radius) overlaps
any robot collision sphere or any registered obstacle.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pointcloud
|
ndarray
|
|
required |
point_radius
|
float
|
Inflation radius for each point. |
required |
config
|
ndarray
|
Active-DOF configuration (same space as |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
|
Source code in wheelchair_planning/planning/motion_planner.py
set_subgroup(robot_name: str, base_config: np.ndarray | None = None) -> None
¶
Switch active joints without rebuilding the collision environment.
Clears all constraints and costs. The pointcloud is preserved.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
robot_name
|
str
|
Subgroup name from |
required |
base_config
|
ndarray | None
|
10-DOF frozen config for inactive joints. Defaults to the previously stored base config. |
None
|
Source code in wheelchair_planning/planning/motion_planner.py
extract_config(full_config: np.ndarray) -> np.ndarray
¶
Extract this planner's joints from a full 10-DOF configuration.
Source code in wheelchair_planning/planning/motion_planner.py
embed_config(config: np.ndarray, base_config: np.ndarray | None = None) -> np.ndarray
¶
Embed a subgroup config into a full 10-DOF configuration.
base_config defaults to the planner's stored base — the same
10-DOF values the C++ collision checker injects for inactive
joints — so the embedded config matches what was validated.
Source code in wheelchair_planning/planning/motion_planner.py
embed_path(path: np.ndarray, base_config: np.ndarray | None = None) -> np.ndarray
¶
Convert a subgroup path (N, sub_dof) to (N, 10).
base_config defaults to the planner's stored base — the same
10-DOF values the C++ collision checker injects for inactive
joints — so the embedded path matches what was validated.
Source code in wheelchair_planning/planning/motion_planner.py
plan(start: np.ndarray, goal: np.ndarray, time_limit: float | None = None) -> PlanningResult
¶
Plan a collision-free path from start to goal.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
start
|
ndarray
|
Start configuration (active DOF). |
required |
goal
|
ndarray
|
Goal configuration (active DOF). |
required |
time_limit
|
float | None
|
Optional per-call override for the solver time
limit. Defaults to |
None
|
Source code in wheelchair_planning/planning/motion_planner.py
plan_kinodynamic(start: np.ndarray, goal: np.ndarray, start_velocity: np.ndarray | None = None, config: KinodynamicConfig | None = None) -> KinodynamicResult
¶
Plan a dynamically feasible, time-parameterised trajectory (FLASK).
Kinodynamic RRT-Connect in the differentially flat output space
(Duong et al., T-RO 2026): every tree edge is the closed-form
minimum-effort cubic between two (position, velocity) states,
validated with SIMD-batched collision checks and the joint /
base velocity and acceleration limits from
:mod:wheelchair_planning.wheelchair. For subgroups with the
base, the wheelchair follows diff-drive (unicycle) kinematics —
heading tangent to the path, forward or reverse, rotate-in-place
allowed while parked — so the output can be tracked directly
with feed-forward + PID; see
:meth:~wheelchair_planning.trajectory.KinodynamicTrajectory.base_twist.
Base positions are sampled inside the planner's joint limits, so
narrow Joint_Virtual_X / Joint_Virtual_Y around the task's
workspace with :meth:set_joint_limits first.
Collision constraints and soft costs registered on this planner
are not used by the kinodynamic planner.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
start
|
ndarray
|
Start configuration (active DOF). |
required |
goal
|
ndarray
|
Goal configuration (active DOF). Reached at rest. |
required |
start_velocity
|
ndarray | None
|
Optional active-DOF velocity at |
None
|
config
|
KinodynamicConfig | None
|
Kinodynamic planner parameters. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
KinodynamicResult
|
class: |
KinodynamicResult
|
|
|
KinodynamicResult
|
class: |
Source code in wheelchair_planning/planning/motion_planner.py
simplify_path(path: np.ndarray, time_limit: float = 1.0) -> np.ndarray
¶
Run OMPL's shortcut-based path simplifier on path.
Same pipeline plan(..., simplify=True) uses internally
(reduceVertices + collapseCloseVertices + shortcutPath
+ B-spline smoothing), but detached so you can apply it to any
path you already have — e.g. replay an old plan with a
different collision environment.
Shortcuts only consult the motion validator. Custom soft
costs (:class:Cost) are ignored; for cost-driven plans, run
:meth:plan with simplify=False and leave the path
untouched unless you've explicitly decided shortcut shaping
is acceptable.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
ndarray
|
|
required |
time_limit
|
float
|
Wall-clock budget for the simplifier, seconds. |
1.0
|
Returns:
| Type | Description |
|---|---|
ndarray
|
|
Source code in wheelchair_planning/planning/motion_planner.py
interpolate_path(path: np.ndarray, count: int = 0, resolution: float = 64.0) -> np.ndarray
¶
Densify path with uniform waypoints along every edge.
Three modes (pick one; the other must be zero):
* ``count > 0`` — exactly that many total waypoints
distributed proportionally to edge length.
* ``resolution > 0.0`` — ``ceil(edge_length * resolution)``
waypoints per edge (uniform density in state-space
distance — the default).
* both ``0`` — OMPL's default longest-valid-segment
fraction.
Uses StateSpace::interpolate internally, so the inserted
states stay on the constraint manifold for projected state
spaces as well as flat ones. No collision check is performed
— the densification only lifts points along the existing
piecewise-linear edges.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
ndarray
|
|
required |
count
|
int
|
Exact total waypoint count if |
0
|
resolution
|
float
|
Waypoints per unit state-space distance if
|
64.0
|
Returns:
| Type | Description |
|---|---|
ndarray
|
|
Source code in wheelchair_planning/planning/motion_planner.py
validate(configuration: np.ndarray) -> bool
¶
Check if a configuration is collision-free.
validate_batch(configurations: np.ndarray) -> np.ndarray
¶
Batched collision check — one SIMD block per rake configs.
Packs rake distinct configurations into a single VAMP
ConfigurationBlock<rake> and runs one fkcc<rake> call
per block, so N queries cost ceil(N / rake) SIMD
sweeps in the common case. When a packed block fails we fall
back to per-lane single-state checks for that block only, so
the returned array is always exactly one bool per input.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
configurations
|
ndarray
|
|
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
|
ndarray
|
|
Source code in wheelchair_planning/planning/motion_planner.py
sample_valid(max_samples: int = 100000) -> np.ndarray
¶
Sample a random collision-free configuration.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If none of |
Source code in wheelchair_planning/planning/motion_planner.py
Factory Function¶
create_planner(robot_name: str = 'wheelchair', config: PlannerConfig | None = None, pointcloud: np.ndarray | None = None, base_config: np.ndarray | None = None, constraints: list | None = None, costs: list | None = None) -> MotionPlanner
¶
Create a motion planner for any robot or subgroup.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
robot_name
|
str
|
Robot or subgroup name. Use :func: |
'wheelchair'
|
config
|
PlannerConfig | None
|
Planner configuration (uses defaults if None). |
None
|
pointcloud
|
ndarray | None
|
|
None
|
base_config
|
ndarray | None
|
10-DOF values to inject for joints not controlled
by this planner (i.e. the frozen joints of a subgroup).
Defaults to |
None
|
constraints
|
list | None
|
Optional list of
:class: |
None
|
costs
|
list | None
|
Optional list of
:class: |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
MotionPlanner
|
class: |
Source code in wheelchair_planning/planning/motion_planner.py
Available Robots¶
available_robots() -> list[str]
¶
Return all available robot names for planning.
Constraint¶
Constraint(residual: ca.SX, q_sym: ca.SX, name: str = 'constraint')
dataclass
¶
A user-defined holonomic constraint, JIT-compiled via CasADi.
Constructing this class triggers (on cold cache):
1. symbolic Jacobian via ca.jacobian(residual, q_sym)
2. C code generation via CasADi
3. compilation to a .so with c++ -O3 -shared -fPIC
4. caching under ~/.cache/wheelchair_planning/constraints/<sha[:2]>/<sha[2:]>/
On a cache hit the whole thing is a single stat + string compare.
SymbolicContext(subgroup: str, base_config: np.ndarray | None = None)
¶
CasADi-friendly view of the planner's active subgroup.
Holds the CasADi symbolic joint vector q matching the subgroup's
active dimension and builds symbolic link poses from the robot URDF.
The context hides the planar-root encoding (the 10-DOF body vector
uses [x, y, theta, j0..j6], with the base applied as a planar
joint at the URDF root link) and the mapping from the active
subspace back to the full 10-DOF body via base_config.
Source code in wheelchair_planning/planning/symbolic.py
link_pose(link_name: str, q_active: ca.SX | None = None) -> _Pose
¶
Return the symbolic world pose of a URDF link.
The result exposes translation (3x1) and rotation (3x3)
CasADi expressions. Pass q_active=self.q (or omit) to get
an expression that depends on the active joints symbolically.
Source code in wheelchair_planning/planning/symbolic.py
link_translation(link_name: str, q_active: ca.SX | None = None) -> ca.SX
¶
Symbolic 3-vector: link position in world frame.
link_rotation(link_name: str, q_active: ca.SX | None = None) -> ca.SX
¶
evaluate_link_pose(link_name: str, q_active_numeric: np.ndarray) -> np.ndarray
¶
Compute a NUMERIC 4x4 link pose (handy for building targets).
Source code in wheelchair_planning/planning/symbolic.py
project(q_init: np.ndarray, residual: ca.SX, tol: float = 1e-08, max_iters: int = 100) -> np.ndarray
¶
Project a joint configuration onto the manifold residual(q) = 0.
Runs damped Gauss-Newton on the CasADi Jacobian — the same
iteration OMPL's ProjectedStateSpace runs internally — so
the returned config will pass the planner's tolerance check
and can be used directly as a start or goal state.
Source code in wheelchair_planning/planning/symbolic.py
Cost¶
Cost(expression: ca.SX, q_sym: ca.SX, name: str = 'cost', weight: float = 1.0)
dataclass
¶
A user-defined soft path cost, JIT-compiled via CasADi.
Pass a scalar CasADi expression in the planner's active joint vector. Construction triggers (on cold cache):
1. symbolic gradient via ``ca.gradient(expression, q_sym)``
2. C code generation via CasADi
3. compilation to a ``.so`` with ``c++ -O3 -shared -fPIC``
4. caching under ``~/.cache/wheelchair_planning/costs/<sha[:2]>/<sha[2:]>/``
The C++ planner wraps the loaded function in an OMPL
StateCostIntegralObjective — so per-state values are
trapezoidally integrated along every motion, which is what
RRT*-family optimal planners expect.
The expression must be non-negative (OMPL objectives accumulate
with operator+ and the optimal-planner tooling assumes the
zero cost is the minimum). We don't enforce this at runtime
because that would require evaluating the symbolic expression,
but violating it produces nonsensical RRT* solutions.
Low-level C++ binding¶
OmplVampPlanner
¶
OMPL planner with VAMP SIMD-accelerated collision checking.
Two construction modes:
OmplVampPlanner()— full body, 10 DOF (3 base + 7 arm joints).OmplVampPlanner(active_indices, frozen_config)— subgroup planner over the joints listed inactive_indices; the C++ collision checker injectsfrozen_configfor every other slot in the 10-DOF body on every state and motion validity query.
add_pointcloud(points: Sequence[Sequence[float]], r_min: float, r_max: float, point_radius: float) -> None
¶
Set the scene pointcloud.
The planner holds at most one cloud; calling this replaces any previously-registered cloud.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
points
|
Sequence[Sequence[float]]
|
|
required |
r_min
|
float
|
Minimum robot collision-sphere radius (from
:meth: |
required |
r_max
|
float
|
Maximum robot collision-sphere radius (from
:meth: |
required |
point_radius
|
float
|
Inflation radius applied to every cloud point. |
required |
Source code in wheelchair_planning/_ompl_vamp.pyi
remove_pointcloud() -> bool
¶
Drop the currently-registered pointcloud.
Returns False if there was no cloud to remove.
has_pointcloud() -> bool
¶
add_sphere(center: Sequence[float], radius: float) -> None
¶
clear_environment() -> None
¶
add_compiled_constraint(so_path: str, symbol_name: str, ambient_dim: int, co_dim: int) -> None
¶
Load a CasADi-generated shared library as an OMPL constraint.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
so_path
|
str
|
Path to the compiled |
required |
symbol_name
|
str
|
CasADi function symbol name inside the library. |
required |
ambient_dim
|
int
|
Dimension of the joint space (must match
:meth: |
required |
co_dim
|
int
|
Number of constraint equations (rows of the residual). |
required |
Source code in wheelchair_planning/_ompl_vamp.pyi
clear_constraints() -> None
¶
num_constraints() -> int
¶
add_compiled_cost(so_path: str, symbol_name: str, ambient_dim: int, weight: float = 1.0) -> None
¶
Load a CasADi-generated shared library as a soft path cost.
The cost is wrapped as an ompl::StateCostIntegralObjective
— trapezoidally integrated along every motion — and drives
the search of asymptotically-optimal planners (RRT*, BIT*,
AIT*, …). Multiple costs are summed with their weight.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
so_path
|
str
|
Path to the compiled |
required |
symbol_name
|
str
|
CasADi function symbol name inside the library. |
required |
ambient_dim
|
int
|
Dimension of the joint space (must match
:meth: |
required |
weight
|
float
|
Non-negative scalar multiplier applied to the cost. |
1.0
|
Source code in wheelchair_planning/_ompl_vamp.pyi
clear_costs() -> None
¶
num_costs() -> int
¶
plan(start: Sequence[float], goal: Sequence[float], planner_name: str = 'rrtc', time_limit: float = 10.0, simplify: bool = True, interpolate: bool = True, resolution: float = 64.0) -> PlanResult
¶
Plan a collision-free path from start to goal.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
start
|
Sequence[float]
|
Active-DOF start configuration (length :meth: |
required |
goal
|
Sequence[float]
|
Active-DOF goal configuration (length :meth: |
required |
planner_name
|
str
|
OMPL planner identifier (e.g. |
'rrtc'
|
time_limit
|
float
|
Solver time limit in seconds. |
10.0
|
simplify
|
bool
|
If true, run |
True
|
interpolate
|
bool
|
If true, densify the simplified path by
|
True
|
resolution
|
float
|
Waypoints per unit of state-space distance. Each
edge of length |
64.0
|
Source code in wheelchair_planning/_ompl_vamp.pyi
plan_kinodynamic(start: Sequence[float], start_velocity: Sequence[float], goal: Sequence[float], time_limit: float, settings: KinodynamicSettings) -> KinodynamicResult
¶
FLASK kinodynamic planning in the flat output space.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
start
|
Sequence[float]
|
Active-DOF start configuration. |
required |
start_velocity
|
Sequence[float]
|
Active-DOF start velocity, or empty for rest.
Base entries are world-frame |
required |
goal
|
Sequence[float]
|
Active-DOF goal configuration, reached at rest. |
required |
time_limit
|
float
|
Solver time limit in seconds. |
required |
settings
|
KinodynamicSettings
|
Limits and planner parameters. |
required |
Source code in wheelchair_planning/_ompl_vamp.pyi
simplify_path(path: Sequence[Sequence[float]], time_limit: float = 1.0) -> list[list[float]]
¶
Run OMPL's shortcut simplifier on a waypoint list.
Reuses the current collision environment and constraints. Shortcuts only consult the motion validator, so custom soft costs are ignored. Returns the simplified path as waypoints.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
Sequence[Sequence[float]]
|
|
required |
time_limit
|
float
|
Simplifier wall-clock budget, seconds. |
1.0
|
Source code in wheelchair_planning/_ompl_vamp.pyi
interpolate_path(path: Sequence[Sequence[float]], count: int = 0, resolution: float = 64.0) -> list[list[float]]
¶
Densify a waypoint list along its existing edges.
Pass at most one of count (exact total waypoints,
distributed by edge length) or resolution (waypoints per
unit of state-space distance). Both zero falls back to OMPL's
default longest-valid-segment fraction. No collision check is
performed — densification only calls StateSpace::interpolate
on the existing edges.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
Sequence[Sequence[float]]
|
|
required |
count
|
int
|
Total waypoint count if > 0. |
0
|
resolution
|
float
|
Waypoints per unit distance if > 0.0. |
64.0
|
Source code in wheelchair_planning/_ompl_vamp.pyi
validate(config: Sequence[float]) -> bool
¶
Return True if config is collision-free.
config must have length :meth:dimension. Subgroup
planners expand it to a full 10-DOF state with the stored
frozen_config before checking.
Source code in wheelchair_planning/_ompl_vamp.pyi
validate_batch(configs: Sequence[Sequence[float]]) -> list[bool]
¶
Batched collision check — deep SIMD, per-config result.
Packs up to rake distinct configurations into a single
VAMP ConfigurationBlock<rake> so one Robot::fkcc<rake>
call sphere-FKs and collision-checks all of them
simultaneously — the same SIMD primitive the motion-edge
validator uses for interpolated samples, fed independent
configs per lane instead.
Returns one bool per input config in input order. In the
common case (most configs valid) costs ceil(N / rake)
SIMD calls; when a packed block fails, only that block falls
back to per-lane single-state checks.
Each configs[i] must have length :meth:dimension.
Subgroup planners expand each to a full 10-DOF state with
the stored frozen_config before packing.
Source code in wheelchair_planning/_ompl_vamp.pyi
dimension() -> int
¶
lower_bounds() -> list[float]
¶
upper_bounds() -> list[float]
¶
min_max_radii() -> tuple[float, float]
¶
(min_radius, max_radius) of the robot's collision spheres.
Pass these to :meth:add_pointcloud so VAMP can index its
broadphase correctly.
default_joint_limits() -> tuple[list[float], list[float]]
staticmethod
¶
The (lower, upper) limits compiled into the robot model, in
full-body order.
joint_limits() -> tuple[list[float], list[float]]
¶
set_joint_limits(lower: Sequence[float], upper: Sequence[float]) -> None
¶
Replace the limits of every joint (full-body order); each needs
finite bounds with lower < upper. Persists across
:meth:set_subgroup / :meth:set_full_body.
Source code in wheelchair_planning/_ompl_vamp.pyi
filter_pointcloud(points: Sequence[Sequence[float]], min_dist: float, max_range: float, origin: Sequence[float], workspace_min: Sequence[float], workspace_max: Sequence[float], cull: bool = True) -> list[list[float]]
¶
Spatially downsample points via Morton-curve sorting.
Keeps one point per min_dist neighbourhood and, with
cull, drops points farther than max_range from origin
or outside [workspace_min, workspace_max].
Source code in wheelchair_planning/_ompl_vamp.pyi
filter_self_from_pointcloud(points: Sequence[Sequence[float]], point_radius: float, config: Sequence[float]) -> list[list[float]]
¶
Drop the points whose sphere of radius point_radius overlaps
a robot collision sphere at config (active DOF) or an obstacle
of the environment.
Source code in wheelchair_planning/_ompl_vamp.pyi
set_subgroup(active_indices: Sequence[int], frozen_config: Sequence[float]) -> None
¶
Switch to a different subgroup without rebuilding the environment.
Clears all constraints and costs. The pointcloud and collision geometry are preserved.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
active_indices
|
Sequence[int]
|
Positions in the full 10-DOF body that this planner will plan over, in DOF order. |
required |
frozen_config
|
Sequence[float]
|
10-DOF stance to inject for every joint not
in |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in wheelchair_planning/_ompl_vamp.pyi
set_full_body() -> None
¶
Switch back to full-body planning (10 DOF).
Clears all constraints and costs. The pointcloud and collision geometry are preserved.