Skip to content

kumiki.joints.workshop.cross

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.cross -- you do not need to import from the submodule path shown in the heading above.

kumiki.joints.workshop.cross

Kumiki - Cross joint construction functions

cut_plain_cross_lap_joint

cut_plain_cross_lap_joint(arrangement: CrossJointTimberArrangement, cut_ratio: Numeric = scalar(1, 2), relief: Optional[CrossJointScribeReliefConfig] = cross_timber_1()) -> Joint

Creates a cross-lap joint between two intersecting timbers.

Each timber has a portion relieved from it so they interlock at their crossing point, maintaining a flush outer face on both sides. By default material removal is split equally (half from each timber).

Parameters:

Name Type Description Default
arrangement CrossJointTimberArrangement

Cross-joint arrangement with timber1 and timber2, and optional front_face_on_timber1 (the cut face on timber1). If not provided, front_face_on_timber1 is chosen automatically to minimize material removed. The cut face on timber2 is implicitly the opposite face.

required
cut_ratio Numeric

Fraction [0, 1] controlling how much is removed from each timber. 0 = only timber2 is cut; 1 = only timber1 is cut; 0.5 = equal split (default).

scalar(1, 2)

Returns:

Type Description
Joint

Joint object containing the two CutTimbers.

Raises:

Type Description
AssertionError

If the timbers don't intersect, are parallel, or cut_ratio is out of range.

Source code in kumiki/joints/workshop/cross/plain_cross_lap_joint.py
def cut_plain_cross_lap_joint(
    arrangement: CrossJointTimberArrangement,
    cut_ratio: Numeric = scalar(1, 2),
    relief: Optional[CrossJointScribeReliefConfig] = CrossJointScribeReliefConfig.cross_timber_1(),
) -> Joint:
    """
    Creates a cross-lap joint between two intersecting timbers.

    Each timber has a portion relieved from it so they interlock at their crossing point,
    maintaining a flush outer face on both sides. By default material removal is split
    equally (half from each timber).

    Args:
        arrangement: Cross-joint arrangement with timber1 and timber2, and optional
                     front_face_on_timber1 (the cut face on timber1). If not provided,
                     front_face_on_timber1 is chosen automatically to minimize material removed.
                     The cut face on timber2 is implicitly the opposite face.
        cut_ratio: Fraction [0, 1] controlling how much is removed from each timber.
            0 = only timber2 is cut; 1 = only timber1 is cut; 0.5 = equal split (default).

    Returns:
        Joint object containing the two CutTimbers.

    Raises:
        AssertionError: If the timbers don't intersect, are parallel, or cut_ratio is out of range.
    """
    timberA = arrangement.timber1
    timberB = arrangement.timber2
    timberA_cut_face = arrangement.front_face_on_timber1

    warn_if_arrangement_timbers_imperfect(arrangement)

    assert 0 <= cut_ratio <= 1, f"cut_ratio must be in range [0, 1], got {cut_ratio}"

    dot_product = safe_dot_product(timberA.get_length_direction_global(), timberB.get_length_direction_global())
    assert abs(abs(dot_product) - 1) > scalar(1, 1000000), (
        "Timbers must not be parallel (their length directions must differ)"
    )

    d1 = timberA.get_length_direction_global()
    d2 = timberB.get_length_direction_global()
    p1 = timberA.get_bottom_position_global()
    p2 = timberB.get_bottom_position_global()
    w = p1 - p2

    a = safe_dot_product(d1, d1)
    b = safe_dot_product(d1, d2)
    c = safe_dot_product(d2, d2)
    d = safe_dot_product(d1, w)
    e = safe_dot_product(d2, w)

    denom = a * c - b * b

    if abs(denom) < scalar(1, 1000000):
        t = -safe_dot_product(d1, w) / a if a > 0 else 0
        closest_on_1 = p1 + t * d1
        distance = safe_norm(p2 - closest_on_1)
    else:
        t1 = (b * e - c * d) / denom
        t2 = (a * e - b * d) / denom

        closest_on_1 = p1 + t1 * d1
        closest_on_2 = p2 + t2 * d2

        distance = safe_norm(closest_on_1 - closest_on_2)

    max_separation = (timberA.size[0] + timberA.size[1] + timberB.size[0] + timberB.size[1]) / 2

    assert float(distance) < float(max_separation), (
        f"Timbers do not intersect (closest distance: {float(distance):.4f}m, max allowed: {float(max_separation):.4f}m)"
    )

    if timberA_cut_face is None:
        perpendicular_axis = cross_product(d1, d2)
        perpendicular_axis = safe_normalize_vector(perpendicular_axis)

        centerA = timberA.get_bottom_position_global() + timberA.get_length_direction_global() * (timberA.length / scalar(2))
        centerB = timberB.get_bottom_position_global() + timberB.get_length_direction_global() * (timberB.length / scalar(2))

        A_to_B = centerB - centerA
        projection = safe_dot_product(A_to_B, perpendicular_axis)
        direction_toward_B = perpendicular_axis if projection >= 0 else -perpendicular_axis

        faces = [TimberFace.RIGHT, TimberFace.LEFT, TimberFace.FRONT, TimberFace.BACK]
        max_dot = None
        best_face = TimberFace.RIGHT

        for face in faces:
            face_normal = timberA.get_face_direction_global(face)
            dot = safe_dot_product(face_normal, direction_toward_B)

            if max_dot is None or dot > max_dot:
                max_dot = dot
                best_face = face

        timberA_cut_face = best_face

    normalA = timberA.get_face_direction_global(timberA_cut_face)
    faces = [TimberFace.RIGHT, TimberFace.LEFT, TimberFace.FRONT, TimberFace.BACK]
    min_dot = None
    timberB_cut_face = TimberFace.RIGHT

    for face in faces:
        face_normal = timberB.get_face_direction_global(face)
        dot = safe_dot_product(face_normal, normalA)

        if min_dot is None or dot < min_dot:
            min_dot = dot
            timberB_cut_face = face

    assert timberA_cut_face is not None and timberB_cut_face is not None
    normalA = timberA.get_face_direction_global(timberA_cut_face)
    normalB = timberB.get_face_direction_global(timberB_cut_face)

    normal_dot = safe_dot_product(normalA, normalB)
    assert normal_dot < 0, (
        f"Face normals must oppose each other (dot product < 0, got {float(normal_dot):.4f})"
    )

    faceA_position = _get_face_center_position(timberA, timberA_cut_face)
    faceB_position = _get_face_center_position(timberB, timberB_cut_face)

    cutting_plane_position = faceA_position * (1 - cut_ratio) + faceB_position * cut_ratio
    cutting_plane_normal = normalA * (1 - cut_ratio) - normalB * cut_ratio
    cutting_plane_normal_normalized = cutting_plane_normal / safe_norm(cutting_plane_normal)
    cutting_plane_offset = safe_dot_product(cutting_plane_normal_normalized, cutting_plane_position)

    cuts_A = []
    cuts_B = []

    if safe_compare(cut_ratio, 0, Comparison.GT):
        relative_orientation_B_in_A = Orientation(safe_transform_vector(timberA.orientation.matrix.T, timberB.orientation.matrix))
        timberB_origin_in_A_local = safe_transform_vector(timberA.orientation.matrix.T, timberB.get_bottom_position_global() - timberA.get_bottom_position_global())

        transform_B_in_A = Transform(position=timberB_origin_in_A_local, orientation=relative_orientation_B_in_A)
        timberB_prism_in_A = RectangularPrism(
            size=timberB.size,
            transform=transform_B_in_A,
            start_distance=None,
            end_distance=None,
            feature_overrides=_CROSSING_SIDES,
            label=CutCSGLabel("crossing_timber"),
        )

        cutting_plane_normal_in_A = safe_transform_vector(timberA.orientation.matrix.T, cutting_plane_normal_normalized)
        cutting_plane_position_in_A = safe_transform_vector(timberA.orientation.matrix.T, cutting_plane_position - timberA.get_bottom_position_global())
        cutting_plane_offset_in_A = safe_dot_product(cutting_plane_normal_in_A, cutting_plane_position_in_A)

        inverse_half_plane_A = HalfSpace(
            normal=-cutting_plane_normal_in_A,
            offset=-cutting_plane_offset_in_A,
            feature_overrides=_LAP_DEPTH,
            label=CutCSGLabel("lap_depth_plane"),
        )

        negative_csg_A = Difference(
            base=timberB_prism_in_A,
            subtract=[inverse_half_plane_A],
            label=CutCSGLabel("lap_waste"),
        )

        cut_A = Cutting(
            timber=timberA,
            negative_csg=negative_csg_A,
            label=CutCSGLabel("lap_cut"),
        )
        cuts_A.append(cut_A)

    if cut_ratio < 1:
        relative_orientation_A_in_B = Orientation(safe_transform_vector(timberB.orientation.matrix.T, timberA.orientation.matrix))
        timberA_origin_in_B_local = safe_transform_vector(timberB.orientation.matrix.T, timberA.get_bottom_position_global() - timberB.get_bottom_position_global())

        transform_A_in_B = Transform(position=timberA_origin_in_B_local, orientation=relative_orientation_A_in_B)
        timberA_prism_in_B = RectangularPrism(
            size=timberA.size,
            transform=transform_A_in_B,
            start_distance=None,
            end_distance=None,
            feature_overrides=_CROSSING_SIDES,
            label=CutCSGLabel("crossing_timber"),
        )

        cutting_plane_normal_in_B = safe_transform_vector(timberB.orientation.matrix.T, cutting_plane_normal_normalized)
        cutting_plane_position_in_B = safe_transform_vector(timberB.orientation.matrix.T, cutting_plane_position - timberB.get_bottom_position_global())
        cutting_plane_offset_in_B = safe_dot_product(cutting_plane_normal_in_B, cutting_plane_position_in_B)

        half_plane_B = HalfSpace(
            normal=cutting_plane_normal_in_B,
            offset=cutting_plane_offset_in_B,
            feature_overrides=_LAP_DEPTH,
            label=CutCSGLabel("lap_depth_plane"),
        )

        negative_csg_B = Difference(
            base=timberA_prism_in_B,
            subtract=[half_plane_B],
            label=CutCSGLabel("lap_waste"),
        )

        cut_B = Cutting(
            timber=timberB,
            negative_csg=negative_csg_B,
            label=CutCSGLabel("lap_cut"),
        )
        cuts_B.append(cut_B)

    cut_timberA = cuts_A[0] if len(cuts_A) > 0 else Cutting(timber=timberA)
    cut_timberB = cuts_B[0] if len(cuts_B) > 0 else Cutting(timber=timberB)

    cuttings: dict[str, Cutting] = {"timberA": cut_timberA, "timberB": cut_timberB}

    if relief is not None:
        if relief.timber_to_be_scribed == ArrangementNames.cross_timber_1:
            scribed_key, cut_key = "timberA", "timberB"
        elif relief.timber_to_be_scribed == ArrangementNames.cross_timber_2:
            scribed_key, cut_key = "timberB", "timberA"
        else:
            raise AssertionError(
                f"Unsupported cross-joint relief target: {relief.timber_to_be_scribed}"
            )

        updated_cut_cutting, updated_scribed_cutting = chop_scribe_relief_and_apply(
            timber_to_be_scribed_cutting=cuttings[scribed_key],
            timber_to_be_cut_cutting=cuttings[cut_key],
        )
        cuttings[scribed_key] = updated_scribed_cutting
        cuttings[cut_key] = updated_cut_cutting

    lap_overlap = Abs(safe_dot_product(cutting_plane_normal_normalized, faceA_position - faceB_position))
    cuttings["timberA"] = replace(
        cuttings["timberA"],
        assembly_freedom=AssemblyFreedom.translation(-cutting_plane_normal_normalized, freed_after=lap_overlap),
    )
    cuttings["timberB"] = replace(
        cuttings["timberB"],
        assembly_freedom=AssemblyFreedom.translation(cutting_plane_normal_normalized, freed_after=lap_overlap),
    )

    return Joint(
        cuttings=cuttings,
        ticket=JointTicket(joint_type="plain_cross_lap"),
        jointAccessories={},
    )

cut_plain_cross_lap_house_joint

cut_plain_cross_lap_house_joint(arrangement: CrossJointTimberArrangement) -> Joint

Creates a house (dado/housing) joint where the housing timber is relieved to receive the housed timber.

Only the housing timber is cut (timber1); the housed timber (timber2) is not modified. Implemented as a cross-lap joint with cut_ratio=1.

Note this different from cut_free_house_joint in the way it handles relief cuts on the non-ptw parts of the timbers.

Parameters:

Name Type Description Default
arrangement CrossJointTimberArrangement

Cross-joint arrangement where timber1 is the housing timber (to be relieved) and timber2 is the housed timber (remains uncut). front_face_on_timber1 specifies the relief face; if None, chosen automatically.

required

Returns:

Type Description
Joint

Joint object containing both timbers.

Raises:

Type Description
AssertionError

If the timbers don't intersect or are parallel.

Source code in kumiki/joints/workshop/cross/plain_cross_lap_joint.py
def cut_plain_cross_lap_house_joint(arrangement: CrossJointTimberArrangement) -> Joint:
    """
    Creates a house (dado/housing) joint where the housing timber is relieved to receive the housed timber.

    Only the housing timber is cut (timber1); the housed timber (timber2) is not modified.
    Implemented as a cross-lap joint with cut_ratio=1.

    Note this different from cut_free_house_joint in the way it handles relief cuts on the non-ptw parts of the timbers.

    Args:
        arrangement: Cross-joint arrangement where timber1 is the housing timber (to be relieved)
                     and timber2 is the housed timber (remains uncut). front_face_on_timber1
                     specifies the relief face; if None, chosen automatically.

    Returns:
        Joint object containing both timbers.

    Raises:
        AssertionError: If the timbers don't intersect or are parallel.
    """
    return cut_plain_cross_lap_joint(arrangement, cut_ratio=scalar(1, 1))

cut_multi_cross_lap_joint_on_plane_aligned_timbers

cut_multi_cross_lap_joint_on_plane_aligned_timbers(timbers: List[TimberLike], starting_face_on_first_timber: TimberFace, cut_distance_ratios: List[Numeric] = []) -> Joint

Weave a chain of N timbers together with N-1 cross-lap joints, cut in order between timbers[n] and timbers[n+1].

All timbers must share a common "stacking axis": a long-face axis that is parallel across every timber in the list. starting_face_on_first_timber names one of these faces on timbers[0] called the "starting" face and the opposing face on timbers[-1] is called the "finish" face.

cut_distance_ratios places the N-1 cut boundaries (one per pair of adjacent timbers) along that start-to-finish axis, as fractions of the total distance between the starting face and the finish face: 0.0 is at the starting face, 1.0 is at the finish face, and ratios must be strictly increasing. If fewer than N-1 ratios are given, the remaining boundaries are spaced uniformly between the last explicit ratio (or the starting face, if none were given) and the finish face.

Each of these ratios is relative to the starting and finish face, NOT the local cut_ratio parameter of the underlying cut_plain_cross_lap_joint call.

Parameters:

Name Type Description Default
timbers List[TimberLike]

Ordered chain of at least 2 timbers to weave together.

required
starting_face_on_first_timber TimberFace

The long face on timbers[0] marking the start of the shared stacking axis (e.g. the global-bottom face shared by all timbers).

required
cut_distance_ratios List[Numeric]

Strictly increasing global position ratios in (0, 1) for the first len(cut_distance_ratios) boundaries; any remaining boundaries are filled in uniformly up to the finish face.

[]

Returns:

Type Description
Joint

Joint combining the N-1 pairwise cross-lap joints between adjacent timbers, plus any

Joint

extra "fill" cuts needed so non-adjacent timbers in the stack don't collide outside

Joint

their pairwise cut boundary. The whole assembly is treated as rigid (no single-timber

Joint

escape freedom).

Source code in kumiki/joints/workshop/cross/multi_cross_lap_joint.py
def cut_multi_cross_lap_joint_on_plane_aligned_timbers(
    timbers: List[TimberLike],
    starting_face_on_first_timber: TimberFace,
    cut_distance_ratios: List[Numeric] = [],
) -> Joint:
    """
    Weave a chain of N timbers together with N-1 cross-lap joints, cut in order between
    timbers[n] and timbers[n+1].

    All timbers must share a common "stacking axis": a long-face axis that is parallel across every timber in the list. starting_face_on_first_timber names one of these faces on timbers[0] called the "starting" face and the opposing face on timbers[-1] is called the "finish" face.

    cut_distance_ratios places the N-1 cut boundaries (one per pair of adjacent timbers)
    along that start-to-finish axis, as fractions of the total distance between the
    starting face and the finish face: 0.0 is at the starting face, 1.0 is at the finish
    face, and ratios must be strictly increasing. If fewer than N-1 ratios are given, the
    remaining boundaries are spaced uniformly between the last explicit ratio (or the
    starting face, if none were given) and the finish face.

    Each of these ratios is relative to the starting and finish face, NOT the local
    cut_ratio parameter of the underlying cut_plain_cross_lap_joint call.

    Args:
        timbers: Ordered chain of at least 2 timbers to weave together.
        starting_face_on_first_timber: The long face on timbers[0] marking the start of
            the shared stacking axis (e.g. the global-bottom face shared by all timbers).
        cut_distance_ratios: Strictly increasing global position ratios in (0, 1) for the
            first len(cut_distance_ratios) boundaries; any remaining boundaries are filled
            in uniformly up to the finish face.

    Returns:
        Joint combining the N-1 pairwise cross-lap joints between adjacent timbers, plus any
        extra "fill" cuts needed so non-adjacent timbers in the stack don't collide outside
        their pairwise cut boundary. The whole assembly is treated as rigid (no single-timber
        escape freedom).
    """
    assert len(timbers) >= 2, "cut_multi_cross_lap_joint_on_plane_aligned_timbers requires at least 2 timbers"
    assert starting_face_on_first_timber in _LONG_FACES, \
        f"starting_face_on_first_timber must be a long face (RIGHT/FRONT/LEFT/BACK), got {starting_face_on_first_timber}"

    axis_direction = -safe_normalize_vector(timbers[0].get_face_direction_global(starting_face_on_first_timber))

    for ratio in cut_distance_ratios:
        assert 0 < ratio < 1, f"cut_distance_ratios must all be in (0, 1), got {ratio}"
    for prev_ratio, next_ratio in zip(cut_distance_ratios, cut_distance_ratios[1:]):
        assert prev_ratio < next_ratio, f"cut_distance_ratios must be monotonically increasing, got {cut_distance_ratios}"

    finish_facing_faces = [_find_long_face_aligned_with_direction(timber, axis_direction) for timber in timbers]
    start_facing_faces = [face.to.face().get_opposite_face().to.long_face() for face in finish_facing_faces]

    starting_face_position = get_center_point_on_face_global(starting_face_on_first_timber, timbers[0])
    finish_face_position = get_center_point_on_face_global(finish_facing_faces[-1], timbers[-1])

    start_coord = safe_dot_product(starting_face_position, axis_direction)
    finish_coord = safe_dot_product(finish_face_position, axis_direction)

    num_boundaries = len(timbers) - 1
    assert len(cut_distance_ratios) <= num_boundaries, \
        f"got {len(cut_distance_ratios)} cut_distance_ratios but only {num_boundaries} boundaries between {len(timbers)} timbers"

    cut_position_coords: List[Numeric] = [
        start_coord + ratio * (finish_coord - start_coord) for ratio in cut_distance_ratios
    ]

    remaining = num_boundaries - len(cut_position_coords)
    last_coord = cut_position_coords[-1] if cut_position_coords else start_coord
    for i in range(1, remaining + 1):
        t = scalar(i, remaining + 1)
        cut_position_coords.append(last_coord + t * (finish_coord - last_coord))

    joints = []
    for n in range(num_boundaries):
        timber1 = timbers[n]
        timber2 = timbers[n + 1]
        front_face_on_timber1 = finish_facing_faces[n]

        face1_position = get_center_point_on_face_global(front_face_on_timber1, timber1)
        face2_position = get_center_point_on_face_global(start_facing_faces[n + 1], timber2)
        coord1 = safe_dot_product(face1_position, axis_direction)
        coord2 = safe_dot_product(face2_position, axis_direction)

        cut_ratio = (cut_position_coords[n] - coord1) / (coord2 - coord1)

        joints.append(cut_plain_cross_lap_joint(
            CrossJointTimberArrangement(
                timber1=timber1,
                timber2=timber2,
                front_face_on_timber1=front_face_on_timber1,
            ),
            cut_ratio=cut_ratio,
        ))

    from kumiki.csg.cutcsg import HalfSpace as _HalfSpace

    # --- Fill-in cuts for non-adjacent board pairs ---
    for n in range(num_boundaries):
        source = timbers[n]
        cut_coord = cut_position_coords[n]
        R = source.transform.orientation.matrix
        t_dot_axis = safe_dot_product(source.transform.position, axis_direction)
        local_axis = safe_transform_vector(R.T, axis_direction)
        above_hs = _HalfSpace(
            normal=local_axis, offset=cut_coord - t_dot_axis,
            label=CutCSGLabel("lap_boundary"))
        source_prism_below = Difference(
            source.get_actual_csg_local(), [above_hs],
            label=CutCSGLabel("crossing_board"))
        for target_idx in range(n + 2, len(timbers)):
            target = timbers[target_idx]
            neg_csg = adopt_csg(source.transform, target.transform, source_prism_below)
            joints.append(Joint(
                cuttings={f"fill_fwd_{n}_{target_idx}": Cutting(
                    timber=target, negative_csg=neg_csg,
                    label=CutCSGLabel("lap_fill_cut"))},
                ticket=JointTicket(joint_type="multi_cross_lap_fill"),
                jointAccessories={},
            ))

    for n in range(num_boundaries):
        source = timbers[n + 1]
        cut_coord = cut_position_coords[n]
        R = source.transform.orientation.matrix
        t_dot_axis = safe_dot_product(source.transform.position, axis_direction)
        local_axis = safe_transform_vector(R.T, axis_direction)
        below_hs = _HalfSpace(
            normal=-local_axis, offset=t_dot_axis - cut_coord,
            label=CutCSGLabel("lap_boundary"))
        source_prism_above = Difference(
            source.get_actual_csg_local(), [below_hs],
            label=CutCSGLabel("crossing_board"))
        for target_idx in range(0, n):
            target = timbers[target_idx]
            neg_csg = adopt_csg(source.transform, target.transform, source_prism_above)
            joints.append(Joint(
                cuttings={f"fill_bwd_{n}_{target_idx}": Cutting(
                    timber=target, negative_csg=neg_csg,
                    label=CutCSGLabel("lap_fill_cut"))},
                ticket=JointTicket(joint_type="multi_cross_lap_fill"),
                jointAccessories={},
            ))

    joints = [
        Joint(
            cuttings={
                key: replace(cutting, assembly_freedom=None)
                for key, cutting in sub_joint.cuttings.items()
            },
            ticket=sub_joint.ticket,
            jointAccessories=sub_joint.jointAccessories,
        )
        for sub_joint in joints
    ]
    return make_compound_joint(joints, ticket=JointTicket(joint_type="multi_cross_lap"))