Skip to content

Post-processing

Path modification utilities after slicing.

post_processing

Post-processing utilities for modifying sliced paths.

offset_polygon

offset_polygon(points, offset, z)

Offset a polygon, using CGAL if available.

Parameters:

Name Type Description Default
points list[Point]

Points of the polygon.

required
offset float

Offset distance (positive = outward).

required
z float

Z coordinate for result points.

required

Returns:

Type Description
list[Point]

Offset polygon points.

Source code in src/compas_slicer/post_processing/generate_brim.py
def offset_polygon(points: list[Point], offset: float, z: float) -> list[Point]:
    """Offset a polygon, using CGAL if available.

    Parameters
    ----------
    points : list[Point]
        Points of the polygon.
    offset : float
        Offset distance (positive = outward).
    z : float
        Z coordinate for result points.

    Returns
    -------
    list[Point]
        Offset polygon points.
    """
    if _USE_CGAL:
        return _offset_polygon_cgal(points, offset, z)
    else:
        return _offset_polygon_pyclipper(points, offset, z)

offset_polygon_with_holes

offset_polygon_with_holes(outer, holes, offset, z)

Offset a polygon with holes using CGAL straight skeleton.

Parameters:

Name Type Description Default
outer list[Point]

Points of the outer boundary (CCW orientation).

required
holes list[list[Point]]

List of hole polygons (CW orientation).

required
offset float

Offset distance (positive = outward, negative = inward).

required
z float

Z coordinate for result points.

required

Returns:

Type Description
list[tuple[list[Point], list[list[Point]]]]

List of (outer_boundary, holes) tuples for resulting polygons.

Raises:

Type Description
ImportError

If CGAL is not available.

Source code in src/compas_slicer/post_processing/generate_brim.py
def offset_polygon_with_holes(
    outer: list[Point],
    holes: list[list[Point]],
    offset: float,
    z: float
) -> list[tuple[list[Point], list[list[Point]]]]:
    """Offset a polygon with holes using CGAL straight skeleton.

    Parameters
    ----------
    outer : list[Point]
        Points of the outer boundary (CCW orientation).
    holes : list[list[Point]]
        List of hole polygons (CW orientation).
    offset : float
        Offset distance (positive = outward, negative = inward).
    z : float
        Z coordinate for result points.

    Returns
    -------
    list[tuple[list[Point], list[list[Point]]]]
        List of (outer_boundary, holes) tuples for resulting polygons.

    Raises
    ------
    ImportError
        If CGAL is not available.
    """
    if not _USE_CGAL:
        raise ImportError("offset_polygon_with_holes requires compas_cgal")

    from compas.geometry import Polygon

    # CGAL expects Polygon objects with z=0, normal up for outer, down for holes
    outer_poly = Polygon([[p[0], p[1], 0] for p in outer])
    hole_polys = [Polygon([[p[0], p[1], 0] for p in hole]) for hole in holes]

    # CGAL: negative = outward, positive = inward (opposite of our convention)
    result = _cgal_offset_with_holes(outer_poly, hole_polys, -offset)

    # Convert back to Points with z coordinate
    output = []
    for poly, poly_holes in result:
        outer_pts = [Point(p[0], p[1], z) for p in poly.points]
        if outer_pts and outer_pts[0] != outer_pts[-1]:
            outer_pts.append(outer_pts[0])

        hole_pts_list = []
        for hole in poly_holes:
            hole_pts = [Point(p[0], p[1], z) for p in hole.points]
            if hole_pts and hole_pts[0] != hole_pts[-1]:
                hole_pts.append(hole_pts[0])
            hole_pts_list.append(hole_pts)

        output.append((outer_pts, hole_pts_list))

    return output

generate_medial_axis_infill

generate_medial_axis_infill(slicer, min_length=5.0, include_bisectors=True)

Generate medial axis infill paths for all layers.

Uses CGAL's straight skeleton to compute the medial axis of each closed contour, then converts skeleton edges to infill paths.

Parameters:

Name Type Description Default
slicer BaseSlicer

Slicer with layers containing boundary paths.

required
min_length float

Minimum skeleton edge length to include. Shorter edges are skipped.

5.0
include_bisectors bool

If True, include bisector edges (skeleton to boundary connections). If False, only include inner_bisector edges (skeleton internal edges).

True
Source code in src/compas_slicer/post_processing/infill/medial_axis_infill.py
def generate_medial_axis_infill(
    slicer: BaseSlicer,
    min_length: float = 5.0,
    include_bisectors: bool = True,
) -> None:
    """Generate medial axis infill paths for all layers.

    Uses CGAL's straight skeleton to compute the medial axis of each
    closed contour, then converts skeleton edges to infill paths.

    Parameters
    ----------
    slicer : BaseSlicer
        Slicer with layers containing boundary paths.
    min_length : float
        Minimum skeleton edge length to include. Shorter edges are skipped.
    include_bisectors : bool
        If True, include bisector edges (skeleton to boundary connections).
        If False, only include inner_bisector edges (skeleton internal edges).

    """
    from compas_cgal.straight_skeleton_2 import interior_straight_skeleton

    logger.info("Generating medial axis infill")

    for layer in slicer.layers:
        infill_paths: list[Path] = []

        for path in layer.paths:
            if not path.is_closed:
                continue

            # Convert path to 2D polygon
            polygon_2d = _path_to_polygon_2d(path)
            if len(polygon_2d) < 3:
                continue

            z_height = path.points[0][2]

            # Compute straight skeleton
            try:
                graph = interior_straight_skeleton(polygon_2d)
            except Exception as e:
                logger.warning(f"Skeleton failed for path: {e}")
                continue

            # Extract skeleton edges as paths
            skeleton_paths = _skeleton_to_paths(
                graph, z_height, min_length, include_bisectors
            )
            infill_paths.extend(skeleton_paths)

        # Add infill paths to layer
        layer.paths.extend(infill_paths)
        logger.info(f"Added {len(infill_paths)} infill paths to layer")

generate_brim

offset_polygon

offset_polygon(points, offset, z)

Offset a polygon, using CGAL if available.

Parameters:

Name Type Description Default
points list[Point]

Points of the polygon.

required
offset float

Offset distance (positive = outward).

required
z float

Z coordinate for result points.

required

Returns:

Type Description
list[Point]

Offset polygon points.

Source code in src/compas_slicer/post_processing/generate_brim.py
def offset_polygon(points: list[Point], offset: float, z: float) -> list[Point]:
    """Offset a polygon, using CGAL if available.

    Parameters
    ----------
    points : list[Point]
        Points of the polygon.
    offset : float
        Offset distance (positive = outward).
    z : float
        Z coordinate for result points.

    Returns
    -------
    list[Point]
        Offset polygon points.
    """
    if _USE_CGAL:
        return _offset_polygon_cgal(points, offset, z)
    else:
        return _offset_polygon_pyclipper(points, offset, z)

offset_polygon_with_holes

offset_polygon_with_holes(outer, holes, offset, z)

Offset a polygon with holes using CGAL straight skeleton.

Parameters:

Name Type Description Default
outer list[Point]

Points of the outer boundary (CCW orientation).

required
holes list[list[Point]]

List of hole polygons (CW orientation).

required
offset float

Offset distance (positive = outward, negative = inward).

required
z float

Z coordinate for result points.

required

Returns:

Type Description
list[tuple[list[Point], list[list[Point]]]]

List of (outer_boundary, holes) tuples for resulting polygons.

Raises:

Type Description
ImportError

If CGAL is not available.

Source code in src/compas_slicer/post_processing/generate_brim.py
def offset_polygon_with_holes(
    outer: list[Point],
    holes: list[list[Point]],
    offset: float,
    z: float
) -> list[tuple[list[Point], list[list[Point]]]]:
    """Offset a polygon with holes using CGAL straight skeleton.

    Parameters
    ----------
    outer : list[Point]
        Points of the outer boundary (CCW orientation).
    holes : list[list[Point]]
        List of hole polygons (CW orientation).
    offset : float
        Offset distance (positive = outward, negative = inward).
    z : float
        Z coordinate for result points.

    Returns
    -------
    list[tuple[list[Point], list[list[Point]]]]
        List of (outer_boundary, holes) tuples for resulting polygons.

    Raises
    ------
    ImportError
        If CGAL is not available.
    """
    if not _USE_CGAL:
        raise ImportError("offset_polygon_with_holes requires compas_cgal")

    from compas.geometry import Polygon

    # CGAL expects Polygon objects with z=0, normal up for outer, down for holes
    outer_poly = Polygon([[p[0], p[1], 0] for p in outer])
    hole_polys = [Polygon([[p[0], p[1], 0] for p in hole]) for hole in holes]

    # CGAL: negative = outward, positive = inward (opposite of our convention)
    result = _cgal_offset_with_holes(outer_poly, hole_polys, -offset)

    # Convert back to Points with z coordinate
    output = []
    for poly, poly_holes in result:
        outer_pts = [Point(p[0], p[1], z) for p in poly.points]
        if outer_pts and outer_pts[0] != outer_pts[-1]:
            outer_pts.append(outer_pts[0])

        hole_pts_list = []
        for hole in poly_holes:
            hole_pts = [Point(p[0], p[1], z) for p in hole.points]
            if hole_pts and hole_pts[0] != hole_pts[-1]:
                hole_pts.append(hole_pts[0])
            hole_pts_list.append(hole_pts)

        output.append((outer_pts, hole_pts_list))

    return output

generate_brim

generate_brim(slicer, layer_width, number_of_brim_offsets)

Creates a brim around the bottom contours of the print.

Parameters:

Name Type Description Default
slicer BaseSlicer

An instance of the compas_slicer.slicers.PlanarSlicer class

required
layer_width float

A number representing the distance between brim contours (typically the width of a layer)

required
number_of_brim_offsets int

Number of brim paths to add.

required
Source code in src/compas_slicer/post_processing/generate_brim.py
def generate_brim(slicer: BaseSlicer, layer_width: float, number_of_brim_offsets: int) -> None:
    """Creates a brim around the bottom contours of the print.

    Parameters
    ----------
    slicer: :class:`compas_slicer.slicers.PlanarSlicer`
        An instance of the compas_slicer.slicers.PlanarSlicer class
    layer_width: float
        A number representing the distance between brim contours
        (typically the width of a layer)
    number_of_brim_offsets: int
        Number of brim paths to add.
    """
    backend = "CGAL" if _USE_CGAL else "pyclipper"
    logger.info(f"Generating brim with layer width: {layer_width:.2f} mm, {number_of_brim_offsets} offsets ({backend})")

    if slicer.layers[0].is_raft:
        raise NameError("Raft found: cannot apply brim when raft is used, choose one")

    # (1) --- find if slicer has vertical or horizontal layers, and select which paths are to be offset.
    if isinstance(slicer.layers[0], compas_slicer.geometry.VerticalLayer):  # Vertical layers
        # then find all paths that lie on the print platform and make them brim.
        paths_to_offset, layers_i = slicer.find_vertical_layers_with_first_path_on_base()
        for i, first_path in zip(layers_i, paths_to_offset):
            slicer.layers[i].paths.remove(first_path)  # remove first path that will become part of the brim layer
        has_vertical_layers = True

    else:  # Horizontal layers
        # then replace the first layer with a brim layer.
        paths_to_offset = slicer.layers[0].paths
        has_vertical_layers = False

    if len(paths_to_offset) == 0:
        raise ValueError('Brim generator did not find any path on the base. Please check the paths of your slicer.')

    # (2) --- create new empty brim_layer
    brim_layer = Layer(paths=[])
    brim_layer.is_brim = True
    brim_layer.number_of_brim_offsets = number_of_brim_offsets

    # (3) --- create offsets and add them to the paths of the brim_layer
    for path in paths_to_offset:
        z = path.points[0][2]

        for i in range(number_of_brim_offsets):
            offset_distance = i * layer_width
            offset_pts = offset_polygon(path.points, offset_distance, z)

            if offset_pts:
                new_path = Path(points=offset_pts, is_closed=True)
                brim_layer.paths.append(new_path)

    brim_layer.paths.reverse()  # go from outside towards the object
    brim_layer.calculate_z_bounds()

    # (4) --- Add the brim layer to the slicer
    if not has_vertical_layers:
        slicer.layers[0] = brim_layer  # replace first layer
    else:
        slicer.layers.insert(0, brim_layer)  # insert brim layer as the first layer of the slicer

    seams_align(slicer, align_with="next_path")

generate_raft

generate_raft

generate_raft(slicer, raft_offset=10, distance_between_paths=10, direction='xy_diagonal', raft_layers=1, raft_layer_height=None)

Creates a raft.

Parameters:

Name Type Description Default
slicer

An instance of one of the compas_slicer.slicers.BaseSlicer classes.

required
raft_offset

Distance (in mm) that the raft should be offsetted from the first layer. Defaults to 10mm

10
distance_between_paths

Distance (in mm) between the printed lines of the raft. Defaults to 10mm

10
direction

x_axis: Create a raft aligned with the x_axis y_axis: Create a raft aligned with the y_axis xy_diagonal: Create a raft int the diagonal direction in the xy_plane

'xy_diagonal'
raft_layers

Number of raft layers to add. Defaults to 1

1
raft_layer_height

Layer height of the raft layers. Defaults to same value as used in the slicer.

None
Source code in src/compas_slicer/post_processing/generate_raft.py
def generate_raft(slicer,
                  raft_offset=10,
                  distance_between_paths=10,
                  direction="xy_diagonal",
                  raft_layers=1,
                  raft_layer_height=None):
    """Creates a raft.

    Parameters
    ----------
    slicer: :class:`compas_slicer.slicers.BaseSlicer`
        An instance of one of the compas_slicer.slicers.BaseSlicer classes.
    raft_offset: float
        Distance (in mm) that the raft should be offsetted from the first layer. Defaults to 10mm
    distance_between_paths: float
        Distance (in mm) between the printed lines of the raft. Defaults to 10mm
    direction: str
        x_axis: Create a raft aligned with the x_axis
        y_axis: Create a raft aligned with the y_axis
        xy_diagonal: Create a raft int the diagonal direction in the xy_plane
    raft_layers: int
        Number of raft layers to add. Defaults to 1
    raft_layer_height: float
        Layer height of the raft layers. Defaults to same value as used in the slicer.
    """

    # check if a raft_layer_height is specified, if not, use the slicer.layer_height value
    if not raft_layer_height:
        raft_layer_height = slicer.layer_height

    logger.info("Generating raft")

    # find if slicer has vertical or horizontal layers, and select which paths are to be offset.
    if isinstance(slicer.layers[0], compas_slicer.geometry.VerticalLayer):  # Vertical layers
        # then find all paths that lie on the print platform and make them brim.
        paths_to_offset, _ = slicer.find_vertical_layers_with_first_path_on_base()

    else:  # Horizontal layers
        # then replace the first layer with a raft layer.
        paths_to_offset = slicer.layers[0].paths

    # get flat lists of points in bottom layer
    all_pts = []
    for path in paths_to_offset:
        for pt in path.points:
            all_pts.append(pt)

    # get xy bounding box of bottom layer and create offset
    bb_xy = bounding_box_xy(all_pts)
    bb_xy_offset = offset_polygon(bb_xy, -raft_offset)
    # bring points in the xy_offset to the correct height
    for pt in bb_xy_offset:
        pt[2] = slicer.layers[0].paths[0].points[0][2]

    # calculate x range, y range, and number of steps
    x_range = abs(bb_xy_offset[0][0] - bb_xy_offset[1][0])
    y_range = abs(bb_xy_offset[0][1] - bb_xy_offset[3][1])

    # get maximum values of the bounding box
    bb_max_x_right = bb_xy_offset[1][0]
    bb_max_y_top = bb_xy_offset[3][1]

    # get point in bottom left corner as raft start point
    raft_start_pt = Point(bb_xy_offset[0][0], bb_xy_offset[0][1], bb_xy_offset[0][2])

    # create starting line for diagonal direction
    if direction == "xy_diagonal":
        c = math.sqrt(2*(distance_between_paths**2))

        pt1 = Point(raft_start_pt[0] + c, raft_start_pt[1], raft_start_pt[2])
        pt2 = Point(pt1[0] - y_range, pt1[1] + y_range, pt1[2])
        line = Line(pt1, pt2)

    # move all points in the slicer up so that raft layers can be inserted
    for i, layer in enumerate(slicer.layers):
        for j, path in enumerate(layer.paths):
            for k, pt in enumerate(path.points):
                slicer.layers[i].paths[j].points[k] = Point(pt[0], pt[1], pt[2] + (raft_layers)*raft_layer_height)

    for i in range(raft_layers):

        iter = 0
        raft_points = []

        # create raft points depending on the chosen direction
        while iter < 9999:  # to avoid infinite while loop in case something is not correct
            # ===============
            # VERTICAL RAFT
            # ===============
            if direction == "y_axis":
                raft_pt1 = Point(raft_start_pt[0] + iter*distance_between_paths, raft_start_pt[1], raft_start_pt[2] + i*raft_layer_height)
                raft_pt2 = Point(raft_start_pt[0] + iter*distance_between_paths, raft_start_pt[1] + y_range, raft_start_pt[2] + i*raft_layer_height)

                if raft_pt2[0] > bb_max_x_right or raft_pt1[0] > bb_max_x_right:
                    break

            # ===============
            # HORIZONTAL RAFT
            # ===============
            elif direction == "x_axis":
                raft_pt1 = Point(raft_start_pt[0], raft_start_pt[1] + iter*distance_between_paths, raft_start_pt[2] + i*raft_layer_height)
                raft_pt2 = Point(raft_start_pt[0] + x_range, raft_start_pt[1] + iter*distance_between_paths, raft_start_pt[2] + i*raft_layer_height)

                if raft_pt2[1] > bb_max_y_top or raft_pt1[1] > bb_max_y_top:
                    break

            # ===============
            # DIAGONAL RAFT
            # ===============
            elif direction == "xy_diagonal":
                # create offset of the initial diagonal line
                offset_l = offset_line(line, iter*distance_between_paths, Vector(0, 0, -1))

                # get intersections for the initial diagonal line with the left and bottom of the bb
                int_left = intersection_line_line(offset_l, [bb_xy_offset[0], bb_xy_offset[3]])
                int_bottom = intersection_line_line(offset_l, [bb_xy_offset[0], bb_xy_offset[1]])

                # get the points at the intersections
                raft_pt1 = Point(int_left[0][0], int_left[0][1], int_left[0][2] + i*raft_layer_height)
                raft_pt2 = Point(int_bottom[0][0], int_bottom[0][1], int_bottom[0][2] + i*raft_layer_height)

                # if the intersection goes beyond the height of the left side of the bounding box:
                if int_left[0][1] > bb_max_y_top:
                    # create intersection with the top side
                    int_top = intersection_line_line(offset_l, [bb_xy_offset[3], bb_xy_offset[2]])
                    raft_pt1 = Point(int_top[0][0], int_top[0][1], int_top[0][2] + i*raft_layer_height)

                    # if intersection goes beyond the length of the top side, break
                    if raft_pt1[0] > bb_max_x_right:
                        break

                # if the intersection goes beyond the length of the bottom side of the bounding box:
                if int_bottom[0][0] > bb_max_x_right:
                    # create intersection with the right side
                    int_right = intersection_line_line(offset_l, [bb_xy_offset[1], bb_xy_offset[2]])
                    raft_pt2 = Point(int_right[0][0], int_right[0][1], int_right[0][2] + i*raft_layer_height)

                    # if intersection goes beyond the height of the right side, break
                    if raft_pt2[1] > bb_xy_offset[2][1]:
                        break

            # append to list alternating
            if iter % 2 == 0:
                raft_points.extend((raft_pt1, raft_pt2))
            else:
                raft_points.extend((raft_pt2, raft_pt1))

            iter += 1

        # create raft layer
        raft_layer = Layer([Path(raft_points, is_closed=False)])
        raft_layer.is_raft = True
        # insert raft layer in the correct position into the slicer
        slicer.layers.insert(i, raft_layer)

infill

Infill generation for sliced paths.

generate_medial_axis_infill

generate_medial_axis_infill(slicer, min_length=5.0, include_bisectors=True)

Generate medial axis infill paths for all layers.

Uses CGAL's straight skeleton to compute the medial axis of each closed contour, then converts skeleton edges to infill paths.

Parameters:

Name Type Description Default
slicer BaseSlicer

Slicer with layers containing boundary paths.

required
min_length float

Minimum skeleton edge length to include. Shorter edges are skipped.

5.0
include_bisectors bool

If True, include bisector edges (skeleton to boundary connections). If False, only include inner_bisector edges (skeleton internal edges).

True
Source code in src/compas_slicer/post_processing/infill/medial_axis_infill.py
def generate_medial_axis_infill(
    slicer: BaseSlicer,
    min_length: float = 5.0,
    include_bisectors: bool = True,
) -> None:
    """Generate medial axis infill paths for all layers.

    Uses CGAL's straight skeleton to compute the medial axis of each
    closed contour, then converts skeleton edges to infill paths.

    Parameters
    ----------
    slicer : BaseSlicer
        Slicer with layers containing boundary paths.
    min_length : float
        Minimum skeleton edge length to include. Shorter edges are skipped.
    include_bisectors : bool
        If True, include bisector edges (skeleton to boundary connections).
        If False, only include inner_bisector edges (skeleton internal edges).

    """
    from compas_cgal.straight_skeleton_2 import interior_straight_skeleton

    logger.info("Generating medial axis infill")

    for layer in slicer.layers:
        infill_paths: list[Path] = []

        for path in layer.paths:
            if not path.is_closed:
                continue

            # Convert path to 2D polygon
            polygon_2d = _path_to_polygon_2d(path)
            if len(polygon_2d) < 3:
                continue

            z_height = path.points[0][2]

            # Compute straight skeleton
            try:
                graph = interior_straight_skeleton(polygon_2d)
            except Exception as e:
                logger.warning(f"Skeleton failed for path: {e}")
                continue

            # Extract skeleton edges as paths
            skeleton_paths = _skeleton_to_paths(
                graph, z_height, min_length, include_bisectors
            )
            infill_paths.extend(skeleton_paths)

        # Add infill paths to layer
        layer.paths.extend(infill_paths)
        logger.info(f"Added {len(infill_paths)} infill paths to layer")

medial_axis_infill

Medial axis based infill generation using CGAL straight skeleton.

generate_medial_axis_infill
generate_medial_axis_infill(slicer, min_length=5.0, include_bisectors=True)

Generate medial axis infill paths for all layers.

Uses CGAL's straight skeleton to compute the medial axis of each closed contour, then converts skeleton edges to infill paths.

Parameters:

Name Type Description Default
slicer BaseSlicer

Slicer with layers containing boundary paths.

required
min_length float

Minimum skeleton edge length to include. Shorter edges are skipped.

5.0
include_bisectors bool

If True, include bisector edges (skeleton to boundary connections). If False, only include inner_bisector edges (skeleton internal edges).

True
Source code in src/compas_slicer/post_processing/infill/medial_axis_infill.py
def generate_medial_axis_infill(
    slicer: BaseSlicer,
    min_length: float = 5.0,
    include_bisectors: bool = True,
) -> None:
    """Generate medial axis infill paths for all layers.

    Uses CGAL's straight skeleton to compute the medial axis of each
    closed contour, then converts skeleton edges to infill paths.

    Parameters
    ----------
    slicer : BaseSlicer
        Slicer with layers containing boundary paths.
    min_length : float
        Minimum skeleton edge length to include. Shorter edges are skipped.
    include_bisectors : bool
        If True, include bisector edges (skeleton to boundary connections).
        If False, only include inner_bisector edges (skeleton internal edges).

    """
    from compas_cgal.straight_skeleton_2 import interior_straight_skeleton

    logger.info("Generating medial axis infill")

    for layer in slicer.layers:
        infill_paths: list[Path] = []

        for path in layer.paths:
            if not path.is_closed:
                continue

            # Convert path to 2D polygon
            polygon_2d = _path_to_polygon_2d(path)
            if len(polygon_2d) < 3:
                continue

            z_height = path.points[0][2]

            # Compute straight skeleton
            try:
                graph = interior_straight_skeleton(polygon_2d)
            except Exception as e:
                logger.warning(f"Skeleton failed for path: {e}")
                continue

            # Extract skeleton edges as paths
            skeleton_paths = _skeleton_to_paths(
                graph, z_height, min_length, include_bisectors
            )
            infill_paths.extend(skeleton_paths)

        # Add infill paths to layer
        layer.paths.extend(infill_paths)
        logger.info(f"Added {len(infill_paths)} infill paths to layer")

reorder_vertical_layers

reorder_vertical_layers

reorder_vertical_layers(slicer, align_with)

Re-orders the vertical layers in a specific way

Parameters:

Name Type Description Default
slicer BaseSlicer

An instance of one of the compas_slicer.slicers classes.

required
align_with AlignWith | Point

x_axis = reorders the vertical layers starting from the positive x-axis y_axis = reorders the vertical layers starting from the positive y-axis Point(x,y,z) = reorders the vertical layers starting from a given Point

required
Source code in src/compas_slicer/post_processing/reorder_vertical_layers.py
def reorder_vertical_layers(slicer: BaseSlicer, align_with: AlignWith | Point) -> None:
    """Re-orders the vertical layers in a specific way

    Parameters
    ----------
    slicer: :class:`compas_slicer.slicers.BaseSlicer`
        An instance of one of the compas_slicer.slicers classes.
    align_with: str or :class:`compas.geometry.Point`
        x_axis       = reorders the vertical layers starting from the positive x-axis
        y_axis       = reorders the vertical layers starting from the positive y-axis
        Point(x,y,z) = reorders the vertical layers starting from a given Point
    """

    if align_with == "x_axis":
        align_pt = Point(2 ** 32, 0, 0)
    elif align_with == "y_axis":
        align_pt = Point(0, 2 ** 32, 0)
    elif isinstance(align_with, Point):
        align_pt = align_with
    else:
        raise NameError("Unknown align_with : " + str(align_with))

    logger.info(f"Re-ordering vertical layers to start with the vertical layer closest to: {align_with}")

    for layer in slicer.layers:
        if layer.min_max_z_height[0] is None or layer.min_max_z_height[1] is None:
            raise ValueError(
                "To use reorder_vertical_layers you need first to calculate the layers' z_bounds. "
                "Use the function Layer.calculate_z_bounds()"
            )

    # group vertical layers based on the min_max_z_height
    grouped_iter = itertools.groupby(slicer.layers, lambda x: x.min_max_z_height)
    grouped_layer_list = [list(group) for _key, group in grouped_iter]

    reordered_layers = []

    for grouped_layers in grouped_layer_list:
        distances = []
        for vert_layer in grouped_layers:
            # recreate head_centroid_pt as compas.Point
            head_centroid_pt = Point(vert_layer.head_centroid[0], vert_layer.head_centroid[1], vert_layer.head_centroid[2])
            # measure distance
            distances.append(distance_point_point(head_centroid_pt, align_pt))

        # sort lists based on closest distance to align pt
        grouped_new = [x for _, x in sorted(zip(distances, grouped_layers))]
        reordered_layers.append(grouped_new)

    # flatten list
    slicer.layers = [item for sublist in reordered_layers for item in sublist]

seams_align

seams_align

seams_align(slicer, align_with='next_path')

Aligns the seams (start- and endpoint) of a print.

Parameters:

Name Type Description Default
slicer BaseSlicer

An instance of one of the compas_slicer.slicers classes.

required
align_with AlignWith | Point

Direction to orient the seams in. next_path = orients the seam to the next path origin = orients the seam to the origin (0,0,0) x_axis = orients the seam to the x_axis y_axis = orients the seam to the y_axis Point(x,y,z) = orients the seam according to the given point

'next_path'
Source code in src/compas_slicer/post_processing/seams_align.py
def seams_align(slicer: BaseSlicer, align_with: AlignWith | Point = "next_path") -> None:
    """Aligns the seams (start- and endpoint) of a print.

    Parameters
    ----------
    slicer: :class:`compas_slicer.slicers.BaseSlicer`
        An instance of one of the compas_slicer.slicers classes.
    align_with: str or :class:`compas.geometry.Point`
        Direction to orient the seams in.
        next_path    = orients the seam to the next path
        origin       = orients the seam to the origin (0,0,0)
        x_axis       = orients the seam to the x_axis
        y_axis       = orients the seam to the y_axis
        Point(x,y,z) = orients the seam according to the given point

    """
    #  TODO: Implement random seams
    logger.info(f"Aligning seams to: {align_with}")

    for i, layer in enumerate(slicer.layers):
        for j, path in enumerate(layer.paths):

            if align_with == "next_path":
                pt_to_align_with = None  # make sure aligning point is cleared

                #  determines the correct point to align the current path with
                if len(layer.paths) == 1 and i == 0:
                    #  if ONE PATH and FIRST LAYER
                    #  >>> align with second layer
                    pt_to_align_with = slicer.layers[i + 1].paths[0].points[0]
                if len(layer.paths) == 1 and i != 0:
                    last_path_index = len(slicer.layers[i - 1].paths) - 1
                    #  if ONE PATH and NOT FIRST LAYER
                    #  >>> align with previous layer
                    pt_to_align_with = slicer.layers[i - 1].paths[last_path_index].points[-1]
                if len(layer.paths) != 1 and i == 0 and j == 0:
                    #  if MULTIPLE PATHS and FIRST LAYER and FIRST PATH
                    #  >>> align with second path of first layer
                    pt_to_align_with = slicer.layers[i].paths[i + 1].points[-1]
                if len(layer.paths) != 1 and j != 0:
                    #  if MULTIPLE PATHS and NOT FIRST PATH
                    #  >>> align with previous path
                    pt_to_align_with = slicer.layers[i].paths[j - 1].points[-1]
                if len(layer.paths) != 1 and i != 0 and j == 0:
                    #  if MULTIPLE PATHS and NOT FIRST LAYER and FIRST PATH
                    #  >>> align with first path of previous layer
                    last_path_index = len(slicer.layers[i - 1].paths) - 1
                    pt_to_align_with = slicer.layers[i - 1].paths[last_path_index].points[-1]

            elif align_with == "origin":
                pt_to_align_with = Point(0, 0, 0)
            elif align_with == "x_axis":
                pt_to_align_with = Point(2 ** 32, 0, 0)
            elif align_with == "y_axis":
                pt_to_align_with = Point(0, 2 ** 32, 0)
            elif isinstance(align_with, Point):
                pt_to_align_with = align_with
            else:
                raise NameError("Unknown align_with : " + str(align_with))

            # CLOSED PATHS
            if path.is_closed:
                #  get the points of the current layer and path
                path_to_change = layer.paths[j].points

                # check if start- and end-points are the same point
                if path_to_change[0] == path_to_change[-1]:
                    first_last_point_the_same = True
                    # if they are, remove the last point
                    path_to_change.pop(-1)
                else:
                    first_last_point_the_same = False

                #  computes distance between pt_to_align_with and the current path points (vectorized)
                ref = np.asarray(pt_to_align_with, dtype=np.float64)
                pts = np.asarray(path_to_change, dtype=np.float64)
                distances = np.linalg.norm(pts - ref, axis=1)
                #  gets the index of the closest point
                new_start_index = int(np.argmin(distances))
                #  shifts the list by the distance determined
                shift_list = path_to_change[new_start_index:] + path_to_change[:new_start_index]

                if first_last_point_the_same:
                    shift_list = shift_list + [shift_list[0]]

                layer.paths[j].points = shift_list

            else:
                # OPEN PATHS
                path_to_change = layer.paths[j].points

                # get the distance between the align point and the start/end point (vectorized)
                ref = np.asarray(pt_to_align_with, dtype=np.float64)
                d_start = np.linalg.norm(np.asarray(path_to_change[0]) - ref)
                d_end = np.linalg.norm(np.asarray(path_to_change[-1]) - ref)

                # if closer to end point > reverse list
                if d_start > d_end:
                    layer.paths[j].points.reverse()

seams_smooth

seams_smooth

seams_smooth(slicer, smooth_distance)

Smooths the seams (transition between layers) by removing points within a certain distance.

Parameters:

Name Type Description Default
slicer BaseSlicer

An instance of one of the compas_slicer.slicers classes.

required
smooth_distance float

Distance (in mm) to perform smoothing

required
Source code in src/compas_slicer/post_processing/seams_smooth.py
def seams_smooth(slicer: BaseSlicer, smooth_distance: float) -> None:
    """Smooths the seams (transition between layers)
    by removing points within a certain distance.

    Parameters
    ----------
    slicer: :class:`compas_slicer.slicers.BaseSlicer`
        An instance of one of the compas_slicer.slicers classes.
    smooth_distance: float
        Distance (in mm) to perform smoothing
    """

    logger.info(f"Smoothing seams with a distance of {smooth_distance} mm")

    for i, layer in enumerate(slicer.layers):
        if len(layer.paths) == 1 or isinstance(layer, compas_slicer.geometry.VerticalLayer):
            for _j, path in enumerate(layer.paths):
                if path.is_closed:  # only for closed paths
                    pt0 = path.points[0]
                    # only points in the first half of a path should be evaluated
                    half_of_path = path.points[:int(len(path.points)/2)]
                    for point in half_of_path:
                        if distance_point_point(pt0, point) < smooth_distance:
                            # remove points if within smooth_distance
                            path.points.pop(0)
                        else:
                            # create new point at a distance of the
                            # 'smooth_distance' from the first point,
                            # so that all seams are of equal length
                            vect = Vector.from_start_end(pt0, point)
                            vect.unitize()
                            new_pt = pt0 + (vect * smooth_distance)
                            path.points.insert(0, new_pt)
                            path.points.pop(-1)  # remove last point
                            break
        else:
            logger.warning(
                "Smooth seams only works for layers consisting out of a single path, or for vertical layers."
                f"\nPaths were not changed, seam smoothing skipped for layer {i}"
            )

simplify_paths_rdp

simplify_paths_rdp

simplify_paths_rdp(slicer, threshold)

Simplify paths using the Ramer-Douglas-Peucker algorithm.

Uses CGAL native implementation if available (10-20x faster), otherwise falls back to Python rdp library.

Parameters:

Name Type Description Default
slicer BaseSlicer

An instance of one of the compas_slicer.slicers classes.

required
threshold float

Controls the degree of polyline simplification. Low threshold removes few points, high threshold removes many points.

required
References

https://en.wikipedia.org/wiki/Ramer-Douglas-Peucker_algorithm

Source code in src/compas_slicer/post_processing/simplify_paths_rdp.py
def simplify_paths_rdp(slicer: BaseSlicer, threshold: float) -> None:
    """Simplify paths using the Ramer-Douglas-Peucker algorithm.

    Uses CGAL native implementation if available (10-20x faster),
    otherwise falls back to Python rdp library.

    Parameters
    ----------
    slicer: :class:`compas_slicer.slicers.BaseSlicer`
        An instance of one of the compas_slicer.slicers classes.
    threshold: float
        Controls the degree of polyline simplification.
        Low threshold removes few points, high threshold removes many points.

    References
    ----------
    https://en.wikipedia.org/wiki/Ramer-Douglas-Peucker_algorithm
    """
    if _USE_CGAL:
        _simplify_paths_cgal(slicer, threshold)
    else:
        _simplify_paths_python(slicer, threshold)

sort_into_vertical_layers

sort_into_vertical_layers

sort_into_vertical_layers(slicer, dist_threshold=25.0, max_paths_per_layer=None)

Sorts the paths from horizontal layers into Vertical Layers.

Vertical Layers are layers at different heights that are grouped together by proximity of their center points. Can be useful for reducing travel time in a robotic printing process.

Parameters:

Name Type Description Default
slicer BaseSlicer

An instance of one of the compas_slicer.slicers classes.

required
dist_threshold float

The maximum get_distance that the centroids of two successive paths can have to belong in the same VerticalLayer Recommended value, slightly bigger than the layer height

25.0
max_paths_per_layer int | None

Maximum number of layers that a vertical layer can consist of. If None, then the vertical layer has an unlimited number of layers.

None
Source code in src/compas_slicer/post_processing/sort_into_vertical_layers.py
def sort_into_vertical_layers(
    slicer: BaseSlicer, dist_threshold: float = 25.0, max_paths_per_layer: int | None = None
) -> None:
    """Sorts the paths from horizontal layers into Vertical Layers.

    Vertical Layers are layers at different heights that are grouped together by proximity
    of their center points. Can be useful for reducing travel time in a robotic printing
    process.

    Parameters
    ----------
    slicer: :class:`compas_slicer.slicers.BaseSlicer`
        An instance of one of the compas_slicer.slicers classes.
    dist_threshold: float
        The maximum get_distance that the centroids of two successive paths can have to belong in the same VerticalLayer
        Recommended value, slightly bigger than the layer height
    max_paths_per_layer: int
        Maximum number of layers that a vertical layer can consist of.
        If None, then the vertical layer has an unlimited number of layers.
    """
    logger.info("Sorting into Vertical Layers")

    vertical_layers_manager = VerticalLayersManager(dist_threshold, max_paths_per_layer)

    for layer in slicer.layers:
        for path in layer.paths:
            vertical_layers_manager.add(path)

    slicer.layers = vertical_layers_manager.layers
    logger.info(f"Number of vertical_layers: {len(slicer.layers)}")

sort_paths_minimum_travel_time

sort_paths_minimum_travel_time

sort_paths_minimum_travel_time(slicer)

Sorts the paths within a horizontal layer to reduce total travel time.

Parameters:

Name Type Description Default
slicer BaseSlicer

An instance of one of the compas_slicer.slicers classes.

required
Source code in src/compas_slicer/post_processing/sort_paths_minimum_travel_time.py
def sort_paths_minimum_travel_time(slicer: BaseSlicer) -> None:
    """Sorts the paths within a horizontal layer to reduce total travel time.

    Parameters
    ----------
    slicer: :class:`compas_slicer.slicers.BaseSlicer`
        An instance of one of the compas_slicer.slicers classes.
    """
    logger.info("Sorting contours to minimize travel time")

    ref_point = Point(2 ** 32, 0, 0)  # set the reference point to the X-axis

    for i, layer in enumerate(slicer.layers):
        sorted_paths = []
        while len(layer.paths) > 0:
            index = closest_path(ref_point, layer.paths)  # find the closest path to the reference point
            sorted_paths.append(layer.paths[index])  # add the closest path to the sorted list
            ref_point = layer.paths[index].points[-1]
            layer.paths.pop(index)

        slicer.layers[i].paths = sorted_paths

adjust_seam_to_closest_pos

adjust_seam_to_closest_pos(ref_point, path)

Aligns the seam (start- and endpoint) of a contour so that it is closest to a given point. for open paths, check if the end point closest to the reference point is the start point

Parameters:

Name Type Description Default
ref_point Point

The reference point

required
path Path

The contour to be adjusted.

required
Source code in src/compas_slicer/post_processing/sort_paths_minimum_travel_time.py
def adjust_seam_to_closest_pos(ref_point: Point, path: SlicerPath) -> None:
    """Aligns the seam (start- and endpoint) of a contour so that it is closest to a given point.
    for open paths, check if the end point closest to the reference point is the start point

    Parameters
    ----------
    ref_point: :class:`compas.geometry.Point`
        The reference point
    path: :class:`compas_slicer.geometry.Path`
        The contour to be adjusted.
    """

    # TODO: flip orientation to reduce angular velocity

    if path.is_closed:  # if path is closed
        # remove first point
        path.points.pop(-1)
        #  calculate distances from ref_point to vertices of path (vectorized)
        ref = np.asarray(ref_point, dtype=np.float64)
        pts = np.asarray(path.points, dtype=np.float64)
        distances = np.linalg.norm(pts - ref, axis=1)
        closest_point = int(np.argmin(distances))
        #  adjust seam
        adjusted_seam = path.points[closest_point:] + path.points[:closest_point] + [path.points[closest_point]]
        path.points = adjusted_seam
    else:  # if path is open
        #  if end point is closer than start point >> flip (vectorized)
        ref = np.asarray(ref_point, dtype=np.float64)
        d_start = np.linalg.norm(np.asarray(path.points[0]) - ref)
        d_end = np.linalg.norm(np.asarray(path.points[-1]) - ref)
        if d_start > d_end:
            path.points.reverse()

closest_path

closest_path(ref_point, somepaths)

Finds the closest path to a reference point in a list of paths.

Parameters:

Name Type Description Default
ref_point Point
required
somepaths list[Path]
required
Source code in src/compas_slicer/post_processing/sort_paths_minimum_travel_time.py
def closest_path(ref_point: Point, somepaths: list[SlicerPath]) -> int:
    """Finds the closest path to a reference point in a list of paths.

    Parameters
    ----------
    ref_point: the reference point
    somepaths: list of paths to look into for finding the closest
    """
    ref = np.asarray(ref_point, dtype=np.float64)

    # First adjust all seams
    for path in somepaths:
        adjust_seam_to_closest_pos(ref_point, path)

    # Then find closest path (vectorized)
    start_pts = np.array([path.points[0] for path in somepaths], dtype=np.float64)
    distances = np.linalg.norm(start_pts - ref, axis=1)
    return int(np.argmin(distances))

spiralize_contours

spiralize_contours

spiralize_contours(slicer)

Spiralizes contours. Only works for Planar Slicer. Can only be used for geometries consisting out of a single closed contour (i.e. vases).

Parameters:

Name Type Description Default
slicer PlanarSlicer

An instance of the compas_slicer.slicers.PlanarSlicer class.

required
Source code in src/compas_slicer/post_processing/spiralize_contours.py
def spiralize_contours(slicer: PlanarSlicer) -> None:
    """Spiralizes contours. Only works for Planar Slicer.
    Can only be used for geometries consisting out of a single closed contour (i.e. vases).

    Parameters
    ----------
    slicer: :class: 'compas_slicer.slicers.PlanarSlicer'
        An instance of the compas_slicer.slicers.PlanarSlicer class.
    """
    logger.info('Spiralizing contours')

    if not isinstance(slicer, compas_slicer.slicers.PlanarSlicer):
        logger.warning("spiralize_contours() contours only works for PlanarSlicer. Skipping function.")
        return

    if slicer.layer_height is None:
        raise ValueError("layer_height must be set before spiralizing contours")

    for j, layer in enumerate(slicer.layers):
        if len(layer.paths) == 1:
            for path in layer.paths:
                d = slicer.layer_height / (len(path.points) - 1)
                for i, _point in enumerate(path.points):
                    # add the distance to move to the z value and create new points
                    path.points[i][2] += d * i

                # project all points of path back on the mesh surface
                _, projected_pts = pull_pts_to_mesh_faces(slicer.mesh, path.points)
                path.points = [Point(*p) for p in projected_pts]

                # remove the last item to create a smooth transition to the next layer
                path.points.pop(len(path.points) - 1)

        else:
            logger.warning(
                "Spiralize contours only works for layers consisting out of a single path, contours were "
                f"not changed, spiralize contour skipped for layer {j}"
            )

unify_paths_orientation

unify_paths_orientation

unify_paths_orientation(slicer)

Unifies the orientation of paths that are closed.

Parameters:

Name Type Description Default
slicer BaseSlicer

An instance of one of the compas_slicer.slicers classes.

required
Source code in src/compas_slicer/post_processing/unify_paths_orientation.py
def unify_paths_orientation(slicer: BaseSlicer) -> None:
    """
    Unifies the orientation of paths that are closed.

    Parameters
    ----------
    slicer: :class:`compas_slicer.slicers.BaseSlicer`
        An instance of one of the compas_slicer.slicers classes.
    """

    for i, layer in enumerate(slicer.layers):
        for j, path in enumerate(layer.paths):
            reference_points = None  # find reference points for each path, if possible
            if j > 0:
                reference_points = layer.paths[j-1].points
            elif i > 0 and j == 0:
                reference_points = slicer.layers[i - 1].paths[0].points

            if reference_points:  # then reorient current pts based on reference
                path.points = match_paths_orientations(path.points, reference_points, path.is_closed)

match_paths_orientations

match_paths_orientations(pts, reference_points, is_closed)

Check if new curve has same direction as prev curve, otherwise reverse.

Parameters:

Name Type Description Default
pts list[Point]
required
reference_points list[Point]
required
is_closed bool, Determines if the path is closed or open
required
Source code in src/compas_slicer/post_processing/unify_paths_orientation.py
def match_paths_orientations(
    pts: list[Point], reference_points: list[Point], is_closed: bool
) -> list[Point]:
    """Check if new curve has same direction as prev curve, otherwise reverse.

    Parameters
    ----------
    pts: list, :class: 'compas.geometry.Point'. The list of points whose direction we are fixing.
    reference_points: list, :class: 'compas.geometry.Point'. [p1, p2] Two reference points.
    is_closed : bool, Determines if the path is closed or open
    """

    if len(pts) > 2 and len(reference_points) > 2:
        v1 = normalize_vector(subtract_vectors(pts[0], pts[2]))
        v2 = normalize_vector(subtract_vectors(reference_points[0], reference_points[2]))
    else:
        v1 = normalize_vector(subtract_vectors(pts[0], pts[1]))
        v2 = normalize_vector(subtract_vectors(reference_points[0], reference_points[1]))

    if dot_vectors(v1, v2) < 0:
        if is_closed:
            items = deque(reversed(pts))
            items.rotate(1)  # bring last point again in the front
            pts = list(items)
        else:
            pts.reverse()
    return pts

zig_zag_open_paths

zig_zag_open_paths

zig_zag_open_paths(slicer)

Reverses half of the open paths of the slicer, so that they can be printed in a zig zag motion.

Source code in src/compas_slicer/post_processing/zig_zag_open_paths.py
def zig_zag_open_paths(slicer: BaseSlicer) -> None:
    """ Reverses half of the open paths of the slicer, so that they can be printed in a zig zag motion. """
    reverse = False
    for layer in slicer.layers:
        for _i, path in enumerate(layer.paths):
            if not path.is_closed:
                if not reverse:
                    reverse = True
                else:
                    path.points.reverse()
                    reverse = False

                path.is_closed = True  # label as closed so that it is printed without interruption