Search Docs
Loading...
Skip to content

Compound Shapes

Build one editable outline from several shapes, and choose whether they merge, add, subtract, intersect or exclude.

8 mins
estimated time
GitHub

A compound shape is a shape block whose outline comes from other shape blocks, its subshapes. The graphic block draws that outline with its fill and stroke. Each subshape stays a separate block with its own type and properties, so a later change to a subshape rebuilds the outline. Boolean operations work differently: they replace the blocks with one new block whose outline you can no longer edit by parts.

This guide covers how to create a compound shape, add and place subshapes, choose how they combine, set the fill rule, change the order, animate a subshape, and the errors to expect.

Creating a Compound Shape#

We create a graphic block with a color fill and attach a new compound shape with setShape(_:shape:). An empty compound shape draws nothing.

// A graphic block draws the outline of its compound shape with its fill
let badge = try engine.block.create(.graphic)
let badgeFill = try engine.block.createFill(.color)
try engine.block.setColor(badgeFill, property: "fill/color/value", color: .rgba(r: 0.2, g: 0.4, b: 0.9, a: 1))
try engine.block.setFill(badge, fill: badgeFill)
try engine.block.setWidth(badge, value: 300)
try engine.block.setHeight(badge, value: 300)
try engine.block.setPositionX(badge, value: 75)
try engine.block.setPositionY(badge, value: 150)
try engine.block.appendChild(to: page, child: badge)
let compound = try engine.block.createShape(.compound)
try engine.block.setShape(badge, shape: compound)

Adding Subshapes#

appendSubshape(_:subshapeID:) adds a shape block at the end of the compound shape. The first subshape is the base, and each later subshape combines with the result so far.

// The first subshape is the base. A new subshape fills the reference box.
let circle = try engine.block.createShape(.ellipse)
try engine.block.appendSubshape(compound, subshapeID: circle)
let diamond = try engine.block.createShape(.rect)
try engine.block.appendSubshape(compound, subshapeID: diamond)

A shape has one owner. When you append a shape that belongs to a graphic block or to another compound shape, the engine moves it. You can nest a compound shape in another compound shape. A shape tree nests at most 128 shapes, from its top shape to its deepest subshape. Line shapes cannot be subshapes.

Placing Subshapes#

Subshapes live in the reference box of the compound shape. The box is 100 by 100 units by default, and it maps onto the frame of the graphic block. Set shape/compound/width and shape/compound/height to change its size.

These properties place a subshape in the reference box:

Property Default Effect
shape/width, shape/height 100 The size of the subshape’s box
shape/anchor/x, shape/anchor/y 0 The point of the box that stays fixed, from its top left corner
shape/position/x, shape/position/y 0 Where the anchor sits in the reference box
shape/rotation 0 Clockwise rotation about the anchor, in degrees
shape/scale/x, shape/scale/y 1 Scale about the anchor
shape/skew, shape/skewAxis 0 Skew angle and skew direction, in degrees

shape/position holds both coordinates as one .vec2 property: setVec2(_:property:value:) and getVec2(_:property:) write and read them in one call.

Here we turn the rect into a centered diamond. The anchor sits at the center of the 40 by 40 box, so the position places the center and the rotation turns the box about it.

// A 40 by 40 square, centered in the 100 by 100 reference box and turned
// into a diamond. Rotation turns the box about its anchor.
try engine.block.setFloat(diamond, property: "shape/width", value: 40)
try engine.block.setFloat(diamond, property: "shape/height", value: 40)
try engine.block.setFloat(diamond, property: "shape/anchor/x", value: 20)
try engine.block.setFloat(diamond, property: "shape/anchor/y", value: 20)
try engine.block.setFloat(diamond, property: "shape/position/x", value: 50)
try engine.block.setFloat(diamond, property: "shape/position/y", value: 50)
try engine.block.setFloat(diamond, property: "shape/rotation", value: 45)

The placement properties take effect only while the shape is a subshape. As the shape of a graphic block, the block’s frame defines the geometry.

Combining Subshapes#

shape/compound/mode sets how each subshape combines with the result of the subshapes before it.

Mode Result
"Merge" Keeps every outline as it is. The fill rule decides how overlaps fill. This is the default.
"Add" Unites the subshape with the result
"Subtract" Cuts the subshape out of the result
"Intersect" Keeps only the area that the subshape and the result share
"Exclude" Keeps the area that exactly one of them covers

We subtract, so the diamond becomes a hole in the circle.

// Cut every later subshape out of the base circle
try engine.block.setEnum(compound, property: "shape/compound/mode", value: "Subtract")

Reordering and Removing Subshapes#

getSubshapes(_:) returns the subshapes in combination order. insertSubshape(_:subshapeID:index:) inserts a subshape at an index from 0 to the number of subshapes. removeSubshape(_:index:) removes the subshape at an index. It does not destroy the subshape, so you can reuse it or destroy it.

// Insert a small dot between the circle and the diamond
let dot = try engine.block.createShape(.ellipse)
try engine.block.setFloat(dot, property: "shape/width", value: 16)
try engine.block.setFloat(dot, property: "shape/height", value: 16)
try engine.block.setFloat(dot, property: "shape/position/x", value: 42)
try engine.block.setFloat(dot, property: "shape/position/y", value: 8)
try engine.block.insertSubshape(compound, subshapeID: dot, index: 1)
print(try engine.block.getSubshapes(compound)) // [circle, dot, diamond]
// Removing a subshape does not destroy it
try engine.block.removeSubshape(compound, index: 1)
print(engine.block.isValid(dot)) // true
try engine.block.destroy(dot)

Appending a subshape that is already in the compound shape moves it to the end. The order matters for every mode except "Merge", because the first subshape is the base.

Setting the Fill Rule#

shape/compound/fillRule decides which areas of overlapping outlines fill in "Merge" mode. "NonZero", the default, fills overlaps. "EvenOdd" leaves an area empty where an even number of outlines overlap.

// Two overlapping squares in Merge mode. EvenOdd makes the overlap a hole.
let squares = try engine.block.create(.graphic)
let squaresFill = try engine.block.createFill(.color)
try engine.block.setColor(squaresFill, property: "fill/color/value", color: .rgba(r: 0.95, g: 0.5, b: 0.1, a: 1))
try engine.block.setFill(squares, fill: squaresFill)
try engine.block.setWidth(squares, value: 300)
try engine.block.setHeight(squares, value: 300)
try engine.block.setPositionX(squares, value: 425)
try engine.block.setPositionY(squares, value: 150)
try engine.block.appendChild(to: page, child: squares)
let squaresCompound = try engine.block.createShape(.compound)
try engine.block.setShape(squares, shape: squaresCompound)
let firstSquare = try engine.block.createShape(.rect)
try engine.block.setFloat(firstSquare, property: "shape/width", value: 60)
try engine.block.setFloat(firstSquare, property: "shape/height", value: 60)
try engine.block.appendSubshape(squaresCompound, subshapeID: firstSquare)
let secondSquare = try engine.block.createShape(.rect)
try engine.block.setFloat(secondSquare, property: "shape/width", value: 60)
try engine.block.setFloat(secondSquare, property: "shape/height", value: 60)
try engine.block.setFloat(secondSquare, property: "shape/position/x", value: 40)
try engine.block.setFloat(secondSquare, property: "shape/position/y", value: 40)
try engine.block.appendSubshape(squaresCompound, subshapeID: secondSquare)
try engine.block.setEnum(squaresCompound, property: "shape/compound/fillRule", value: "EvenOdd")

The fill rule of the compound shape replaces the fill rule of each subshape. It applies in "Merge" mode and when the compound shape has one subshape. The other modes fill each subshape with its own fill rule and resolve overlaps by the mode.

Animating Subshapes#

A subshape accepts keyframes on its placement properties. shape/position takes .vec2 keyframes that move shape/position/x and shape/position/y together. The other placement properties take .float keyframes. Keyframe times refer to the duration of the graphic block. For details, see Keyframes.

// Slide the second square to the top left corner and back over the
// duration of the graphic block
try engine.block.setKeyframes(secondSquare, property: "shape/position", keyframes: [
Keyframe(time: 0, value: .vec2([40, 40])),
Keyframe(time: 0.5, value: .vec2([0, 0])),
Keyframe(time: 1, value: .vec2([40, 40])),
])

The track renders at the current playback time, in frame exports and in video export. It does not change the static shape/position/x and shape/position/y values.

Handling Errors#

Invalid input throws an EngineError. Its catalogCode names the problem. A failed call leaves the compound shape unchanged.

let line = try engine.block.createShape(.line)
do {
try engine.block.appendSubshape(compound, subshapeID: line)
} catch let error as EngineError {
print(error.catalogCode == .blockSubshapeTypeUnsupported) // true
}
try engine.block.destroy(line)

Exporting the Result#

We export the page as a PNG. The keyframe track starts at the static position, so the export shows both squares at their placed positions.

let png = try await engine.block.export(page, mimeType: .png)
print("Exported \(png.count) bytes")

Compound Shapes and Other Block Operations#

  • Undo and redo restore the subshapes, their order and their placement.
  • Saving and loading a scene keeps the compound shape. Scenes with compound shapes do not open in earlier versions of CE.SDK.
  • Duplicating the graphic block copies the compound shape and its subshapes.
  • Destroying the graphic block or the compound shape also destroys the subshapes. Destroying a subshape removes it from the compound shape.
  • Resizing the graphic block scales the outline, because the reference box maps onto the frame.

appendSubshape(_:subshapeID:), insertSubshape(_:subshapeID:index:), removeSubshape(_:index:) and the placement properties need the shape/change scope.

Troubleshooting#

  • The block has no subshapes (BLOCK.SUBSHAPES_UNSUPPORTED): call the subshape methods on a compound shape, not on the graphic block or on another shape type. Use getShape(_:) to get the compound shape of a graphic block.
  • The subshape is not a shape (BLOCK.SUBSHAPE_NOT_A_SHAPE): create the subshape with createShape(_:). A graphic block or a fill cannot be a subshape.
  • A line cannot be a subshape (BLOCK.SUBSHAPE_TYPE_UNSUPPORTED): use a vector path with two points instead.
  • The compound shape contains itself (BLOCK.SUBSHAPE_CYCLE): do not add a compound shape to itself or to one of its own subshapes.
  • The shape tree is too deep (BLOCK.SUBSHAPE_TOO_DEEP): a shape tree nests at most 128 shapes. Insert the subshape into a compound shape nearer to the top.
  • The index is out of range (BLOCK.SUBSHAPE_INDEX_OUT_OF_BOUNDS): insert at an index from 0 to the number of subshapes, and remove at an index below that number.
  • The outline does not show: check that the compound shape has subshapes and that the graphic block has a fill. In "Intersect" mode, an empty subshape makes the whole outline empty.
  • A subshape is in the wrong place: placement values use the units of the reference box, not of the page. With the default box, 100 units span the frame of the graphic block.

API Reference#

Methods#

Method Description
engine.block.createShape(_:) Create the compound shape with .compound, and the subshapes
engine.block.setShape(_:shape:) Attach the compound shape to the graphic block
engine.block.appendSubshape(_:subshapeID:) Add a subshape at the end of a compound shape
engine.block.insertSubshape(_:subshapeID:index:) Add a subshape at an index
engine.block.getSubshapes(_:) Get the subshapes in combination order
engine.block.removeSubshape(_:index:) Remove the subshape at an index without destroying it
engine.block.setEnum(_:property:value:) Set shape/compound/mode and shape/compound/fillRule
engine.block.setFloat(_:property:value:) Set a placement property of a subshape
engine.block.setVec2(_:property:value:) Set both coordinates of shape/position
engine.block.setKeyframes(_:property:keyframes:mode:) Animate the position of a subshape
engine.block.isValid(_:) Check that a removed subshape still exists
engine.block.destroy(_:) Destroy a subshape that you no longer need
engine.block.export(_:mimeType:options:onPreExport:uriResolver:onProgress:) Export the page

Next Steps#

  • Combine Shapes: replace several blocks with one new block through boolean operations.
  • Create Shapes: create the shape types that you use as subshapes.
  • Keyframes: animate shape properties over time.