Skip to content

kumiki.timber

Flat import path

kumiki/__init__.py re-exports everything on this page via from kumiki import *, so every name below is also available directly as kumiki.CSGUnion -- you do not need to import from the submodule path shown in the heading above.

kumiki.timber

Kumiki - Timber types, enums, constants, and core classes Contains all core data structures and type definitions for the timber framing system

CSGUnion module-attribute

CSGUnion = SolidUnion

CSGDifference module-attribute

CSGDifference = Difference

OFFSET_TEST_POINT module-attribute

OFFSET_TEST_POINT = scalar(1, 1000)

TimberLike module-attribute

BlockLike module-attribute

BlockLike = Union[Timber, Board]

PTW_FACE_PREFIX module-attribute

PTW_FACE_PREFIX = 'ptw.'

ROUGH_FACE_PREFIX module-attribute

ROUGH_FACE_PREFIX = 'rough.'

RPTW_FACE_PREFIX module-attribute

RPTW_FACE_PREFIX = 'rptw.'

PerfectTimberWithin dataclass

Bases: ABC

Base class for all timber types in the timber framing system (immutable)

This is an abstract base class (ABC) to prevent direct instantiation. All timbers contain a perfect rectangular timber within their rough bounding box.

Note: Use create_timber() factory function to construct timber instances from length_direction and width_direction vectors. Subclasses are frozen to ensure immutability after construction.

Alternatively, if you already have a Transform object, you can construct a timber directly by passing: Timber(length, size, transform, ticket)

Attributes:

Name Type Description
length Numeric

Length of the timber along its centerline axis

size V2

Cross-sectional size (width, height) of the perfect timber within

transform Transform

Position and orientation in global coordinates

ticket TimberTicket

Ticket for this timber (used for rendering/debugging)

length instance-attribute

length: Numeric

size instance-attribute

size: V2

transform instance-attribute

transform: Transform

ticket class-attribute instance-attribute

ticket: TimberTicket = field(default_factory=TimberTicket)

orientation property

orientation: Orientation

Get the orientation from the transform.

__post_init__

__post_init__()
Source code in kumiki/timber.py
def __post_init__(self):
    self._warn_about_imperfect_reference_features()
    object.__setattr__(self, '_perfect_timber_within_csg_local', RectangularPrism(
        size=self.size,
        transform=Transform.identity(),
        start_distance=scalar(0),
        end_distance=self.length,
        feature_overrides=_ptw_face_tags(),
        extra_features=_ptw_centerplanes(),
        label=self.csg_label("perfect"),
    ))

get_orientation_global

get_orientation_global() -> Orientation

Get the orientation from the transform.

Source code in kumiki/timber.py
def get_orientation_global(self) -> Orientation:
    """Get the orientation from the transform."""
    return self.orientation

get_bottom_position_global

get_bottom_position_global() -> V3

Get the bottom position (center of bottom cross-section) in global coordinates from the transform.

Source code in kumiki/timber.py
def get_bottom_position_global(self) -> V3:
    """Get the bottom position (center of bottom cross-section) in global coordinates from the transform."""
    return self.transform.position

get_length_direction_global

get_length_direction_global() -> Direction3D

Get the length direction vector in global coordinates from the orientation matrix

Source code in kumiki/timber.py
def get_length_direction_global(self) -> Direction3D:
    """Get the length direction vector in global coordinates from the orientation matrix"""
    # Length direction is the 3rd column (index 2) of the rotation matrix
    # The +length direction is the +Z direction
    return Matrix([
        self.orientation.matrix[0, 2],
        self.orientation.matrix[1, 2],
        self.orientation.matrix[2, 2]
    ])

get_width_direction_global

get_width_direction_global() -> Direction3D

Get the width direction vector in global coordinates from the orientation matrix

Source code in kumiki/timber.py
def get_width_direction_global(self) -> Direction3D:
    """Get the width direction vector in global coordinates from the orientation matrix"""
    # Width direction is the 1st column (index 0) of the rotation matrix
    # The +width direction is the +X direction
    return Matrix([
        self.orientation.matrix[0, 0],
        self.orientation.matrix[1, 0],
        self.orientation.matrix[2, 0]
    ])

get_height_direction_global

get_height_direction_global() -> Direction3D

Get the height direction vector in global coordinates from the orientation matrix

Source code in kumiki/timber.py
def get_height_direction_global(self) -> Direction3D:
    """Get the height direction vector in global coordinates from the orientation matrix"""
    # Height direction is the 2nd column (index 1) of the rotation matrix
    # The +height direction is the +Y direction
    return Matrix([
        self.orientation.matrix[0, 1],
        self.orientation.matrix[1, 1],
        self.orientation.matrix[2, 1]
    ])

get_face_direction_global

get_face_direction_global(face: SomeTimberFace) -> Direction3D

Get the world direction vector for a specific face of this timber.

Parameters:

Name Type Description Default
face SomeTimberFace

The face to get the direction for (can be TimberFace, TimberEnd, or TimberLongFace)

required

Returns:

Type Description
Direction3D

Direction vector pointing outward from the specified face in world coordinates

Source code in kumiki/timber.py
def get_face_direction_global(self, face: SomeTimberFace) -> Direction3D:
    """
    Get the world direction vector for a specific face of this timber.

    Args:
        face: The face to get the direction for (can be TimberFace, TimberEnd, or TimberLongFace)

    Returns:
        Direction vector pointing outward from the specified face in world coordinates
    """
    # Convert to TimberFace
    face = face.to.face()

    if face == TimberFace.TOP:
        return self.get_length_direction_global()
    elif face == TimberFace.BOTTOM:
        return -self.get_length_direction_global()
    elif face == TimberFace.RIGHT:
        return self.get_width_direction_global()
    elif face == TimberFace.LEFT:
        return -self.get_width_direction_global()
    elif face == TimberFace.FRONT:
        return self.get_height_direction_global()
    else:  # BACK
        return -self.get_height_direction_global()

get_corner_position_global

get_corner_position_global(corner: TimberCorner) -> V3

Get the position of a corner in global coordinates.

Source code in kumiki/timber.py
def get_corner_position_global(self, corner: TimberCorner) -> V3:
    """Get the position of a corner in global coordinates."""
    _corner_to_faces = {
        TimberCorner.BOT_RIGHT_FRONT: (TimberFace.BOTTOM, TimberFace.RIGHT, TimberFace.FRONT),
        TimberCorner.BOT_FRONT_LEFT:  (TimberFace.BOTTOM, TimberFace.FRONT, TimberFace.LEFT),
        TimberCorner.BOT_LEFT_BACK:   (TimberFace.BOTTOM, TimberFace.LEFT,  TimberFace.BACK),
        TimberCorner.BOT_BACK_RIGHT:  (TimberFace.BOTTOM, TimberFace.BACK,  TimberFace.RIGHT),
        TimberCorner.TOP_RIGHT_FRONT: (TimberFace.TOP,    TimberFace.RIGHT, TimberFace.FRONT),
        TimberCorner.TOP_FRONT_LEFT:  (TimberFace.TOP,    TimberFace.FRONT, TimberFace.LEFT),
        TimberCorner.TOP_LEFT_BACK:   (TimberFace.TOP,    TimberFace.LEFT,  TimberFace.BACK),
        TimberCorner.TOP_BACK_RIGHT:  (TimberFace.TOP,    TimberFace.BACK,  TimberFace.RIGHT),
    }
    faces = _corner_to_faces[corner]
    timber_center = self.get_bottom_position_global() + self.get_length_direction_global() * self.length / 2
    position = timber_center
    for face in faces:
        position = position + self.get_face_direction_global(face) * self.get_size_in_face_normal_axis(face) / 2
    return position

get_size_index_in_long_face_normal_axis

get_size_index_in_long_face_normal_axis(face: TimberLongFace) -> int

Get the index of the size in the direction normal to the specified face.

Parameters:

Name Type Description Default
face TimberLongFace

The long face to get the size index for (RIGHT/LEFT or FRONT/BACK)

required

Returns:

Type Description
int

Index into self.size: 0 (width) for RIGHT/LEFT, 1 (height) for FRONT/BACK

Source code in kumiki/timber.py
def get_size_index_in_long_face_normal_axis(self, face: TimberLongFace) -> int:
    """
    Get the index of the size in the direction normal to the specified face.

    Args:
        face: The long face to get the size index for (RIGHT/LEFT or FRONT/BACK)

    Returns:
        Index into self.size: 0 (width) for RIGHT/LEFT, 1 (height) for FRONT/BACK
    """
    assert isinstance(face, TimberLongFace), f"expected TimberLongFace, got {type(face).__name__}"
    if face == TimberLongFace.RIGHT or face == TimberLongFace.LEFT:
        return 0
    elif face == TimberLongFace.FRONT or face == TimberLongFace.BACK:
        return 1
    else:
        raise ValueError(f"Unknown face: {face}")

get_size_in_face_normal_axis

get_size_in_face_normal_axis(face: SomeTimberFace) -> Numeric

Get the size of the timber in the direction normal to the specified face.

Parameters:

Name Type Description Default
face SomeTimberFace

The face to get the size for (can be TimberFace, TimberEnd, or TimberLongFace)

required

Returns:

Type Description
Numeric

The timber's extent along the axis normal to the given face: self.length for

Numeric

TOP/BOTTOM, self.size[0] (width) for RIGHT/LEFT, self.size[1] (height) for FRONT/BACK

Source code in kumiki/timber.py
def get_size_in_face_normal_axis(self, face: SomeTimberFace) -> Numeric:
    """
    Get the size of the timber in the direction normal to the specified face.

    Args:
        face: The face to get the size for (can be TimberFace, TimberEnd, or TimberLongFace)

    Returns:
        The timber's extent along the axis normal to the given face: self.length for
        TOP/BOTTOM, self.size[0] (width) for RIGHT/LEFT, self.size[1] (height) for FRONT/BACK
    """
    # Convert to TimberFace
    face = face.to.face()

    if face == TimberFace.TOP or face == TimberFace.BOTTOM:
        return self.length
    elif face == TimberFace.RIGHT or face == TimberFace.LEFT:
        return self.size[0]
    else:  # FRONT or BACK
        return self.size[1]

get_rough_size_in_face_normal_axis

get_rough_size_in_face_normal_axis(face: SomeTimberFace) -> Numeric

Get the full rough size of the timber in the direction normal to the specified face.

For long faces this returns the sum of the two half-sizes (e.g. right + left for RIGHT or LEFT). For end faces (TOP/BOTTOM) this returns the length.

Parameters:

Name Type Description Default
face SomeTimberFace

The face to get the size for (can be TimberFace, TimberEnd, or TimberLongFace)

required
Source code in kumiki/timber.py
def get_rough_size_in_face_normal_axis(self, face: SomeTimberFace) -> Numeric:
    """
    Get the full rough size of the timber in the direction normal to the specified face.

    For long faces this returns the sum of the two half-sizes (e.g. right + left for
    RIGHT or LEFT). For end faces (TOP/BOTTOM) this returns the length.

    Args:
        face: The face to get the size for (can be TimberFace, TimberEnd, or TimberLongFace)
    """
    face = face.to.face()

    if face == TimberFace.TOP or face == TimberFace.BOTTOM:
        return self.length

    width_halves, height_halves = self.get_rough_half_sizes()
    if face == TimberFace.RIGHT or face == TimberFace.LEFT:
        return width_halves[0] + width_halves[1]
    else:  # FRONT or BACK
        return height_halves[0] + height_halves[1]

get_half_rough_size_in_face_normal_axis

get_half_rough_size_in_face_normal_axis(face: SomeTimberFace) -> Numeric

Get the rough half-size of the timber from the centerline to the specified face.

Parameters:

Name Type Description Default
face SomeTimberFace

A long face (RIGHT, LEFT, FRONT, or BACK). TOP/BOTTOM will raise ValueError since length has no asymmetry concept.

required

Returns:

Type Description
Numeric

The half-size from centerline to the specified face.

Source code in kumiki/timber.py
def get_half_rough_size_in_face_normal_axis(self, face: SomeTimberFace) -> Numeric:
    """
    Get the rough half-size of the timber from the centerline to the specified face.

    Args:
        face: A long face (RIGHT, LEFT, FRONT, or BACK). TOP/BOTTOM will raise ValueError
              since length has no asymmetry concept.

    Returns:
        The half-size from centerline to the specified face.
    """
    face = face.to.face()
    width_halves, height_halves = self.get_rough_half_sizes()

    if face == TimberFace.RIGHT:
        return width_halves[0]
    elif face == TimberFace.LEFT:
        return width_halves[1]
    elif face == TimberFace.FRONT:
        return height_halves[0]
    elif face == TimberFace.BACK:
        return height_halves[1]
    else:
        raise ValueError(f"get_half_rough_size_in_face_normal_axis does not support end faces (got {face})")

get_nominal_size_in_face_normal_axis

get_nominal_size_in_face_normal_axis(face: SomeTimberFace) -> Numeric
Source code in kumiki/timber.py
@deprecated("use get_rough_size_in_face_normal_axis instead")
def get_nominal_size_in_face_normal_axis(self, face: SomeTimberFace) -> Numeric:
    return self.get_rough_size_in_face_normal_axis(face)

get_half_nominal_size_in_face_normal_axis

get_half_nominal_size_in_face_normal_axis(face: SomeTimberFace) -> Numeric
Source code in kumiki/timber.py
@deprecated("use get_half_rough_size_in_face_normal_axis instead")
def get_half_nominal_size_in_face_normal_axis(self, face: SomeTimberFace) -> Numeric:
    return self.get_half_rough_size_in_face_normal_axis(face)

get_size_in_direction_2d

get_size_in_direction_2d(direction: V2) -> Numeric

Get the size of the timber's cross-section measured along an arbitrary 2D direction.

The direction is in the timber's local cross-section plane where x is the width axis and y is the height axis. Returns the total extent (support width) of the rectangular cross-section projected onto that direction.

For axis-aligned directions this matches get_size_in_face_normal_axis.

Parameters:

Name Type Description Default
direction V2

A 2D direction vector (x=width, y=height) in local cross-section space. Does not need to be normalized.

required

Returns:

Type Description
Numeric

The size of the cross-section measured along the given direction.

Source code in kumiki/timber.py
def get_size_in_direction_2d(self, direction: V2) -> Numeric:
    """
    Get the size of the timber's cross-section measured along an arbitrary 2D direction.

    The direction is in the timber's local cross-section plane where x is the width
    axis and y is the height axis. Returns the total extent (support width) of the
    rectangular cross-section projected onto that direction.

    For axis-aligned directions this matches get_size_in_face_normal_axis.

    Args:
        direction: A 2D direction vector (x=width, y=height) in local cross-section space.
                   Does not need to be normalized.

    Returns:
        The size of the cross-section measured along the given direction.
    """
    d = safe_normalize_vector(direction)
    return self.size[0] * Abs(d[0]) + self.size[1] * Abs(d[1])

get_size_in_direction_3d

get_size_in_direction_3d(direction: Direction3D) -> Numeric

Get the size of the timber measured along an arbitrary 3D direction in global space.

Transforms the direction into the timber's local frame and computes the total extent (support width) of the rectangular prism projected onto that direction.

For axis-aligned directions this matches get_size_in_face_normal_axis.

Parameters:

Name Type Description Default
direction Direction3D

A 3D direction vector in global coordinates. Does not need to be normalized.

required

Returns:

Type Description
Numeric

The size of the timber measured along the given direction.

Source code in kumiki/timber.py
def get_size_in_direction_3d(self, direction: Direction3D) -> Numeric:
    """
    Get the size of the timber measured along an arbitrary 3D direction in global space.

    Transforms the direction into the timber's local frame and computes the total
    extent (support width) of the rectangular prism projected onto that direction.

    For axis-aligned directions this matches get_size_in_face_normal_axis.

    Args:
        direction: A 3D direction vector in global coordinates.
                   Does not need to be normalized.

    Returns:
        The size of the timber measured along the given direction.
    """
    d_global = safe_normalize_vector(direction)
    # Rotate to local frame (transpose of rotation matrix, no translation for directions)
    d_local = safe_transform_vector(self.orientation.matrix.T, d_global)
    return self.size[0] * Abs(d_local[0]) + self.size[1] * Abs(d_local[1]) + self.length * Abs(d_local[2])

get_closest_oriented_face_from_global_direction

get_closest_oriented_face_from_global_direction(target_direction: Direction3D) -> TimberFace

Find which face of this timber best aligns with the target direction.

The target_direction should point "outwards" from the desired face (not into it).

Parameters:

Name Type Description Default
target_direction Direction3D

Direction vector to match against

required

Returns:

Type Description
TimberFace

The TimberFace that best aligns with the target direction

Source code in kumiki/timber.py
def get_closest_oriented_face_from_global_direction(self, target_direction: Direction3D) -> TimberFace:
    """
    Find which face of this timber best aligns with the target direction.

    The target_direction should point "outwards" from the desired face (not into it).

    Args:
        target_direction: Direction vector to match against

    Returns:
        The TimberFace that best aligns with the target direction
    """
    faces = [
        TimberFace.TOP, TimberFace.BOTTOM, TimberFace.RIGHT,
        TimberFace.LEFT, TimberFace.FRONT, TimberFace.BACK,
    ]
    return self._get_closest_oriented_face_from_faces(faces, target_direction)

get_closest_oriented_long_face_from_global_direction

get_closest_oriented_long_face_from_global_direction(target_direction: Direction3D) -> TimberLongFace

Find which long face of this timber best aligns with the target direction.

The target_direction should point "outwards" from the desired face (not into it).

Parameters:

Name Type Description Default
target_direction Direction3D

Direction vector to match against

required

Returns:

Type Description
TimberLongFace

The TimberLongFace that best aligns with the target direction

Source code in kumiki/timber.py
def get_closest_oriented_long_face_from_global_direction(self, target_direction: Direction3D) -> TimberLongFace:
    """
    Find which long face of this timber best aligns with the target direction.

    The target_direction should point "outwards" from the desired face (not into it).

    Args:
        target_direction: Direction vector to match against

    Returns:
        The TimberLongFace that best aligns with the target direction
    """
    faces = [TimberFace.RIGHT, TimberFace.LEFT, TimberFace.FRONT, TimberFace.BACK]
    return self._get_closest_oriented_face_from_faces(faces, target_direction).to.long_face()

get_closest_oriented_end_face_from_global_direction

get_closest_oriented_end_face_from_global_direction(target_direction: Direction3D) -> TimberEnd

Find which end face of this timber best aligns with the target direction.

The target_direction should point "outwards" from the desired end face (not into it).

Returns:

Type Description
TimberEnd

The TimberEnd that best aligns with the target direction

Source code in kumiki/timber.py
def get_closest_oriented_end_face_from_global_direction(self, target_direction: Direction3D) -> TimberEnd:
    """
    Find which end face of this timber best aligns with the target direction.

    The target_direction should point "outwards" from the desired end face (not into it).

    Returns:
        The TimberEnd that best aligns with the target direction
    """
    faces = [TimberFace.TOP, TimberFace.BOTTOM]
    return self._get_closest_oriented_face_from_faces(faces, target_direction).to.end()

get_inside_face_from_footprint

get_inside_face_from_footprint(footprint: Footprint) -> TimberFace

Get the inside face of this timber relative to the footprint.

This method finds which face of the timber is oriented toward the interior of the footprint by: 1. Finding the nearest boundary of the footprint to the timber's centerline 2. Getting the inward normal of that boundary 3. Finding which timber face best aligns with that inward direction

Parameters:

Name Type Description Default
footprint Footprint

The footprint to determine inside/outside orientation

required

Returns:

Type Description
TimberFace

The TimberFace that points toward the inside of the footprint

Source code in kumiki/timber.py
def get_inside_face_from_footprint(self, footprint: Footprint) -> TimberFace:
    """
    Get the inside face of this timber relative to the footprint.

    This method finds which face of the timber is oriented toward the interior
    of the footprint by:
    1. Finding the nearest boundary of the footprint to the timber's centerline
    2. Getting the inward normal of that boundary
    3. Finding which timber face best aligns with that inward direction

    Args:
        footprint: The footprint to determine inside/outside orientation

    Returns:
        The TimberFace that points toward the inside of the footprint
    """
    from .measuring import locate_top_center_position

    # Project timber's centerline onto XY plane for footprint comparison
    bottom_2d = create_v2(self.get_bottom_position_global()[0], self.get_bottom_position_global()[1])
    top_position = locate_top_center_position(self).position
    top_2d = create_v2(top_position[0], top_position[1])

    # Find nearest boundary to timber's centerline
    boundary_idx, boundary_side, distance = footprint.nearest_boundary_from_line(bottom_2d, top_2d)

    # Get the inward normal of that boundary
    inward_normal = footprint.get_inward_normal(boundary_idx)

    # Find which face of the timber aligns with the inward direction
    return self.get_closest_oriented_face_from_global_direction(inward_normal)

get_outside_face_from_footprint

get_outside_face_from_footprint(footprint: Footprint) -> TimberFace

Get the outside face of this timber relative to the footprint.

This method finds which face of the timber is oriented toward the exterior of the footprint by: 1. Finding the nearest boundary of the footprint to the timber's centerline 2. Getting the inward normal of that boundary 3. Finding which timber face best aligns with the opposite (outward) direction

Parameters:

Name Type Description Default
footprint Footprint

The footprint to determine inside/outside orientation

required

Returns:

Type Description
TimberFace

The TimberFace that points toward the outside of the footprint

Source code in kumiki/timber.py
def get_outside_face_from_footprint(self, footprint: Footprint) -> TimberFace:
    """
    Get the outside face of this timber relative to the footprint.

    This method finds which face of the timber is oriented toward the exterior
    of the footprint by:
    1. Finding the nearest boundary of the footprint to the timber's centerline
    2. Getting the inward normal of that boundary
    3. Finding which timber face best aligns with the opposite (outward) direction

    Args:
        footprint: The footprint to determine inside/outside orientation

    Returns:
        The TimberFace that points toward the outside of the footprint
    """
    from .measuring import locate_top_center_position

    # Project timber's centerline onto XY plane for footprint comparison
    bottom_2d = create_v2(self.get_bottom_position_global()[0], self.get_bottom_position_global()[1])
    top_position = locate_top_center_position(self).position
    top_2d = create_v2(top_position[0], top_position[1])

    # Find nearest boundary to timber's centerline
    boundary_idx, boundary_side, distance = footprint.nearest_boundary_from_line(bottom_2d, top_2d)

    # Get the inward normal of that boundary
    inward_normal = footprint.get_inward_normal(boundary_idx)

    # Find which face of the timber aligns with the outward direction (negative of inward)
    outward_normal = -inward_normal
    return self.get_closest_oriented_face_from_global_direction(outward_normal)

get_transform_matrix

get_transform_matrix() -> Matrix

Get the 4x4 transformation matrix for this timber

Source code in kumiki/timber.py
def get_transform_matrix(self) -> Matrix:
    """Get the 4x4 transformation matrix for this timber"""
    # Create 4x4 transformation matrix
    transform = Matrix([
        [self.orientation.matrix[0,0], self.orientation.matrix[0,1], self.orientation.matrix[0,2], self.get_bottom_position_global()[0]],
        [self.orientation.matrix[1,0], self.orientation.matrix[1,1], self.orientation.matrix[1,2], self.get_bottom_position_global()[1]],
        [self.orientation.matrix[2,0], self.orientation.matrix[2,1], self.orientation.matrix[2,2], self.get_bottom_position_global()[2]],
        [0, 0, 0, 1]
    ])
    return transform

project_global_point_onto_timber_face_global

project_global_point_onto_timber_face_global(global_point: V3, face: SomeTimberFace) -> V3

Project a point from global coordinates onto the timber's face and return result in global coordinates.

Parameters:

Name Type Description Default
global_point V3

The point to project in global coordinates (3x1 Matrix)

required
face SomeTimberFace

The face to project onto (can be TimberFace, TimberEnd, or TimberLongFace)

required
Source code in kumiki/timber.py
def project_global_point_onto_timber_face_global(self, global_point: V3, face: SomeTimberFace) -> V3:
    """
    Project a point from global coordinates onto the timber's face and return result in global coordinates.

    Args:
        global_point: The point to project in global coordinates (3x1 Matrix)
        face: The face to project onto (can be TimberFace, TimberEnd, or TimberLongFace)
    """
    # Convert to TimberFace
    face = face.to.face()

    # Convert global point to local coordinates
    local_point = self.transform.global_to_local(global_point)

    # project the 0,0 point onto the face
    face_zero_local = face.get_direction() * self.get_size_in_face_normal_axis(face) / 2
    local_point_face_component = (local_point-face_zero_local).dot(face.get_direction()) * face.get_direction()
    local_point_projected = local_point - local_point_face_component
    return self.transform.local_to_global(local_point_projected)

get_perfect_size

get_perfect_size() -> V2

Returns the perfect cross sectional size of the timber.

The perfect size is the cross sectional size of the perfect timber within.

Source code in kumiki/timber.py
@final
def get_perfect_size(self) -> V2:
    """
    Returns the perfect cross sectional size of the timber.

    The perfect size is the cross sectional size of the perfect timber within.
    """
    return self.size

can_be_extended_for_joints

can_be_extended_for_joints() -> bool

Returns True if the timber can be extended when cutting joints.

Returns:

Type Description
bool

True if the timber can be extended when cutting joints.

Source code in kumiki/timber.py
def can_be_extended_for_joints(self) -> bool:
    """
    Returns True if the timber can be extended when cutting joints.

    Returns:
        True if the timber can be extended when cutting joints.
    """
    return True

get_rough_half_sizes abstractmethod

get_rough_half_sizes() -> Tuple[V2, V2]

Returns the rough half-sizes of the timber measured from the centerline.

The rough bounding box is defined by four half-sizes measured from the centerline in each direction. This allows the rough timber to be non-coaxial with the perfect timber within (useful for square rule layout).

Returns:

Type Description
Tuple[V2, V2]

Tuple of two V2s: - width_halves: V2(right_half, left_half) — half-sizes in the width dimension - height_halves: V2(front_half, back_half) — half-sizes in the height dimension

Source code in kumiki/timber.py
@abstractmethod
def get_rough_half_sizes(self) -> Tuple[V2, V2]:
    """
    Returns the rough half-sizes of the timber measured from the centerline.

    The rough bounding box is defined by four half-sizes measured from the
    centerline in each direction. This allows the rough timber to be non-coaxial
    with the perfect timber within (useful for square rule layout).

    Returns:
        Tuple of two V2s:
          - width_halves: V2(right_half, left_half) — half-sizes in the width dimension
          - height_halves: V2(front_half, back_half) — half-sizes in the height dimension
    """
    pass

get_nominal_half_sizes

get_nominal_half_sizes() -> Tuple[V2, V2]
Source code in kumiki/timber.py
@deprecated("use get_rough_half_sizes instead")
def get_nominal_half_sizes(self) -> Tuple[V2, V2]:
    return self.get_rough_half_sizes()

get_rough_size

get_rough_size() -> V2

Returns the rough cross sectional size of the timber.

The rough size is the total cross sectional size defined by the rough half-sizes. For a perfect timber, this matches the perfect size. For an imperfect timber, this may differ and represents the intended bounding box for joint layout and intersection tests.

Source code in kumiki/timber.py
def get_rough_size(self) -> V2:
    """
    Returns the rough cross sectional size of the timber.

    The rough size is the total cross sectional size defined by the rough half-sizes.
    For a perfect timber, this matches the perfect size. For an imperfect timber, this
    may differ and represents the intended bounding box for joint layout and intersection tests.
    """
    width_halves, height_halves = self.get_rough_half_sizes()
    total_w = width_halves[0] + width_halves[1]
    total_h = height_halves[0] + height_halves[1]
    return create_v2(total_w, total_h)

get_nominal_size

get_nominal_size() -> V2
Source code in kumiki/timber.py
@deprecated("use get_rough_size instead")
def get_nominal_size(self) -> V2:
    return self.get_rough_size()

get_perfect_timber_within_csg_local

get_perfect_timber_within_csg_local(with_feature_labels: bool = True) -> RectangularPrism

Returns the perfect rectangular prism CSG in local coordinates.

This represents the perfect timber within as a CSG object -- the idealized, finished-dimension bounding box (self.size), not the rough/as-sawn stock boundary. All timber types have a perfect rectangular prism that bounds their actual geometry.

Built once, at construction.

Parameters:

Name Type Description Default
with_feature_labels bool

False to leave the override labels off, giving a copy of the same prism whose faces carry the primitive's own default names instead. For a caller with a second copy of this timber in one tree, where two features of one name on one plane would be one surface declared twice. The two centerplanes are declared features of their own rather than overrides, and stay either way.

True

Returns:

Type Description
RectangularPrism

RectangularPrism in local coordinates (relative to timber's bottom position)

Source code in kumiki/timber.py
def get_perfect_timber_within_csg_local(self, with_feature_labels: bool = True) -> RectangularPrism:
    """
    Returns the perfect rectangular prism CSG in local coordinates.

    This represents the perfect timber within as a CSG object -- the idealized,
    finished-dimension bounding box (self.size), not the rough/as-sawn stock
    boundary. All timber types have a perfect rectangular prism that bounds
    their actual geometry.

    Built once, at construction.

    Args:
        with_feature_labels: False to leave the override labels off, giving a
            copy of the same prism whose faces carry the primitive's own default
            names instead. For a caller with a second copy of this timber in one
            tree, where two features of one name on one plane would be one
            surface declared twice. The two centerplanes are declared features
            of their own rather than overrides, and stay either way.

    Returns:
        RectangularPrism in local coordinates (relative to timber's bottom position)
    """
    assert self._perfect_timber_within_csg_local is not None
    if with_feature_labels:
        return self._perfect_timber_within_csg_local
    return dataclass_replace(self._perfect_timber_within_csg_local, feature_overrides=())

csg_label_name classmethod

csg_label_name() -> str

What this kind of timber is called in a CSG label.

Derived from the class name -- "board", "round_timber" -- so a new timber type names itself without anyone remembering to add it here.

Source code in kumiki/timber.py
@classmethod
def csg_label_name(cls) -> str:
    """What this kind of timber is called in a CSG label.

    Derived from the class name -- "board", "round_timber" -- so a new
    timber type names itself without anyone remembering to add it here.
    """
    return re.sub(r"(?<!^)(?=[A-Z])", "_", cls.__name__).lower()

csg_label classmethod

csg_label(*qualifiers: str) -> CutCSGLabel

Label for one of this timber's own CSG shapes.

A classmethod so the name follows the derived class -- a Board's rough extended prism reads "board (rough, extended)", not "timber (...)".

Source code in kumiki/timber.py
@classmethod
def csg_label(cls, *qualifiers: str) -> CutCSGLabel:
    """Label for one of this timber's own CSG shapes.

    A classmethod so the name follows the derived class -- a Board's rough
    extended prism reads "board (rough, extended)", not "timber (...)".
    """
    if not qualifiers:
        return CutCSGLabel(cls.csg_label_name())
    return CutCSGLabel(f"{cls.csg_label_name()} ({', '.join(qualifiers)})")

get_actual_csg_local

get_actual_csg_local(with_feature_labels: bool = True) -> CutCSG

Returns the actual CSG geometry for this timber.

For the base PerfectTimberWithin class, this returns the perfect rectangular prism. Subclasses override this to return different geometries (cylinder, mesh, etc.).

Parameters:

Name Type Description Default
with_feature_labels bool

False to leave the override labels off -- see get_perfect_timber_within_csg_local.

True

Returns:

Type Description
CutCSG

CutCSG representing the actual geometry in local coordinates

Source code in kumiki/timber.py
def get_actual_csg_local(self, with_feature_labels: bool = True) -> CutCSG:
    """
    Returns the actual CSG geometry for this timber.

    For the base PerfectTimberWithin class, this returns the perfect rectangular
    prism. Subclasses override this to return different geometries (cylinder, mesh, etc.).

    Args:
        with_feature_labels: False to leave the override labels off -- see
            get_perfect_timber_within_csg_local.

    Returns:
        CutCSG representing the actual geometry in local coordinates
    """
    # The base timber's rough shape is its perfect one, but it is still
    # the rough CSG in the tree, so it is named as such.
    return dataclass_replace(
        self.get_perfect_timber_within_csg_local(with_feature_labels=with_feature_labels),
        label=self.csg_label("rough"),
    )

get_extended_actual_csg_local

get_extended_actual_csg_local(extend_bot: bool, extend_top: bool, with_feature_labels: bool = True) -> CutCSG

Returns the actual CSG geometry extended to infinity at specified ends.

For the base PerfectTimberWithin class, this returns a rectangular prism using the perfect timber within size, optionally extended to infinity.

Parameters:

Name Type Description Default
extend_bot bool

If True, extend to -infinity at bottom (z=0)

required
extend_top bool

If True, extend to +infinity at top (z=length)

required
with_feature_labels bool

False to leave the override labels off, so the faces carry the primitive's default names. This geometry is often a copy of something already in the tree -- a cutter, a relief bound -- and one surface declared twice puts two features of one name on one plane, which derives the same edge twice.

True

Returns:

Type Description
CutCSG

CutCSG representing the extended geometry in local coordinates

Source code in kumiki/timber.py
def get_extended_actual_csg_local(
        self, extend_bot: bool, extend_top: bool, with_feature_labels: bool = True) -> CutCSG:
    """
    Returns the actual CSG geometry extended to infinity at specified ends.

    For the base PerfectTimberWithin class, this returns a rectangular prism
    using the perfect timber within size, optionally extended to infinity.

    Args:
        extend_bot: If True, extend to -infinity at bottom (z=0)
        extend_top: If True, extend to +infinity at top (z=length)
        with_feature_labels: False to leave the override labels off, so the faces
            carry the primitive's default names. This geometry is often a copy of
            something already in the tree -- a cutter, a relief bound -- and one
            surface declared twice puts two features of one name on one plane,
            which derives the same edge twice.

    Returns:
        CutCSG representing the extended geometry in local coordinates
    """
    return _create_extended_rectangular_prism(
        face_tags=self.rough_face_tags() if with_feature_labels else (),
        size=self.get_perfect_size(),
        length=self.length,
        extend_bot=extend_bot,
        extend_top=extend_top,
        label=self.csg_label("rough", "extended"),
    )

get_extended_perfect_csg_local

get_extended_perfect_csg_local(extend_bot: bool, extend_top: bool, with_feature_labels: bool = True) -> CutCSG

Returns the PERFECT (finished-dimension) CSG geometry extended to infinity at specified ends -- always self.get_perfect_size(), regardless of any rough/actual sizing a subclass's get_extended_actual_csg_local may use instead. Unlike get_extended_actual_csg_local, this is not overridden per-subclass: every timber type's perfect timber within is a rectangular prism (see get_perfect_timber_within_csg_local), so one implementation suffices for all of them.

Parameters:

Name Type Description Default
extend_bot bool

If True, extend to -infinity at bottom (z=0)

required
extend_top bool

If True, extend to +infinity at top (z=length)

required
with_feature_labels bool

False to leave the override labels off -- see get_extended_actual_csg_local. The centerplanes stay.

True

Returns:

Type Description
CutCSG

CutCSG representing the extended geometry in local coordinates

Source code in kumiki/timber.py
@final
def get_extended_perfect_csg_local(
        self, extend_bot: bool, extend_top: bool, with_feature_labels: bool = True) -> CutCSG:
    """
    Returns the PERFECT (finished-dimension) CSG geometry extended to infinity at
    specified ends -- always self.get_perfect_size(), regardless of any rough/actual
    sizing a subclass's get_extended_actual_csg_local may use instead. Unlike
    get_extended_actual_csg_local, this is not overridden per-subclass: every timber
    type's perfect timber within is a rectangular prism (see
    get_perfect_timber_within_csg_local), so one implementation suffices for all of them.

    Args:
        extend_bot: If True, extend to -infinity at bottom (z=0)
        extend_top: If True, extend to +infinity at top (z=length)
        with_feature_labels: False to leave the override labels off -- see
            get_extended_actual_csg_local. The centerplanes stay.

    Returns:
        CutCSG representing the extended geometry in local coordinates
    """
    return _create_extended_rectangular_prism(
        face_tags=_ptw_face_tags() if with_feature_labels else (),
        extra_features=_ptw_centerplanes(),
        size=self.get_perfect_size(),
        length=self.length,
        extend_bot=extend_bot,
        extend_top=extend_top,
        label=self.csg_label("perfect", "extended"),
    )

get_extended_rough_csg_with_perfect_features_local

get_extended_rough_csg_with_perfect_features_local(extend_bot: bool = False, extend_top: bool = False) -> CutCSG

The rough body (get_extended_actual_csg_local), carrying every perfect plane it lacks: the drawn timber, and the one its features are solved on.

A long face lying on a rough face is already that face, named rptw.. Every other one is added as a PlaneFeature named ptw. -- its own carrier, so the solver measures to it -- and both centerplanes as MidplaneFeatures between whichever planes are the perfect ones.

Raises NotImplementedError for a rough body that cannot carry features.

Source code in kumiki/timber.py
@final
def get_extended_rough_csg_with_perfect_features_local(
        self, extend_bot: bool = False, extend_top: bool = False) -> CutCSG:
    """The rough body (get_extended_actual_csg_local), carrying every perfect plane it lacks:
    the drawn timber, and the one its features are solved on.

    A long face lying on a rough face is already that face, named rptw.*. Every other one is
    added as a PlaneFeature named ptw.* -- its own carrier, so the solver measures to it -- and
    both centerplanes as MidplaneFeatures between whichever planes are the perfect ones.

    Raises NotImplementedError for a rough body that cannot carry features.
    """
    rough = self.get_extended_actual_csg_local(extend_bot, extend_top)
    if not isinstance(rough, HasFeatures):
        raise NotImplementedError(
            f"{type(self).__name__}'s rough body is a {type(rough).__name__}, which carries no features")
    # Each perfect face in the timber's local space, then in the rough body's own frame.
    transform = getattr(rough, "transform", None)
    half_width, half_height = self.size[0] / scalar(2), self.size[1] / scalar(2)
    one, zero = scalar(1), scalar(0)
    planes = []
    for face, normal, half in ((TimberFace.RIGHT, create_v3(one, zero, zero), half_width),
                               (TimberFace.LEFT, create_v3(-one, zero, zero), half_width),
                               (TimberFace.FRONT, create_v3(zero, one, zero), half_height),
                               (TimberFace.BACK, create_v3(zero, -one, zero), half_height)):
        offset = half
        if transform is not None:
            offset = half - safe_dot_product(normal, transform.position)
            normal = safe_transform_vector(transform.orientation.matrix.T, normal)
        planes.append((face, (normal[0], normal[1], normal[2]), offset))
    on_rough = {feature.name for feature in rough.get_declared_features()}
    perfect = FeatureProperties(group=FeatureGroup.PERFECT, nature=FeatureNature.PERFECT_REFERENCE)
    centerplane = FeatureProperties(nature=FeatureNature.PERFECT_REFERENCE)
    added: List[CSGFeature] = []
    named: Dict[TimberFace, str] = {}
    for face, normal, offset in planes:
        if RPTW_FACE_PREFIX + face.name.lower() in on_rough:
            named[face] = RPTW_FACE_PREFIX + face.name.lower()
        else:
            named[face] = PTW_FACE_PREFIX + face.name.lower()
            added.append(PlaneFeature(name=named[face], normal=normal, offset=offset, properties=perfect))
    added.append(MidplaneFeature(name=PTW_FACE_PREFIX + "centerplane_left_right",
                                 between=(named[TimberFace.RIGHT], named[TimberFace.LEFT]), properties=centerplane))
    added.append(MidplaneFeature(name=PTW_FACE_PREFIX + "centerplane_front_back",
                                 between=(named[TimberFace.FRONT], named[TimberFace.BACK]), properties=centerplane))
    return cast(CutCSG, dataclass_replace(rough, extra_features=tuple(rough.extra_features) + tuple(added)))

rough_face_tags

rough_face_tags() -> Tuple[FeatureOverride, ...]

This timber's names for its rough prism: rptw.* wherever it lies on the perfect timber within.

Source code in kumiki/timber.py
def rough_face_tags(self) -> Tuple[FeatureOverride, ...]:
    """This timber's names for its rough prism: rptw.* wherever it lies on the perfect timber within."""
    perfect = frozenset(PrismFace(face.value) for face in TimberFace if self.is_face_perfect(face))
    return _rough_face_tags(perfect)

is_face_perfect

is_face_perfect(face: TimberFace) -> bool

Check if the specified face of the timber is perfect (matches the perfect timber within).

Parameters:

Name Type Description Default
face TimberFace

The TimberFace to check

required
Source code in kumiki/timber.py
def is_face_perfect(self, face: TimberFace) -> bool:
    """
    Check if the specified face of the timber is perfect (matches the perfect timber within).

    Args:
        face: The TimberFace to check
    """
    width_halves, height_halves = self.get_rough_half_sizes()
    w_half = self.size[0] / scalar(2)
    h_half = self.size[1] / scalar(2)

    if face == TimberFace.TOP or face == TimberFace.BOTTOM:
        return True  # Length is always perfect
    elif face == TimberFace.RIGHT:
        return safe_equality_test(width_halves[0], w_half)
    elif face == TimberFace.LEFT:
        return safe_equality_test(width_halves[1], w_half)
    elif face == TimberFace.FRONT:
        return safe_equality_test(height_halves[0], h_half)
    elif face == TimberFace.BACK:
        return safe_equality_test(height_halves[1], h_half)
    else:
        raise ValueError(f"Face {face} is not a long face; only RIGHT, LEFT, FRONT, BACK are valid for this check.")

is_perfect_timber

is_perfect_timber() -> bool

Check if this timber's actual geometry matches its rough bounding box.

Returns True when the rough half-sizes are symmetric and equal to half the perfect timber within size.

Returns:

Type Description
bool

True if the timber is a perfect timber, False otherwise

Source code in kumiki/timber.py
def is_perfect_timber(self) -> bool:
    """
    Check if this timber's actual geometry matches its rough bounding box.

    Returns True when the rough half-sizes are symmetric and equal to half
    the perfect timber within size.

    Returns:
        True if the timber is a perfect timber, False otherwise
    """
    width_halves, height_halves = self.get_rough_half_sizes()
    w_half = self.size[0] / scalar(2)
    h_half = self.size[1] / scalar(2)
    return (safe_equality_test(width_halves[0], w_half) and
            safe_equality_test(width_halves[1], w_half) and
            safe_equality_test(height_halves[0], h_half) and
            safe_equality_test(height_halves[1], h_half))

get_imperfect_fringe_csg_local

get_imperfect_fringe_csg_local(with_feature_labels: bool = True) -> CutCSG

Returns the CSG (local coordinates) of the region where this timber's actual geometry sticks out beyond its perfect-timber-within boundary, i.e. actual minus perfect.

Parameters:

Name Type Description Default
with_feature_labels bool

False to leave the override labels off both shapes. A caller using this as a cutter wants the geometry, not a second copy of names the timber's own body already carries (see PerfectTimberWithin.get_extended_actual_csg_local).

True
Source code in kumiki/timber.py
def get_imperfect_fringe_csg_local(self, with_feature_labels: bool = True) -> CutCSG:
    """
    Returns the CSG (local coordinates) of the region where this timber's actual
    geometry sticks out beyond its perfect-timber-within boundary, i.e. actual
    minus perfect.

    Args:
        with_feature_labels: False to leave the override labels off both shapes.
            A caller using this as a cutter wants the geometry, not a second copy
            of names the timber's own body already carries (see
            PerfectTimberWithin.get_extended_actual_csg_local).
    """
    if self.is_perfect_timber():
        return EmptyCSG()
    return Difference(
        base=self.get_extended_actual_csg_local(
            extend_bot=False, extend_top=False, with_feature_labels=with_feature_labels),
        subtract=[self.get_perfect_timber_within_csg_local(with_feature_labels=with_feature_labels)],
    )

Timber dataclass

Bases: PerfectTimberWithin

Rectangular timber which may or may not be perfect.

Inherits all attributes and methods from PerfectTimberWithin
  • length: Length of the timber
  • size: Cross-sectional size (width, height)
  • transform: Position and orientation
  • name: Optional name

rough_half_sizes class-attribute instance-attribute

rough_half_sizes: Optional[Tuple[V2, V2]] = None

from_perfect_timber_within staticmethod

from_perfect_timber_within(perfect_timber: PerfectTimberWithin, rough_half_sizes: Optional[Tuple[V2, V2]] = None) -> Timber

Create a Timber instance from a PerfectTimberWithin instance.

Parameters:

Name Type Description Default
perfect_timber PerfectTimberWithin

An instance of PerfectTimberWithin

required
rough_half_sizes Optional[Tuple[V2, V2]]

Optional asymmetric half-sizes from centerline

None
Source code in kumiki/timber.py
@staticmethod
def from_perfect_timber_within(perfect_timber: PerfectTimberWithin, rough_half_sizes: Optional[Tuple[V2, V2]] = None) -> 'Timber':
    """
    Create a Timber instance from a PerfectTimberWithin instance.

    Args:
        perfect_timber: An instance of PerfectTimberWithin
        rough_half_sizes: Optional asymmetric half-sizes from centerline
    """
    return Timber(
        length=perfect_timber.length,
        size=perfect_timber.size,
        transform=perfect_timber.transform,
        ticket=perfect_timber.ticket,
        rough_half_sizes=rough_half_sizes
    )

get_rough_half_sizes

get_rough_half_sizes() -> Tuple[V2, V2]

Returns the rough half-sizes of the timber.

If rough_half_sizes is set, returns that. Otherwise returns symmetric half-sizes derived from the perfect timber within size.

Returns:

Type Description
Tuple[V2, V2]

Tuple of (V2(right_half, left_half), V2(front_half, back_half))

Source code in kumiki/timber.py
def get_rough_half_sizes(self) -> Tuple[V2, V2]:
    """
    Returns the rough half-sizes of the timber.

    If rough_half_sizes is set, returns that. Otherwise returns symmetric
    half-sizes derived from the perfect timber within size.

    Returns:
        Tuple of (V2(right_half, left_half), V2(front_half, back_half))
    """
    if self.rough_half_sizes is not None:
        return self.rough_half_sizes
    w_half = self.size[0] / scalar(2)
    h_half = self.size[1] / scalar(2)
    return (create_v2(w_half, w_half), create_v2(h_half, h_half))

get_actual_csg_local

get_actual_csg_local(with_feature_labels: bool = True) -> CutCSG

Returns the actual CSG geometry for this timber.

For Timber, this returns a rectangular prism using the rough half-sizes, offset from the centerline when the half-sizes are asymmetric.

Parameters:

Name Type Description Default
with_feature_labels bool

False to leave the override labels off -- see PerfectTimberWithin.get_extended_actual_csg_local.

True

Returns:

Type Description
CutCSG

RectangularPrism representing the actual geometry in local coordinates

Source code in kumiki/timber.py
def get_actual_csg_local(self, with_feature_labels: bool = True) -> CutCSG:
    """
    Returns the actual CSG geometry for this timber.

    For Timber, this returns a rectangular prism using the rough half-sizes,
    offset from the centerline when the half-sizes are asymmetric.

    Args:
        with_feature_labels: False to leave the override labels off -- see
            PerfectTimberWithin.get_extended_actual_csg_local.

    Returns:
        RectangularPrism representing the actual geometry in local coordinates
    """
    rough_size, offset = _get_rough_size_and_offset(self)
    return RectangularPrism(
        size=rough_size,
        transform=Transform(position=offset, orientation=Orientation.identity()),
        start_distance=scalar(0),
        end_distance=self.length,
        feature_overrides=self.rough_face_tags() if with_feature_labels else (),
        label=self.csg_label("rough"),
    )

get_extended_actual_csg_local

get_extended_actual_csg_local(extend_bot: bool, extend_top: bool, with_feature_labels: bool = True) -> CutCSG

Returns the actual CSG geometry extended to infinity at specified ends.

For Timber, this returns a rectangular prism using the rough half-sizes, offset from the centerline when the half-sizes are asymmetric.

Parameters:

Name Type Description Default
extend_bot bool

If True, extend to -infinity at bottom (z=0)

required
extend_top bool

If True, extend to +infinity at top (z=length)

required
with_feature_labels bool

False to leave the override labels off -- see PerfectTimberWithin.get_extended_actual_csg_local.

True

Returns:

Type Description
CutCSG

CutCSG representing the extended geometry in local coordinates

Source code in kumiki/timber.py
def get_extended_actual_csg_local(
        self, extend_bot: bool, extend_top: bool, with_feature_labels: bool = True) -> CutCSG:
    """
    Returns the actual CSG geometry extended to infinity at specified ends.

    For Timber, this returns a rectangular prism using the rough half-sizes,
    offset from the centerline when the half-sizes are asymmetric.

    Args:
        extend_bot: If True, extend to -infinity at bottom (z=0)
        extend_top: If True, extend to +infinity at top (z=length)
        with_feature_labels: False to leave the override labels off -- see
            PerfectTimberWithin.get_extended_actual_csg_local.

    Returns:
        CutCSG representing the extended geometry in local coordinates
    """
    rough_size, offset = _get_rough_size_and_offset(self)
    return RectangularPrism(
        size=rough_size,
        transform=Transform(position=offset, orientation=Orientation.identity()),
        start_distance=None if extend_bot else scalar(0),
        end_distance=None if extend_top else self.length,
        feature_overrides=self.rough_face_tags() if with_feature_labels else (),
        label=self.csg_label("rough", "extended"),
    )

Board dataclass

Bases: PerfectTimberWithin

Boards are perfect timbers with board-specific semantics

Boards are structurally identical to perfect timbers but carry additional semantics: - the "length" of the board runs in the Z direction so the TOP and BOTTOM faces are referred to as the "ends" of the board - the "width" of the board runs in the X direction so the LEFT and RIGHT faces are referred to as the "sides" of the board - the "thickness" of the board runs in the Y direction so the FRONT and BACK faces are the same as the "faces" of the board

Like timbers, we assume the grain is always running in the length direction.

Note that you can end cut along the length direction but not in the other directions so you must ensure the board dimensions are large enough to incorporate the cuts

get_rough_half_sizes

get_rough_half_sizes() -> Tuple[V2, V2]

Returns the rough half-sizes of the board.

For Board, these are symmetric halves of the perfect timber within size.

Returns:

Type Description
Tuple[V2, V2]

Tuple of (V2(right_half, left_half), V2(front_half, back_half))

Source code in kumiki/timber.py
def get_rough_half_sizes(self) -> Tuple[V2, V2]:
    """
    Returns the rough half-sizes of the board.

    For Board, these are symmetric halves of the perfect timber within size.

    Returns:
        Tuple of (V2(right_half, left_half), V2(front_half, back_half))
    """
    w_half = self.size[0] / scalar(2)
    h_half = self.size[1] / scalar(2)
    return (create_v2(w_half, w_half), create_v2(h_half, h_half))

get_extended_actual_csg_local

get_extended_actual_csg_local(extend_bot: bool, extend_top: bool, with_feature_labels: bool = True) -> CutCSG

Returns the actual CSG geometry extended to infinity at specified ends.

For Board, this returns a rectangular prism using the perfect timber within size.

Parameters:

Name Type Description Default
extend_bot bool

If True, extend to -infinity at bottom (z=0)

required
extend_top bool

If True, extend to +infinity at top (z=length)

required
with_feature_labels bool

False to leave the override labels off -- see PerfectTimberWithin.get_extended_actual_csg_local.

True

Returns:

Type Description
CutCSG

CutCSG representing the extended geometry in local coordinates

Source code in kumiki/timber.py
def get_extended_actual_csg_local(
        self, extend_bot: bool, extend_top: bool, with_feature_labels: bool = True) -> CutCSG:
    """
    Returns the actual CSG geometry extended to infinity at specified ends.

    For Board, this returns a rectangular prism using the perfect timber within size.

    Args:
        extend_bot: If True, extend to -infinity at bottom (z=0)
        extend_top: If True, extend to +infinity at top (z=length)
        with_feature_labels: False to leave the override labels off -- see
            PerfectTimberWithin.get_extended_actual_csg_local.

    Returns:
        CutCSG representing the extended geometry in local coordinates
    """
    return _create_extended_rectangular_prism(
        face_tags=self.rough_face_tags() if with_feature_labels else (),
        size=self.get_perfect_size(),
        length=self.length,
        extend_bot=extend_bot,
        extend_top=extend_top,
        label=self.csg_label("rough", "extended"),
    )

RoundTimber dataclass

Bases: PerfectTimberWithin

Cylindrical timber (e.g., logs, poles)

Round timbers have a circular cross-section centered on the centerline. The rough bounding box is a square that contains the circle, but the actual geometry is a cylinder.

diameter class-attribute instance-attribute

diameter: Numeric = field(kw_only=True)

is_perfect_timber

is_perfect_timber() -> bool

Round timber has a perfect cylindrical geometry, so it is perfect.

Source code in kumiki/timber.py
def is_perfect_timber(self) -> bool:
    """Round timber has a perfect cylindrical geometry, so it is perfect."""
    return True

from_perfect_timber_within staticmethod

from_perfect_timber_within(perfect_timber: PerfectTimberWithin, diameter: Optional[Numeric] = None) -> RoundTimber

Create a Timber instance from a PerfectTimberWithin instance.

Parameters:

Name Type Description Default
perfect_timber PerfectTimberWithin

An instance of PerfectTimberWithin

required
diameter Optional[Numeric]

Optional diameter for the round timber, if None, then the diagonal of the perfect_timber.size is used to compute the diameter.

None
Source code in kumiki/timber.py
@staticmethod
def from_perfect_timber_within(perfect_timber: PerfectTimberWithin, diameter: Optional[Numeric] = None) -> 'RoundTimber':
    """
    Create a Timber instance from a PerfectTimberWithin instance.

    Args:
        perfect_timber: An instance of PerfectTimberWithin
        diameter: Optional diameter for the round timber, if None, then the diagonal of the perfect_timber.size is used to compute the diameter.
    """
    if diameter is None:
        diameter = sqrt(perfect_timber.size[0]**2 + perfect_timber.size[1]**2)
    return RoundTimber(
        length=perfect_timber.length,
        size=perfect_timber.size,
        transform=perfect_timber.transform,
        ticket=perfect_timber.ticket,
        diameter=diameter
    )

get_rough_half_sizes

get_rough_half_sizes() -> Tuple[V2, V2]

Returns the rough half-sizes of the round timber.

For round timbers, this is a symmetric square bounding box using the diameter.

Returns:

Type Description
Tuple[V2, V2]

Tuple of (V2(d/2, d/2), V2(d/2, d/2))

Source code in kumiki/timber.py
def get_rough_half_sizes(self) -> Tuple[V2, V2]:
    """
    Returns the rough half-sizes of the round timber.

    For round timbers, this is a symmetric square bounding box using the diameter.

    Returns:
        Tuple of (V2(d/2, d/2), V2(d/2, d/2))
    """
    half_d = self.diameter / scalar(2)
    return (create_v2(half_d, half_d), create_v2(half_d, half_d))

get_actual_csg_local

get_actual_csg_local(with_feature_labels: bool = True) -> CutCSG

Returns the actual CSG geometry for this timber.

For RoundTimber, this returns a Cylinder with the specified diameter.

Parameters:

Name Type Description Default
with_feature_labels bool

Accepted so that callers can pass it to any timber. A cylinder's round surface declares its own features and carries no override labels, so there is nothing here for it to leave off.

True

Returns:

Type Description
CutCSG

Cylinder representing the actual geometry in local coordinates

Source code in kumiki/timber.py
def get_actual_csg_local(self, with_feature_labels: bool = True) -> CutCSG:
    """
    Returns the actual CSG geometry for this timber.

    For RoundTimber, this returns a Cylinder with the specified diameter.

    Args:
        with_feature_labels: Accepted so that callers can pass it to any timber.
            A cylinder's round surface declares its own features and carries no
            override labels, so there is nothing here for it to leave off.

    Returns:
        Cylinder representing the actual geometry in local coordinates
    """
    return Cylinder(
        radius=self.diameter / scalar(2),
        axis_direction=create_v3(scalar(0), scalar(0), scalar(1)),  # Local Z-axis
        position=create_v3(scalar(0), scalar(0), scalar(0)),  # Origin in local coords
        start_distance=scalar(0),
        end_distance=self.length,
        label=self.csg_label("rough"),
    )

get_extended_actual_csg_local

get_extended_actual_csg_local(extend_bot: bool, extend_top: bool, with_feature_labels: bool = True) -> CutCSG

Returns the actual CSG geometry extended to infinity at specified ends.

For RoundTimber, this returns a Cylinder optionally extended to infinity.

Parameters:

Name Type Description Default
extend_bot bool

If True, extend to -infinity at bottom (z=0)

required
extend_top bool

If True, extend to +infinity at top (z=length)

required
with_feature_labels bool

Accepted so that callers can pass it to any timber; a cylinder carries no override labels to leave off.

True

Returns:

Type Description
CutCSG

Cylinder representing the extended geometry in local coordinates

Source code in kumiki/timber.py
def get_extended_actual_csg_local(
        self, extend_bot: bool, extend_top: bool, with_feature_labels: bool = True) -> CutCSG:
    """
    Returns the actual CSG geometry extended to infinity at specified ends.

    For RoundTimber, this returns a Cylinder optionally extended to infinity.

    Args:
        extend_bot: If True, extend to -infinity at bottom (z=0)
        extend_top: If True, extend to +infinity at top (z=length)
        with_feature_labels: Accepted so that callers can pass it to any timber;
            a cylinder carries no override labels to leave off.

    Returns:
        Cylinder representing the extended geometry in local coordinates
    """
    return Cylinder(
        radius=self.diameter / scalar(2),
        axis_direction=create_v3(scalar(0), scalar(0), scalar(1)),  # Local Z-axis
        position=create_v3(scalar(0), scalar(0), scalar(0)),  # Origin in local coords
        start_distance=None if extend_bot else scalar(0),
        end_distance=None if extend_top else self.length,
        label=self.csg_label("rough", "extended"),
    )

MeshTimber dataclass

Bases: PerfectTimberWithin

Timber represented by an arbitrary mesh geometry

This timber type uses a mesh CSG to represent complex or irregular timber geometries that cannot be represented by simple primitives.

TODO: Add mesh_csg field and override get_actual_csg_local()

get_rough_half_sizes

get_rough_half_sizes() -> Tuple[V2, V2]
Source code in kumiki/timber.py
def get_rough_half_sizes(self) -> Tuple[V2, V2]:
    w_half = self.size[0] / scalar(2)
    h_half = self.size[1] / scalar(2)
    return (create_v2(w_half, w_half), create_v2(h_half, h_half))

can_be_extended_for_joints

can_be_extended_for_joints() -> bool
Source code in kumiki/timber.py
def can_be_extended_for_joints(self) -> bool:
    return False

get_extended_actual_csg_local

get_extended_actual_csg_local(extend_bot: bool, extend_top: bool, with_feature_labels: bool = True) -> CutCSG

Returns the actual CSG geometry extended to infinity at specified ends.

For MeshTimber, this returns a rectangular prism using the perfect timber within size (the bounding box). Note: MeshTimber cannot be extended for joints.

Parameters:

Name Type Description Default
extend_bot bool

If True, extend to -infinity at bottom (z=0)

required
extend_top bool

If True, extend to +infinity at top (z=length)

required
with_feature_labels bool

False to leave the override labels off -- see PerfectTimberWithin.get_extended_actual_csg_local.

True

Returns:

Type Description
CutCSG

CutCSG representing the extended geometry in local coordinates

Source code in kumiki/timber.py
def get_extended_actual_csg_local(
        self, extend_bot: bool, extend_top: bool, with_feature_labels: bool = True) -> CutCSG:
    """
    Returns the actual CSG geometry extended to infinity at specified ends.

    For MeshTimber, this returns a rectangular prism using the perfect timber within size
    (the bounding box). Note: MeshTimber cannot be extended for joints.

    Args:
        extend_bot: If True, extend to -infinity at bottom (z=0)
        extend_top: If True, extend to +infinity at top (z=length)
        with_feature_labels: False to leave the override labels off -- see
            PerfectTimberWithin.get_extended_actual_csg_local.

    Returns:
        CutCSG representing the extended geometry in local coordinates
    """
    return _create_extended_rectangular_prism(
        face_tags=self.rough_face_tags() if with_feature_labels else (),
        size=self.get_perfect_size(),
        length=self.length,
        extend_bot=extend_bot,
        extend_top=extend_top,
        label=self.csg_label("rough", "extended"),
    )

RegularPolygonTimber dataclass

Bases: PerfectTimberWithin

Timber with regular polygonal cross-section

This timber type has a polygonal (non-rectangular) cross-section that is extruded along the length axis. Examples include hexagonal or octagonal timbers.

The polygon is inscribed in a circle with radius equal to half the minimum dimension of the rough bounding box.

num_sides class-attribute instance-attribute

num_sides: int = field(kw_only=True)

is_perfect_timber

is_perfect_timber() -> bool

Polygonal timber has a perfect regular geometry, so it is perfect.

Source code in kumiki/timber.py
def is_perfect_timber(self) -> bool:
    """Polygonal timber has a perfect regular geometry, so it is perfect."""
    return True

get_rough_half_sizes

get_rough_half_sizes() -> Tuple[V2, V2]

Returns the rough half-sizes of the polygon timber.

For polygon extrusion timbers, these are symmetric halves of the rectangular bounding box.

Returns:

Type Description
Tuple[V2, V2]

Tuple of (V2(w/2, w/2), V2(h/2, h/2))

Source code in kumiki/timber.py
def get_rough_half_sizes(self) -> Tuple[V2, V2]:
    """
    Returns the rough half-sizes of the polygon timber.

    For polygon extrusion timbers, these are symmetric halves of the rectangular bounding box.

    Returns:
        Tuple of (V2(w/2, w/2), V2(h/2, h/2))
    """
    w_half = self.size[0] / scalar(2)
    h_half = self.size[1] / scalar(2)
    return (create_v2(w_half, w_half), create_v2(h_half, h_half))

get_actual_csg_local

get_actual_csg_local(with_feature_labels: bool = True) -> CutCSG

Returns the actual CSG geometry for this timber.

For RegularPolygonTimber, this returns a ConvexPolygonExtrusion with the specified number of sides.

Parameters:

Name Type Description Default
with_feature_labels bool

Accepted so that callers can pass it to any timber. This shape declares its own features and carries no override labels, so there is nothing here for it to leave off.

True

Returns:

Type Description
CutCSG

ConvexPolygonExtrusion representing the actual geometry in local coordinates

Source code in kumiki/timber.py
def get_actual_csg_local(self, with_feature_labels: bool = True) -> CutCSG:
    """
    Returns the actual CSG geometry for this timber.

    For RegularPolygonTimber, this returns a ConvexPolygonExtrusion with the specified number of sides.

    Args:
        with_feature_labels: Accepted so that callers can pass it to any timber.
            This shape declares its own features and carries no override labels,
            so there is nothing here for it to leave off.

    Returns:
        ConvexPolygonExtrusion representing the actual geometry in local coordinates
    """
    return ConvexPolygonExtrusion(
        points=self._compute_polygon_vertices(),
        transform=Transform.identity(),
        start_distance=scalar(0),
        end_distance=self.length,
        label=self.csg_label("rough"),
    )

get_extended_actual_csg_local

get_extended_actual_csg_local(extend_bot: bool, extend_top: bool, with_feature_labels: bool = True) -> CutCSG

Returns the actual CSG geometry extended to infinity at specified ends.

For RegularPolygonTimber, this returns a ConvexPolygonExtrusion optionally extended to infinity.

Parameters:

Name Type Description Default
extend_bot bool

If True, extend to -infinity at bottom (z=0)

required
extend_top bool

If True, extend to +infinity at top (z=length)

required
with_feature_labels bool

Accepted so that callers can pass it to any timber; this shape carries no override labels to leave off.

True

Returns:

Type Description
CutCSG

ConvexPolygonExtrusion representing the extended geometry in local coordinates

Source code in kumiki/timber.py
def get_extended_actual_csg_local(
        self, extend_bot: bool, extend_top: bool, with_feature_labels: bool = True) -> CutCSG:
    """
    Returns the actual CSG geometry extended to infinity at specified ends.

    For RegularPolygonTimber, this returns a ConvexPolygonExtrusion optionally extended to infinity.

    Args:
        extend_bot: If True, extend to -infinity at bottom (z=0)
        extend_top: If True, extend to +infinity at top (z=length)
        with_feature_labels: Accepted so that callers can pass it to any timber;
            this shape carries no override labels to leave off.

    Returns:
        ConvexPolygonExtrusion representing the extended geometry in local coordinates
    """
    return ConvexPolygonExtrusion(
        points=self._compute_polygon_vertices(),
        transform=Transform.identity(),
        start_distance=None if extend_bot else scalar(0),
        end_distance=None if extend_top else self.length,
        label=self.csg_label("rough", "extended"),
    )

Cutting dataclass

A set of cuts on a timber (to create a joint, for example), defined by a CSG object representing the volume to be removed.

The CSG object represents the volume to be REMOVED from the timber (negative CSG), in LOCAL coordinates (relative to timber.bottom_position).

timber instance-attribute

maybe_top_end_cut_distance_from_bottom class-attribute instance-attribute

maybe_top_end_cut_distance_from_bottom: Optional[Numeric] = None

maybe_bottom_end_cut_distance_from_bottom class-attribute instance-attribute

maybe_bottom_end_cut_distance_from_bottom: Optional[Numeric] = None

negative_csg class-attribute instance-attribute

negative_csg: Optional[CutCSG] = None

label class-attribute instance-attribute

label: CutCSGLabel = field(default_factory=CutCSGLabel.NoLabel)

assembly_freedom class-attribute instance-attribute

assembly_freedom: Optional[AssemblyFreedom] = None

assembly_ordering class-attribute instance-attribute

assembly_ordering: Ordering = Ordering()

__post_init__

__post_init__()
Source code in kumiki/timber.py
def __post_init__(self):
    if self.maybe_top_end_cut_distance_from_bottom is not None:
        object.__setattr__(self, '_top_end_cut', HalfSpace(
            normal=create_v3(scalar(0), scalar(0), scalar(1)),
            offset=self.maybe_top_end_cut_distance_from_bottom,
            label=CutCSGLabel("top_end_cut"),
        ))
    if self.maybe_bottom_end_cut_distance_from_bottom is not None:
        object.__setattr__(self, '_bottom_end_cut', HalfSpace(
            normal=create_v3(scalar(0), scalar(0), scalar(-1)),
            offset=-self.maybe_bottom_end_cut_distance_from_bottom,
            label=CutCSGLabel("bottom_end_cut"),
        ))
    object.__setattr__(self, '_negative_csg_local', self._build_negative_csg_local())

get_maybe_top_end_cut

get_maybe_top_end_cut() -> Optional[HalfSpace]

The top end cut HalfSpace, or None. Built once, at construction.

Source code in kumiki/timber.py
def get_maybe_top_end_cut(self) -> Optional[HalfSpace]:
    """The top end cut HalfSpace, or None. Built once, at construction."""
    return self._top_end_cut

get_maybe_bottom_end_cut

get_maybe_bottom_end_cut() -> Optional[HalfSpace]

The bottom end cut HalfSpace, or None. Built once, at construction.

Source code in kumiki/timber.py
def get_maybe_bottom_end_cut(self) -> Optional[HalfSpace]:
    """The bottom end cut HalfSpace, or None. Built once, at construction."""
    return self._bottom_end_cut

get_negative_csg_local

get_negative_csg_local() -> Optional[CutCSG]

Get the complete negative CSG including end cuts. Built once, at construction.

Returns the union of negative_csg with any end cuts that are defined, or None when this cutting removes nothing at all.

Source code in kumiki/timber.py
def get_negative_csg_local(self) -> Optional[CutCSG]:
    """
    Get the complete negative CSG including end cuts. Built once, at construction.

    Returns the union of negative_csg with any end cuts that are defined,
    or None when this cutting removes nothing at all.
    """
    return self._negative_csg_local

make_end_cut_distance_from_bottom staticmethod

make_end_cut_distance_from_bottom(timber: PerfectTimberWithin, end: TimberEnd, distance_from_end_to_cut: Numeric) -> Numeric

Convert distance-from-end to cut-plane distance from timber bottom.

Source code in kumiki/timber.py
@staticmethod
def make_end_cut_distance_from_bottom(
    timber: PerfectTimberWithin,
    end: TimberEnd,
    distance_from_end_to_cut: Numeric,
) -> Numeric:
    """Convert distance-from-end to cut-plane distance from timber bottom."""
    assert isinstance(end, TimberEnd), f"expected TimberEnd, got {type(end).__name__}"
    if end == TimberEnd.TOP:
        return timber.length - distance_from_end_to_cut
    return distance_from_end_to_cut

CutTimber

CutTimber(timber: PerfectTimberWithin, cuts: Optional[List[Cutting]] = None, joints: Optional[List[Joint]] = None)

A timber with cuts applied to it.

Create a CutTimber from a Timber.

Parameters:

Name Type Description Default
timber PerfectTimberWithin

The timber to be cut

required
cuts Optional[List[Cutting]]

List of cuts to apply (default: empty list)

None
joints Optional[List[Joint]]

Joints this timber participates in (default: empty list). Populated by the from_joints constructors. Anything asking "which joint produced this cut?" reads it, so a CutTimber built by hand simply cannot answer that -- which is the honest outcome, since by hand there is no joint to name.

None
Source code in kumiki/timber.py
def __init__(
    self,
    timber: PerfectTimberWithin,
    cuts: Optional[List['Cutting']] = None,
    joints: Optional[List['Joint']] = None,
):
    """
    Create a CutTimber from a Timber.

    Args:
        timber: The timber to be cut
        cuts: List of cuts to apply (default: empty list)
        joints: Joints this timber participates in (default: empty list).
            Populated by the from_joints constructors. Anything asking
            "which joint produced this cut?" reads it, so a CutTimber built
            by hand simply cannot answer that -- which is the honest
            outcome, since by hand there is no joint to name.
    """
    self.timber = timber
    self.cuts = cuts if cuts is not None else []
    self.joints = joints if joints is not None else []

    # Built once here, so every caller gets the same node objects.
    extend_bot, extend_top = _ends_extended_by_cuts(timber, self.cuts)
    self._extended_rough_csg_local_precomputed = timber.get_extended_rough_csg_with_perfect_features_local(
        extend_bot=extend_bot, extend_top=extend_top)
    self._extended_perfect_csg_local_precomputed = timber.get_extended_perfect_csg_local(
        extend_bot=extend_bot, extend_top=extend_top)

    # A cut that removes nothing contributes no node.
    negative_csgs = [csg for csg in (cut.get_negative_csg_local() for cut in self.cuts) if csg is not None]
    self._rendered_csg_local_precomputed = (
        Difference(self._extended_rough_csg_local_precomputed, negative_csgs) if negative_csgs
        else self._extended_rough_csg_local_precomputed)
    self._rendered_perfect_csg_local_precomputed = (
        Difference(self._extended_perfect_csg_local_precomputed, negative_csgs) if negative_csgs
        else self._extended_perfect_csg_local_precomputed)

timber instance-attribute

timber: PerfectTimberWithin = timber

cuts instance-attribute

cuts: List[Cutting] = cuts if cuts is not None else []

joints instance-attribute

joints: List[Joint] = joints if joints is not None else []

name property

name: str

Get the name from the underlying timber's ticket.

resolve_joint_path

resolve_joint_path(path: JointPath) -> List[ResolvedJointPath]

Which of this timber's joints a name refers to.

A list, for the same reason Frame.resolve_timber_path returns one: two identical joints on one timber -- both ends of a brace -- share a name, and pretending a name means one joint quietly picks whichever was cut first. Where it does match several the reference stops being stable, so it warns.

Counted over this timber's cuts, in order, which is what the cut labels and the CSG paths below them are numbered by.

Source code in kumiki/timber.py
def resolve_joint_path(self, path: 'JointPath') -> List['ResolvedJointPath']:
    """Which of this timber's joints a name refers to.

    A list, for the same reason Frame.resolve_timber_path returns one: two
    identical joints on one timber -- both ends of a brace -- share a name,
    and pretending a name means one joint quietly picks whichever was cut
    first. Where it does match several the reference stops being stable, so
    it warns.

    Counted over this timber's cuts, in order, which is what the cut labels
    and the CSG paths below them are numbered by.
    """
    from .identity import ResolvedJointPath

    wanted = str(path)
    matches = [
        ResolvedJointPath(path=wanted, occurrence=occurrence)
        for occurrence, _ in enumerate(
            cut for cut in (self.cuts or [])
            if getattr(getattr(cut, "label", None), "name", None) == wanted
        )
    ]
    if len(matches) > 1:
        warnings.warn(
            f"{len(matches)} joints on this timber share the name {wanted!r}. They can "
            "only be told apart by the order they were cut in, so adding another before "
            "them will move anything that refers to them -- a drawing, or a measurement."
        )
    return matches

from_joints classmethod

from_joints(timber: PerfectTimberWithin, joints: List[Joint]) -> CutTimber

Build a CutTimber for timber by collecting every Cutting across joints whose Cutting.timber is this exact timber (matched by identity -- the same matching Frame.from_joints uses to merge cuttings for a timber across the whole frame).

Useful when a joint function needs "this timber's actual body so far" (e.g. cut_free_house_joint's housed_timbers) but the timber has cuts from more than one joint (e.g. a corner miter plus a roundover decoration): rather than manually picking which Joint.cuttings key belongs to which timber (easy to mix up -- see cuttings["timberA"] vs cuttings["timberB"]), this collects every relevant cutting automatically, in the order joints are given.

Parameters:

Name Type Description Default
timber PerfectTimberWithin

The timber to build a CutTimber for

required
joints List[Joint]

Joints to search for cuttings on timber. Joints that don't involve timber at all contribute nothing.

required

Returns:

Type Description
CutTimber

CutTimber wrapping timber with all matching cuts, in joint order.

Source code in kumiki/timber.py
@classmethod
def from_joints(cls, timber: PerfectTimberWithin, joints: List['Joint']) -> 'CutTimber':
    """
    Build a CutTimber for `timber` by collecting every Cutting across `joints`
    whose Cutting.timber is this exact timber (matched by identity -- the same
    matching Frame.from_joints uses to merge cuttings for a timber across the
    whole frame).

    Useful when a joint function needs "this timber's actual body so far" (e.g.
    cut_free_house_joint's housed_timbers) but the timber has cuts from more than
    one joint (e.g. a corner miter plus a roundover decoration): rather than
    manually picking which Joint.cuttings key belongs to which timber (easy to
    mix up -- see cuttings["timberA"] vs cuttings["timberB"]), this collects
    every relevant cutting automatically, in the order `joints` are given.

    Args:
        timber: The timber to build a CutTimber for
        joints: Joints to search for cuttings on `timber`. Joints that don't
            involve `timber` at all contribute nothing.

    Returns:
        CutTimber wrapping `timber` with all matching cuts, in joint order.
    """
    cuts = [
        cutting
        for joint in joints
        for cutting in joint.cuttings.values()
        if cutting.timber is timber
    ]
    contributing = _joints_touching_timber(joints, timber)
    return cls(timber, cuts=cuts, joints=contributing)

get_extended_rough_csg_with_perfect_features_local

get_extended_rough_csg_with_perfect_features_local() -> CutCSG

The rough timber, carrying every perfect feature, with each end that has an end cut extended to infinity: what is drawn and solved, before the cuts. Built once, at construction.

Source code in kumiki/timber.py
def get_extended_rough_csg_with_perfect_features_local(self) -> CutCSG:
    """The rough timber, carrying every perfect feature, with each end that has an end cut extended
    to infinity: what is drawn and solved, before the cuts. Built once, at construction."""
    return self._extended_rough_csg_local_precomputed

get_extended_perfect_csg_local

get_extended_perfect_csg_local() -> CutCSG

The perfect timber within, with each end that has an end cut extended to infinity. Built once, at construction.

Source code in kumiki/timber.py
def get_extended_perfect_csg_local(self) -> CutCSG:
    """The perfect timber within, with each end that has an end cut extended to infinity. Built once, at construction."""
    return self._extended_perfect_csg_local_precomputed

render_timber_with_cuts_csg_local

render_timber_with_cuts_csg_local() -> CutCSG

The rough timber with every cut subtracted. Built once, at construction.

Source code in kumiki/timber.py
def render_timber_with_cuts_csg_local(self) -> CutCSG:
    """The rough timber with every cut subtracted. Built once, at construction."""
    return self._rendered_csg_local_precomputed

render_perfect_timber_within_with_cuts_csg_local

render_perfect_timber_within_with_cuts_csg_local() -> CutCSG

The perfect timber within with every cut subtracted. Built once, at construction.

Only for showing the piece without its rough stock: the viewer's perfect mesh of a timber whose stock is not its perfect box. What is drawn, measured, picked and solved is the rough tree carrying every perfect feature, render_timber_with_cuts_csg_local.

Source code in kumiki/timber.py
def render_perfect_timber_within_with_cuts_csg_local(self) -> CutCSG:
    """The perfect timber within with every cut subtracted. Built once, at construction.

    Only for showing the piece without its rough stock: the viewer's perfect mesh of a timber
    whose stock is not its perfect box. What is drawn, measured, picked and solved is the
    rough tree carrying every perfect feature, render_timber_with_cuts_csg_local.
    """
    return self._rendered_perfect_csg_local_precomputed

get_perfect_timber_within_bounding_box_prism

get_perfect_timber_within_bounding_box_prism() -> RectangularPrism

Get the bounding box prism for this timber cropped based on its end cuts if any, otherwise the original perfet timber within box is produced. The bounding box is aligned with the timber's orientation.

Uses PerfectTimberWithin size to determine the cross-sectional size of the bounding box. Uses the end cuts (maybe_top_end_cut and maybe_bottom_end_cut) to determine the extent of the timber along its length. For skewed end cuts, finds where the plane intersects the four long edges of the timber and takes the max/min.

Returns:

Name Type Description
RectangularPrism RectangularPrism

The bounding box for the cut timber in global coordinates

Source code in kumiki/timber.py
def get_perfect_timber_within_bounding_box_prism(self) -> RectangularPrism:
    """
    Get the bounding box prism for this timber cropped based on its end cuts if any, otherwise the original perfet timber within box is produced.
    The bounding box is aligned with the timber's orientation.

    Uses PerfectTimberWithin size to determine the cross-sectional size of the bounding box.
    Uses the end cuts (maybe_top_end_cut and maybe_bottom_end_cut) to determine
    the extent of the timber along its length. For skewed end cuts, finds where
    the plane intersects the four long edges of the timber and takes the max/min.

    Returns:
        RectangularPrism: The bounding box for the cut timber in global coordinates
    """
    return self._bounding_box_prism_for_cross_section(self.timber.size)

get_rough_bounding_box_prism

get_rough_bounding_box_prism() -> RectangularPrism

Get the bounding box prism for this timber's ROUGH (as-sawn) cross-section, cropped in length the same way as get_perfect_timber_within_bounding_box_prism (the most restrictive end cut across every Cutting on this timber -- the frame's aggregated outer length trims, not each joint's own internal cut geometry).

Unlike the perfect-timber-within box, the rough box may be off-center from the timber's centerline (see get_rough_half_sizes -- e.g. for square-rule layout) and is generally larger than the perfect/finished size.

Returns:

Name Type Description
RectangularPrism RectangularPrism

The rough bounding box for the cut timber, in global coordinates

Source code in kumiki/timber.py
def get_rough_bounding_box_prism(self) -> RectangularPrism:
    """
    Get the bounding box prism for this timber's ROUGH (as-sawn) cross-section,
    cropped in length the same way as get_perfect_timber_within_bounding_box_prism
    (the most restrictive end cut across every Cutting on this timber -- the
    frame's aggregated outer length trims, not each joint's own internal cut
    geometry).

    Unlike the perfect-timber-within box, the rough box may be off-center from
    the timber's centerline (see get_rough_half_sizes -- e.g. for square-rule
    layout) and is generally larger than the perfect/finished size.

    Returns:
        RectangularPrism: The rough bounding box for the cut timber, in global coordinates
    """
    rough_size, offset = _get_rough_size_and_offset(self.timber)
    return self._bounding_box_prism_for_cross_section(rough_size, offset[0], offset[1])

get_bounding_box_prism

get_bounding_box_prism() -> RectangularPrism
Source code in kumiki/timber.py
@deprecated("use get_perfect_timber_within_bounding_box_prism instead")
def get_bounding_box_prism(self) -> RectangularPrism:
    return self.get_perfect_timber_within_bounding_box_prism()

DEPRECATED_approximate_bounding_prism

DEPRECATED_approximate_bounding_prism() -> RectangularPrism

TODO someday we want a fully analytical solution for this, but for now this is sufficient for our needs.

Get the bounding box prism for this timber including all its cuts. The bounding box is aligned with the timber's orientation.

Uses a hybrid approach: analytical methods for simple cases (HalfSpace cuts), and sampling for complex CSG operations. Works with all CSG types and orientations.

Returns:

Name Type Description
RectangularPrism RectangularPrism

The bounding box for the cut timber in global coordinates

Source code in kumiki/timber.py
@deprecated("use get_perfect_timber_within_bounding_box_prism instead")
def DEPRECATED_approximate_bounding_prism(self) -> RectangularPrism:
    """
    TODO someday we want a fully analytical solution for this, but for now this is sufficient for our needs.

    Get the bounding box prism for this timber including all its cuts.
    The bounding box is aligned with the timber's orientation.

    Uses a hybrid approach: analytical methods for simple cases (HalfSpace cuts),
    and sampling for complex CSG operations. Works with all CSG types and orientations.

    Returns:
        RectangularPrism: The bounding box for the cut timber in global coordinates
    """

    # Start with the timber's original bounds (in local coordinates)
    min_z = scalar(0)
    max_z = self.timber.length

    # Length direction in local coordinates (always +Z)
    length_direction_local = Matrix([scalar(0), scalar(0), scalar(1)])

    # Try analytical approach first for simple HalfSpace cuts
    can_use_analytical = True
    for cut in self.cuts:
        csg = cut.get_negative_csg_local()
        if csg is None:
            continue

        # A cutting always wraps what it removes in a SolidUnion of its
        # own, so look through that to the pieces doing the removing.
        components = list(csg.children) if isinstance(csg, SolidUnion) else [csg]

        for half_space in components:
            # Check if it's a simple HalfSpace
            if not isinstance(half_space, HalfSpace):
                # Complex CSG - need sampling
                can_use_analytical = False
                break

            dot_product = safe_dot_product(half_space.normal, length_direction_local)
            if not safe_equality_test(Abs(dot_product), 1):
                # HalfSpace not aligned with length - need sampling
                can_use_analytical = False
                break

            # HalfSpace aligned with length direction.
            # HalfSpace contains points where (p · normal) >= offset
            # When subtracted, remaining points are where (p · normal) < offset
            if safe_compare(dot_product, 0, Comparison.GT):
                # Normal points in +Z direction
                # Subtraction removes points with Z >= offset
                max_z = Min(max_z, half_space.offset)
            else:
                # Normal points in -Z direction
                # Subtraction removes points with Z <= -offset
                min_z = Max(min_z, -half_space.offset)

        if not can_use_analytical:
            break

    if can_use_analytical:
        # All cuts were simple aligned HalfSpaces, we're done
        return RectangularPrism(
            size=self.timber.size,
            transform=Transform(
                position=self.timber.get_bottom_position_global(),
                orientation=self.timber.orientation
            ),
            start_distance=min_z,
            end_distance=max_z
        )

    # Fall back to sampling for complex cases
    cut_csg = self.render_timber_with_cuts_csg_local()

    # Use fewer samples for speed, using float arithmetic
    num_length_samples = 50
    num_cross_section_samples = 5

    # Get timber half-sizes
    half_width = self.timber.size[0] / 2
    half_height = self.timber.size[1] / 2

    # Find actual min Z (bottom bound)
    for i in range(num_length_samples + 1):
        z_float = float(min_z) + (float(max_z) - float(min_z)) * (i / num_length_samples)
        z = scalar(int(z_float * 1000), 1000)  # Round to 3 decimal places for speed

        # Sample points in the cross-section
        found_point_at_z = False
        for ix in range(-num_cross_section_samples, num_cross_section_samples + 1):
            if found_point_at_z:
                break
            for iy in range(-num_cross_section_samples, num_cross_section_samples + 1):
                x = half_width * scalar(ix, num_cross_section_samples)
                y = half_height * scalar(iy, num_cross_section_samples)

                test_point = Matrix([x, y, z])
                if cut_csg.contains_point(test_point):
                    found_point_at_z = True
                    min_z = z
                    break

        if found_point_at_z:
            break

    # Find actual max Z (top bound)
    for i in range(num_length_samples + 1):
        z_float = float(max_z) - (float(max_z) - float(min_z)) * (i / num_length_samples)
        z = scalar(int(z_float * 1000), 1000)  # Round to 3 decimal places for speed

        # Sample points in the cross-section
        found_point_at_z = False
        for ix in range(-num_cross_section_samples, num_cross_section_samples + 1):
            if found_point_at_z:
                break
            for iy in range(-num_cross_section_samples, num_cross_section_samples + 1):
                x = half_width * scalar(ix, num_cross_section_samples)
                y = half_height * scalar(iy, num_cross_section_samples)

                test_point = Matrix([x, y, z])
                if cut_csg.contains_point(test_point):
                    found_point_at_z = True
                    max_z = z
                    break

        if found_point_at_z:
            break

    # Create the bounding box prism in global coordinates
    return RectangularPrism(
        size=self.timber.size,
        transform=Transform(
            position=self.timber.get_bottom_position_global(),
            orientation=self.timber.orientation
        ),
        start_distance=min_z,
        end_distance=max_z
    )

Accessory dataclass

Bases: ABC

Base class for joint accessories like wedges, drawbores, etc.

ticket class-attribute instance-attribute

ticket: AccessoryTicket = field(default_factory=AccessoryTicket, kw_only=True)

assembly_freedom class-attribute instance-attribute

assembly_freedom: Optional[AssemblyFreedom] = field(default=None, kw_only=True)

assembly_ordering class-attribute instance-attribute

assembly_ordering: Ordering = field(default=Ordering(), kw_only=True)

get_csg_local abstractmethod

get_csg_local() -> CutCSG

Generate CSG representation of the accessory in local space.

The local space is defined by the accessory's orientation and position, where the CSG is generated at the origin with identity orientation.

Returns:

Name Type Description
CutCSG CutCSG

The CSG representation of the accessory in local space

Source code in kumiki/timber.py
@abstractmethod
def get_csg_local(self) -> CutCSG:
    """
    Generate CSG representation of the accessory in local space.

    The local space is defined by the accessory's orientation and position,
    where the CSG is generated at the origin with identity orientation.

    Returns:
        CutCSG: The CSG representation of the accessory in local space
    """
    pass

PegShape

Bases: Enum

Shape of a peg.

SQUARE class-attribute instance-attribute

SQUARE = 'square'

ROUND class-attribute instance-attribute

ROUND = 'round'

Peg dataclass

Bases: Accessory

Represents a peg used in timber joinery (e.g., draw bore pegs, komisen).

The peg is stored in GLOBAL SPACE with absolute position and orientation. In identity orientation, the peg points in the +Z direction, with the insertion end at the origin.

By convention, the origin of the peg is on the mortise face that the peg is going into. This is why there are 2 lengths parameters, one for how deep the peg goes past the mortise face, and one for how far the peg sticks out of the mortise face.

Attributes:

Name Type Description
transform Transform

Transform (position and orientation) of the peg in global space

size Numeric

Size/diameter of the peg (for square pegs, this is the side length)

shape PegShape

Shape of the peg (SQUARE or ROUND)

forward_length Numeric

How far the peg reaches in the forward direction (into the mortise)

stickout_length Numeric

How far the peg "sticks out" in the back direction (outside the mortise)

transform instance-attribute

transform: Transform

size instance-attribute

size: Numeric

shape instance-attribute

shape: PegShape

forward_length instance-attribute

forward_length: Numeric

stickout_length instance-attribute

stickout_length: Numeric

get_csg_local

get_csg_local() -> CutCSG

Generate CSG representation of the peg in local space.

The peg is centered at the origin with identity orientation, extending from -stickout_length to forward_length along the Z axis.

Returns:

Name Type Description
CutCSG CutCSG

The CSG representation of the peg

Source code in kumiki/timber.py
def get_csg_local(self) -> CutCSG:
    """
    Generate CSG representation of the peg in local space.

    The peg is centered at the origin with identity orientation,
    extending from -stickout_length to forward_length along the Z axis.

    Returns:
        CutCSG: The CSG representation of the peg
    """
    if self.shape == PegShape.SQUARE:
        # Square peg - use RectangularPrism with square cross-section
        return RectangularPrism(
            size=create_v2(self.size, self.size),
            transform=Transform.identity(),
            start_distance=-self.stickout_length,
            end_distance=self.forward_length
        )
    else:  # PegShape.ROUND
        # Round peg - use Cylinder
        radius = self.size / scalar(2)
        return Cylinder(
            axis_direction=create_v3(scalar(0), scalar(0), scalar(1)),
            radius=radius,
            position=create_v3(scalar(0), scalar(0), scalar(0)),
            start_distance=-self.stickout_length,
            end_distance=self.forward_length
        )

WedgeShape dataclass

Specification for wedge dimensions.

base_width instance-attribute

base_width: Numeric

tip_width instance-attribute

tip_width: Numeric

height instance-attribute

height: Numeric

length instance-attribute

length: Numeric

Wedge dataclass

Bases: Accessory

Represents a wedge used in timber joinery (e.g., wedged tenons).

The wedge is stored in local space of a timber. In identity orientation, the pointy end of the wedge goes in the length direction of the timber.

The profile of the wedge (trapezoidal shape) is in the Y axis (height in Y). The width of the wedge is in the X axis. The origin (0,0) is at the bottom center of the longer side of the triangle.

Visual representation (looking at wedge from the side): +z _ <- tip width
/ \ \ / \ \ +y -x /
____\ <- base width ↑ origin

transform instance-attribute

transform: Transform

base_width instance-attribute

base_width: Numeric

tip_width instance-attribute

tip_width: Numeric

height instance-attribute

height: Numeric

length instance-attribute

length: Numeric

stickout_length class-attribute instance-attribute

stickout_length: Numeric = scalar(0)

width property

width: Numeric

Alias for base_width for convenience.

get_csg_local

get_csg_local() -> CutCSG

Generate CSG representation of the wedge in local space.

The wedge is created using a polyline extrusion (ConvexPolygonExtrusion) with a trapezoidal profile in the XZ plane. The base is at z=0 with base_width, and the tip is at z=length with tip_width. The extrusion extends along Y from -height/2 to height/2.

The polygon profile is a trapezoid in the XZ plane: - Base at z=0 with width = base_width (centered at x=0) - Tip at z=length with width = tip_width (centered at x=0)

The transform is rotated so that +Y goes to +Z (rotation around X axis by +90°).

Returns:

Name Type Description
CutCSG CutCSG

The CSG representation of the wedge

Source code in kumiki/timber.py
def get_csg_local(self) -> CutCSG:
    """
    Generate CSG representation of the wedge in local space.

    The wedge is created using a polyline extrusion (ConvexPolygonExtrusion)
    with a trapezoidal profile in the XZ plane. The base is at z=0 with base_width,
    and the tip is at z=length with tip_width. The extrusion extends along Y
    from -height/2 to height/2.

    The polygon profile is a trapezoid in the XZ plane:
    - Base at z=0 with width = base_width (centered at x=0)
    - Tip at z=length with width = tip_width (centered at x=0)

    The transform is rotated so that +Y goes to +Z (rotation around X axis by +90°).

    Returns:
        CutCSG: The CSG representation of the wedge
    """
    # Create trapezoid polygon in XZ plane
    # Points are (x, z) where x is X coordinate and y (2D) is Z coordinate
    # Ordered counter-clockwise when viewed from +Y
    half_base_width = self.base_width / scalar(2)
    half_tip_width = self.tip_width / scalar(2)

    # Calculate width at stickout position (z = -stickout_length)
    # The taper goes from base_width at z=0 to tip_width at z=length
    # Linear interpolation: width(z) = base_width + (tip_width - base_width) * z / length
    has_stickout = safe_compare(self.stickout_length, 0, Comparison.GT)
    if has_stickout:
        # Width at z = -stickout_length
        stickout_width = self.base_width + (self.tip_width - self.base_width) * (-self.stickout_length) / self.length
        half_stickout_width = stickout_width / scalar(2)
        base_z = -self.stickout_length
    else:
        half_stickout_width = half_base_width
        base_z = scalar(0)

    trapezoid_points = [
        create_v2(-half_stickout_width, base_z),      # Bottom-left (base with stickout)
        create_v2(half_stickout_width, base_z),       # Bottom-right (base with stickout)
        create_v2(half_tip_width, self.length),       # Top-right (tip)
        create_v2(-half_tip_width, self.length)        # Top-left (tip)
    ]

    # Rotate transform so that +Y goes to +Z
    # This rotates around X axis by +90° (pi/2 radians)
    x_axis = create_v3(scalar(1), scalar(0), scalar(0))
    rotation_orientation = Orientation.from_axis_angle(x_axis, radians(pi / scalar(2)))

    wedge_transform = Transform(
        position=create_v3(scalar(0), scalar(0), scalar(0)),
        orientation=rotation_orientation
    )

    # Extrusion extends along Y from -height/2 to height/2
    half_height = self.height / scalar(2)

    return ConvexPolygonExtrusion(
        points=trapezoid_points,
        transform=wedge_transform,
        start_distance=-half_height,
        end_distance=half_height
    )

CSGAccessory dataclass

Bases: Accessory

Generic accessory represented as local-space positive CSG plus a global transform.

transform instance-attribute

transform: Transform

positive_csg instance-attribute

positive_csg: CutCSG

get_csg_local

get_csg_local() -> CutCSG
Source code in kumiki/timber.py
def get_csg_local(self) -> CutCSG:
    return self.positive_csg

Sticker dataclass

Bases: Accessory

Just a marking used for debugging (ball at center + shaft in local +Z).

transform instance-attribute

transform: Transform

size class-attribute instance-attribute

size: Numeric = inches(1)

FrameWarning

Bases: UserWarning

A problem with a frame's design that still builds, e.g. a joint whose timbers don't touch.

Joint dataclass

cuttings instance-attribute

cuttings: Dict[str, Cutting]

ticket instance-attribute

ticket: JointTicket

jointAccessories class-attribute instance-attribute

jointAccessories: Dict[str, Accessory] = field(default_factory=dict)

__post_init__

__post_init__() -> None
Source code in kumiki/timber.py
def __post_init__(self) -> None:
    # A cutting that removes nothing contributes no CSG node, so a joint
    # whose cuttings all remove nothing would cut no timber at all.
    assert any(
        cutting.get_negative_csg_local() is not None
        for cutting in self.cuttings.values()
    ), f"Joint '{self.ticket.path}' has no cutting that removes anything"

is_decorative

is_decorative() -> bool
Source code in kumiki/timber.py
def is_decorative(self) -> bool:
    return len(self.cuttings) == 1

with_order

with_order(order: Union[int, Mapping[str, int], Iterable[Tuple[Union[str, PerfectTimberWithin, Accessory], int]]]) -> Joint

Return a copy of this joint with assembly order(s) assigned.

Assembly freedoms and suborders are authored by the cut functions; the order is the frame-level plan and is assigned here, after cutting (smaller order = extracted earlier during disassembly).

with_order(n): sets order=n on every cutting and accessory, keeping their suborders, so intra-joint sequencing (peg pops before the tenon slides) is preserved within step n.

with_order({key: n, ...}) or with_order([(member, n), ...]): sets Ordering(n, 0) on each named member — referenced by cutting/accessory string key, or by the timber / accessory object itself (a timber reference applies to every cutting holding it; use the pair-list form for object references, which are unhashable). Unnamed members keep their current ordering. Raises ValueError for unknown references, or when the new orderings break the strict precedence the cut function expressed via suborders (any member pair previously strictly ordered must remain strictly ordered).

Assign orders BEFORE building the Frame: this rebuilds the member objects (dataclasses.replace, preserving timber references), so a Frame built earlier would still hold the previous orderings.

Source code in kumiki/timber.py
def with_order(
    self,
    order: Union[
        int,
        Mapping[str, int],
        Iterable[Tuple[Union[str, "PerfectTimberWithin", "Accessory"], int]],
    ],
) -> "Joint":
    """Return a copy of this joint with assembly order(s) assigned.

    Assembly freedoms and suborders are authored by the cut functions;
    the order is the frame-level plan and is assigned here, after cutting
    (smaller order = extracted earlier during disassembly).

    with_order(n): sets order=n on every cutting and accessory, keeping
    their suborders, so intra-joint sequencing (peg pops before the tenon
    slides) is preserved within step n.

    with_order({key: n, ...}) or with_order([(member, n), ...]): sets
    Ordering(n, 0) on each named member — referenced by cutting/accessory
    string key, or by the timber / accessory object itself (a timber
    reference applies to every cutting holding it; use the pair-list form
    for object references, which are unhashable). Unnamed members keep
    their current ordering. Raises ValueError for unknown references, or
    when the new orderings break the strict precedence the cut function
    expressed via suborders (any member pair previously strictly ordered
    must remain strictly ordered).

    Assign orders BEFORE building the Frame: this rebuilds the member
    objects (dataclasses.replace, preserving timber references), so a
    Frame built earlier would still hold the previous orderings.
    """
    if isinstance(order, int):
        new_cuttings = {
            key: replace(cutting, assembly_ordering=Ordering(order, cutting.assembly_ordering.suborder))
            for key, cutting in self.cuttings.items()
        }
        new_accessories = {
            key: replace(accessory, assembly_ordering=Ordering(order, accessory.assembly_ordering.suborder))
            for key, accessory in self.jointAccessories.items()
        }
        return Joint(cuttings=new_cuttings, ticket=self.ticket, jointAccessories=new_accessories)

    # Per-member form. Members are addressed as ("cutting"|"accessory", key).
    def resolve(reference) -> List[Tuple[str, str]]:
        if isinstance(reference, str):
            if reference in self.cuttings:
                return [("cutting", reference)]
            if reference in self.jointAccessories:
                return [("accessory", reference)]
            raise ValueError(
                f"with_order: unknown member key '{reference}'; this joint has cuttings "
                f"{sorted(self.cuttings)} and accessories {sorted(self.jointAccessories)}"
            )
        timber_matches = [("cutting", key) for key, cutting in self.cuttings.items() if cutting.timber is reference]
        if timber_matches:
            return timber_matches
        accessory_matches = [("accessory", key) for key, accessory in self.jointAccessories.items() if accessory is reference]
        if accessory_matches:
            return accessory_matches
        reference_name = getattr(getattr(reference, "ticket", None), "path", repr(type(reference)))
        raise ValueError(f"with_order: '{reference_name}' is not a timber or accessory of this joint")

    old_orderings: Dict[Tuple[str, str], Ordering] = {
        ("cutting", key): cutting.assembly_ordering for key, cutting in self.cuttings.items()
    }
    old_orderings.update(
        (("accessory", key), accessory.assembly_ordering) for key, accessory in self.jointAccessories.items()
    )

    order_pairs: List[Tuple[Union[str, "PerfectTimberWithin", "Accessory"], int]]
    if isinstance(order, Mapping):
        order_pairs = [(str(key), int(value)) for key, value in cast(Mapping[str, int], order).items()]
    else:
        order_pairs = [(reference, int(member_order)) for reference, member_order in order]
    new_orderings = dict(old_orderings)
    for reference, member_order in order_pairs:
        for member_id in resolve(reference):
            new_orderings[member_id] = Ordering(member_order, 0)

    # The cut function's suborders express required sequencing; explicit
    # per-member orders must not invert or collapse it.
    member_ids = list(old_orderings)
    for first in member_ids:
        for second in member_ids:
            if old_orderings[first] < old_orderings[second] and not new_orderings[first] < new_orderings[second]:
                raise ValueError(
                    f"with_order: '{first[1]}' must be extracted before '{second[1]}' "
                    f"(orderings {old_orderings[first].label()} < {old_orderings[second].label()}), "
                    f"but the new orders place them at {new_orderings[first].label()} "
                    f"vs {new_orderings[second].label()}"
                )

    new_cuttings = {
        key: replace(cutting, assembly_ordering=new_orderings[("cutting", key)])
        for key, cutting in self.cuttings.items()
    }
    new_accessories = {
        key: replace(accessory, assembly_ordering=new_orderings[("accessory", key)])
        for key, accessory in self.jointAccessories.items()
    }
    return Joint(cuttings=new_cuttings, ticket=self.ticket, jointAccessories=new_accessories)

Frame dataclass

Represents a complete timber frame structure with all cut timbers and accessories.

In traditional timber framing, a 'frame' is the complete structure ready for raising. This class encapsulates all the timbers that have been cut with their joints, plus any accessories like pegs, wedges, or drawbores.

Attributes:

Name Type Description
cut_timbers List[CutTimber]

List of CutTimber objects representing all timbers in the frame

accessories List[Accessory]

List of Accessory objects (already in global space)

name Optional[str]

Optional name for this frame (e.g., "Oscar's Shed", "Main Frame")

cut_timbers instance-attribute

cut_timbers: List[CutTimber]

accessories class-attribute instance-attribute

accessories: List[Accessory] = field(default_factory=list)

name class-attribute instance-attribute

name: Optional[str] = None

source_joints class-attribute instance-attribute

source_joints: Optional[List] = field(default=None, compare=False, hash=False, repr=False)

footprints class-attribute instance-attribute

footprints: List[Footprint] = field(default_factory=list)

drawings class-attribute instance-attribute

drawings: List[Drawing] = field(default_factory=list)

kiwari class-attribute instance-attribute

kiwari: Optional[Kiwari] = field(default=None, compare=False)

__post_init__

__post_init__()
Source code in kumiki/timber.py
def __post_init__(self):
    self._check_drawings_are_of_this_frame()

resolve_timber_path

resolve_timber_path(path: TimberPath) -> List[ResolvedTimberPath]

Which timbers a name refers to, in this frame.

A list, because a name may match several: paths are not required to be unique, and pretending one always means one timber would quietly pick whichever came first. Where it does match several, the reference stops being stable -- each is then told apart by the order the frame built them, so inserting another above them moves every reference below. That is worth saying out loud rather than discovering later, so it warns.

Source code in kumiki/timber.py
def resolve_timber_path(self, path: 'TimberPath') -> List['ResolvedTimberPath']:
    """Which timbers a name refers to, in this frame.

    A list, because a name may match several: paths are not required to be
    unique, and pretending one always means one timber would quietly pick
    whichever came first. Where it does match several, the reference stops
    being stable -- each is then told apart by the order the frame built
    them, so inserting another above them moves every reference below. That
    is worth saying out loud rather than discovering later, so it warns.
    """
    from .identity import ResolvedTimberPath

    wanted = str(path)
    matches = [
        ResolvedTimberPath(path=wanted, occurrence=occurrence)
        for occurrence, _ in enumerate(
            cut for cut in self.cut_timbers
            if _timber_path_of(cut.timber) == wanted
        )
    ]
    if len(matches) > 1:
        warnings.warn(
            f"{len(matches)} timbers share the path {wanted!r}. They can only be told "
            "apart by the order they were built in, so adding another above them will "
            "move anything that refers to them -- a drawing, or a measurement. Give "
            "them distinct ticket paths to keep those references stable."
        )
    return matches

timber_paths

timber_paths() -> List[TimberPath]

Every name in the frame, in order, duplicates included.

Source code in kumiki/timber.py
def timber_paths(self) -> List['TimberPath']:
    """Every name in the frame, in order, duplicates included."""
    from .identity import TimberPath

    return [TimberPath(_timber_path_of(cut.timber)) for cut in self.cut_timbers]

cut_timber_of

cut_timber_of(timber: PerfectTimberWithin) -> Optional[CutTimber]

The cut timber holding this exact timber object, or None.

Source code in kumiki/timber.py
def cut_timber_of(self, timber: PerfectTimberWithin) -> Optional[CutTimber]:
    """The cut timber holding this exact timber object, or None."""
    return next((cut for cut in self.cut_timbers if cut.timber is timber), None)

resolved_timber_path_of

resolved_timber_path_of(timber: PerfectTimberWithin) -> Optional[ResolvedTimberPath]

This exact timber's path in this frame, with its occurrence among timbers sharing the name.

Source code in kumiki/timber.py
def resolved_timber_path_of(self, timber: PerfectTimberWithin) -> Optional['ResolvedTimberPath']:
    """This exact timber's path in this frame, with its occurrence among timbers sharing the name."""
    from .identity import ResolvedTimberPath

    wanted = _timber_path_of(timber)
    same_name = [cut for cut in self.cut_timbers if _timber_path_of(cut.timber) == wanted]
    occurrence = next((i for i, cut in enumerate(same_name) if cut.timber is timber), None)
    return None if occurrence is None else ResolvedTimberPath(path=wanted, occurrence=occurrence)

cut_timber_at

cut_timber_at(path: ResolvedTimberPath) -> Optional[CutTimber]

The cut timber a resolved path names in this frame, or None.

Source code in kumiki/timber.py
def cut_timber_at(self, path: 'ResolvedTimberPath') -> Optional[CutTimber]:
    """The cut timber a resolved path names in this frame, or None."""
    same_name = [cut for cut in self.cut_timbers if _timber_path_of(cut.timber) == path.path]
    return same_name[path.occurrence] if path.occurrence < len(same_name) else None

from_joints classmethod

from_joints(joints: List[Joint], additional_unjointed_timbers: Optional[List[PerfectTimberWithin]] = None, name: Optional[str] = None, kiwari: Optional[Kiwari] = None) -> Frame

Create a Frame from a list of joints and optional additional unjointed timbers.

This constructor extracts all cut timbers and accessories from the joints, and combines cut timbers that share the same underlying timber reference.

Parameters:

Name Type Description Default
joints List[Joint]

List of Joint objects

required
additional_unjointed_timbers Optional[List[PerfectTimberWithin]]

Optional list of PerfectTimberWithin objects that don't participate in any joints (default: empty list)

None
name Optional[str]

Optional name for the frame

None
kiwari Optional[Kiwari]

The numbers the frame was built from, if it takes any

None

Returns:

Name Type Description
Frame Frame

A new Frame object with merged cut timbers and collected accessories

Raises:

Type Description
ValueError

If two timbers with the same name but same underlying timber have different references (indicates a bug)

Source code in kumiki/timber.py
@classmethod
def from_joints(cls, joints: List[Joint],
                additional_unjointed_timbers: Optional[List[PerfectTimberWithin]] = None,
                name: Optional[str] = None,
                kiwari: Optional['Kiwari'] = None) -> 'Frame':
    """
    Create a Frame from a list of joints and optional additional unjointed timbers.

    This constructor extracts all cut timbers and accessories from the joints,
    and combines cut timbers that share the same underlying timber reference.

    Args:
        joints: List of Joint objects
        additional_unjointed_timbers: Optional list of PerfectTimberWithin objects that don't
                                     participate in any joints (default: empty list)
        name: Optional name for the frame
        kiwari: The numbers the frame was built from, if it takes any

    Returns:
        Frame: A new Frame object with merged cut timbers and collected accessories

    Raises:
        ValueError: If two timbers with the same name but same underlying timber 
                   have different references (indicates a bug)

    Warnings:
        Prints a warning if two timbers with the same name have different underlying 
        timber references and the underlying timbers are actually different.
    """
    import warnings

    if additional_unjointed_timbers is None:
        additional_unjointed_timbers = []

    # Dictionary to group Cutting objects by their underlying Timber reference (identity)
    # Key: id(timber), Value: List of Cutting objects
    timber_ref_to_cuttings: Dict[int, List[Cutting]] = {}
    timber_ref_to_timber: Dict[int, PerfectTimberWithin] = {}

    # Extract cuttings from all joints
    for joint in joints:
        for cutting in joint.cuttings.values():
            timber_id = id(cutting.timber)
            timber_ref_to_timber[timber_id] = cutting.timber
            if timber_id not in timber_ref_to_cuttings:
                timber_ref_to_cuttings[timber_id] = []
            timber_ref_to_cuttings[timber_id].append(cutting)

    warn_joints_whose_timber_extended_rough_prisms_dont_touch(joints)

    # Check for name conflicts
    # Build a mapping from name to list of timber references
    name_to_timber_refs: Dict[str, List[PerfectTimberWithin]] = {}
    for timber_id, timber in timber_ref_to_timber.items():
        timber_name = timber.ticket.path
        if timber_name is not None:
            if timber_name not in name_to_timber_refs:
                name_to_timber_refs[timber_name] = []
            # Only add if not already in the list (check by identity)
            if not any(t is timber for t in name_to_timber_refs[timber_name]):
                name_to_timber_refs[timber_name].append(timber)

    # Check for conflicts
    for timber_name, timber_refs in name_to_timber_refs.items():
        if len(timber_refs) > 1:
            # Multiple timbers with the same name
            # Check if the underlying timbers are actually different
            for i in range(len(timber_refs)):
                for j in range(i + 1, len(timber_refs)):
                    timber_i = timber_refs[i]
                    timber_j = timber_refs[j]

                    # Compare using structural equality (==)
                    if timber_i == timber_j:
                        # Same timber data but different references - this is a bug
                        raise ValueError(
                            f"Error: Found two timber references with the same name '{timber_name}' "
                            f"that have identical underlying timber data. This indicates a bug "
                            f"where the same timber was created multiple times instead of reusing "
                            f"the same reference."
                        )
                    else:
                        # Different timber data with the same name - just a warning
                        warnings.warn(
                            f"Warning: Found multiple timbers with the same name '{timber_name}' "
                            f"but different properties (length, size, position, or orientation). "
                            f"This may indicate an error in timber naming. "
                            f"Timber 1: length={timber_i.length}, size={timber_i.size}, "
                            f"position={timber_i.get_bottom_position_global()}. "
                            f"Timber 2: length={timber_j.length}, size={timber_j.size}, "
                            f"position={timber_j.get_bottom_position_global()}."
                        )

    # Merge cut timbers with the same underlying timber reference
    merged_cut_timbers: List[CutTimber] = []
    for timber_id, cutting_list in timber_ref_to_cuttings.items():
        timber = timber_ref_to_timber[timber_id]

        # Collect all cuts from all joints for this timber
        all_cuts: List[Cutting] = []
        all_cuts.extend(cutting_list)

        # Create a single merged CutTimber
        merged_cut_timber = CutTimber(
            timber,
            cuts=all_cuts,
            joints=_joints_touching_timber(joints, timber),
        )
        merged_cut_timbers.append(merged_cut_timber)

    # Add additional unjointed timbers as CutTimbers with no cuts
    for timber in additional_unjointed_timbers:
        merged_cut_timbers.append(CutTimber(timber, cuts=[], joints=[]))

    # Collect all accessories from all joints
    all_accessories: List[Accessory] = []
    for joint in joints:
        all_accessories.extend(joint.jointAccessories.values())

    # Create and return the Frame
    return cls(
        cut_timbers=merged_cut_timbers,
        accessories=all_accessories,
        name=name,
        source_joints=list(joints),
        kiwari=kiwari,
    )

get_bounding_box

get_bounding_box() -> tuple[V3, V3]

Get the axis-aligned bounding box for the entire frame in global coordinates.

This computes the bounding box by getting the bounding prism for each cut timber and finding the global min/max coordinates that enclose all of them.

Returns:

Type Description
tuple[V3, V3]

tuple[V3, V3]: (min_corner, max_corner) where each is a 3x1 Matrix representing the minimum and maximum corners of the axis-aligned bounding box in global coordinates

Raises:

Type Description
ValueError

If the frame contains no cut timbers

Source code in kumiki/timber.py
def get_bounding_box(self) -> tuple[V3, V3]:
    """
    Get the axis-aligned bounding box for the entire frame in global coordinates.

    This computes the bounding box by getting the bounding prism for each cut timber
    and finding the global min/max coordinates that enclose all of them.

    Returns:
        tuple[V3, V3]: (min_corner, max_corner) where each is a 3x1 Matrix representing
                      the minimum and maximum corners of the axis-aligned bounding box
                      in global coordinates

    Raises:
        ValueError: If the frame contains no cut timbers
    """
    if not self.cut_timbers:
        raise ValueError("Cannot compute bounding box for empty frame (no cut timbers)")

    # Get bounding prism for each cut timber
    bounding_prisms = [ct.get_perfect_timber_within_bounding_box_prism() for ct in self.cut_timbers]

    # For each prism, we need to find its 8 corners and track global min/max
    # Initialize with infinities
    min_x = None
    min_y = None
    min_z = None
    max_x = None
    max_y = None
    max_z = None

    for prism in bounding_prisms:
        # Get the 8 corners of the rectangular prism
        # The prism is defined by its size (width, height) in the XY plane
        # and start_distance/end_distance along the Z axis

        half_width = prism.size[0] / 2
        half_height = prism.size[1] / 2

        # Generate 8 corners in local coordinates
        # (±half_width, ±half_height, start_distance or end_distance)
        local_corners = []
        for x_sign in [-1, 1]:
            for y_sign in [-1, 1]:
                for z_val in [prism.start_distance, prism.end_distance]:
                    local_corner = Matrix([
                        x_sign * half_width,
                        y_sign * half_height,
                        z_val
                    ])
                    local_corners.append(local_corner)

        # Transform each corner to global coordinates
        for local_corner in local_corners:
            global_corner = prism.transform.position + safe_transform_vector(prism.transform.orientation.matrix, local_corner)

            # Update min/max for each axis
            if min_x is None:
                min_x = global_corner[0]
                max_x = global_corner[0]
                min_y = global_corner[1]
                max_y = global_corner[1]
                min_z = global_corner[2]
                max_z = global_corner[2]
            else:
                min_x = min(min_x, global_corner[0])
                max_x = max(max_x, global_corner[0])
                min_y = min(min_y, global_corner[1])
                max_y = max(max_y, global_corner[1])
                min_z = min(min_z, global_corner[2])
                max_z = max(max_z, global_corner[2])

    min_corner = Matrix([min_x, min_y, min_z])
    max_corner = Matrix([max_x, max_y, max_z])

    return (min_corner, max_corner)

KumikiArrangementError

Bases: ValueError

Raised when a timber arrangement or joint parameter fails a validation check.

Unlike AssertionError, this survives python -O and is safe for callers to catch specifically when handling invalid joint/arrangement configurations.

compute_timber_orientation

compute_timber_orientation(length_direction: Direction3D, width_direction: Direction3D) -> Orientation

Compute the orientation matrix from length and width directions

Parameters:

Name Type Description Default
length_direction Direction3D

Direction vector for the length axis as 3D vector, the +length direction is the +Z direction

required
width_direction Direction3D

Direction vector for the width axis as 3D vector, the +width direction is the +X direction

required

Returns:

Type Description
Orientation

Orientation object representing the timber's orientation in 3D space

Source code in kumiki/timber.py
def compute_timber_orientation(length_direction: Direction3D, width_direction: Direction3D) -> Orientation:
    """Compute the orientation matrix from length and width directions

    Args:
        length_direction: Direction vector for the length axis as 3D vector, the +length direction is the +Z direction
        width_direction: Direction vector for the width axis as 3D vector, the +width direction is the +X direction

    Returns:
        Orientation object representing the timber's orientation in 3D space
    """
    # Normalize the length direction first (this will be our primary axis)
    length_norm = safe_normalize_vector(length_direction)

    # Orthogonalize face direction relative to length direction using Gram-Schmidt
    face_input = safe_normalize_vector(width_direction)

    # Project face_input onto length_norm and subtract to get orthogonal component
    projection = length_norm * (face_input.dot(length_norm))
    face_orthogonal = face_input - projection

    # Check if face_orthogonal is too small (vectors were nearly parallel)
    if safe_zero_test(safe_norm(face_orthogonal)):
        # Choose an arbitrary orthogonal direction
        # Find a vector that's not parallel to length_norm
        if Abs(length_norm[0]) < scalar(9, 10):  # Threshold comparison
            temp_vector = create_v3(scalar(1), scalar(0), scalar(0))
        else:
            temp_vector = create_v3(scalar(0), scalar(1), scalar(0))

        # Project and orthogonalize
        projection = length_norm * (temp_vector.dot(length_norm))
        face_orthogonal = temp_vector - projection

    # Normalize the orthogonalized face direction
    face_norm = safe_normalize_vector(face_orthogonal)

    # Cross product to get the third axis (guaranteed to be orthogonal)
    cross_result = cross_product(length_norm, face_norm)
    height_norm = safe_normalize_vector(cross_result)

    # Create rotation matrix [face_norm, height_norm, length_norm]
    rotation_matrix = Matrix([
        [face_norm[0], height_norm[0], length_norm[0]],
        [face_norm[1], height_norm[1], length_norm[1]],
        [face_norm[2], height_norm[2], length_norm[2]]
    ])

    # Convert to Orientation
    return Orientation(rotation_matrix)

create_timber

create_timber(length: Numeric, size: V2, bottom_position: V3, length_direction: Direction3D, width_direction: Direction3D, ticket: Optional[Union[TimberTicket, str]] = None) -> Timber

Factory function to create a Timber with computed orientation from direction vectors

This is the main way to construct Timber instances. It takes direction vectors and computes the proper orientation matrix automatically.

AGENT NOTE: AVOID this function if possible, prefer methods like join_timber, attach_timber, create_*_timber_on_footprint, or even create_axis_aligned_timber, which are more robust and easier to use.

Parameters:

Name Type Description Default
length Numeric

Length of the timber

required
size V2

Cross-sectional size (width, height) as 2D vector, width is the X dimension (left to right), height is the Y dimension (front to back)

required
bottom_position V3

Position of the bottom point (center of cross-section) as 3D vector

required
length_direction Direction3D

Direction vector for the length axis as 3D vector, the +length direction is the +Z direction

required
width_direction Direction3D

Direction vector for the width axis as 3D vector, the +width direction is the +X direction

required
ticket Optional[Union[TimberTicket, str]]

Optional ticket for this timber (can be TimberTicket object or string name, used for rendering/debugging)

None

Returns:

Type Description
Timber

Timber instance with computed orientation

Source code in kumiki/timber.py
def create_timber(length: Numeric, size: V2, bottom_position: V3,
                          length_direction: Direction3D, width_direction: Direction3D,
                          ticket: Optional[Union[TimberTicket, str]] = None) -> 'Timber':
    """Factory function to create a Timber with computed orientation from direction vectors

    This is the main way to construct Timber instances. It takes direction vectors
    and computes the proper orientation matrix automatically.

    AGENT NOTE: AVOID this function if possible, prefer methods like join_timber, attach_timber, create_*_timber_on_footprint, or even create_axis_aligned_timber, which are more robust and easier to use.

    Args:
        length: Length of the timber
        size: Cross-sectional size (width, height) as 2D vector, width is the X dimension (left to right), height is the Y dimension (front to back)
        bottom_position: Position of the bottom point (center of cross-section) as 3D vector
        length_direction: Direction vector for the length axis as 3D vector, the +length direction is the +Z direction
        width_direction: Direction vector for the width axis as 3D vector, the +width direction is the +X direction
        ticket: Optional ticket for this timber (can be TimberTicket object or string name, used for rendering/debugging)

    Returns:
        Timber instance with computed orientation
    """
    orientation = compute_timber_orientation(length_direction, width_direction)
    transform = Transform(position=bottom_position, orientation=orientation)
    return Timber(length=length, size=size, transform=transform, ticket=_ensure_ticket(ticket))

did_end_cuts_extend_timber

did_end_cuts_extend_timber(timber: PerfectTimberWithin, cuts: List[Cutting]) -> bool

Check if any end cuts extend beyond the timber's original bounds.

An end cut extends beyond if: - Top cut: The cutting plane is at z > timber.length (cuts beyond the top) - Bottom cut: The cutting plane is at z < 0 (cuts beyond the bottom)

In local coordinates, HalfSpace end cuts are defined with: - Top cuts: normal pointing up (+Z), offset at the cut location - Bottom cuts: normal pointing down (-Z), offset at the cut location (negative value)

Parameters:

Name Type Description Default
timber PerfectTimberWithin

The timber being cut

required
cuts List[Cutting]

List of cuts on the timber

required

Returns:

Type Description
bool

True if any end cut extends beyond the timber's original length

Source code in kumiki/timber.py
def did_end_cuts_extend_timber(timber: PerfectTimberWithin, cuts: List['Cutting']) -> bool:
    """
    Check if any end cuts extend beyond the timber's original bounds.

    An end cut extends beyond if:
    - Top cut: The cutting plane is at z > timber.length (cuts beyond the top)
    - Bottom cut: The cutting plane is at z < 0 (cuts beyond the bottom)

    In local coordinates, HalfSpace end cuts are defined with:
    - Top cuts: normal pointing up (+Z), offset at the cut location
    - Bottom cuts: normal pointing down (-Z), offset at the cut location (negative value)

    Args:
        timber: The timber being cut
        cuts: List of cuts on the timber

    Returns:
        True if any end cut extends beyond the timber's original length
    """

    for cut in cuts:
        top_end_cut = cut.get_maybe_top_end_cut()
        bottom_end_cut = cut.get_maybe_bottom_end_cut()

        # Check top end cut
        if top_end_cut is not None:
            # For top cuts, normal is (0,0,1) and offset is the z-position of the cut
            # If offset > timber.length, the cut extends beyond the top
            if safe_compare(top_end_cut.offset - timber.length, 0, Comparison.GT):
                return True

        # Check bottom end cut
        if bottom_end_cut is not None:
            # For bottom cuts, normal is (0,0,-1) and offset is negative
            # If offset > 0, the cut extends beyond the bottom (into negative z)
            if safe_compare(bottom_end_cut.offset, 0, Comparison.GT):
                return True

    return False

warn_joints_whose_timber_extended_rough_prisms_dont_touch

warn_joints_whose_timber_extended_rough_prisms_dont_touch(joints: Iterable[Joint]) -> None

A FrameWarning for each joint whose timber's extended prisms don't touch. This is just a permissive backstop. Instead, joints should do more specific checks but that's currently not the case for all joints.

Source code in kumiki/timber.py
def warn_joints_whose_timber_extended_rough_prisms_dont_touch(joints: Iterable['Joint']) -> None:
    """A FrameWarning for each joint whose timber's extended prisms don't touch. This is just a
    permissive backstop. Instead, joints should do more specific checks but that's currently not
    the case for all joints."""
    prisms: Dict[int, Optional[_WorldPrism]] = {}
    for joint in joints:
        timbers = list({id(c.timber): c.timber for c in joint.cuttings.values()}.values())
        maybe = [prisms.setdefault(id(t), _extended_rough_prism(t)) for t in timbers]
        found = [prism for prism in maybe if prism is not None]
        if len(timbers) < 2 or len(found) < len(timbers):
            continue
        # Timbers that touch, transitively, from the first: a joint may cut many (rafters on a plate).
        reached = {0}
        frontier = [0]
        while frontier:
            i = frontier.pop()
            for j in range(len(timbers)):
                if j not in reached and _prism_gap(found[i], found[j]) <= _TOUCH_TOLERANCE:
                    reached.add(j)
                    frontier.append(j)
        for j in range(len(timbers)):
            if j in reached:
                continue
            gap = min(_prism_gap(found[i], found[j]) for i in reached)
            warnings.warn(f"Joint '{joint.ticket.path}': {timbers[j].ticket.path} doesn't touch the joint's other timbers, "
                          f"even extended past their ends (a {gap * 1000:.0f} mm gap), so the joint cuts into air.",
                          FrameWarning, stacklevel=3)


    def get_csg_local(self) -> CutCSG:
        # Ball diameter = size, shaft diameter = size/2, shaft length = 2*size
        ball_radius = self.size / scalar(2)
        shaft_radius = self.size / scalar(4)
        shaft_length = self.size * scalar(2)
        axis_z = create_v3(scalar(0), scalar(0), scalar(1))
        origin = create_v3(scalar(0), scalar(0), scalar(0))
        ball = Cylinder(
            position=origin,
            axis_direction=axis_z,
            radius=ball_radius,
            start_distance=-ball_radius,
            end_distance=ball_radius,
        )
        shaft_position = axis_z * ball_radius
        shaft = Cylinder(
            position=shaft_position,
            axis_direction=axis_z,
            radius=shaft_radius,
            start_distance=scalar(0),
            end_distance=shaft_length,
        )
        return SolidUnion(children=[ball, shaft])

make_compound_joint

make_compound_joint(joints: List[Joint], ticket: JointTicket) -> Joint

Create a compound joint that combines multiple joints together.

The cuttings and accessories from all joints are merged into a single Joint object. Numeric suffixes are added to accessory and cutting keys if there are conflicts. The tickets of the input joints are ignored.

Parameters:

Name Type Description Default
joints List[Joint]

List of Joint objects to combine

required
ticket JointTicket

JointTicket for the compound joint

required
Source code in kumiki/timber.py
def make_compound_joint(joints: List[Joint], ticket: JointTicket) -> Joint:
    """
    Create a compound joint that combines multiple joints together.

    The cuttings and accessories from all joints are merged into a single Joint object.
    Numeric suffixes are added to accessory and cutting keys if there are conflicts.
    The tickets of the input joints are ignored.

    Args:
        joints: List of Joint objects to combine
        ticket: JointTicket for the compound joint
    """
    def _add_with_unique_key(target: dict, key: str, value) -> None:
        if key not in target:
            target[key] = value
            return
        suffix = 2
        while f"{key}_{suffix}" in target:
            suffix += 1
        target[f"{key}_{suffix}"] = value

    merged_cuttings: Dict[str, Cutting] = {}
    merged_accessories: Dict[str, Accessory] = {}
    for joint in joints:
        for key, cutting in joint.cuttings.items():
            _add_with_unique_key(merged_cuttings, key, cutting)
        for key, accessory in joint.jointAccessories.items():
            _add_with_unique_key(merged_accessories, key, accessory)

    return Joint(cuttings=merged_cuttings, ticket=ticket, jointAccessories=merged_accessories)

require_check

require_check(err: Optional[str])
Source code in kumiki/timber.py
def require_check(err: Optional[str]):
    if err is not None:
        raise KumikiArrangementError(err)

add_milestone

add_milestone(name: str)

Emit a milestone marker for the viewer loading screen.

Writes a JSON protocol message to the real stdout pipe so the viewer extension can display progress during script execution. No-ops when not running inside the Kigumi extension (checks KIGUMI_VIEWER_MILESTONES environment variable).

Source code in kumiki/timber.py
def add_milestone(name: str):
    """Emit a milestone marker for the viewer loading screen.

    Writes a JSON protocol message to the real stdout pipe so the viewer
    extension can display progress during script execution.  No-ops when
    not running inside the Kigumi extension (checks KIGUMI_VIEWER_MILESTONES
    environment variable).
    """
    import os, sys, json as _json  # noqa: E401 — lazy imports to avoid burdening the core module
    if not os.environ.get("KIGUMI_VIEWER_MILESTONES"):
        return
    stdout = sys.__stdout__
    assert stdout is not None
    _json.dump({"type": "milestone", "name": name}, stdout)
    stdout.write("\n")
    stdout.flush()

solve_frame_assembly

solve_frame_assembly(frame: Frame, should_cancel: Optional[Callable[[], bool]] = None) -> Optional[AssemblySolution]

Solve the disassembly sequence for a frame's source joints.

Adapts the frame into the abstract assembly graph of kumiki/assembly.py — one AssemblyMember per distinct timber/accessory (keyed by ticket kumiki_id, positioned at the timber centroid) and one AssemblyJoint per source joint — then delegates to solve_assembly.

Returns None when no member of any source joint has an assembly freedom.

Source code in kumiki/timber.py
def solve_frame_assembly(
    frame: Frame,
    should_cancel: Optional[Callable[[], bool]] = None,
) -> Optional[AssemblySolution]:
    """Solve the disassembly sequence for a frame's source joints.

    Adapts the frame into the abstract assembly graph of kumiki/assembly.py —
    one AssemblyMember per distinct timber/accessory (keyed by ticket
    kumiki_id, positioned at the timber centroid) and one AssemblyJoint per
    source joint — then delegates to solve_assembly.

    Returns None when no member of any source joint has an assembly freedom.
    """
    source_joints = list(frame.source_joints or [])
    has_any_freedom = any(
        cutting.assembly_freedom is not None
        for joint in source_joints
        for cutting in joint.cuttings.values()
    ) or any(
        accessory.assembly_freedom is not None
        for joint in source_joints
        for accessory in joint.jointAccessories.values()
    )
    if not has_any_freedom:
        return None

    members: Dict[int, AssemblyMember] = {}

    def register_timber(timber: PerfectTimberWithin) -> int:
        key = timber.ticket.kumiki_id
        if key not in members:
            centroid = (
                timber.get_bottom_position_global()
                + timber.get_length_direction_global() * timber.length / 2
            )
            corners = [
                [float(giraffe_evalf(corner_position[axis, 0])) for axis in range(3)]
                for corner_position in (
                    timber.get_corner_position_global(corner) for corner in TimberCorner
                )
            ]
            bbox = AssemblyBoundingBox(
                min_x=min(c[0] for c in corners), max_x=max(c[0] for c in corners),
                min_y=min(c[1] for c in corners), max_y=max(c[1] for c in corners),
                min_z=min(c[2] for c in corners), max_z=max(c[2] for c in corners),
            )
            members[key] = AssemblyMember(key=key, name=timber.ticket.path, position=centroid, bbox=bbox)
        return key

    def register_accessory(accessory: Accessory) -> int:
        key = accessory.ticket.kumiki_id
        if key not in members:
            transform = getattr(accessory, "transform", None)
            position = transform.position if transform is not None else create_v3(0, 0, 0)
            # Accessory extents are not modeled yet; a small box at the
            # transform position lets the clear-out pass shove parked pegs.
            px, py, pz = (float(giraffe_evalf(position[axis, 0])) for axis in range(3))
            radius = 0.02
            bbox = AssemblyBoundingBox(
                min_x=px - radius, max_x=px + radius,
                min_y=py - radius, max_y=py + radius,
                min_z=pz - radius, max_z=pz + radius,
            )
            members[key] = AssemblyMember(key=key, name=accessory.ticket.path, position=position, bbox=bbox)
        return key

    def add_spec(specs: Dict[int, JointMemberSpec], key: int,
                 freedom: Optional[AssemblyFreedom], ordering: Ordering) -> None:
        existing = specs.get(key)
        if existing is None:
            specs[key] = JointMemberSpec(freedom=freedom, ordering=ordering)
            return
        # The same member can appear under several cutting keys of one
        # (compound) joint; its escape DOFs are the union of all of them and
        # the earliest ordering wins.
        if existing.freedom is not None and freedom is not None:
            combined = AssemblyFreedom.combine(existing.freedom, freedom)
        else:
            combined = existing.freedom if freedom is None else freedom
        specs[key] = JointMemberSpec(freedom=combined, ordering=min(existing.ordering, ordering))

    assembly_joints: List[AssemblyJoint] = []
    for joint in source_joints:
        specs: Dict[int, JointMemberSpec] = {}
        for cutting in joint.cuttings.values():
            add_spec(specs, register_timber(cutting.timber), cutting.assembly_freedom, cutting.assembly_ordering)
        for accessory in joint.jointAccessories.values():
            add_spec(specs, register_accessory(accessory), accessory.assembly_freedom, accessory.assembly_ordering)
        joint_name = joint.ticket.get_name()
        if joint_name == "[no-name]":
            joint_name = joint.ticket.joint_type or "joint"
        assembly_joints.append(AssemblyJoint(name=joint_name, members=specs))

    return solve_assembly(list(members.values()), assembly_joints, should_cancel=should_cancel)