Skip to content

Shapes

Bolt operates on shapes, the shape table holds information like radius for spheres, or half extents for boxes.

There are functions for every supported shape, as well as a function for getting the shape table from a Part.

bolt.create_from_part(part: Part): Shape

create_from_part accepts ordinary Part instances. MeshPart, UnionOperation, WedgePart, and CornerWedgePart instances are rejected. A Part whose Shape property is Enum.PartType.Wedge or Enum.PartType.CornerWedge is supported.

bolt.create_box(size: Vector3): BoxShape
bolt.create_sphere(radius: number): SphereShape
bolt.create_ellipsoid(size: Vector3): EllipsoidShape
bolt.create_capsule(radius: number, height: number): CapsuleShape
bolt.create_cylinder(radius: number, height: number): CylinderShape
bolt.create_wedge(size: Vector3): WedgeShape
bolt.create_corner_wedge(size: Vector3): CornerWedgeShape
bolt.create_mesh(mesh_info: {hulls: {Hull}, size: Vector3}, size: Vector3): StaticComposite
bolt.create_hull(hull_info: {vertices: {Vector3}, adjacency: {{number}}, size: Vector3}, size: Vector3): HullShape

A single convex hull, usable anywhere a primitive is. hull_info mirrors what a mesh’s hulls carry, minus the offset: a mesh positions its hulls relative to the mesh origin, while a standalone hull is placed entirely by the CFrame you query it with. Its offset is always zero.

The second argument scales it the same way mesh size does, component wise against hull_info.size.

vertices must be the vertices of a convex shape, and adjacency[i] must list the vertices sharing an edge with vertex i. The support function hill-climbs that graph, so an adjacency graph that does not connect the real hull edges can stop at a vertex that is not the true support point, which shows up as silently wrong collisions rather than an error. A fully connected graph (every vertex listing every other) is always safe if you are unsure.

Mesh size applies component wise, non-uniform scaling to hull vertices and hull offsets.

bolt.resize_mesh(mesh: StaticComposite, size: Vector3)

A resize method is specifically only needed for meshes and hulls as there is more work needed than a single value change. resize_mesh updates every hull’s scale and rebuilds the mesh’s local AABB tree with the scaled hull offsets.

bolt.resize_hull(hull: HullShape, size: Vector3)

Updates a standalone hull’s scale. A hull has no local tree to rebuild, so this only touches the scale, but it exists so hulls resize through the same call shape as meshes rather than by writing scale yourself.

A composite is a shape built out of other shapes, each sitting at its own transform inside it. A mesh is one, with a convex hull per child. So is anything assembled out of parts, a model or a character rig.

Composites do not nest. A child is always a leaf shape, so recursion can be avoided.

bolt.create_static_composite(children: {Child}): StaticComposite
bolt.create_dynamic_composite(children: {Child}, config: DynamicTreeConfig?): DynamicComposite

A Child is {cf: CFrame, shape: Shape}, the transform being relative to the composite’s own origin.

local rig = bolt.create_dynamic_composite({
{cf = CFrame.new(0, 2, 0), shape = bolt.create_box(Vector3.new(2, 1, 1))},
{cf = CFrame.new(0, 0, 0), shape = bolt.create_box(Vector3.new(2, 2, 1))},
})

The two differ in whether the children ever move, which is the only thing that matters to everything downstream.

A static composite is built once. Its local tree uses the same binned SAH the static tree does, which is the better tree. This is what create_mesh gives you.

A dynamic composite expects its children to move. Its local tree is dynamic, so a child can be moved without a rebuild, and set_child is the only thing that will move one:

bolt.set_child(composite: DynamicComposite, index: number, cf: CFrame)

That call moves the child, keeps the local tree in sync, and marks the composite so anything cached off its overall size knows that it has to look again. Writing children[i].cf yourself does none of that.

Composites are queried through bolt.dispatch, exactly as meshes were.

bolt.shape_aabb(cf: CFrame, shape: Shape): (Vector3, Vector3)

The axis aligned bounds of a shape placed at a CFrame, as (min, max). This is the same computation an AABB tree runs when you insert a proxy, margin included.

You do not need this for ordinary queries, the tree already does it for you. It is here for bounds you assemble yourself, which is what tree:insert_bounds takes.

bolt.circumradius(shape: Shape): number

The radius of the smallest sphere centred on the shape’s origin that still contains it, whatever direction the shape is facing.

An AABB grows and shrinks as a shape turns, so a bound built from one only describes the orientation it was measured at. A circumradius does not change with rotation, which is what you want when a bound has to hold across orientations rather than at one of them: a volume swept while something spins, or the extent an object covered over a window of time.

It follows the shape, so a margin adds to it and a resize changes it. Negative margins are ignored here exactly as they are everywhere else.

This is exact for spheres, boxes, ellipsoids, capsules, cylinders, hulls and composites. Wedges take the diagonal of the box their half extents describe, which is never too small. Hulls measure their vertices rather than their declared size. A composite is measured from the root of its own local tree, so it follows its children exactly as its bounds do.

Every shape has an optional margin, which inflates it by that distance in all directions, as if it were swept by a sphere of that radius.

local shape = bolt.create_box(Vector3.new(4, 4, 4))
shape.margin = 0.05

Nothing else changes: every query treats the inflated surface as the real one. Casts stop margin earlier, intersection tests report contact margin sooner, MPR includes it in the reported depth, and AABB trees widen their bounds to match. Shapes without a margin behave exactly as before.

This is the supported way to keep clearance around a moving shape. Shrinking a shape by hand to leave room only works for a few types, a capsule inset by a constant is still a capsule but a hull is not, whereas a margin works on every shape including hulls, where no field could express it.

Because it is a true Minkowski sum, an inflated shape has rounded edges and corners, not enlarged flat ones. A box with a margin is a box with rounded edges, which is usually what you want for a collision skin.

A margin on a mesh applies to all of its hulls, and the mesh’s own tree widens with them. Both happen on the first query after the margin changes, not when you assign it, so the cost lands on a query rather than on the write.

That widening only ever grows. Lowering a margin narrows what the queries report straight away, but the tree keeps the wider bounds, which costs a rejected candidate rather than a wrong answer. resize_mesh rebuilds the bounds from the hulls and takes that extra bit of margin back out, so a mesh whose margin drops a long way and stays there can be resized to its current size to tighten it again.

Shapecasts otherwise use the exact supplied shape geometry.