Unions

A place's unions made and read by a program: RobloxFileService opens the place, Csg's UnionAsync, NegateAsync and SeparateAsync do what Studio's Union, Negate and Separate do, Csg.Triangles and Csg.Hulls read a union's triangles and collision hulls, and the place is saved with the new union.

A worked example

Shapes.rbxl is a place with three anchored parts in its Workspace: Block, a 4 x 2 x 2 brick at 0, 1, 0; Ball, a ball 2 studs across at 2, 1, 0, sticking out of Block's +X end; and Hole, a 1 x 4 x 1 post through Block's other half. This program makes Hole a negative part, unions the three, reads the union's triangles and collision hulls, takes it apart again and saves the place with the union in place of the parts as ShapesUnion.rbxl:

use RobloxFileService
use Csg

local file = RobloxFileService:OpenAsync("Shapes.rbxl")
local block = assert(file:FindInstance("Workspace/Block"))
local ball = assert(file:FindInstance("Workspace/Ball"))
local hole = assert(file:FindInstance("Workspace/Hole"))

local cut = Csg.NegateAsync(hole)
print(cut.ClassName, cut.Name)
local union = Csg.UnionAsync({ block, ball, cut })
print(union.ClassName, union.Size, union.CFrame.Position)

local corners = Csg.Triangles(union.MeshData, union.Size, union.InitialSize)
local low, high = math.huge, -math.huge
for at = 0, buffer.len(corners) - 1, 36 do
    local x = buffer.readf32(corners, at)
    low, high = math.min(low, x), math.max(high, x)
end
print(buffer.len(corners) // 108, low, high)

local hulls = Csg.Hulls(union.PhysicsData, union.Size, union.InitialSize)
local points = 0
for _, hull in hulls do
    points += #hull
end
print(#hulls, points, hulls[1][1])

for _, part in Csg.SeparateAsync(union) do
    print(part.Name, part.ClassName, part.CFrame.Position)
end

union.Parent = block.Parent
block:Destroy()
ball:Destroy()
hole:Destroy()
file:SaveAsync("ShapesUnion.rbxl")

local saved = RobloxFileService:OpenAsync("ShapesUnion.rbxl", { ReadOnly = true })
local again = assert(saved:FindInstance("Workspace/Union"))
print(again.ClassName, buffer.len(Csg.OfPartAsync(again)) // 108, #Csg.HullsAsync(again))

shot run Demo.ssp prints

NegateOperation Hole
UnionOperation <Vector3(5, 2, 2)> <Vector3(0.5, 1, 0)>
460 -2.5 2.5
5 135 <Vector3(-2.5, 1, 1)>
Block Part <Vector3(0, 1, 0)>
Ball Part <Vector3(2, 1, 0)>
Hole NegateOperation <Vector3(-1, 1, 0)>
UnionOperation 460 5

and leaves ShapesUnion.rbxl beside Demo.ssp, with Shapes.rbxl as it was. The union runs from Block's end at x = -2 to Ball's at x = 3, so it is 5 studs long and sits at the centre of its bounds, 0.5, 1, 0; its triangles are in its own space, -2.5 to 2.5 on x. Its collision shape is 5 convex hulls of 135 points in all, in the same space (the first point is a corner of Block, -2.5, 1, 1). Separate gives back each part where it was, Hole still a NegateOperation, and the union saved and opened again has the same 460 triangles and 5 hulls.

Corner buffers

Csg's triangles are a buffer of corners, laid out as EditableMesh's ReadTriangles and WriteTriangles take them (Editables): 36 bytes a corner, the position and the normal as three f32 each, the UV as two f32 and the colour as four u8 (RGBA; opaque white when the mesh has none), and three corners a triangle, so n triangles take n * 108 bytes. The example reads each corner's x at its first byte and steps 36 bytes at a time.

Union, negate and separate

Csg.UnionAsync(parts) is Studio's Union: a new UnionOperation named Union, of two parts or more. A part is a Part shaped Block, Ball, Cylinder, Wedge or CornerWedge, a WedgePart, a CornerWedgePart, a MeshPart, or a union; a NegateOperation in the list is cut away from the rest. A MeshPart's mesh comes through AssetService.Cache, downloaded when it isn't there yet (one that can't be fetched stops the call with why). The union sits at the centre of its bounds with the first other part's rotation and look (its Color, Material, Anchored, CanCollide, CanTouch, CanQuery, CastShadow, Transparency, Reflectance and Locked). Its MeshData is the mesh itself (Csg.Kind gives "CSGMDL2"), its PhysicsData its collision hulls, its InitialSize is its Size, and its ChildData keeps the parts for Separate.

Csg.NegateAsync(part) is Studio's Negate: a new NegateOperation of the part, where it is and with its name; negating a NegateOperation gives back the part it was made from. Csg.SeparateAsync(operation) is Studio's Separate: the parts a union or a negate was made from, placed where it is now, and stretched with it when its Size isn't its InitialSize.

What the three give is unparented, and the parts they were given are left as they are: parent the result and destroy the parts, as the example does, to change the place. A call that can't be done stops the run with why, the member's name in front:

Csg.UnionAsync needs two parts or more
Csg.UnionAsync: every part is a NegateOperation, and a union needs one that isn't

Reading a union

A union's shape is in its MeshData. Csg.Kind(meshData) says what that holds: "CSGMDL2", "CSGMDL4" or "CSGMDL5" for a mesh, "CSGK" or "empty" when the mesh is the asset at the union's AssetId instead, and nil for anything else. Csg.Triangles(meshData, size, initialSize) gives a mesh's triangles in the part's space, scaled on each axis by size / initialSize (initialSize the mesh's own bounds when left out, no scaling without a size), so pass the union's Size and InitialSize as the example does. MeshData that isn't a mesh stops the call:

Csg.Triangles: not a union's MeshData (no CSGMDL or CSGK magic)

A union Studio saved often keeps its mesh in the asset. Csg.OfPartAsync(part) works either way for a UnionOperation, NegateOperation or IntersectOperation: its MeshData, or else its AssetId's asset fetched through AssetService, scaled to its Size. Csg.MeshDataOf(bytes) takes the asset file's bytes themselves (a PartOperationAsset file, binary or XML) and gives its MeshData.

Collision hulls

A union's PhysicsData is the shape Roblox collides it as: convex pieces. Csg.Hulls(physicsData, size, initialSize) gives them as a list of hulls, each a list of Vector3 points in the part's space, scaled as Triangles scales them, so pass the union's Size and InitialSize. It reads the forms Studio saves (CSGPHS 0, 3, 5, 6, 7 and 8); a union made here has a CSGPHS 7. A CSGK PhysicsData means the hulls are in the asset at the union's AssetId instead. Csg.HullsAsync(part) works either way, as OfPartAsync does: the part's PhysicsData, or else its AssetId's asset fetched through AssetService, scaled to its Size.

Booleans on buffers

Csg.Union(first, second), Csg.Subtract(first, second) and Csg.Intersect(first, second) work on corner buffers directly: two closed meshes in one space give the solid of both, the first less the second, or what the two share, as a corner buffer. Csg.EncodeV2(corners) writes a corner buffer as a union's MeshData (CSGMDL 2), which Triangles reads back. A buffer that isn't whole triangles stops the call:

Csg.Union: a corner buffer is 36 bytes, not a multiple of 108 (three 36-byte corners a triangle)

Csg.SolidAsync(kind, part, others, fetch) is the geometry of Roblox's BasePart:UnionAsync, SubtractAsync and IntersectAsync, for a program that makes its parts itself: kind "Union" adds others to part (a NegateOperation among them cuts away), "Subtract" cuts them away and "Intersect" keeps what part and others all hold. It makes no instance: it gives a table of ClassName, CFrame (the result's centre, with part's rotation), Size, MeshData (CSGMDL 2) and, when it has one, PhysicsData, both in the result's own space. fetch, when given, gives a MeshPart's or union's file by its MeshId or AssetId, in place of AssetService's cache. A failure stops the call with Csg.SolidAsync: <why>. In Mime's Play the three BasePart methods run this call (Places, IN PLAY).

Limits

  • Roblox opening a union made here is unverified. Its ChildData, where Separate finds the parts, holds their copies in a binary form of our own, and its PhysicsData cuts the solid into convex pieces our own way.
  • Where the booleans cut a face they join the pieces back into convex polygons and weld equal corners: fewer triangles than a plain fan, but not always the fewest.
  • On Lune a part Separate gives back has no Position: reading it stops the run ("Failed to get property 'Position' - missing default value"), so read part.CFrame.Position, as the example does.

See also

Terrainthe same open, edit and save for a place's Terrain
Roblox placesshot import keeps the Workspace's parts and unions in <Name>.world/
ServicesRobloxFileService and the other services
EditablesEditableMesh and its triangles