Skip to content

kumiki.joints.workshop.butt

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

kumiki.joints.workshop.butt

Kumiki - Butt joint construction functions

cut_wedged_half_dovetail_joint_on_face_aligned_timbers module-attribute

cut_wedged_half_dovetail_joint_on_face_aligned_timbers = cut_wedged_half_dovetail_mortise_and_tenon_joint_on_face_aligned_timbers

cut_plain_butt_joint

cut_plain_butt_joint(arrangement: ButtJointTimberArrangement, relief: Union[None, ButtJointScribeReliefConfig] = butt_timber()) -> Joint

Creates a butt joint where the butt timber is cut flush with the face of the receiving timber.

The butt timber's end is trimmed along the plane of the best-matching long face of the receiving timber. The receiving timber otherwise isn't cut, aside from any scribe relief.

Works for any non-parallel angle between the timbers, including oblique 3D angles. The cut plane follows the actual receiving face geometry rather than being perpendicular to the butt timber's axis, so the mating face is always flush.

Parameters:

Name Type Description Default
arrangement ButtJointTimberArrangement

Butt joint arrangement with butt_timber, receiving_timber, butt_timber_end.

required
relief Union[None, ButtJointScribeReliefConfig]

Scribe-relief configuration for imperfect timbers. Defaults to scribing the butt timber onto the receiving timber. Pass None to skip scribe relief entirely.

butt_timber()

Returns:

Type Description
Joint

Joint object containing the cut butt timber and uncut receiving timber.

Raises:

Type Description
AssertionError

If the timbers are parallel.

Source code in kumiki/joints/workshop/butt/plain_butt_joint.py
def cut_plain_butt_joint(
    arrangement: ButtJointTimberArrangement,
    relief: Union[None, ButtJointScribeReliefConfig] = ButtJointScribeReliefConfig.butt_timber(),
) -> Joint:
    """
    Creates a butt joint where the butt timber is cut flush with the face of the receiving timber.

    The butt timber's end is trimmed along the plane of the best-matching long face of the
    receiving timber. The receiving timber otherwise isn't cut, aside from any scribe relief.

    Works for any non-parallel angle between the timbers, including oblique 3D angles.
    The cut plane follows the actual receiving face geometry rather than being perpendicular
    to the butt timber's axis, so the mating face is always flush.

    Args:
        arrangement: Butt joint arrangement with butt_timber, receiving_timber, butt_timber_end.
        relief: Scribe-relief configuration for imperfect timbers. Defaults to scribing the
            butt timber onto the receiving timber. Pass None to skip scribe relief entirely.

    Returns:
        Joint object containing the cut butt timber and uncut receiving timber.

    Raises:
        AssertionError: If the timbers are parallel.
    """
    receiving_timber = arrangement.receiving_timber
    butt_timber = arrangement.butt_timber
    butt_end = arrangement.butt_timber_end

    warn_if_arrangement_timbers_imperfect(arrangement)

    assert not are_vectors_parallel(
        receiving_timber.get_length_direction_global(),
        butt_timber.get_length_direction_global(),
    ), "Timbers cannot be parallel for a butt joint"

    # Get the direction of the butt end (pointing outward from the timber body)
    if butt_end == TimberEnd.TOP:
        butt_direction = butt_timber.get_length_direction_global()
    else:
        butt_direction = -butt_timber.get_length_direction_global()

    # Find the long face of the receiving timber that faces the incoming butt timber.
    # We pass -butt_direction because the face we want has its outward normal pointing
    # toward the butt timber (i.e., opposite to the butt travel direction).
    receiving_face = receiving_timber.get_closest_oriented_long_face_from_global_direction(-butt_direction)
    receiving_face_dir_global = receiving_timber.get_face_direction_global(receiving_face)

    # A point on the receiving face plane (use face center)
    face_center = _get_face_center_position(receiving_timber, receiving_face)

    # Orient the cut-plane normal to point in the direction material is removed
    # (i.e., away from the butt timber body, toward the receiving timber).
    # The receiving face normal points toward the butt, so flip it.
    dot_check = safe_dot_product(receiving_face_dir_global, butt_direction)
    if safe_compare(dot_check, 0, Comparison.GT):
        cut_normal_global = receiving_face_dir_global
    else:
        cut_normal_global = -receiving_face_dir_global

    # Convert cut plane to butt timber local coordinates
    local_normal = safe_transform_vector(butt_timber.orientation.matrix.T, cut_normal_global)
    local_offset = (
        safe_dot_product(cut_normal_global, face_center)
        - safe_dot_product(cut_normal_global, butt_timber.get_bottom_position_global())
    )
    # Where the stock is cut to length: the furthest any arris reaches before the
    # cut plane, so an oblique cut keeps its long point.
    half_width, half_height = butt_timber.size[0] / scalar(2), butt_timber.size[1] / scalar(2)
    arris_reaches = [
        (local_offset - local_normal[0] * x - local_normal[1] * y) / local_normal[2]
        for x in (-half_width, half_width)
        for y in (-half_height, half_height)
    ]
    end_cut_distance_from_bottom = arris_reaches[0]
    for reach in arris_reaches[1:]:
        further = Comparison.GT if butt_end == TimberEnd.TOP else Comparison.LT
        if safe_compare(reach, end_cut_distance_from_bottom, further):
            end_cut_distance_from_bottom = reach

    end_cut = HalfSpace(normal=local_normal, offset=local_offset)

    cut_no_relief = Cutting(
        timber=butt_timber,
        maybe_top_end_cut_distance_from_bottom=end_cut_distance_from_bottom if butt_end == TimberEnd.TOP else None,
        maybe_bottom_end_cut_distance_from_bottom=end_cut_distance_from_bottom if butt_end == TimberEnd.BOTTOM else None,
        negative_csg=end_cut,
        label=CutCSGLabel("butt_cross_cut"),
    )
    receiving_cut_no_relief = Cutting(timber=receiving_timber)

    cut, receiving_cut = apply_scribe_relief_if_configured(
        relief=relief,
        butt_cut=cut_no_relief,
        receiving_cut=receiving_cut_no_relief,
    )

    # Assembly: a plain butt has no mechanical engagement, so it is free after
    # 0 travel. Disassembly code adds its own visual-separation padding on top
    # of freed_after, so no nominal travel is needed here.
    joint = Joint(
        cuttings={
            "receiving_timber": replace(
                receiving_cut,
                assembly_freedom=AssemblyFreedom.translation(butt_direction, freed_after=scalar(0)),
            ),
            "butt_timber": replace(
                cut,
                assembly_freedom=AssemblyFreedom.translation(-butt_direction, freed_after=scalar(0)),
            ),
        },
        ticket=JointTicket(joint_type="plain_butt"),
        jointAccessories={},
    )

    return joint

cut_plain_butt_joint_on_face_aligned_timbers

cut_plain_butt_joint_on_face_aligned_timbers(arrangement: ButtJointTimberArrangement) -> Joint

Creates a butt joint where the butt timber is cut flush with the face of the receiving timber.

Requires the timbers to be face-aligned. For an unrestricted version that works at any angle, use cut_plain_butt_joint directly.

Parameters:

Name Type Description Default
arrangement ButtJointTimberArrangement

Butt joint arrangement with butt_timber, receiving_timber, butt_timber_end. Timbers must be face-aligned and non-parallel.

required

Returns:

Type Description
Joint

Joint object containing the cut butt timber and uncut receiving timber.

Raises:

Type Description
AssertionError

If the timbers are not face-aligned or are parallel.

Source code in kumiki/joints/workshop/butt/plain_butt_joint.py
def cut_plain_butt_joint_on_face_aligned_timbers(arrangement: ButtJointTimberArrangement) -> Joint:
    """
    Creates a butt joint where the butt timber is cut flush with the face of the receiving timber.

    Requires the timbers to be face-aligned. For an unrestricted version that works at any
    angle, use `cut_plain_butt_joint` directly.

    Args:
        arrangement: Butt joint arrangement with butt_timber, receiving_timber, butt_timber_end.
                     Timbers must be face-aligned and non-parallel.

    Returns:
        Joint object containing the cut butt timber and uncut receiving timber.

    Raises:
        AssertionError: If the timbers are not face-aligned or are parallel.
    """
    assert are_timbers_face_aligned(arrangement.receiving_timber, arrangement.butt_timber), \
        "Timbers must be face-aligned (orientations related by 90-degree rotations) for this joint type"
    return cut_plain_butt_joint(arrangement)

cut_tongue_and_fork_butt_joint_on_plane_aligned_timbers

cut_tongue_and_fork_butt_joint_on_plane_aligned_timbers(arrangement: ButtJointTimberArrangement, tongue_thickness: Optional[Numeric] = None, tongue_position: Numeric = scalar(0), shoulder_inset: Numeric = scalar(0), relief: Union[None, ButtJointScribeReliefConfig] = butt_timber()) -> Joint

Creates a plain tongue-and-fork butt joint.

In this joint, the butt timber forms the fork (2 prongs with a central slot cut into it) and the receiving timber forms the tongue (material removed from both cheeks). The receiving (tongue) timber does not receive an end cut — it continues through the joint.

Parameters:

Name Type Description Default
arrangement ButtJointTimberArrangement

Butt arrangement where butt_timber is the fork and receiving_timber is the tongue.

required
tongue_thickness Optional[Numeric]

Tongue thickness along the shared plane normal. If None, defaults to 1/3 of the receiving timber dimension in that axis.

None
tongue_position Numeric

Offset of the tongue center from the receiving timber centerline along the shared plane normal. 0 means centered.

scalar(0)
shoulder_inset Numeric

Distance from the receiving timber entry face to the shoulder plane, measured perpendicular to the face inward. 0 = shoulder flush with the entry face.

scalar(0)
relief Union[None, ButtJointScribeReliefConfig]

Scribe-relief configuration for imperfect timbers. Defaults to scribing the fork (butt) timber onto the tongue (receiving) timber. Pass None to skip.

butt_timber()

Returns:

Type Description
Joint

Joint containing both cut timbers.

Raises:

Type Description
AssertionError

If timbers are not plane aligned, are parallel, or tongue parameters are out of bounds.

Source code in kumiki/joints/workshop/butt/tongue_and_fork_butt_joint.py
def cut_tongue_and_fork_butt_joint_on_plane_aligned_timbers(
    arrangement: ButtJointTimberArrangement,
    tongue_thickness: Optional[Numeric] = None,
    tongue_position: Numeric = scalar(0),
    shoulder_inset: Numeric = scalar(0),
    relief: Union[None, ButtJointScribeReliefConfig] = ButtJointScribeReliefConfig.butt_timber(),
) -> Joint:
    """
    Creates a plain tongue-and-fork butt joint.

    In this joint, the butt timber forms the fork (2 prongs with a central slot cut into it)
    and the receiving timber forms the tongue (material removed from both cheeks). The receiving
    (tongue) timber does **not** receive an end cut — it continues through the joint.

    Args:
        arrangement: Butt arrangement where butt_timber is the fork and
            receiving_timber is the tongue.
        tongue_thickness: Tongue thickness along the shared plane normal.
            If None, defaults to 1/3 of the receiving timber dimension in that axis.
        tongue_position: Offset of the tongue center from the receiving timber
            centerline along the shared plane normal. 0 means centered.
        shoulder_inset: Distance from the receiving timber entry face to the shoulder plane,
            measured perpendicular to the face inward. 0 = shoulder flush with the entry face.
        relief: Scribe-relief configuration for imperfect timbers. Defaults to scribing the
            fork (butt) timber onto the tongue (receiving) timber. Pass None to skip.

    Returns:
        Joint containing both cut timbers.

    Raises:
        AssertionError: If timbers are not plane aligned, are parallel, or
            tongue parameters are out of bounds.
    """

    error = arrangement.check_plane_aligned()
    assert error is None, error

    fork_timber = arrangement.butt_timber
    tongue_timber = arrangement.receiving_timber
    fork_end = arrangement.butt_timber_end

    warn_if_arrangement_timbers_imperfect(arrangement)

    assert not are_vectors_parallel(
        fork_timber.get_length_direction_global(),
        tongue_timber.get_length_direction_global(),
    ), "Timbers cannot be parallel for a tongue-and-fork butt joint"

    # -------------------------------------------------------------------------
    # Tongue geometry: shared plane normal, thickness, width
    # -------------------------------------------------------------------------
    shared_plane_normal_hint = arrangement.compute_normalized_timber_cross_product()
    tongue_normal_face = tongue_timber.get_closest_oriented_long_face_from_global_direction(shared_plane_normal_hint)
    tongue_normal_direction = tongue_timber.get_face_direction_global(tongue_normal_face)

    tongue_normal_dimension = tongue_timber.get_size_in_face_normal_axis(tongue_normal_face)
    if tongue_thickness is None:
        tongue_thickness = tongue_normal_dimension / scalar(3)

    assert safe_compare(tongue_thickness, 0, Comparison.GT), "tongue_thickness must be greater than 0"
    assert safe_compare(tongue_normal_dimension - tongue_thickness, 0, Comparison.GE), \
        "tongue_thickness must be <= the tongue timber size in the shared plane normal axis"

    half_tongue_dimension = tongue_normal_dimension / scalar(2)
    half_tongue_thickness = tongue_thickness / scalar(2)
    assert safe_compare(half_tongue_dimension - (Abs(tongue_position) + half_tongue_thickness), 0, Comparison.GE), \
        "tongue_position and tongue_thickness place the tongue outside the tongue timber boundary"

    fork_end_direction = fork_timber.get_face_direction_global(fork_end)

    # -------------------------------------------------------------------------
    # Shoulder plane (M&T pattern): compute on tongue (receiving) timber
    # -------------------------------------------------------------------------
    fork_entry_long_face = tongue_timber.get_closest_oriented_long_face_from_global_direction(-fork_end_direction)
    fork_shoulder_distance = convert_mortise_shoulder_inset_to_centerline_distance(
        mortise_shoulder_inset=shoulder_inset,
        mortise_face=fork_entry_long_face.to.face(),
        receiving_timber=tongue_timber,
    )

    shoulder_plane = locate_mortise_timber_shoulder_plane_from_centerline_towards_tenon_timber(
        arrangement, fork_shoulder_distance
    )
    shoulder_from_fork_end_mark = mark_distance_from_end_along_centerline(
        shoulder_plane, fork_timber, fork_end
    )
    shoulder_point_global = shoulder_from_fork_end_mark.locate().position

    # -------------------------------------------------------------------------
    # Marking space at shoulder (M&T pattern)
    # -------------------------------------------------------------------------
    marking_origin_global = shoulder_point_global + tongue_normal_direction * tongue_position

    fork_orientation_global = Orientation.from_z_and_y(
        z_direction=safe_normalize_vector(fork_end_direction),
        y_direction=safe_normalize_vector(tongue_normal_direction),
    )
    marking_space_transform = Transform(position=marking_origin_global, orientation=fork_orientation_global)
    marking_space = Space(transform=marking_space_transform)

    # Dimension of fork timber along marking space local x (receiving timber length axis)
    marking_space_x_dir = safe_transform_vector(marking_space.transform.orientation.matrix, create_v3(1, 0, 0))
    fork_width_along_tongue = fork_timber.get_size_in_direction_3d(marking_space_x_dir)

    # -------------------------------------------------------------------------
    # Fork slot depth and far face of tongue timber
    # -------------------------------------------------------------------------
    fork_far_face = fork_entry_long_face.to.face().get_opposite_face()
    fork_far_face_normal_global = tongue_timber.get_face_direction_global(fork_far_face)
    fork_far_face_point_global = get_center_point_on_face_global(fork_far_face, tongue_timber)

    fork_slot_depth = safe_dot_product(
        fork_far_face_point_global - shoulder_point_global,
        safe_normalize_vector(fork_end_direction),
    )
    assert safe_compare(fork_slot_depth, 0, Comparison.GT), \
        "Fork slot depth must be > 0; check timber arrangement and end selections"

    # Fork slot: bounded at the shoulder by shoulder_half_space so the slot shoulder matches the angle of the receiving timber face
    shoulder_half_space_global = HalfSpace(
        normal=-shoulder_plane.normal,
        offset=safe_dot_product(-shoulder_plane.normal, shoulder_point_global),
        label=CutCSGLabel("shoulder"),
        feature_overrides=[shoulder_override(HALF_SPACE_PLANE, name="shoulder",
                                             group=FeatureGroup.SHOULDER_PLANE)],
    )
    shoulder_half_space_local = adopt_csg(None, fork_timber.transform, shoulder_half_space_global)

    fork_slot_end_overshoot = max(tongue_timber.size[0], tongue_timber.size[1])
    fork_slot_back_extension = max(fork_timber.size[0], fork_timber.size[1]) * scalar(2)
    fork_max_cross = max(fork_timber.size[0], fork_timber.size[1]) * scalar(2)
    fork_slot_prism_global = RectangularPrism(
        size=create_v2(fork_max_cross, tongue_thickness),
        transform=marking_space.transform,
        start_distance=-fork_slot_back_extension,
        end_distance=fork_slot_depth + fork_slot_end_overshoot,
        label=CutCSGLabel("fork_slot"),
    )
    fork_slot_prism_local = adopt_csg(None, fork_timber.transform, fork_slot_prism_global)
    fork_slot_csg_local = Intersection(
        left=shoulder_half_space_local,
        right=fork_slot_prism_local,
        label=CutCSGLabel("fork_slot_waste"),
    )

    fork_end_hs_normal_global = (
        fork_far_face_normal_global
        if safe_dot_product(fork_far_face_normal_global, fork_end_direction) > 0
        else -fork_far_face_normal_global
    )
    fork_end_cut_local_normal = safe_transform_vector(
        fork_timber.orientation.matrix.T, fork_end_hs_normal_global
    )
    fork_end_cut_local_offset = (
        safe_dot_product(fork_end_hs_normal_global, fork_far_face_point_global)
        - safe_dot_product(fork_end_hs_normal_global, fork_timber.get_bottom_position_global())
    )
    fork_end_cut = HalfSpace(
        normal=fork_end_cut_local_normal,
        offset=fork_end_cut_local_offset,
        label=CutCSGLabel("fork_end_cut"),
    )
    # Calculate local z coordinates of the 4 cross-section corners on fork_end_cut plane
    sx = fork_timber.size[0] / scalar(2)
    sy = fork_timber.size[1] / scalar(2)
    corners = [(sx, sy), (sx, -sy), (-sx, sy), (-sx, -sy)]
    nx, ny, nz = fork_end_cut.normal[0], fork_end_cut.normal[1], fork_end_cut.normal[2]

    z_corners = []
    if not safe_zero_test(nz):
        for cx, cy in corners:
            cz = (fork_end_cut.offset - nx * cx - ny * cy) / nz
            z_corners.append(cz)
    else:
        z_corners = [fork_timber.length / scalar(2)]

    if fork_end == TimberEnd.TOP:
        fork_end_cut_distance_from_bottom = max(z_corners)
    else:
        fork_end_cut_distance_from_bottom = min(z_corners)

    fork_negative_csg = SolidUnion(children=[fork_slot_csg_local, fork_end_cut])

    # -------------------------------------------------------------------------
    # Tongue timber cuts (receiving_timber: housing + 2 cheeks removed)
    # -------------------------------------------------------------------------
    shoulder_half_space_tongue_global = HalfSpace(
        normal=-shoulder_plane.normal,
        offset=safe_dot_product(-shoulder_plane.normal, shoulder_point_global),
        label=CutCSGLabel("shoulder"),
        feature_overrides=[shoulder_override(HALF_SPACE_PLANE, name="shoulder",
                                             group=FeatureGroup.SHOULDER_PLANE)],
    )
    shoulder_half_space_tongue_local = adopt_csg(None, tongue_timber.transform, shoulder_half_space_tongue_global)

    overshoot = max(tongue_timber.size[0], tongue_timber.size[1]) * scalar(2)
    tongue_cheek_box_global = RectangularPrism(
        size=create_v2(fork_width_along_tongue, tongue_normal_dimension * scalar(2)),
        transform=marking_space.transform,
        start_distance=-overshoot,
        end_distance=fork_slot_depth + overshoot,
        label=CutCSGLabel("tongue_cheeks"),
        # The two faces of the waste that become the tongue's own ends: the tongue
        # runs the timber's full width, so these are what it is edged by.
        feature_overrides=[
            FeatureOverride(prism_face_key(PrismFace.RIGHT), "tongue_right", measured_against(_TONGUE_FACE, "tongue_left")),
            FeatureOverride(prism_face_key(PrismFace.LEFT), "tongue_left", measured_against(_TONGUE_FACE, "tongue_right")),
        ],
    )
    tongue_cheek_box_local = adopt_csg(None, tongue_timber.transform, tongue_cheek_box_global)

    tongue_central_prism_global = RectangularPrism(
        size=create_v2(fork_width_along_tongue * scalar(3), tongue_thickness),
        transform=marking_space.transform,
        start_distance=-overshoot * scalar(2),
        end_distance=fork_slot_depth + overshoot * scalar(2),
        # The tongue itself. Its two long faces are the cheeks, which is what the
        # fork slot grips; the other two are buried in the timber it stands on.
        feature_overrides=[
            FeatureOverride(prism_face_key(PrismFace.FRONT), "tongue_front", measured_against(_TONGUE_FACE, "tongue_back")),
            FeatureOverride(prism_face_key(PrismFace.BACK), "tongue_back", measured_against(_TONGUE_FACE, "tongue_front")),
        ],
    )
    tongue_central_prism_local = adopt_csg(None, tongue_timber.transform, tongue_central_prism_global)

    # The tongue only exists on the joint side of shoulder_plane (inside shoulder_half_space).
    # Between entry face and shoulder_plane, the full housing box is removed to house the fork stem.
    tongue_preserved_local = Intersection(
        left=shoulder_half_space_tongue_local,
        right=tongue_central_prism_local,
        label=CutCSGLabel("tongue"),
    )

    tongue_negative_csg_local = Difference(
        base=tongue_cheek_box_local,
        subtract=[tongue_preserved_local],
        label=CutCSGLabel("tongue_waste"),
    )

    # -------------------------------------------------------------------------
    # Assemble cuts and joint
    # -------------------------------------------------------------------------
    fork_engagement = tongue_timber.get_size_in_direction_3d(fork_end_direction)
    fork_cut_no_relief = Cutting(
        timber=fork_timber,
        maybe_top_end_cut_distance_from_bottom=fork_end_cut_distance_from_bottom if fork_end == TimberEnd.TOP else None,
        maybe_bottom_end_cut_distance_from_bottom=fork_end_cut_distance_from_bottom if fork_end == TimberEnd.BOTTOM else None,
        negative_csg=fork_negative_csg,
        label=CutCSGLabel("fork_cut"),
        assembly_freedom=AssemblyFreedom.translation(-fork_end_direction, freed_after=fork_engagement),
    )

    tongue_cut_no_relief = Cutting(
        timber=tongue_timber,
        negative_csg=tongue_negative_csg_local,
        label=CutCSGLabel("tongue_cut"),
        assembly_freedom=AssemblyFreedom.translation(fork_end_direction, freed_after=fork_engagement),
    )

    fork_cut, tongue_cut = apply_scribe_relief_if_configured(
        relief=relief,
        butt_cut=fork_cut_no_relief,
        receiving_cut=tongue_cut_no_relief,
    )

    return Joint(
        cuttings={
            "tongue_timber": tongue_cut,
            "fork_timber": fork_cut,
        },
        ticket=JointTicket(joint_type="tongue_and_fork_butt"),
        jointAccessories={},
    )

cut_dropin_dovetail_butt_joint_on_face_aligned_timbers

cut_dropin_dovetail_butt_joint_on_face_aligned_timbers(arrangement: ButtJointTimberArrangement, receiving_timber_shoulder_inset: Numeric, dovetail_length: Numeric, dovetail_small_width: Numeric, dovetail_angle: Numeric, dovetail_lateral_offset: Numeric = scalar(0), dovetail_depth: Optional[Numeric] = None) -> Joint

Creates a dovetail butt joint (蟻継ぎ / Ari Tsugi) between two orthogonal timbers.

This is a traditional Japanese timber joint where a dovetail-shaped tenon on one timber fits into a matching dovetail socket on another timber. The dovetail shape provides mechanical resistance to pulling apart.

Parameters:

Name Type Description Default
arrangement ButtJointTimberArrangement

Butt joint arrangement where butt_timber is the dovetail timber, receiving_timber receives the dovetail socket, butt_timber_end is the cut end, and front_face_on_butt_timber is the face where the dovetail profile is visible.

required
receiving_timber_shoulder_inset Numeric

Distance to inset the shoulder notch on the receiving timber

required
dovetail_length Numeric

Length of the dovetail tenon

required
dovetail_small_width Numeric

Width of the narrow end of the dovetail (at the tip)

required
dovetail_angle Numeric

Flare angle of the dovetail in radians (0 for straight dovetails)

required
dovetail_lateral_offset Numeric

Lateral offset of the dovetail from center (default 0)

scalar(0)
dovetail_depth Optional[Numeric]

Depth of the dovetail cut. If None, defaults to half the timber dimension

None

Returns:

Type Description
Joint

Joint object containing the two CutTimbers with the dovetail cuts applied

Raises:

Type Description
ValueError

If the parameters are invalid or the timbers are not orthogonal

Source code in kumiki/joints/workshop/butt/dropin_dovetail_butt_joint.py
def cut_dropin_dovetail_butt_joint_on_face_aligned_timbers(
    arrangement: ButtJointTimberArrangement,
    receiving_timber_shoulder_inset: Numeric,
    dovetail_length: Numeric,
    dovetail_small_width: Numeric,
    dovetail_angle: Numeric,
    dovetail_lateral_offset: Numeric = scalar(0),
    dovetail_depth: Optional[Numeric] = None,
) -> Joint:
    """
    Creates a dovetail butt joint (蟻継ぎ / Ari Tsugi) between two orthogonal timbers.

    This is a traditional Japanese timber joint where a dovetail-shaped tenon on one timber
    fits into a matching dovetail socket on another timber. The dovetail shape provides
    mechanical resistance to pulling apart.

    Args:
        arrangement: Butt joint arrangement where butt_timber is the dovetail timber,
            receiving_timber receives the dovetail socket, butt_timber_end is the cut end,
            and front_face_on_butt_timber is the face where the dovetail profile is visible.
        receiving_timber_shoulder_inset: Distance to inset the shoulder notch on the receiving timber
        dovetail_length: Length of the dovetail tenon
        dovetail_small_width: Width of the narrow end of the dovetail (at the tip)
        dovetail_angle: Flare angle of the dovetail in radians (0 for straight dovetails)
        dovetail_lateral_offset: Lateral offset of the dovetail from center (default 0)
        dovetail_depth: Depth of the dovetail cut. If None, defaults to half the timber dimension

    Returns:
        Joint object containing the two CutTimbers with the dovetail cuts applied

    Raises:
        ValueError: If the parameters are invalid or the timbers are not orthogonal
    """
    require_check(arrangement.check_face_aligned_and_orthogonal())

    warn_if_arrangement_timbers_imperfect(arrangement)

    assert arrangement.front_face_on_butt_timber is not None, (
        "arrangement.front_face_on_butt_timber must be set to determine the dovetail face"
    )
    dovetail_timber = arrangement.butt_timber
    receiving_timber = arrangement.receiving_timber
    dovetail_timber_end = arrangement.butt_timber_end
    dovetail_timber_face = arrangement.front_face_on_butt_timber

    # Validate positive dimensions
    if dovetail_length <= 0:
        raise ValueError(f"dovetail_length must be positive, got {dovetail_length}")
    if dovetail_small_width <= 0:
        raise ValueError(f"dovetail_small_width must be positive, got {dovetail_small_width}")
    if dovetail_angle < 0:
        raise ValueError(f"dovetail_angle must be non-negative, got {dovetail_angle}")
    if dovetail_angle >= pi / scalar(2):
        raise ValueError(
            f"dovetail_angle must be less than 90 degrees (pi/2 radians), got {dovetail_angle}"
        )
    if receiving_timber_shoulder_inset < 0:
        raise ValueError(f"receiving_timber_shoulder_inset must be non-negative, got {receiving_timber_shoulder_inset}")

    dovetail_large_width = dovetail_small_width + scalar(2) * dovetail_length * tan(dovetail_angle)

    if dovetail_depth is not None and dovetail_depth <= 0:
        raise ValueError(f"dovetail_depth must be positive if provided, got {dovetail_depth}")

    if are_vectors_parallel(dovetail_timber.get_face_direction_global(dovetail_timber_face), receiving_timber.get_length_direction_global()):
        raise ValueError(
            "Dovetail timber face must be perpendicular to receiving timber length direction for dovetail butt joint. "
            "The face should be oriented such that the dovetail profile is visible when looking along the receiving timber. "
            "Try rotating the dovetail face by 90 degrees."
        )

    if dovetail_depth is None:
        dovetail_depth = dovetail_timber.get_size_in_face_normal_axis(dovetail_timber_face.to.face()) / scalar(2)

    # Dovetail profile in 2D (X = lateral, Y = along timber length from end)
    dovetail_profile = [
        Matrix([-dovetail_small_width / scalar(2) + dovetail_lateral_offset, 0]),
        Matrix([dovetail_small_width / scalar(2) + dovetail_lateral_offset, 0]),
        Matrix([dovetail_large_width / scalar(2) + dovetail_lateral_offset, dovetail_length]),
        Matrix([-dovetail_large_width / scalar(2) + dovetail_lateral_offset, dovetail_length]),
    ]

    receiving_timber_shoulder_face = receiving_timber.get_closest_oriented_face_from_global_direction(-dovetail_timber.get_face_direction_global(dovetail_timber_end.to.face()))
    face_plane = scribe_face_plane_onto_centerline(
        face=receiving_timber_shoulder_face,
        face_timber=receiving_timber
    )
    marking = mark_distance_from_end_along_centerline(face_plane, dovetail_timber, dovetail_timber_end)
    shoulder_distance_from_end = marking.distance - receiving_timber_shoulder_inset

    offset_to_dovetail_face = dovetail_timber.get_size_in_face_normal_axis(dovetail_timber_face) / scalar(2) * dovetail_timber.get_face_direction_global(dovetail_timber_face)

    marking_transform_position = dovetail_timber.get_bottom_position_global() + shoulder_distance_from_end * dovetail_timber.get_length_direction_global() + offset_to_dovetail_face
    marking_transform_orientation = orientation_pointing_towards_face_sitting_on_face(towards_face=dovetail_timber_end.to.face(), sitting_face=dovetail_timber_face.to.face())
    dovetail_timber_marking_transform = Transform(position=marking_transform_position, orientation=marking_transform_orientation)

    dovetail_profile_csg = chop_profile_on_timber_face(
        timber=dovetail_timber,
        end=dovetail_timber_end,
        face=dovetail_timber_face.to.face(),
        profile=dovetail_profile,
        depth=dovetail_depth,
        profile_y_offset_from_end=shoulder_distance_from_end,
        label=CutCSGLabel("dovetail"),
    )

    # The tail is the material the housing cut leaves standing, so this prism's
    # faces are the tail's own. Its sides run profile point n to point n+1: side 0
    # is the base, then the two flared cheeks, then the wide leading face. Only the
    # faces that are a surface of the finished timber and are not already one of the
    # timber's own are named. Side 0 is buried where the tail leaves the timber, and
    # the end cap lies in the timber's own face, which rough.* already names --
    # naming that one too would derive the same line as the body's own edge with the
    # shoulder plane (see _drop_duplicate_derived).
    dovetail_prism_csg = replace(
        dovetail_profile_csg,
        feature_overrides=[
            FeatureOverride(side_key(1), "dovetail_right", _DOVETAIL_FACE),
            FeatureOverride(side_key(2), "dovetail_front", _DOVETAIL_FACE),
            FeatureOverride(side_key(3), "dovetail_left", _DOVETAIL_FACE),
            FeatureOverride(START_CAP, "dovetail_bot", _DOVETAIL_FACE),
        ],
    )

    dovetail_housing_prism = chop_timber_end_with_prism(
        timber=dovetail_timber,
        end=dovetail_timber_end,
        distance_from_end_to_cut=shoulder_distance_from_end,
        label=CutCSGLabel("dovetail_housing"),
        # The plane the tail leaves the timber at, so it is the joint's shoulder:
        # marking it is what pairs it with the tail's own faces and derives the
        # base arris (see FeatureGroup.SHOULDER_PLANE).
        is_shoulder=True,
    )

    dovetail_centerline = scribe_centerline_onto_centerline(dovetail_timber)
    marking_receiving = mark_distance_from_end_along_centerline(dovetail_centerline, receiving_timber)
    receiving_timber_notch_center = marking_receiving.distance

    if receiving_timber_shoulder_inset > 0:
        notch_width = dovetail_timber.get_size_in_face_normal_axis(dovetail_timber_face.rotate_right().to.face())
        notch_depth = receiving_timber_shoulder_inset

        receiving_timber_shoulder_notch = chop_shoulder_notch_on_timber_face(
            timber=receiving_timber,
            notch_face=receiving_timber_shoulder_face,
            distance_along_timber=receiving_timber_notch_center,
            notch_width=notch_width,
            notch_depth=notch_depth,
        )

    # The plain prism, not the named copy above: the walls this cuts are the
    # receiving timber's surfaces, and a socket wall is not a face of the tail.
    dovetail_socket_csg = adopt_csg(dovetail_timber.transform, receiving_timber.transform, dovetail_profile_csg)

    if dovetail_timber_end == TimberEnd.TOP:
        dovetail_end_local_z = dovetail_timber.length - shoulder_distance_from_end + dovetail_length
    else:
        dovetail_end_local_z = shoulder_distance_from_end - dovetail_length

    dovetail_lift_direction_global = dovetail_timber.get_face_direction_global(dovetail_timber_face.to.face())
    dovetail_timber_cut_obj = Cutting(
        timber=dovetail_timber,
        maybe_top_end_cut_distance_from_bottom=dovetail_end_local_z if dovetail_timber_end == TimberEnd.TOP else None,
        maybe_bottom_end_cut_distance_from_bottom=dovetail_end_local_z if dovetail_timber_end == TimberEnd.BOTTOM else None,
        negative_csg=Difference(
            dovetail_housing_prism, [dovetail_prism_csg],
            label=CutCSGLabel("dovetail_waste"),
        ),
        label=CutCSGLabel("dovetail_cut"),
        assembly_freedom=AssemblyFreedom.translation(dovetail_lift_direction_global, freed_after=dovetail_depth),
    )

    if receiving_timber_shoulder_inset > 0:
        receiving_timber_negative_csg = SolidUnion([receiving_timber_shoulder_notch, dovetail_socket_csg])
    else:
        receiving_timber_negative_csg = dovetail_socket_csg

    receiving_timber_cut_obj = Cutting(
        timber=receiving_timber,
        negative_csg=receiving_timber_negative_csg,
        label=CutCSGLabel("dovetail_socket_cut"),
        assembly_freedom=AssemblyFreedom.translation(-dovetail_lift_direction_global, freed_after=dovetail_depth),
    )

    return Joint(
        cuttings={
            dovetail_timber.ticket.path: dovetail_timber_cut_obj,
            receiving_timber.ticket.path: receiving_timber_cut_obj
        },
        ticket=JointTicket(joint_type="housed_dovetail_butt"),
        jointAccessories={},
    )

cut_dropin_housed_butt_joint_on_face_aligned_timbers

cut_dropin_housed_butt_joint_on_face_aligned_timbers(arrangement: ButtJointTimberArrangement, receiving_timber_shoulder_inset: Numeric, housing_length: Numeric, housing_width: Numeric, housing_lateral_offset: Numeric = scalar(0), housing_depth: Optional[Numeric] = None) -> Joint

Creates a drop-in housed butt joint (housing slot pocket) between two orthogonal timbers.

Only the housing timber is cut to receive the square end of the housed timber. The housed timber drops into the pocket along the housing face normal.

Parameters:

Name Type Description Default
arrangement ButtJointTimberArrangement

Butt joint arrangement where butt_timber is the housed timber, receiving_timber receives the housing pocket socket, butt_timber_end is the cut end, and front_face_on_butt_timber is the face where the pocket profile is open (normally the top face).

required
receiving_timber_shoulder_inset Numeric

Distance to inset the shoulder notch on the receiving timber

required
housing_length Numeric

Length of the housing slot pocket (the joist extension depth into the mudsill)

required
housing_width Numeric

Width of the housing pocket

required
housing_lateral_offset Numeric

Lateral offset of the housing pocket from center (default 0)

scalar(0)
housing_depth Optional[Numeric]

Depth of the pocket. If None, defaults to half the timber dimension

None

Returns:

Type Description
Joint

Joint object containing the two CutTimbers with the housing cuts applied

Source code in kumiki/joints/workshop/butt/dropin_housed_butt_joint.py
def cut_dropin_housed_butt_joint_on_face_aligned_timbers(
    arrangement: ButtJointTimberArrangement,
    receiving_timber_shoulder_inset: Numeric,
    housing_length: Numeric,
    housing_width: Numeric,
    housing_lateral_offset: Numeric = scalar(0),
    housing_depth: Optional[Numeric] = None,
) -> Joint:
    """
    Creates a drop-in housed butt joint (housing slot pocket) between two orthogonal timbers.

    Only the housing timber is cut to receive the square end of the housed timber.
    The housed timber drops into the pocket along the housing face normal.

    Args:
        arrangement: Butt joint arrangement where butt_timber is the housed timber,
            receiving_timber receives the housing pocket socket, butt_timber_end is the cut end,
            and front_face_on_butt_timber is the face where the pocket profile is open (normally the top face).
        receiving_timber_shoulder_inset: Distance to inset the shoulder notch on the receiving timber
        housing_length: Length of the housing slot pocket (the joist extension depth into the mudsill)
        housing_width: Width of the housing pocket
        housing_lateral_offset: Lateral offset of the housing pocket from center (default 0)
        housing_depth: Depth of the pocket. If None, defaults to half the timber dimension

    Returns:
        Joint object containing the two CutTimbers with the housing cuts applied
    """
    require_check(arrangement.check_face_aligned_and_orthogonal())
    warn_if_arrangement_timbers_imperfect(arrangement)
    assert arrangement.front_face_on_butt_timber is not None, (
        "arrangement.front_face_on_butt_timber must be set to determine the housing face"
    )
    housed_timber = arrangement.butt_timber
    receiving_timber = arrangement.receiving_timber
    housed_timber_end = arrangement.butt_timber_end
    housed_timber_face = arrangement.front_face_on_butt_timber

    # Validate positive dimensions
    if housing_length <= 0:
        raise ValueError(f"housing_length must be positive, got {housing_length}")
    if housing_width <= 0:
        raise ValueError(f"housing_width must be positive, got {housing_width}")
    if receiving_timber_shoulder_inset < 0:
        raise ValueError(f"receiving_timber_shoulder_inset must be non-negative, got {receiving_timber_shoulder_inset}")
    if housing_depth is not None and housing_depth <= 0:
        raise ValueError(f"housing_depth must be positive if provided, got {housing_depth}")

    if are_vectors_parallel(housed_timber.get_face_direction_global(housed_timber_face), receiving_timber.get_length_direction_global()):
        raise ValueError(
            "Housed timber face must be perpendicular to receiving timber length direction for drop-in housed butt joint. "
            "Try rotating the housing face."
        )

    if housing_depth is None:
        housing_depth = housed_timber.get_size_in_face_normal_axis(housed_timber_face.to.face()) / scalar(2)

    housing_profile = [
        Matrix([-housing_width / scalar(2) + housing_lateral_offset, 0]),
        Matrix([housing_width / scalar(2) + housing_lateral_offset, 0]),
        Matrix([housing_width / scalar(2) + housing_lateral_offset, housing_length]),
        Matrix([-housing_width / scalar(2) + housing_lateral_offset, housing_length]),
    ]

    receiving_timber_shoulder_face = receiving_timber.get_closest_oriented_face_from_global_direction(-housed_timber.get_face_direction_global(housed_timber_end.to.face()))
    face_plane = scribe_face_plane_onto_centerline(
        face=receiving_timber_shoulder_face,
        face_timber=receiving_timber
    )
    marking = mark_distance_from_end_along_centerline(face_plane, housed_timber, housed_timber_end)
    shoulder_distance_from_end = marking.distance - receiving_timber_shoulder_inset

    housing_profile_csg = chop_profile_on_timber_face(
        timber=housed_timber,
        end=housed_timber_end,
        face=housed_timber_face.to.face(),
        profile=housing_profile,
        depth=housing_depth,
        profile_y_offset_from_end=shoulder_distance_from_end,
        label=CutCSGLabel("housing"),
    )

    housing_housing_prism = chop_timber_end_with_prism(
        timber=housed_timber,
        end=housed_timber_end,
        distance_from_end_to_cut=shoulder_distance_from_end,
        label=CutCSGLabel("housing_shoulder"),
        is_shoulder=True,
    )

    housed_centerline = scribe_centerline_onto_centerline(housed_timber)
    marking_receiving = mark_distance_from_end_along_centerline(housed_centerline, receiving_timber)
    receiving_timber_notch_center = marking_receiving.distance

    if receiving_timber_shoulder_inset > 0:
        notch_width = housed_timber.get_size_in_face_normal_axis(housed_timber_face.rotate_right().to.face())
        notch_depth = receiving_timber_shoulder_inset
        receiving_timber_shoulder_notch = chop_shoulder_notch_on_timber_face(
            timber=receiving_timber,
            notch_face=receiving_timber_shoulder_face,
            distance_along_timber=receiving_timber_notch_center,
            notch_width=notch_width,
            notch_depth=notch_depth
        )

    housing_socket_csg = adopt_csg(housed_timber.transform, receiving_timber.transform, housing_profile_csg)

    if housed_timber_end == TimberEnd.TOP:
        housing_end_local_z = housed_timber.length - shoulder_distance_from_end + housing_length
    else:
        housing_end_local_z = shoulder_distance_from_end - housing_length

    housed_lift_direction_global = housed_timber.get_face_direction_global(housed_timber_face.to.face())
    housed_timber_cut_obj = Cutting(
        timber=housed_timber,
        maybe_top_end_cut_distance_from_bottom=housing_end_local_z if housed_timber_end == TimberEnd.TOP else None,
        maybe_bottom_end_cut_distance_from_bottom=housing_end_local_z if housed_timber_end == TimberEnd.BOTTOM else None,
        negative_csg=Difference(
            housing_housing_prism, [housing_profile_csg],
            label=CutCSGLabel("housing_waste"),
        ),
        label=CutCSGLabel("housed_cut"),
        assembly_freedom=AssemblyFreedom.translation(housed_lift_direction_global, freed_after=housing_depth),
    )

    if receiving_timber_shoulder_inset > 0:
        receiving_timber_negative_csg = SolidUnion([receiving_timber_shoulder_notch, housing_socket_csg])
    else:
        receiving_timber_negative_csg = housing_socket_csg

    receiving_timber_cut_obj = Cutting(
        timber=receiving_timber,
        negative_csg=receiving_timber_negative_csg,
        label=CutCSGLabel("housing_socket_cut"),
        assembly_freedom=AssemblyFreedom.translation(-housed_lift_direction_global, freed_after=housing_depth),
    )

    return Joint(
        cuttings={
            housed_timber.ticket.path: housed_timber_cut_obj,
            receiving_timber.ticket.path: receiving_timber_cut_obj
        },
        ticket=JointTicket(joint_type="housed_butt"),
        jointAccessories={},
    )

cut_wedged_half_dovetail_mortise_and_tenon_joint_on_face_aligned_timbers

cut_wedged_half_dovetail_mortise_and_tenon_joint_on_face_aligned_timbers(arrangement: ButtJointTimberArrangement, tenon_size: V2, tenon_depth: Numeric, dovetail_depth: Numeric, wedge_accessory_parameters: DovetailTenonWedgeAccessoryParameters, tenon_lateral_offset: Numeric = scalar(0), receiving_timber_mortise_extra_depth: Numeric = scalar(0), mortise_shoulder_inset: Numeric = scalar(0), relief: Union[None, ButtJointScribeReliefConfig] = butt_timber()) -> Joint

Create a half-dovetail mortise-and-tenon joint (with an optional wedge accessory).

Built on top of dovetail_tenon_geometry. The "top" of the dovetail is flush with arrangement.top_face_on_butt_timber; the opposite side slopes outward by dovetail_depth over tenon_depth to give the joint its mechanical pull-out resistance.

Parameters:

Name Type Description Default
arrangement ButtJointTimberArrangement

Butt joint arrangement (butt_timber = tenon, receiving_timber = mortise). Must be face-aligned and orthogonal, with top_face_on_butt_timber set to the face the dovetail's flat "top" is flush with (the opposite face is the sloped side).

required
tenon_size V2

Cross-section of the tenon (X = butt RIGHT axis, Y = butt TOP axis).

required
tenon_depth Numeric

Depth of the tenon into the receiving timber, measured from the shoulder.

required
dovetail_depth Numeric

How far the sloped side of the dovetail kicks out over tenon_depth.

required
tenon_lateral_offset Numeric

Offset of the tenon along the lateral direction (perpendicular to both length and top-to-bottom). 0 = centered on the butt timber.

scalar(0)
receiving_timber_mortise_extra_depth Numeric

Extra mortise depth in the receiving timber past the tenon tip.

scalar(0)
mortise_shoulder_inset Numeric

Distance from the mortise entry face to the shoulder plane, measured perpendicular to the entry face inward. 0 = shoulder flush with the entry face (the default). Positive pushes the shoulder deeper into the receiving timber.

scalar(0)
wedge_accessory_parameters DovetailTenonWedgeAccessoryParameters

If provided, a wedge accessory is added on the arrangement.top_face_on_butt_timber side of the tenon and a matching slot is cut into the receiving timber.

required
relief Union[None, ButtJointScribeReliefConfig]

Scribe-relief configuration for imperfect timbers. Defaults to scribing the tenon (butt) timber onto the mortise (receiving) timber. Pass None to skip.

butt_timber()

Returns:

Type Description
Joint

Joint object with cuts on both timbers and (optionally) a "wedge" accessory.

Source code in kumiki/joints/workshop/butt/wedged_half_dovetail_joint.py
def cut_wedged_half_dovetail_mortise_and_tenon_joint_on_face_aligned_timbers(
    arrangement: ButtJointTimberArrangement,
    tenon_size: V2,
    tenon_depth: Numeric,
    dovetail_depth: Numeric,
    wedge_accessory_parameters: DovetailTenonWedgeAccessoryParameters,
    tenon_lateral_offset: Numeric = scalar(0),
    receiving_timber_mortise_extra_depth: Numeric = scalar(0),
    mortise_shoulder_inset: Numeric = scalar(0),
    relief: Union[None, ButtJointScribeReliefConfig] = ButtJointScribeReliefConfig.butt_timber(),
) -> Joint:
    """
    Create a half-dovetail mortise-and-tenon joint (with an optional wedge accessory).

    Built on top of `dovetail_tenon_geometry`. The "top" of the dovetail is flush with
    `arrangement.top_face_on_butt_timber`; the opposite side slopes outward by `dovetail_depth`
    over `tenon_depth` to give the joint its mechanical pull-out resistance.

    Args:
        arrangement: Butt joint arrangement (butt_timber = tenon, receiving_timber = mortise).
            Must be face-aligned and orthogonal, with top_face_on_butt_timber set to the
            face the dovetail's flat "top" is flush with (the opposite face is the sloped side).
        tenon_size: Cross-section of the tenon (X = butt RIGHT axis, Y = butt TOP axis).
        tenon_depth: Depth of the tenon into the receiving timber, measured from the shoulder.
        dovetail_depth: How far the sloped side of the dovetail kicks out over `tenon_depth`.
        tenon_lateral_offset: Offset of the tenon along the lateral direction (perpendicular
            to both length and top-to-bottom). 0 = centered on the butt timber.
        receiving_timber_mortise_extra_depth: Extra mortise depth in the receiving timber past
            the tenon tip.
        mortise_shoulder_inset: Distance from the mortise entry face to the shoulder plane,
            measured perpendicular to the entry face inward. 0 = shoulder flush with the
            entry face (the default). Positive pushes the shoulder deeper into the receiving
            timber.
        wedge_accessory_parameters: If provided, a wedge accessory is added on the
            `arrangement.top_face_on_butt_timber` side of the tenon and a matching slot is cut
            into the receiving timber.
        relief: Scribe-relief configuration for imperfect timbers. Defaults to scribing the
            tenon (butt) timber onto the mortise (receiving) timber. Pass None to skip.

    Returns:
        Joint object with cuts on both timbers and (optionally) a "wedge" accessory.
    """
    assert arrangement.top_face_on_butt_timber is not None, (
        "arrangement.top_face_on_butt_timber must be set to determine the dovetail's flat side"
    )
    dovetail_top_side_on_butt_timber = arrangement.top_face_on_butt_timber
    tenon_timber = arrangement.butt_timber
    mortise_timber = arrangement.receiving_timber
    tenon_end = arrangement.butt_timber_end

    tenon_end_direction = tenon_timber.get_face_direction_global(tenon_end)
    mortise_face = mortise_timber.get_closest_oriented_long_face_from_global_direction(
        -tenon_end_direction
    ).to.face()
    mortise_shoulder_distance_from_centerline_or_centerplane = convert_mortise_shoulder_inset_to_centerline_distance(
        mortise_shoulder_inset=mortise_shoulder_inset,
        mortise_face=mortise_face,
        receiving_timber=mortise_timber,
    )

    up_direction = tenon_timber.get_height_direction_global()

    shoulder_result = compute_butt_joint_shoulder(
        arrangement=arrangement,
        distance_from_centerline_or_centerplane=mortise_shoulder_distance_from_centerline_or_centerplane,
        up_direction=up_direction,
    )

    geo = dovetail_tenon_geometry(
        arrangement=arrangement,
        shoulder_result=shoulder_result,
        dovetail_top_side_on_butt_timber=dovetail_top_side_on_butt_timber,
        tenon_size=tenon_size,
        tenon_depth=tenon_depth,
        dovetail_depth=dovetail_depth,
        wedge_accessory_parameters=wedge_accessory_parameters,
        tenon_lateral_offset=tenon_lateral_offset,
        receiving_timber_mortise_extra_depth=receiving_timber_mortise_extra_depth,
    )

    tenon_negative_local = adopt_csg(None, tenon_timber.transform, geo.tenon_negative_csg)
    mortise_negative_local = adopt_csg(None, mortise_timber.transform, geo.mortise_negative_csg)

    relief_geom = chop_butt_joint_shoulder_notch_relief_on_plane_aligned_timbers_2sided(
        arrangement,
        mortise_shoulder_distance_from_centerline_or_centerplane,
        notch_angle=degrees(45),
    )
    if relief_geom is not None:
        mortise_negative_local = _union_into_cut(
            mortise_negative_local,
            [relief_geom.receiving_timber_notch_negative_CSG],
            MORTISE_CUT_LABEL,
        )
        if relief_geom.butting_timber_relief_negative_CSG is not None:
            tenon_negative_local = _union_into_cut(
                tenon_negative_local,
                [relief_geom.butting_timber_relief_negative_CSG],
                TENON_CUT_LABEL,
            )

    tenon_tip_position_global = (
        shoulder_result.marking_space.transform.position
        + shoulder_result.butt_direction * tenon_depth
    )
    tip_position_local = tenon_timber.transform.global_to_local(tenon_tip_position_global)
    tip_z_local = tip_position_local[2]

    wedge_angle = wedge_accessory_parameters.wedge_angle
    cos_angle = cos(wedge_angle)
    sin_angle = sin(wedge_angle)

    top_face_dir = tenon_timber.get_face_direction_global(
        dovetail_top_side_on_butt_timber.to.face()
    )

    tenon_disassembly_dir = -cos_angle * shoulder_result.butt_direction + sin_angle * top_face_dir
    mortise_disassembly_dir = cos_angle * shoulder_result.butt_direction - sin_angle * top_face_dir
    freed_dist = tenon_depth / cos_angle

    timber_suborder = 1 if geo.wedge_accessory_csg is not None else 0
    tenon_cut_no_relief = Cutting(
        timber=tenon_timber,
        maybe_top_end_cut_distance_from_bottom=tip_z_local if tenon_end == TimberEnd.TOP else None,
        maybe_bottom_end_cut_distance_from_bottom=tip_z_local if tenon_end == TimberEnd.BOTTOM else None,
        negative_csg=tenon_negative_local,
        label=CutCSGLabel("wedged_half_dovetail_mortise_and_tenon"),
        assembly_freedom=AssemblyFreedom.translation(tenon_disassembly_dir, freed_after=freed_dist),
        assembly_ordering=Ordering(0, timber_suborder),
    )

    mortise_cut_no_relief = Cutting(
        timber=mortise_timber,
        negative_csg=mortise_negative_local,
        label=CutCSGLabel("wedged_half_dovetail_mortise_and_tenon"),
        assembly_freedom=AssemblyFreedom.translation(mortise_disassembly_dir, freed_after=freed_dist),
        assembly_ordering=Ordering(0, timber_suborder),
    )

    tenon_cut, mortise_cut = apply_scribe_relief_if_configured(
        relief=relief,
        butt_cut=tenon_cut_no_relief,
        receiving_cut=mortise_cut_no_relief,
    )

    joint_accessories = {}
    if geo.wedge_accessory_csg is not None:
        joint_accessories["wedge"] = geo.wedge_accessory_csg

    return Joint(
        cuttings={
            tenon_timber.ticket.path: tenon_cut,
            mortise_timber.ticket.path: mortise_cut,
        },
        ticket=JointTicket(joint_type="wedged_half_dovetail_mortise_and_tenon"),
        jointAccessories=joint_accessories,
    )

cut_splined_opposing_double_butt_joint_on_face_aligned_timbers

cut_splined_opposing_double_butt_joint_on_face_aligned_timbers(arrangement: DoubleButtJointTimberArrangement, slot_thickness: Numeric, slot_depth: Numeric, spline_length: Numeric, slot_facing_end_on_receiving_timber: TimberEnd, spline_extra_depth=None, slot_symmetric_extra_length=mm(3), shoulder_symmetric_inset=scalar(0), slot_lateral_offset=scalar(0), peg_parameters: Optional[SimplePegParameters] = None) -> Joint

Creates a splined opposing double butt joint.

Two butt timbers approach the receiving timber from opposite cardinal directions. All three timbers must be face-aligned, each butt timber must be perpendicular to the receiving timber, and the two butt timbers must be antiparallel.

Parameters:

Name Type Description Default
arrangement DoubleButtJointTimberArrangement

Double butt joint arrangement with butt_timber_1, butt_timber_2, receiving_timber, butt_timber_1_end, butt_timber_2_end.

required

Returns:

Type Description
Joint

Joint containing all three cut timbers.

Raises:

Type Description
AssertionError

If the arrangement fails the cardinal-and-opposing-butts check.

Source code in kumiki/joints/workshop/butt/splined_opposing_double_butt_joint.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
def cut_splined_opposing_double_butt_joint_on_face_aligned_timbers(
    arrangement: DoubleButtJointTimberArrangement,
    # thickness is in the axis perpendicular to the joint plane
    slot_thickness: Numeric,
    # depth is in the axis of the receiving timber, measured from the face of the butt timber that aligns with slot_facing_end_on_receiving_timber
    slot_depth: Numeric,
    # length is in the axis parallel to the butt timbers
    spline_length: Numeric,
    # REQUIRED: the slot faces this end of the receiving timber
    slot_facing_end_on_receiving_timber: TimberEnd,
    # the spline has this much extra depth beyond the slot depth; defaults to 1/4 of slot_depth
    spline_extra_depth=None,
    # the slot extends this much beyond the spline on each end for clearance
    slot_symmetric_extra_length=mm(3),
    # inset the shoulder plane on both sides by this amount, flush with faces of receiving timber if 0
    shoulder_symmetric_inset=scalar(0),
    # offset the slot by this much, measured relative to receiving timber centerline in the axis perpendicular to the joint plane
    slot_lateral_offset=scalar(0),
    # optional peg setup; pegs will be drilled through each butt timber and the spline
    peg_parameters: Optional[SimplePegParameters] = None,
) -> Joint:
    """
    Creates a splined opposing double butt joint.

    Two butt timbers approach the receiving timber from opposite cardinal directions.
    All three timbers must be face-aligned, each butt timber must be perpendicular to the
    receiving timber, and the two butt timbers must be antiparallel.

    Args:
        arrangement: Double butt joint arrangement with butt_timber_1, butt_timber_2,
            receiving_timber, butt_timber_1_end, butt_timber_2_end.

    Returns:
        Joint containing all three cut timbers.

    Raises:
        AssertionError: If the arrangement fails the cardinal-and-opposing-butts check.
    """
    error = arrangement.check_face_aligned_cardinal_and_opposing_butts()
    assert error is None, error
    warn_if_arrangement_timbers_imperfect(arrangement)

    assert isinstance(slot_facing_end_on_receiving_timber, TimberEnd), (
        f"slot_facing_end_on_receiving_timber must be TimberEnd, got "
        f"{type(slot_facing_end_on_receiving_timber).__name__}"
    )

    butt_timber_1 = arrangement.butt_timber_1
    butt_timber_2 = arrangement.butt_timber_2
    receiving_timber = arrangement.receiving_timber

    butt_length_direction_global = butt_timber_1.get_length_direction_global()
    receiving_length_direction_global = receiving_timber.get_length_direction_global()
    slot_direction_global = receiving_timber.get_face_direction_global(slot_facing_end_on_receiving_timber)
    joint_plane_normal_global = safe_normalize_vector(
        cross_product(butt_length_direction_global, receiving_length_direction_global)
    )

    slot_face_on_butt_1 = butt_timber_1.get_closest_oriented_long_face_from_global_direction(slot_direction_global)
    slot_face_on_butt_2 = butt_timber_2.get_closest_oriented_long_face_from_global_direction(slot_direction_global)

    slot_face_direction_1 = butt_timber_1.get_face_direction_global(slot_face_on_butt_1)
    slot_face_direction_2 = butt_timber_2.get_face_direction_global(slot_face_on_butt_2)

    assert are_vectors_parallel(slot_face_direction_1, slot_direction_global), (
        "slot-facing face on butt_timber_1 must align with slot_direction"
    )
    assert are_vectors_parallel(slot_face_direction_2, slot_direction_global), (
        "slot-facing face on butt_timber_2 must align with slot_direction"
    )

    slot_depth_axis_dimension = butt_timber_1.get_size_in_face_normal_axis(slot_face_on_butt_1)
    slot_thickness_axis_dimension = butt_timber_1.get_size_in_direction_3d(joint_plane_normal_global)

    if spline_extra_depth is None:
        spline_extra_depth = slot_depth / scalar(4)

    assert safe_compare(slot_thickness, 0, Comparison.GT), "slot_thickness must be > 0"
    assert safe_compare(slot_depth, 0, Comparison.GT), "slot_depth must be > 0"
    assert safe_compare(slot_depth_axis_dimension - slot_depth, 0, Comparison.GE), (
        "slot_depth must be <= butt timber thickness along slot depth axis"
    )
    assert safe_compare(spline_length, 0, Comparison.GT), "spline_length must be > 0"
    effective_slot_depth = slot_depth + spline_extra_depth
    assert safe_compare(slot_thickness_axis_dimension - slot_thickness, 0, Comparison.GE), (
        "slot_thickness must be <= butt timber thickness along joint-plane-normal axis"
    )
    assert safe_compare(spline_extra_depth, 0, Comparison.GE), "spline_extra_depth must be >= 0"
    assert safe_compare(slot_symmetric_extra_length, 0, Comparison.GE), "slot_symmetric_extra_length must be >= 0"

    slot_length = spline_length + scalar(2) * slot_symmetric_extra_length
    assert safe_compare(slot_length, 0, Comparison.GT), "slot_length must be > 0"

    def _locate_butt_end_center_global(timber: TimberLike, end: TimberEnd) -> V3:
        if end == TimberEnd.TOP:
            return locate_top_center_position(timber).position
        return timber.get_bottom_position_global()

    butt_end_center_1_global = _locate_butt_end_center_global(butt_timber_1, arrangement.butt_timber_1_end)
    butt_end_center_2_global = _locate_butt_end_center_global(butt_timber_2, arrangement.butt_timber_2_end)

    entry_face_point_1_global = (
        butt_end_center_1_global
        + slot_face_direction_1 * (butt_timber_1.get_size_in_face_normal_axis(slot_face_on_butt_1) / scalar(2))
    )
    entry_face_point_2_global = (
        butt_end_center_2_global
        + slot_face_direction_2 * (butt_timber_2.get_size_in_face_normal_axis(slot_face_on_butt_2) / scalar(2))
    )

    slot_center_from_butt_1_global = entry_face_point_1_global - slot_direction_global * (slot_depth / scalar(2))
    slot_center_from_butt_2_global = entry_face_point_2_global - slot_direction_global * (slot_depth / scalar(2))
    slot_center_global = (
        slot_center_from_butt_1_global + slot_center_from_butt_2_global
    ) / scalar(2) + joint_plane_normal_global * slot_lateral_offset

    slot_marking_orientation_global = Orientation.from_z_and_y(
        z_direction=butt_length_direction_global,
        y_direction=slot_direction_global,
    )
    slot_marking_transform_global = Transform(
        position=slot_center_global,
        orientation=slot_marking_orientation_global,
    )

    slot_negative_csg_global = RectangularPrism(
        size=create_v2(slot_thickness, slot_depth),
        transform=slot_marking_transform_global,
        start_distance=-(slot_length / scalar(2)),
        end_distance=slot_length / scalar(2),
        label=CutCSGLabel("spline_slot"),
    )

    extended_slot_negative_csg_global = RectangularPrism(
        size=create_v2(slot_thickness, effective_slot_depth),
        transform=Transform(
            position=slot_marking_transform_global.position + slot_direction_global * (effective_slot_depth - slot_depth) / 2,
            orientation=slot_marking_transform_global.orientation,
        ),
        start_distance=-(slot_length / scalar(2)),
        end_distance=slot_length / scalar(2),
        label=CutCSGLabel("spline_slot"),
    )

    def _make_shoulder_end_cut(timber: TimberLike, timber_end: TimberEnd) -> HalfSpace:
        butt_end_direction_global = timber.get_face_direction_global(timber_end)
        receiving_face = receiving_timber.get_closest_oriented_face_from_global_direction(-butt_end_direction_global)
        receiving_face_center_global = get_center_point_on_face_global(receiving_face, receiving_timber)

        distance_from_bottom = safe_dot_product(
            receiving_face_center_global - timber.get_bottom_position_global(),
            timber.get_length_direction_global(),
        )
        if timber_end == TimberEnd.TOP:
            distance_from_end = timber.length - distance_from_bottom
        else:
            distance_from_end = distance_from_bottom

        distance_from_end = distance_from_end - shoulder_symmetric_inset
        assert safe_compare(distance_from_end, 0, Comparison.GE), "shoulder cut distance must be >= 0"
        assert safe_compare(timber.length - distance_from_end, 0, Comparison.GE), (
            "shoulder cut distance from end exceeds timber length"
        )
        return chop_timber_end_with_half_plane(timber, timber_end, distance_from_end)

    butt_1_shoulder_end_cut = _make_shoulder_end_cut(butt_timber_1, arrangement.butt_timber_1_end)
    butt_2_shoulder_end_cut = _make_shoulder_end_cut(butt_timber_2, arrangement.butt_timber_2_end)
    butt_1_shoulder_distance_from_bottom = (
        butt_1_shoulder_end_cut.offset
        if arrangement.butt_timber_1_end == TimberEnd.TOP
        else -butt_1_shoulder_end_cut.offset
    )
    butt_2_shoulder_distance_from_bottom = (
        butt_2_shoulder_end_cut.offset
        if arrangement.butt_timber_2_end == TimberEnd.TOP
        else -butt_2_shoulder_end_cut.offset
    )

    receiving_slot_negative_csg_local = adopt_csg(None, receiving_timber.transform, extended_slot_negative_csg_global)
    butt_1_slot_negative_csg_local = adopt_csg(None, butt_timber_1.transform, slot_negative_csg_global)
    butt_2_slot_negative_csg_local = adopt_csg(None, butt_timber_2.transform, slot_negative_csg_global)

    receiving_negative_csg_parts = [receiving_slot_negative_csg_local]

    if safe_compare(shoulder_symmetric_inset, 0, Comparison.GT):
        def _make_receiving_shoulder_notch_local(
            butting_timber: TimberLike,
            butting_timber_end: TimberEnd,
        ) -> CutCSG:
            butt_end_direction_global = butting_timber.get_face_direction_global(butting_timber_end)
            receiving_face = receiving_timber.get_closest_oriented_long_face_from_global_direction(
                -butt_end_direction_global
            )
            receiving_face_half_size = receiving_timber.get_size_in_face_normal_axis(receiving_face) / scalar(2)
            shoulder_distance_from_centerline = receiving_face_half_size - shoulder_symmetric_inset

            assert safe_compare(shoulder_distance_from_centerline, 0, Comparison.GE), (
                "shoulder_symmetric_inset is too large for receiving timber half size"
            )

            return chop_shoulder_notch_aligned_with_timber(
                notch_timber=receiving_timber,
                butting_timber=butting_timber,
                butting_timber_end=butting_timber_end,
                distance_from_centerline=shoulder_distance_from_centerline,
            )

        receiving_negative_csg_parts.append(
            _make_receiving_shoulder_notch_local(butt_timber_1, arrangement.butt_timber_1_end)
        )
        receiving_negative_csg_parts.append(
            _make_receiving_shoulder_notch_local(butt_timber_2, arrangement.butt_timber_2_end)
        )

    receiving_negative_csg_local = (
        receiving_negative_csg_parts[0]
        if len(receiving_negative_csg_parts) == 1
        else CSGUnion(children=receiving_negative_csg_parts)
    )

    receiving_cut = Cutting(
        timber=receiving_timber,
        negative_csg=receiving_negative_csg_local,
        label=CutCSGLabel("spline_socket_cut"),
    )
    butt_1_cut = Cutting(
        timber=butt_timber_1,
        maybe_top_end_cut_distance_from_bottom=butt_1_shoulder_distance_from_bottom if arrangement.butt_timber_1_end == TimberEnd.TOP else None,
        maybe_bottom_end_cut_distance_from_bottom=butt_1_shoulder_distance_from_bottom if arrangement.butt_timber_1_end == TimberEnd.BOTTOM else None,
        negative_csg=butt_1_slot_negative_csg_local,
        label=CutCSGLabel("spline_slot_cut"),
    )
    butt_2_cut = Cutting(
        timber=butt_timber_2,
        maybe_top_end_cut_distance_from_bottom=butt_2_shoulder_distance_from_bottom if arrangement.butt_timber_2_end == TimberEnd.TOP else None,
        maybe_bottom_end_cut_distance_from_bottom=butt_2_shoulder_distance_from_bottom if arrangement.butt_timber_2_end == TimberEnd.BOTTOM else None,
        negative_csg=butt_2_slot_negative_csg_local,
        label=CutCSGLabel("spline_slot_cut"),
    )

    spline_transform = Transform(
        position=slot_marking_transform_global.position + slot_direction_global * (effective_slot_depth-slot_depth)/2,
        orientation=slot_marking_orientation_global,
    )

    peg_holes_in_spline_local: List[CutCSG] = []
    joint_accessories: Dict[str, Accessory] = {}

    # peg logic slightly different so we don't use compute_peg_positions
    if peg_parameters is not None:
        peg_size = peg_parameters.size
        butt_1_negative_parts = [butt_1_slot_negative_csg_local]
        butt_2_negative_parts = [butt_2_slot_negative_csg_local]

        assert arrangement.front_face_on_butt_timber_1 is not None, (
            "front_face_on_butt_timber_1 must be provided when peg_parameters are set"
        )

        peg_entry_direction_global = butt_timber_1.get_face_direction_global(
            arrangement.front_face_on_butt_timber_1
        )

        peg_face_on_butt_1 = arrangement.front_face_on_butt_timber_1
        peg_face_on_butt_2 = butt_timber_2.get_closest_oriented_long_face_from_global_direction(
            peg_entry_direction_global
        )

        def _append_pegs_for_butt(
            butt_timber: TimberLike,
            butt_end: TimberEnd,
            peg_face_on_butt: TimberLongFace,
            butt_negative_parts: List[CutCSG],
            accessory_prefix: str,
        ) -> None:
            # Peg geometry: enters from peg_face, drills perpendicular to the joint plane.
            peg_face: TimberFace = peg_face_on_butt.to.face()
            peg_face_normal_global = butt_timber.get_face_direction_global(peg_face_on_butt)
            peg_drill_direction = -peg_face_normal_global
            peg_face_plane = locate_face(butt_timber, peg_face)

            # Peg depth = full width of the butt timber in the drill direction.
            # The peg goes from the entry face, through the timber, to the exit face.
            peg_depth = butt_timber.get_size_in_face_normal_axis(peg_face_on_butt)
            actual_depth = peg_parameters.depth if peg_parameters.depth is not None else peg_depth
            stickout = actual_depth * scalar(1, 2)

            # Away-from-joint axis: from the shoulder toward the main butt body.
            butt_end_direction = butt_timber.get_face_direction_global(butt_end)
            away_from_joint_axis = -butt_end_direction

            # Lateral axis in the peg face plane, matching compute_peg_positions convention.
            if peg_face_on_butt in [TimberLongFace.RIGHT, TimberLongFace.LEFT]:
                lateral_axis = butt_timber.get_face_direction_global(TimberFace.FRONT)
            else:
                lateral_axis = butt_timber.get_face_direction_global(TimberFace.RIGHT)

            # Peg orientation: drill direction is Z, butt length direction is Y.
            butt_length_dir = butt_timber.get_length_direction_global()
            peg_orientation_global = Orientation.from_z_and_y(
                z_direction=peg_drill_direction,
                y_direction=butt_length_dir,
            )

            # Shoulder reference: the external face of the receiving timber that the butt enters.
            # Using the existing shoulder-plane helper with a ButtJointTimberArrangement.
            butt_arrangement = ButtJointTimberArrangement(
                butt_timber=butt_timber,
                receiving_timber=receiving_timber,
                butt_timber_end=butt_end,
                front_face_on_butt_timber=peg_face_on_butt,
            )
            receiving_face_towards_butt = receiving_timber.get_closest_oriented_long_face_from_global_direction(
                butt_timber.get_face_direction_global(butt_end)
            )
            shoulder_distance_from_centerline = (
                receiving_timber.get_size_in_face_normal_axis(receiving_face_towards_butt) / scalar(2)
            ) - shoulder_symmetric_inset
            assert safe_compare(shoulder_distance_from_centerline, 0, Comparison.GE), (
                "shoulder_symmetric_inset is too large for peg shoulder reference"
            )
            shoulder_plane = locate_mortise_timber_shoulder_plane_from_centerline_towards_tenon_timber(
                butt_arrangement,
                shoulder_distance_from_centerline,
            )
            shoulder_mark = mark_distance_from_end_along_centerline(
                shoulder_plane, butt_timber, butt_end
            )
            shoulder_point_global = shoulder_mark.locate().position

            # Lateral peg positions are referenced from the centerline of the non-extra
            # spline body, not from the butt timber centerline at the shoulder.
            non_extra_spline_center_lateral_offset = safe_dot_product(
                slot_center_global - shoulder_point_global,
                lateral_axis,
            )
            spline_lateral_reference_global = (
                shoulder_point_global
                + lateral_axis * non_extra_spline_center_lateral_offset
            )

            for peg_idx, (dist_from_shoulder, dist_from_center) in enumerate(peg_parameters.peg_positions):
                # Place peg away from the receiving-timber shoulder, into the butt body.
                peg_center_global = (
                    spline_lateral_reference_global
                    + away_from_joint_axis * dist_from_shoulder
                    + lateral_axis * dist_from_center
                )

                # Project peg center onto the peg entry face plane.
                dist_to_face = safe_dot_product(
                    peg_face_plane.normal,
                    peg_face_plane.point - peg_center_global,
                )
                peg_face_pos_global = peg_center_global + peg_face_plane.normal * dist_to_face

                # tenon_hole_offset shifts the spline hole in the same away-from-joint direction.
                peg_face_pos_with_offset_global = (
                    peg_face_pos_global
                    + away_from_joint_axis * peg_parameters.tenon_hole_offset
                )

                peg_hole_in_butt_global = RectangularPrism(
                    size=create_v2(peg_size, peg_size),
                    transform=Transform(
                        position=peg_face_pos_global,
                        orientation=peg_orientation_global,
                    ),
                    start_distance=scalar(0),
                    end_distance=actual_depth,
                    label=CutCSGLabel("peg_hole"),
                )
                butt_negative_parts.append(
                    adopt_csg(None, butt_timber.transform, peg_hole_in_butt_global)
                )

                # For splined double butt joints, tenon_hole_offset shifts the spline hole
                # in the away-from-joint direction (toward the butt body).
                peg_hole_in_spline_global = RectangularPrism(
                    size=create_v2(peg_size, peg_size),
                    transform=Transform(
                        position=peg_face_pos_with_offset_global,
                        orientation=peg_orientation_global,
                    ),
                    start_distance=scalar(0),
                    end_distance=actual_depth,
                    label=CutCSGLabel("peg_hole"),
                )
                peg_holes_in_spline_local.append(
                    adopt_csg(None, spline_transform, peg_hole_in_spline_global)
                )

                # Assembly: the peg backs out of its entry face; it locks the
                # joint, so it pops (suborder 0) before the members slide.
                joint_accessories[f"{accessory_prefix}_{peg_idx}"] = Peg(
                    transform=Transform(
                        position=peg_face_pos_global,
                        orientation=peg_orientation_global,
                    ),
                    size=peg_size,
                    shape=peg_parameters.shape,
                    forward_length=actual_depth,
                    stickout_length=stickout,
                    assembly_freedom=AssemblyFreedom.translation(
                        peg_face_normal_global, freed_after=actual_depth + stickout
                    ),
                    assembly_ordering=Ordering(0, -1),
                )

        _append_pegs_for_butt(
            butt_timber_1,
            arrangement.butt_timber_1_end,
            peg_face_on_butt_1,
            butt_1_negative_parts,
            "peg_butt_1",
        )
        _append_pegs_for_butt(
            butt_timber_2,
            arrangement.butt_timber_2_end,
            peg_face_on_butt_2,
            butt_2_negative_parts,
            "peg_butt_2",
        )

        butt_1_cut = Cutting(
            timber=butt_timber_1,
            maybe_top_end_cut_distance_from_bottom=butt_1_shoulder_distance_from_bottom if arrangement.butt_timber_1_end == TimberEnd.TOP else None,
            maybe_bottom_end_cut_distance_from_bottom=butt_1_shoulder_distance_from_bottom if arrangement.butt_timber_1_end == TimberEnd.BOTTOM else None,
            negative_csg=butt_1_negative_parts[0] if len(butt_1_negative_parts) == 1 else CSGUnion(children=butt_1_negative_parts),
        )
        butt_2_cut = Cutting(
            timber=butt_timber_2,
            maybe_top_end_cut_distance_from_bottom=butt_2_shoulder_distance_from_bottom if arrangement.butt_timber_2_end == TimberEnd.TOP else None,
            maybe_bottom_end_cut_distance_from_bottom=butt_2_shoulder_distance_from_bottom if arrangement.butt_timber_2_end == TimberEnd.BOTTOM else None,
            negative_csg=butt_2_negative_parts[0] if len(butt_2_negative_parts) == 1 else CSGUnion(children=butt_2_negative_parts),
        )

    # Accessory geometry is local-space and centered at origin; transform places it globally.
    spline_positive_csg = RectangularPrism(
        size=create_v2(slot_thickness, effective_slot_depth),
        transform=Transform.identity(),
        start_distance=-(spline_length / scalar(2)),
        end_distance=spline_length / scalar(2),
        label=CutCSGLabel("spline"),
    )

    if peg_holes_in_spline_local:
        spline_positive_csg = Difference(
            base=spline_positive_csg,
            subtract=peg_holes_in_spline_local,
            label=CutCSGLabel("spline_drilled"),
        )

    # Assembly: each butt timber pulls back along its own axis, sliding off
    # the spline (free after clearing the spline's reach into it); the spline
    # backs out of its slot. When pegs lock the joint they pop first
    # (suborder 0), so the sliding members go at suborder 1.
    member_suborder = 1 if peg_parameters is not None else 0
    butt_1_escape_direction = -butt_timber_1.get_face_direction_global(arrangement.butt_timber_1_end)
    butt_2_escape_direction = -butt_timber_2.get_face_direction_global(arrangement.butt_timber_2_end)
    butt_1_cut = replace(
        butt_1_cut,
        assembly_freedom=AssemblyFreedom.translation(butt_1_escape_direction, freed_after=spline_length / scalar(2)),
        assembly_ordering=Ordering(0, 0),
    )
    butt_2_cut = replace(
        butt_2_cut,
        assembly_freedom=AssemblyFreedom.translation(butt_2_escape_direction, freed_after=spline_length / scalar(2)),
        assembly_ordering=Ordering(0, 0),
    )

    spline = CSGAccessory(
        transform=spline_transform,
        positive_csg=spline_positive_csg,
        assembly_freedom=AssemblyFreedom.translation(-slot_direction_global, freed_after=effective_slot_depth),
        assembly_ordering=Ordering(0, 0),
    )
    joint_accessories["spline"] = spline

    return Joint(
        cuttings={
            "receiving_timber": receiving_cut,
            "butt_timber_1": butt_1_cut,
            "butt_timber_2": butt_2_cut,
        },
        ticket=JointTicket(joint_type="splined_opposing_double_butt"),
        jointAccessories=joint_accessories,
    )