Skip to content

kumiki.joints.workshop.corner_joints

Flat import path

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

kumiki.joints.workshop.corner_joints

Kumiki - Corner joint construction functions Contains miter, tongue-and-fork corner, and corner lap joint implementations.

CSGUnion module-attribute

CSGUnion = SolidUnion

cut_箱相欠き車知栓仕口 module-attribute

cut_hako_aikaki_shachi_sen_shikuchi module-attribute

cut_hako_aikaki_shachi_sen_shikuchi = cut_mitered_and_keyed_lap_joint_on_plane_aligned_timbers

cut_plain_miter_joint

cut_plain_miter_joint(arrangement: CornerJointTimberArrangement) -> Joint

Creates a miter joint between two timbers, cutting each end at half the angle between them.

Both ends are cut by the same bisecting plane so they meet flush at the corner. Works for any angle including parallel timbers (parallel produces a perpendicular end cut).

Parameters:

Name Type Description Default
arrangement CornerJointTimberArrangement

Corner joint arrangement with timber1, timber2, timber1_end, timber2_end.

required

Returns:

Type Description
Joint

Joint object containing the two CutTimbers.

Source code in kumiki/joints/workshop/corner_joints.py
def cut_plain_miter_joint(arrangement: CornerJointTimberArrangement) -> Joint:
    """
    Creates a miter joint between two timbers, cutting each end at half the angle between them.

    Both ends are cut by the same bisecting plane so they meet flush at the corner.
    Works for any angle including parallel timbers (parallel produces a perpendicular end cut).

    Args:
        arrangement: Corner joint arrangement with timber1, timber2, timber1_end, timber2_end.

    Returns:
        Joint object containing the two CutTimbers.
    """
    import warnings

    timberA = arrangement.timber1
    timberA_end = arrangement.timber1_end
    timberB = arrangement.timber2
    timberB_end = arrangement.timber2_end

    warn_if_arrangement_timbers_imperfect(arrangement)

    # Get the end directions for each timber (pointing outward from the timber)
    if timberA_end == TimberEnd.TOP:
        directionA = timberA.get_length_direction_global()
        endA_position = locate_top_center_position(timberA).position
    else:  # BOTTOM
        directionA = -timberA.get_length_direction_global()
        endA_position = locate_bottom_center_position(timberA).position

    if timberB_end == TimberEnd.TOP:
        directionB = timberB.get_length_direction_global()
        endB_position = locate_top_center_position(timberB).position
    else:  # BOTTOM
        directionB = -timberB.get_length_direction_global()
        endB_position = locate_bottom_center_position(timberB).position

    # Find the intersection point (or closest point) between the two timber centerlines
    # Using the formula for closest point between two lines in 3D
    # Line 1: P1 = endA_position + t * directionA
    # Line 2: P2 = endB_position + s * directionB

    w0 = prune(endA_position - endB_position)
    a = safe_dot_product(directionA, directionA)  # always >= 0
    b = safe_dot_product(directionA, directionB)
    c = safe_dot_product(directionB, directionB)  # always >= 0
    d = safe_dot_product(directionA, w0)
    e = safe_dot_product(directionB, w0)

    denom = prune(a * c - b * b)
    if safe_zero_test(denom):
        # Parallel timbers: lines don't converge; bisect between the two end positions
        intersection_point = prune((endA_position + endB_position) / 2)
    else:
        # Parameters for closest points on each line
        t = prune((b * e - c * d) / denom)
        s = prune((a * e - b * d) / denom)

        # Get the closest points on each centerline
        pointA = prune(endA_position + directionA * t)
        pointB = prune(endB_position + directionB * s)

        # The intersection point is the midpoint between the two closest points
        intersection_point = prune((pointA + pointB) / 2)

    # Create the miter plane normal
    # Normalize the directions first
    normA = safe_normalize_vector(directionA)
    normB = safe_normalize_vector(directionB)

    # The bisecting direction is the normalized sum of the two directions
    # This points "into" the joint (towards the acute angle)
    # IMPORTANT: The bisector lives IN the miter plane (it's the line you draw on the wood)
    bisector = safe_normalize_vector(normA + normB)

    # The plane formed by the two timber directions has normal:
    plane_normal = cross_product(normA, normB)

    # The miter plane:
    # 1. Contains the bisector line
    # 2. Is perpendicular to the plane formed by directionA and directionB
    # Therefore, the miter plane's normal is perpendicular to both the bisector
    # and the plane_normal. This is the cross product: bisector × plane_normal
    # For parallel timbers plane_normal is zero, so fall back to a perpendicular end cut.
    plane_normal_sq = safe_dot_product(plane_normal, plane_normal)
    if safe_zero_test_sq(plane_normal_sq):
        miter_normal = normA
    else:
        miter_normal = safe_normalize_vector(cross_product(bisector, plane_normal))

    # The miter plane passes through the intersection point
    # Both timbers will be cut by this same plane, but each timber needs its half-plane
    # normal oriented to point "away from" that timber (into the material to remove).

    # For each timber, we need to create a HalfSpaceCut with the miter plane
    # The key is that the half-plane normal must be oriented so that:
    # 1. It represents the miter plane (normal perpendicular to the bisector line)
    # 2. The normal points "away from" the timber (into the material to remove)
    #
    # The miter_normal is perpendicular to both the bisector and the plane formed by
    # the two timbers. For each timber, we need to determine which orientation of the
    # miter plane normal points "away from" that timber.
    #
    # If directionA · miter_normal > 0, then miter_normal points away from timberA,
    # so we use +miter_normal. Otherwise, we use -miter_normal.

    # For timberA: check if miter_normal points away from or towards the timber
    dot_A = safe_dot_product(normA, miter_normal)
    if safe_compare(dot_A, 0, Comparison.GT):
        # Miter normal points away from timberA (in the direction of timberA's end)
        # This is what we want - the half-plane removes material in this direction
        normalA = miter_normal
    else:
        # Miter normal points towards timberA, so flip it
        normalA = -miter_normal

    # Convert to LOCAL coordinates for timberA
    # Transform normal: local_normal = orientation^T * global_normal
    # Transform offset: local_offset = global_offset - (global_normal · timber.get_bottom_position_global())
    local_normalA = safe_transform_vector(timberA.orientation.matrix.T, normalA)

    # For timberB: check if miter_normal points away from or towards the timber
    dot_B = safe_dot_product(normB, miter_normal)
    if safe_compare(dot_B, 0, Comparison.GT):
        # Miter normal points away from timberB (in the direction of timberB's end)
        normalB = miter_normal
    else:
        # Miter normal points towards timberB, so flip it
        normalB = -miter_normal

    # Convert to LOCAL coordinates for timberB
    local_normalB = safe_transform_vector(timberB.orientation.matrix.T, normalB)

    # Calculate offset for each timber's miter cut
    # The cut plane should pass through the outer edges (tips) of the joint, not at the centerline intersection.
    # For each timber, we offset the plane outward by an amount depending on the cross-section and joint angle.

    # Compute the angle between the timber directions
    cos_angle = safe_dot_product(normA, normB)
    # Clamp to [-1, 1] to avoid numerical issues with acos
    cos_angle_clamped = max(prune(scalar(-1)), min(prune(scalar(1)), cos_angle))
    # angle = acos(cos_angle_clamped)  # angle between centerlines
    # For offset calculation, we need sin(angle/2)
    # sin(angle/2) = sqrt((1 - cos(angle)) / 2)
    sin_half_angle_sq = (scalar(1) - cos_angle_clamped) / scalar(2)
    sin_half_angle = sqrt(sin_half_angle_sq) if safe_compare(sin_half_angle_sq, 0, Comparison.GT) else scalar(1)

    # For each timber, calculate the offset distance from centerline to outer corner
    # This is the distance from the centerline to the farthest corner of the cross-section,
    # projected along the bisector direction
    half_width_A = timberA.size[0] / scalar(2)
    half_height_A = timberA.size[1] / scalar(2)
    # Outer corner distance: sqrt((half_width)^2 + (half_height)^2)
    outer_corner_dist_A = sqrt(half_width_A ** scalar(2) + half_height_A ** scalar(2))

    half_width_B = timberB.size[0] / scalar(2)
    half_height_B = timberB.size[1] / scalar(2)
    outer_corner_dist_B = sqrt(half_width_B ** scalar(2) + half_height_B ** scalar(2))

    # Offset the miter plane along the bisector (which is in the miter plane)
    # For a timber with outer corner distance d and joint half-angle α:
    # offset ≈ d / sin(α) if sin(α) > 0, else use a large value
    if not safe_zero_test(sin_half_angle):
        offset_dist_A = outer_corner_dist_A / sin_half_angle
        offset_dist_B = outer_corner_dist_B / sin_half_angle
    else:
        # Degenerate case: parallel timbers or very shallow angle, use large offset
        offset_dist_A = outer_corner_dist_A * scalar(100)
        offset_dist_B = outer_corner_dist_B * scalar(100)

    # Move the intersection point along the bisector direction (within the miter plane)
    # to get the actual cut position at the timber tips
    miter_cut_point_A = prune(intersection_point + bisector * offset_dist_A)
    miter_cut_point_B = prune(intersection_point + bisector * offset_dist_B)

    # Create the offset for the HalfSpace in global coordinates
    local_offsetA = safe_dot_product(miter_cut_point_A, normalA) - safe_dot_product(normalA, timberA.get_bottom_position_global())
    local_offsetB = safe_dot_product(miter_cut_point_B, normalB) - safe_dot_product(normalB, timberB.get_bottom_position_global())

    # Create the end cut HalfSpaces (in LOCAL coordinates relative to each timber)
    end_cut_A = HalfSpace(normal=local_normalA, offset=local_offsetA)
    end_cut_B = HalfSpace(normal=local_normalB, offset=local_offsetB)

    # Distance from timber bottom to the miter cut plane along the timber's length direction
    end_cut_A_distance_from_bottom = safe_dot_product(
        miter_cut_point_A - timberA.get_bottom_position_global(),
        timberA.get_length_direction_global(),
    )
    end_cut_B_distance_from_bottom = safe_dot_product(
        miter_cut_point_B - timberB.get_bottom_position_global(),
        timberB.get_length_direction_global(),
    )

    # Assembly: a miter has no mechanical engagement — each timber pulls back
    # along its own axis and 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.
    cutA = Cutting(
        timber=timberA,
        maybe_top_end_cut_distance_from_bottom=end_cut_A_distance_from_bottom if timberA_end == TimberEnd.TOP else None,
        maybe_bottom_end_cut_distance_from_bottom=end_cut_A_distance_from_bottom if timberA_end == TimberEnd.BOTTOM else None,
        negative_csg=end_cut_A,
        assembly_freedom=AssemblyFreedom.translation(-directionA, freed_after=scalar(0)),
        label=CutCSGLabel("miter_cut"),
    )

    cutB = Cutting(
        timber=timberB,
        maybe_top_end_cut_distance_from_bottom=end_cut_B_distance_from_bottom if timberB_end == TimberEnd.TOP else None,
        maybe_bottom_end_cut_distance_from_bottom=end_cut_B_distance_from_bottom if timberB_end == TimberEnd.BOTTOM else None,
        negative_csg=end_cut_B,
        assembly_freedom=AssemblyFreedom.translation(-directionB, freed_after=scalar(0)),
        label=CutCSGLabel("miter_cut"),
    )

    # Create CutTimbers with cuts passed at construction
    cut_timberA = cutA
    cut_timberB = cutB

    # Create and return the Joint with all data at construction
    joint = Joint(
        cuttings={"timberA": cut_timberA, "timberB": cut_timberB},
        ticket=JointTicket(joint_type="plain_miter"),
        jointAccessories={},
    )

    return joint

cut_plain_miter_joint_on_face_aligned_timbers

cut_plain_miter_joint_on_face_aligned_timbers(arrangement: CornerJointTimberArrangement) -> Joint

Creates a miter joint between two face-aligned timbers meeting at a 90-degree corner.

Like cut_plain_miter_joint, but requires the timbers to be orthogonal (perpendicular length axes). This is the common case for timber-frame corners.

Parameters:

Name Type Description Default
arrangement CornerJointTimberArrangement

Corner joint arrangement with timber1, timber2, timber1_end, timber2_end. Timbers must be face-aligned and orthogonal.

required

Returns:

Type Description
Joint

Joint object containing the two CutTimbers.

Raises:

Type Description
AssertionError

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

Source code in kumiki/joints/workshop/corner_joints.py
def cut_plain_miter_joint_on_face_aligned_timbers(arrangement: CornerJointTimberArrangement) -> Joint:
    """
    Creates a miter joint between two face-aligned timbers meeting at a 90-degree corner.

    Like `cut_plain_miter_joint`, but requires the timbers to be orthogonal (perpendicular
    length axes). This is the common case for timber-frame corners.

    Args:
        arrangement: Corner joint arrangement with timber1, timber2, timber1_end, timber2_end.
                     Timbers must be face-aligned and orthogonal.

    Returns:
        Joint object containing the two CutTimbers.

    Raises:
        AssertionError: If the timbers are not face-aligned or not orthogonal.
    """
    assert are_timbers_face_aligned(arrangement.timber1, arrangement.timber2), \
        "Timbers must be face-aligned (orientations related by 90-degree rotations) for this joint type"
    # Assert that the timber length axes are perpendicular (90-degree corner)
    assert are_timbers_orthogonal(arrangement.timber1, arrangement.timber2), \
        "Timbers must have perpendicular length axes (90-degree angle) for this joint type"

    return cut_plain_miter_joint(arrangement)

cut_tongue_and_fork_corner_joint_on_plane_aligned_timbers

cut_tongue_and_fork_corner_joint_on_plane_aligned_timbers(arrangement: CornerJointTimberArrangement, tongue_thickness: Optional[Numeric] = None, tongue_position: Numeric = scalar(0)) -> Joint

Creates a plain tongue-and-fork corner joint (corner bridle style). Note, you can make an identical joint with cut_practice_mortise_and_tenon_corner_joint_on_plane_aligned_timbers setting tenon_distance_from_end to 0

In this joint, timber1 forms the tongue (material removed on both cheeks), and timber2 forms the fork (slot cut into its end). Tongue thickness is measured along the shared plane normal between the two timbers and defaults to one-third of the tongue timber dimension in that axis. Tongue position is an offset from the tongue timber centerline in that same axis.

End cuts are aligned to the opposing timber's opposite face: - Tongue timber end is cut to the face opposite the fork entry face. - Fork timber end is cut to the face opposite the tongue entry face.

Parameters:

Name Type Description Default
arrangement CornerJointTimberArrangement

Corner arrangement where timber1 is the tongue timber and timber2 is the fork timber.

required
tongue_thickness Optional[Numeric]

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

None
tongue_position Numeric

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

scalar(0)

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/corner_joints.py
def cut_tongue_and_fork_corner_joint_on_plane_aligned_timbers(
    arrangement: CornerJointTimberArrangement,
    tongue_thickness: Optional[Numeric] = None,
    # TODO rename to tongue_lateral_position
    tongue_position: Numeric = scalar(0),
) -> Joint:
    """
    Creates a plain tongue-and-fork corner joint (corner bridle style).
    Note, you can make an identical joint with cut_practice_mortise_and_tenon_corner_joint_on_plane_aligned_timbers setting tenon_distance_from_end to 0

    In this joint, timber1 forms the tongue (material removed on both cheeks), and timber2
    forms the fork (slot cut into its end). Tongue thickness is measured along the shared
    plane normal between the two timbers and defaults to one-third of the tongue timber
    dimension in that axis. Tongue position is an offset from the tongue timber centerline
    in that same axis.

    End cuts are aligned to the opposing timber's opposite face:
    - Tongue timber end is cut to the face opposite the fork entry face.
    - Fork timber end is cut to the face opposite the tongue entry face.

    Args:
        arrangement: Corner arrangement where timber1 is the tongue timber and timber2 is
            the fork timber.
        tongue_thickness: Tongue thickness along the shared plane normal. If None, defaults
            to 1/3 of the tongue timber dimension in that axis.
        tongue_position: Offset of the tongue center from the tongue timber centerline along
            the shared plane normal. 0 means centered.

    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

    tongue_timber = arrangement.timber1
    fork_timber = arrangement.timber2
    tongue_end = arrangement.timber1_end
    fork_end = arrangement.timber2_end

    warn_if_arrangement_timbers_imperfect(arrangement)

    assert not are_vectors_parallel(
        tongue_timber.get_length_direction_global(),
        fork_timber.get_length_direction_global(),
    ), "Timbers cannot be parallel for a tongue-and-fork corner 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"

    tongue_normal_axis_index = tongue_timber.get_size_index_in_long_face_normal_axis(tongue_normal_face)
    tongue_width_axis_index = 1 if tongue_normal_axis_index == 0 else 0
    tongue_width = tongue_timber.size[tongue_width_axis_index]

    tongue_end_direction = tongue_timber.get_face_direction_global(tongue_end)
    fork_end_direction = fork_timber.get_face_direction_global(fork_end)

    # -------------------------------------------------------------------------
    # Shoulder plane (M&T pattern): compute on fork timber, mark onto tongue
    # -------------------------------------------------------------------------
    butt_arrangement_for_shoulder = ButtJointTimberArrangement(
        receiving_timber=fork_timber,
        butt_timber=tongue_timber,
        butt_timber_end=tongue_end,
    )
    # Push the shoulder plane from the fork's centerline to its entry face
    fork_entry_long_face = fork_timber.get_closest_oriented_long_face_from_global_direction(-tongue_end_direction)
    fork_shoulder_distance = fork_timber.get_size_in_face_normal_axis(fork_entry_long_face) / scalar(2)

    shoulder_plane = locate_mortise_timber_shoulder_plane_from_centerline_towards_tenon_timber(
        butt_arrangement_for_shoulder, fork_shoulder_distance
    )
    shoulder_from_tongue_end_mark = mark_distance_from_end_along_centerline(
        shoulder_plane, tongue_timber, tongue_end
    )
    shoulder_point_global = shoulder_from_tongue_end_mark.locate().position

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

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

    # -------------------------------------------------------------------------
    # Tongue prism and shoulder half-space (M&T pattern)
    # -------------------------------------------------------------------------
    tongue_back_extension = max(tongue_timber.size[0], tongue_timber.size[1])
    tongue_prism_global = RectangularPrism(
        size=create_v2(tongue_width, tongue_thickness),
        transform=marking_space.transform,
        start_distance=-tongue_back_extension,
        end_distance=tongue_timber.length,
        label=CutCSGLabel("tongue"),
    )

    # Shoulder half-space: normal = -shoulder_plane.normal (points from fork toward tongue end)
    shoulder_half_space_global = HalfSpace(
        normal=-shoulder_plane.normal,
        offset=safe_dot_product(-shoulder_plane.normal, marking_space.transform.position),
        label=CutCSGLabel("shoulder"),
    )

    tongue_prism_local = adopt_csg(None, tongue_timber.transform, tongue_prism_global)
    shoulder_half_space_local = adopt_csg(None, tongue_timber.transform, shoulder_half_space_global)

    tongue_negative_csg = Difference(
        base=shoulder_half_space_local,
        subtract=[tongue_prism_local],
        label=CutCSGLabel("tongue_waste"),
    )

    # -------------------------------------------------------------------------
    # Fork slot: depth extends to the fork's far face so the slot matches
    # the tongue after its end-cut extension (same orientation as tongue)
    # -------------------------------------------------------------------------
    fork_entry_long_face_for_end_cut = fork_timber.get_closest_oriented_long_face_from_global_direction(-tongue_end_direction)
    fork_far_face = fork_entry_long_face_for_end_cut.to.face().get_opposite_face()
    fork_far_face_normal_global = fork_timber.get_face_direction_global(fork_far_face)
    fork_far_face_point_global = get_center_point_on_face_global(fork_far_face, fork_timber)

    # Slot depth = distance from shoulder to fork far face along tongue direction
    fork_slot_depth = safe_dot_product(
        fork_far_face_point_global - shoulder_point_global,
        safe_normalize_vector(tongue_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 uses the same orientation as the tongue prism so the slot
    # receives the tongue correctly at any joint angle
    fork_slot_back_extension = max(fork_timber.size[0], fork_timber.size[1]) * scalar(2)
    fork_slot_prism_global = RectangularPrism(
        size=create_v2(tongue_width, tongue_thickness),
        transform=marking_space.transform,
        start_distance=-fork_slot_back_extension,
        end_distance=fork_slot_depth,
        label=CutCSGLabel("fork_slot"),
    )
    fork_negative_csg = adopt_csg(None, fork_timber.transform, fork_slot_prism_global)

    # -------------------------------------------------------------------------
    # End cuts: each timber is cut to align with the far face of the
    # opposing timber (the face opposite the entry face).  The cut planes
    # use the actual face orientation of the opposing timber so they are
    # correct at any joint angle.
    # -------------------------------------------------------------------------

    # Tongue end cut — aligns with the fork face opposite the entry face
    # (fork_far_face already computed above for slot depth)

    # HalfSpace normal points in the tongue end direction (away from the tongue body)
    tongue_end_hs_normal_global = (
        fork_far_face_normal_global
        if safe_dot_product(fork_far_face_normal_global, tongue_end_direction) > 0
        else -fork_far_face_normal_global
    )
    # Convert to tongue timber local coordinates
    tongue_end_cut_local_normal = safe_transform_vector(
        tongue_timber.orientation.matrix.T, tongue_end_hs_normal_global
    )
    tongue_end_cut_local_offset = (
        safe_dot_product(tongue_end_hs_normal_global, fork_far_face_point_global)
        - safe_dot_product(tongue_end_hs_normal_global, tongue_timber.get_bottom_position_global())
    )
    tongue_end_cut = HalfSpace(
        normal=tongue_end_cut_local_normal,
        offset=tongue_end_cut_local_offset,
        label=CutCSGLabel("tongue_end_cut"),
    )

    # Fork end cut — aligns with the tongue face opposite the entry face
    tongue_entry_long_face = tongue_timber.get_closest_oriented_long_face_from_global_direction(-fork_end_direction)
    tongue_far_face = tongue_entry_long_face.to.face().get_opposite_face()
    tongue_far_face_normal_global = tongue_timber.get_face_direction_global(tongue_far_face)
    tongue_far_face_point_global = get_center_point_on_face_global(tongue_far_face, tongue_timber)

    fork_end_hs_normal_global = (
        tongue_far_face_normal_global
        if safe_dot_product(tongue_far_face_normal_global, fork_end_direction) > 0
        else -tongue_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, tongue_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"),
    )

    tongue_end_cut_distance_from_bottom = safe_dot_product(
        fork_far_face_point_global - tongue_timber.get_bottom_position_global(),
        tongue_timber.get_length_direction_global(),
    )
    fork_end_cut_distance_from_bottom = safe_dot_product(
        tongue_far_face_point_global - fork_timber.get_bottom_position_global(),
        fork_timber.get_length_direction_global(),
    )

    tongue_negative_parts: list[CutCSG] = [tongue_negative_csg, tongue_end_cut]
    fork_negative_parts: list[CutCSG] = [fork_negative_csg, fork_end_cut]

    # -------------------------------------------------------------------------
    # Assemble cuts and joint
    # -------------------------------------------------------------------------
    tongue_cut = Cutting(
        timber=tongue_timber,
        maybe_top_end_cut_distance_from_bottom=tongue_end_cut_distance_from_bottom if tongue_end == TimberEnd.TOP else None,
        maybe_bottom_end_cut_distance_from_bottom=tongue_end_cut_distance_from_bottom if tongue_end == TimberEnd.BOTTOM else None,
        negative_csg=CSGUnion(children=tongue_negative_parts),
        label=CutCSGLabel("tongue_cut"),
        # Assembly: the tongue withdraws out of the fork slot along its axis;
        # it engages the fork's full thickness in that direction.
        assembly_freedom=AssemblyFreedom.combine(
            AssemblyFreedom.translation(
                -tongue_end_direction,
                freed_after=fork_timber.get_size_in_direction_3d(tongue_end_direction),
            ),
            AssemblyFreedom.translation(
                fork_end_direction,
                freed_after=fork_timber.get_size_in_direction_3d(fork_end_direction),
            ),
        )
    )

    fork_cut = 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=CSGUnion(children=fork_negative_parts),
        label=CutCSGLabel("fork_cut"),
        assembly_freedom=AssemblyFreedom.combine(
            AssemblyFreedom.translation(
                tongue_end_direction,
                freed_after=fork_timber.get_size_in_direction_3d(tongue_end_direction),
            ),
            AssemblyFreedom.translation(
                -fork_end_direction,
                freed_after=fork_timber.get_size_in_direction_3d(fork_end_direction),
            ),
        )
    )

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

cut_plain_tongue_and_fork_joint_on_plane_aligned_timbers

cut_plain_tongue_and_fork_joint_on_plane_aligned_timbers(arrangement: CornerJointTimberArrangement, tongue_thickness: Optional[Numeric] = None, tongue_position: Numeric = scalar(0)) -> Joint

Compatibility alias for cut_tongue_and_fork_corner_joint_on_plane_aligned_timbers.

Source code in kumiki/joints/workshop/corner_joints.py
def cut_plain_tongue_and_fork_joint_on_plane_aligned_timbers(
    arrangement: CornerJointTimberArrangement,
    tongue_thickness: Optional[Numeric] = None,
    tongue_position: Numeric = scalar(0),
) -> Joint:
    """Compatibility alias for `cut_tongue_and_fork_corner_joint_on_plane_aligned_timbers`."""
    return cut_tongue_and_fork_corner_joint_on_plane_aligned_timbers(
        arrangement=arrangement,
        tongue_thickness=tongue_thickness,
        tongue_position=tongue_position,
    )

cut_plain_corner_lap_joint_on_plane_aligned_timbers

cut_plain_corner_lap_joint_on_plane_aligned_timbers(arrangement: CornerJointTimberArrangement, cut_ratio: Numeric = scalar(1, 2)) -> Joint

Creates a corner-lap joint between two corner timbers with trimmed ends.

Geometry is the same lap construction as cut_plain_cross_lap_joint, but applied to corner-arranged timbers and augmented with one end cut per timber so neither member extends past the corner.

Parameters:

Name Type Description Default
arrangement CornerJointTimberArrangement

Corner-joint arrangement with timber1, timber2, timber1_end, timber2_end, and optional front_face_on_timber1.

required
cut_ratio Numeric

Fraction [0, 1] controlling how much lap material is removed from each timber (same semantics as cross lap).

scalar(1, 2)

Returns:

Type Description
Joint

Joint object containing the two CutTimbers.

Raises:

Type Description
AssertionError

If timbers are not plane-aligned.

Source code in kumiki/joints/workshop/corner_joints.py
def cut_plain_corner_lap_joint_on_plane_aligned_timbers(arrangement: CornerJointTimberArrangement, cut_ratio: Numeric = scalar(1, 2)) -> Joint:
    """
    Creates a corner-lap joint between two corner timbers with trimmed ends.

    Geometry is the same lap construction as `cut_plain_cross_lap_joint`, but applied
    to corner-arranged timbers and augmented with one end cut per timber so neither
    member extends past the corner.

    Args:
        arrangement: Corner-joint arrangement with timber1, timber2, timber1_end,
            timber2_end, and optional front_face_on_timber1.
        cut_ratio: Fraction [0, 1] controlling how much lap material is removed
            from each timber (same semantics as cross lap).

    Returns:
        Joint object containing the two CutTimbers.

    Raises:
        AssertionError: If timbers are not plane-aligned.
    """
    from .cross_joints import cut_plain_cross_lap_joint

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

    timberA = arrangement.timber1
    timberB = arrangement.timber2
    timberA_end = arrangement.timber1_end
    timberB_end = arrangement.timber2_end

    # Reuse cross-lap geometry for the lap volume split.
    cross_lap_joint = cut_plain_cross_lap_joint(
        CrossJointTimberArrangement(
            timber1=timberA,
            timber2=timberB,
            front_face_on_timber1=arrangement.front_face_on_timber1,
        ),
        cut_ratio=cut_ratio,
    )


    timberA_end_direction = timberA.get_face_direction_global(timberA_end)
    timberB_end_direction = timberB.get_face_direction_global(timberB_end)

    timberB_entry_face = timberB.get_closest_oriented_face_from_global_direction(-timberA_end_direction)
    timberB_far_face = timberB_entry_face.get_opposite_face()
    timberB_far_face_point_global = get_center_point_on_face_global(timberB_far_face, timberB)

    timberA_entry_face = timberA.get_closest_oriented_face_from_global_direction(-timberB_end_direction)
    timberA_far_face = timberA_entry_face.get_opposite_face()
    timberA_far_face_point_global = get_center_point_on_face_global(timberA_far_face, timberA)

    timberA_end_cut_distance_from_bottom = safe_dot_product(
        timberB_far_face_point_global - timberA.get_bottom_position_global(),
        timberA.get_length_direction_global(),
    )
    timberB_end_cut_distance_from_bottom = safe_dot_product(
        timberA_far_face_point_global - timberB.get_bottom_position_global(),
        timberB.get_length_direction_global(),
    )

    # LOL so smart, whatever
    cross_lap_cutA = cross_lap_joint.cuttings["timberA"]
    cross_lap_cutB = cross_lap_joint.cuttings["timberB"]

    cutA = Cutting(
        timber=timberA,
        maybe_top_end_cut_distance_from_bottom=(
            timberA_end_cut_distance_from_bottom
            if timberA_end == TimberEnd.TOP
            else cross_lap_cutA.maybe_top_end_cut_distance_from_bottom
        ),
        maybe_bottom_end_cut_distance_from_bottom=(
            timberA_end_cut_distance_from_bottom
            if timberA_end == TimberEnd.BOTTOM
            else cross_lap_cutA.maybe_bottom_end_cut_distance_from_bottom
        ),
        negative_csg=cross_lap_cutA.negative_csg,
        label=cross_lap_cutA.label,
        # Assembly: the corner lap separates exactly like the cross lap it is
        # built from — carry the lap's escape freedom through the rebuild.
        assembly_freedom=cross_lap_cutA.assembly_freedom,
    )
    cutB = Cutting(
        timber=timberB,
        maybe_top_end_cut_distance_from_bottom=(
            timberB_end_cut_distance_from_bottom
            if timberB_end == TimberEnd.TOP
            else cross_lap_cutB.maybe_top_end_cut_distance_from_bottom
        ),
        maybe_bottom_end_cut_distance_from_bottom=(
            timberB_end_cut_distance_from_bottom
            if timberB_end == TimberEnd.BOTTOM
            else cross_lap_cutB.maybe_bottom_end_cut_distance_from_bottom
        ),
        negative_csg=cross_lap_cutB.negative_csg,
        label=cross_lap_cutB.label,
        assembly_freedom=cross_lap_cutB.assembly_freedom,
    )

    return Joint(
        cuttings={
            "timberA": cutA,
            "timberB": cutB,
        },
        ticket=JointTicket(joint_type="plain_corner_lap"),
        jointAccessories={},
    )

cut_mitered_and_keyed_lap_joint_on_plane_aligned_timbers

cut_mitered_and_keyed_lap_joint_on_plane_aligned_timbers(arrangement: CornerJointTimberArrangement, lap_thickness: Optional[Numeric] = None, lap_start_distance_from_reference_miter_face: Optional[Numeric] = None, distance_between_lap_and_outside: Optional[Numeric] = None, num_laps: int = 2, key_width: Optional[Numeric] = None, key_thickness: Optional[Numeric] = None) -> Joint

Creates a mitered and keyed lap joint (箱相欠き車知栓仕口 / Hako Aikaki Shachi Sen Shikuchi) between two timbers.

This is a traditional Japanese timber joint that combines a miter joint with interlocking finger laps on the inside of the miter for additional mechanical strength.

Parameters:

Name Type Description Default
arrangement CornerJointTimberArrangement

Corner joint arrangement where timber1 and timber2 are the joined timbers, timber1_end and timber2_end are the joined ends, and front_face_on_timber1 defines the reference miter face on timber1.

required
lap_thickness Optional[Numeric]

Thickness of each lap/finger (optional, auto-calculated if None)

None
lap_start_distance_from_reference_miter_face Optional[Numeric]

Distance from miter face to first lap (optional)

None
distance_between_lap_and_outside Optional[Numeric]

Inset distance from outer face (optional)

None
num_laps int

Number of interlocking laps/fingers (minimum 2)

2
key_width Optional[Numeric]

Width of each key measured along the diagonal direction. If None, defaults to lap_thickness.

None
key_thickness Optional[Numeric]

Thickness of each key (the narrow dimension). If None, defaults to lap_thickness / 3.

None

Returns:

Type Description
Joint

Joint object containing the two CutTimbers with miter and finger cuts applied

Raises:

Type Description
ValueError

If parameters are invalid or timbers are not properly positioned

Source code in kumiki/joints/workshop/corner_joints.py
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
def cut_mitered_and_keyed_lap_joint_on_plane_aligned_timbers(arrangement: CornerJointTimberArrangement, lap_thickness: Optional[Numeric] = None, lap_start_distance_from_reference_miter_face: Optional[Numeric] = None, distance_between_lap_and_outside: Optional[Numeric] = None, num_laps: int = 2, key_width: Optional[Numeric] = None, key_thickness: Optional[Numeric] = None) -> Joint:
    """
    Creates a mitered and keyed lap joint (箱相欠き車知栓仕口 / Hako Aikaki Shachi Sen Shikuchi)
    between two timbers.

    This is a traditional Japanese timber joint that combines a miter joint with interlocking
    finger laps on the inside of the miter for additional mechanical strength.

    Args:
        arrangement: Corner joint arrangement where timber1 and timber2 are the joined
            timbers, timber1_end and timber2_end are the joined ends, and
            front_face_on_timber1 defines the reference miter face on timber1.
        lap_thickness: Thickness of each lap/finger (optional, auto-calculated if None)
        lap_start_distance_from_reference_miter_face: Distance from miter face to first lap (optional)
        distance_between_lap_and_outside: Inset distance from outer face (optional)
        num_laps: Number of interlocking laps/fingers (minimum 2)
        key_width: Width of each key measured along the diagonal direction. If None,
            defaults to lap_thickness.
        key_thickness: Thickness of each key (the narrow dimension). If None,
            defaults to lap_thickness / 3.

    Returns:
        Joint object containing the two CutTimbers with miter and finger cuts applied

    Raises:
        ValueError: If parameters are invalid or timbers are not properly positioned
    """
    require_check(arrangement.check_plane_aligned())
    warn_if_arrangement_timbers_imperfect(arrangement)
    assert arrangement.front_face_on_timber1 is not None, (
        "arrangement.front_face_on_timber1 must be set to determine the reference miter face"
    )
    timberA = arrangement.timber1
    timberA_end = arrangement.timber1_end
    timberA_reference_miter_face = arrangement.front_face_on_timber1
    timberB = arrangement.timber2
    timberB_end = arrangement.timber2_end

    # ========================================================================
    # Step 1: Parameter validation and find matching miter faces
    # ========================================================================

    # Validate num_laps
    if num_laps < 2:
        raise ValueError(f"num_laps must be at least 2, got {num_laps}")

    # Find matching miter face on timberB
    timberA_miter_face_normal = timberA.get_face_direction_global(timberA_reference_miter_face)
    timberB_reference_miter_face_enum = timberB.get_closest_oriented_face_from_global_direction(
        timberA_miter_face_normal
    )

    # Convert to TimberLongFace if it's a long face
    if timberB_reference_miter_face_enum == TimberFace.RIGHT:
        timberB_reference_miter_face = TimberLongFace.RIGHT
    elif timberB_reference_miter_face_enum == TimberFace.LEFT:
        timberB_reference_miter_face = TimberLongFace.LEFT
    elif timberB_reference_miter_face_enum == TimberFace.FRONT:
        timberB_reference_miter_face = TimberLongFace.FRONT
    elif timberB_reference_miter_face_enum == TimberFace.BACK:
        timberB_reference_miter_face = TimberLongFace.BACK
    else:
        raise ValueError(
            f"timberB matching face must be a long face (not TOP or BOTTOM), got {timberB_reference_miter_face_enum}"
        )

    # Verify the normals are parallel
    timberB_miter_face_normal = timberB.get_face_direction_global(timberB_reference_miter_face)
    if not are_vectors_parallel(timberA_miter_face_normal, timberB_miter_face_normal):
        raise ValueError(
            f"Miter face normals must be parallel. "
            f"timberA face normal: {timberA_miter_face_normal.T}, "
            f"timberB face normal: {timberB_miter_face_normal.T}"
        )


    # ========================================================================
    # Step 2: Calculate and validate angle between timbers
    # ========================================================================

    # Get end directions (pointing outward from timber)
    if timberA_end == TimberEnd.TOP:
        directionA = timberA.get_length_direction_global()
    else:  # BOTTOM
        directionA = -timberA.get_length_direction_global()

    if timberB_end == TimberEnd.TOP:
        directionB = timberB.get_length_direction_global()
    else:  # BOTTOM
        directionB = -timberB.get_length_direction_global()

    # Calculate angle between timbers using dot product
    # angle = acos(directionA · directionB)
    dot_product = safe_dot_product(safe_normalize_vector(directionA), safe_normalize_vector(directionB))

    # Clamp to [-1, 1] to avoid numerical issues with acos
    dot_product_clamped = Max(scalar(-1), Min(scalar(1), dot_product))
    angle = acos(dot_product_clamped)

    # Validate angle is between 45 and 135 degrees
    min_angle = radians(pi / scalar(4))  # 45 degrees
    max_angle = radians(scalar(3) * pi / scalar(4))  # 135 degrees

    if angle < min_angle or angle > max_angle:
        raise ValueError(
            f"Angle between timbers must be between 45° and 135°, "
            f"got {float(angle * 180 / pi):.1f}°"
        )

    # ========================================================================
    # Step 3: Calculate dimensions and default values
    # ========================================================================

    # Get miter face dimensions
    miter_face_depth = timberA.get_size_in_face_normal_axis(timberA_reference_miter_face.to.face())
    miter_face_width = timberA.get_size_in_face_normal_axis(timberA_reference_miter_face.rotate_right().to.face())

    # Calculate lap_thickness and lap_start_distance defaults
    lap_thickness_final: Numeric
    lap_start_distance_final: Numeric

    if lap_thickness is None and lap_start_distance_from_reference_miter_face is None:
        # Both None: distribute evenly
        # Total space for laps plus margins: miter_face_depth
        # We want: margin + num_laps * thickness + margin = miter_face_depth
        # With equal margins: 2*margin + num_laps*thickness = miter_face_depth
        # Let's use: margin = thickness, so: (num_laps + 2)*thickness = miter_face_depth
        lap_thickness_final = miter_face_depth / (num_laps + scalar(2))
        lap_start_distance_final = lap_thickness_final

    elif lap_thickness is None:
        # Only start distance given, calculate thickness
        assert lap_start_distance_from_reference_miter_face is not None  # Type narrowing
        remaining_depth: Numeric = miter_face_depth - lap_start_distance_from_reference_miter_face
        lap_thickness_final = remaining_depth / scalar(num_laps+1)
        lap_start_distance_final = lap_start_distance_from_reference_miter_face
    elif lap_start_distance_from_reference_miter_face is None:
        # Only thickness given, calculate start distance
        total_lap_depth: Numeric = lap_thickness * num_laps
        lap_start_distance_final = (miter_face_depth - total_lap_depth) / scalar(2)
        lap_thickness_final = lap_thickness
    else:
        # Both provided
        lap_thickness_final = lap_thickness
        lap_start_distance_final = lap_start_distance_from_reference_miter_face

    # Default distance_between_lap_and_outside
    if distance_between_lap_and_outside is None:
        distance_between_lap_and_outside = miter_face_width * scalar(1, 5)


    # ========================================================================
    # Step 4: Validate fit
    # ========================================================================

    # Check laps fit in depth
    total_lap_depth = lap_start_distance_final + lap_thickness_final * num_laps
    if total_lap_depth >= miter_face_depth:
        raise ValueError(
            f"Laps do not fit in timber depth. "
            f"Total lap depth: {float(total_lap_depth):.3f}, "
            f"Miter face depth: {float(miter_face_depth):.3f}"
        )

    # Check laps fit on timberB
    # TODO fix, you need to mark the lap start/end positions and measure on timberB to see that they are both within the timberB miter face depth
    timberB_miter_face_depth = timberB.get_size_in_face_normal_axis(timberB_reference_miter_face.to.face())
    if total_lap_depth >= timberB_miter_face_depth:
        raise ValueError(
            f"Laps do not fit on timberB. "
            f"Total lap depth: {float(total_lap_depth):.3f}, "
            f"TimberB miter face depth: {float(timberB_miter_face_depth):.3f}"
        )

    # ========================================================================
    # Step 5: Find inner face normals and inner shoulder direction
    # ========================================================================

    # Determine which faces are on the "inside" of the corner
    # The inside faces are perpendicular to the miter face and point toward each other

    # For a corner joint, we need to find the faces that face toward the other timber
    # Use cross product of miter normal and length direction to find the perpendicular faces

    # Calculate the diagonal direction (bisector between timberA and timberB)
    # This is the average of the two end directions
    diagonal_direction = safe_normalize_vector(directionA + directionB)

    # Find inner faces by looking for the closest oriented face to the negative diagonal direction
    # The negative diagonal points toward the inside of the corner
    negative_diagonal = -diagonal_direction
    timberA_inner_face_enum = timberA.get_closest_oriented_long_face_from_global_direction(negative_diagonal).to.face()
    timberB_inner_face_enum = timberB.get_closest_oriented_long_face_from_global_direction(negative_diagonal).to.face()

    # Get the inner face normals
    timberA_inner_face_normal = timberA.get_face_direction_global(timberA_inner_face_enum)
    timberB_inner_face_normal = timberB.get_face_direction_global(timberB_inner_face_enum)

    # The inner shoulder axis is the intersection line of the two inner face planes
    # Direction of intersection line = cross product of the two normals
    inner_shoulder_direction = cross_product(timberA_inner_face_normal, timberB_inner_face_normal)
    inner_shoulder_direction = safe_normalize_vector(inner_shoulder_direction)

    # validate that the timber size in the inner_face axis direction is the same on both timbers
    timberA_inner_face_size = timberA.get_size_in_face_normal_axis(timberA_inner_face_enum)
    timberB_inner_face_size = timberB.get_size_in_face_normal_axis(timberB_inner_face_enum)
    if not safe_zero_test(timberA_inner_face_size - timberB_inner_face_size):
        raise ValueError(
            f"Timber widths in the miter plane are not the same. "
            f"TimberA size: {float(timberA_inner_face_size):.3f}, "
            f"TimberB size: {float(timberB_inner_face_size):.3f}"
        )

    # ========================================================================
    # Step 6: Create marking transform on timberA
    # ========================================================================


    # Find where timberB's centerline intersects timberA's centerline
    timberB_centerline = locate_centerline(timberB)
    centerline_marking = mark_distance_from_end_along_centerline(timberB_centerline, timberA, end=TimberEnd.BOTTOM)

    marking_position = timberA.get_bottom_position_global()
    marking_position = marking_position + timberA.get_length_direction_global() * centerline_marking.distance

    marking_position_on_centerline = marking_position + timberA_miter_face_normal * (-timberA.get_size_in_face_normal_axis(timberA_reference_miter_face.to.face()) / scalar(2))

    # Compute the angle between the two timbers to get the correct scale factor
    # The half-angle is the angle between the diagonal and either timber's length direction
    cos_angle = safe_dot_product(directionA, directionB)
    timber_angle = acos(cos_angle)  # Angle between the two timbers
    half_angle = timber_angle / scalar(2)  # Angle between diagonal and timber length

    # Scale factor for moving along diagonal to achieve perpendicular offset
    # For 90° joint: half_angle = 45°, scale = 1/sin(45°) = sqrt(2)
    # For 180° joint: half_angle = 90°, scale = 1/sin(90°) = 1
    # For 135° joint: half_angle = 67.5°, scale = 1/sin(67.5°)
    diagonal_scale_factor = scalar(1) / sin(half_angle)

    # Move to the inner shoulder edge along the diagonal direction
    inner_edge_offset = timberA.get_size_in_face_normal_axis(timberA_inner_face_enum) / scalar(2)
    diagonal_offset = inner_edge_offset * diagonal_scale_factor
    marking_position = marking_position_on_centerline - diagonal_direction * diagonal_offset

    # Create orientation for the marking transform
    # Z-axis points toward the end (along timber length direction or opposite)
    if timberA_end == TimberEnd.TOP:
        marking_z = timberA.get_length_direction_global()
    else:
        marking_z = -timberA.get_length_direction_global()

    # X-axis aligns with the inner shoulder direction
    marking_x = inner_shoulder_direction

    # Ensure correct orientation (might need to flip based on handedness)
    # Y-axis from cross product
    marking_y = cross_product(marking_z, marking_x)
    marking_y = safe_normalize_vector(marking_y)

    # Re-orthogonalize X to be perpendicular to Z
    marking_x = cross_product(marking_y, marking_z)
    marking_x = safe_normalize_vector(marking_x)

    # ========================================================================
    # Step 7: Generate finger prisms
    # ========================================================================

    # Calculate finger dimensions
    finger_length = lap_thickness_final

    # Finger size: X = miter_face_width, Y = miter_face_width * tan(half_angle)
    finger_size_x = miter_face_width
    finger_size_y = miter_face_width * tan(half_angle)
    finger_size = create_v2(finger_size_x, finger_size_y)

    # Create all fingers in global space
    all_fingers_global = []

    for i in range(num_laps):
        # Determine if this lap belongs to timber A or B
        # Even indices (0, 2, 4, ...) = timber A, odd indices (1, 3, 5, ...) = timber B
        is_timber_a = (i % 2 == 0)

        # Calculate Z position along timber length for this lap
        z_offset = lap_start_distance_final + i * lap_thickness_final

        # Start from marking_position (at centerline intersection, on miter face surface)
        finger_position = marking_position_on_centerline

        # Move along miter face normal (into timber) by z_offset
        finger_position = finger_position + timberA_miter_face_normal * z_offset

        # Create finger orientation with Z-axis pointing along miter face normal
        # Z-axis: points along miter face normal (into timber)
        finger_z = timberA_miter_face_normal

        # X-axis: along the inner face normal direction based on which timber this lap belongs to
        if is_timber_a:
            finger_x = timberA_inner_face_normal
        else:
            finger_x = timberB_inner_face_normal

        # Y-axis: perpendicular to both (computed via cross product)
        finger_y = cross_product(finger_z, finger_x)
        finger_y = safe_normalize_vector(finger_y)

        finger_orientation = Orientation(Matrix([
            [finger_x[0], finger_y[0], finger_z[0]],
            [finger_x[1], finger_y[1], finger_z[1]],
            [finger_x[2], finger_y[2], finger_z[2]]
        ]))

        # Rotate the finger transform around the inner shoulder edge axis
        finger_transform_global = Transform(position=finger_position, orientation=finger_orientation)

        # Create finger prism in global space
        finger_prism_global = RectangularPrism(
            size=finger_size,
            transform=finger_transform_global,
            start_distance=scalar(0),
            end_distance=finger_length,
            label=CutCSGLabel("lap_finger"),
        )

        all_fingers_global.append((finger_prism_global, is_timber_a))

    # Separate fingers by which timber they belong to (even = A, odd = B)
    A_finger_indices = [i for i in range(num_laps) if i % 2 == 0]
    B_finger_indices = [i for i in range(num_laps) if i % 2 == 1]

    # Helper function to convert finger from global to local and apply crop
    def convert_finger_to_local(finger_global: RectangularPrism, target_timber: TimberLike, is_timber_a_finger: bool) -> CutCSG:
        """Convert finger from global to local coordinates and apply crop using opposing timber's inner face."""
        # Determine opposing timber from finger type
        if is_timber_a_finger:
            # Crop A fingers using timberB's inner face
            opposing_timber = timberB
            opposing_inner_face_enum = timberB_inner_face_enum
        else:
            # Crop B fingers using timberA's inner face
            opposing_timber = timberA
            opposing_inner_face_enum = timberA_inner_face_enum

        # Convert to local space
        finger_transform_local = finger_global.transform.to_local_transform(target_timber.transform)
        finger_local = replace(finger_global, transform=finger_transform_local)

        # Apply crop using the opposing timber's inner face
        from kumiki.measuring import locate_into_face

        # Measure into opposing timber's inner face to create a cutting plane (in global space)
        crop_distance = miter_face_width - distance_between_lap_and_outside
        crop_plane = locate_into_face(crop_distance, opposing_inner_face_enum, opposing_timber)

        # Convert crop plane to local space of target timber
        # Convert UnsignedPlane point and normal to local coordinates
        crop_point_local = target_timber.transform.global_to_local(crop_plane.point)
        crop_normal_local = safe_transform_vector(target_timber.orientation.matrix.T, crop_plane.normal)

        # Convert to HalfSpace in local coordinates
        crop_offset_local = safe_dot_product(crop_point_local, crop_normal_local)
        # Use negative normal to represent the "too far into opposing timber" side
        crop_halfspace_local = HalfSpace(
            normal=-crop_normal_local,
            offset=-crop_offset_local,
            label=CutCSGLabel("finger_crop"),
        )

        # Crop the finger by subtracting everything beyond the half space
        finger_local = Difference(
            base=finger_local,
            subtract=[crop_halfspace_local],
            label=CutCSGLabel("lap_finger_cropped"),
        )

        return finger_local

    # A fingers in timberA local space (for subtracting from timberA)
    A_fingers_in_timberA = []
    for idx in A_finger_indices:
        finger_global, is_timber_a_finger = all_fingers_global[idx]
        finger_local = convert_finger_to_local(finger_global, timberA, is_timber_a_finger)
        A_fingers_in_timberA.append(finger_local)

    # B fingers in timberA local space (for adding voids to timberA)
    B_fingers_in_timberA = []
    for idx in B_finger_indices:
        finger_global, is_timber_a_finger = all_fingers_global[idx]
        finger_local = convert_finger_to_local(finger_global, timberA, is_timber_a_finger)
        B_fingers_in_timberA.append(finger_local)

    # A fingers in timberB local space (for adding voids to timberB)
    A_fingers_in_timberB = []
    for idx in A_finger_indices:
        finger_global, is_timber_a_finger = all_fingers_global[idx]
        finger_local = convert_finger_to_local(finger_global, timberB, is_timber_a_finger)
        A_fingers_in_timberB.append(finger_local)

    # B fingers in timberB local space (for subtracting from timberB)
    B_fingers_in_timberB = []
    for idx in B_finger_indices:
        finger_global, is_timber_a_finger = all_fingers_global[idx]
        finger_local = convert_finger_to_local(finger_global, timberB, is_timber_a_finger)
        B_fingers_in_timberB.append(finger_local)

    # ========================================================================
    # Step 7.5: Create Keys
    # ========================================================================

    # Set default key dimensions if not provided
    if key_width is None:
        key_width = lap_thickness_final
    if key_thickness is None:
        key_thickness = lap_thickness_final / scalar(3)


    key_depth = miter_face_width/sin(half_angle)-distance_between_lap_and_outside/sin(half_angle)

    keys_in_timberA = []
    keys_in_timberB = []
    key_wedges = []

    num_keys = num_laps - 1
    for i in range(num_keys):
        key_position = marking_position
        z_offset = lap_start_distance_final + (i+1) * lap_thickness_final
        key_position = key_position + timberA_miter_face_normal * z_offset

        # set key_orientation, it should point in the diagonal direction with y axis in the -timberA_miter_face_normal direction
        key_orientation_before_rotation = Orientation.from_z_and_y(
            z_direction=diagonal_direction,
            y_direction=-timberA_miter_face_normal
        )

        # Rotate it around the diagonal direction (X-axis) by the same rotation angle as the fingers
        # This aligns the key with the finger geometry
        key_rotation_sign = -1 if i % 2 == 0 else 1
        key_rotation_angle = key_rotation_sign * atan2(key_thickness, key_width)
        rotation_for_key = Orientation.from_axis_angle(diagonal_direction, radians(key_rotation_angle))
        key_orientation = rotation_for_key * key_orientation_before_rotation
        key_transform_global = Transform(position=key_position, orientation=key_orientation)

        # Create key prism in global space
        # Size: key_width (X) x key_thickness (Y), depth (Z) in start/end_distance
        key_size = create_v2(key_width, key_thickness)
        key_prism_global = RectangularPrism(
            size=key_size,
            transform=key_transform_global,
            start_distance=-key_depth,
            end_distance=key_depth,
            label=CutCSGLabel("key_slot"),
        )

        # Convert to timberA's local space using replace
        key_transform_local_A = key_transform_global.to_local_transform(timberA.transform)
        key_prism_in_timberA = replace(key_prism_global, transform=key_transform_local_A)
        keys_in_timberA.append(key_prism_in_timberA)

        # Convert to timberB's local space using replace
        key_transform_local_B = key_transform_global.to_local_transform(timberB.transform)
        key_prism_in_timberB = replace(key_prism_global, transform=key_transform_local_B)
        keys_in_timberB.append(key_prism_in_timberB)

        # Create Wedge accessory matching the key prism
        # The wedge has the same transform as the key prism (in global space)
        # base_width and tip_width are the same (key_width) since it's a rectangular shape
        # height is key_thickness, length is 2 * key_depth (from -key_depth to +key_depth)
        # Assembly: the key backs out along the diagonal (the way it was
        # driven); it locks the joint, so it pops at suborder 0 before the
        # halves slide apart.
        key_wedge = Wedge(
            transform=key_transform_global,
            base_width=key_width,
            tip_width=key_width,  # Same as base_width for rectangular shape
            height=key_thickness,
            length=key_depth,
            stickout_length=key_depth*scalar(1,4),
            assembly_freedom=AssemblyFreedom.translation(-diagonal_direction, freed_after=key_depth * scalar(2)),
            assembly_ordering=Ordering(0, -1),
        )
        key_wedges.append(key_wedge)

    # ========================================================================
    # Step 8: Create rough end cuts and miter half plane cuts
    # ========================================================================

    # Create rough end cuts perpendicular to timbers
    # These cross the corner of the miter


    if timberA_end == TimberEnd.TOP:
        # Cut the top: position is outward from marking, normal points up (to cut away top)
        rough_cut_position_A = marking_position + timberA.get_length_direction_global() * finger_size_y
        rough_cut_normal_A_global = timberA.get_length_direction_global()
    else:  # BOTTOM
        # Cut the bottom: position is outward from marking, normal points down (to cut away bottom)
        rough_cut_position_A = marking_position - timberA.get_length_direction_global() * finger_size_y
        rough_cut_normal_A_global = -timberA.get_length_direction_global()

    local_normal_A_rough = safe_transform_vector(timberA.orientation.matrix.T, rough_cut_normal_A_global)
    local_offset_A_rough = safe_dot_product(rough_cut_position_A, rough_cut_normal_A_global) - safe_dot_product(rough_cut_normal_A_global, timberA.get_bottom_position_global())
    rough_end_cut_A = HalfSpace(normal=local_normal_A_rough, offset=local_offset_A_rough)

    if timberB_end == TimberEnd.TOP:
        rough_cut_position_B = marking_position + timberB.get_length_direction_global() * finger_size_y
        rough_cut_normal_B_global = timberB.get_length_direction_global()
    else:  # BOTTOM
        rough_cut_position_B = marking_position - timberB.get_length_direction_global() * finger_size_y
        rough_cut_normal_B_global = -timberB.get_length_direction_global()

    local_normal_B_rough = safe_transform_vector(timberB.orientation.matrix.T, rough_cut_normal_B_global)
    local_offset_B_rough = safe_dot_product(rough_cut_position_B, rough_cut_normal_B_global) - safe_dot_product(rough_cut_normal_B_global, timberB.get_bottom_position_global())
    rough_end_cut_B = HalfSpace(normal=local_normal_B_rough, offset=local_offset_B_rough)

    # Create miter half plane cuts (for negative_csg)
    intersection_point = marking_position

    # Create miter plane normal
    normA = safe_normalize_vector(directionA)
    normB = safe_normalize_vector(directionB)
    bisector = safe_normalize_vector(normA + normB)
    plane_normal = cross_product(normA, normB)
    miter_normal = safe_normalize_vector(cross_product(bisector, plane_normal))

    # Create HalfSpace cuts for miter planes
    dot_A = safe_dot_product(normA, miter_normal)
    if safe_compare(dot_A, 0, Comparison.GT):
        normalA = miter_normal
    else:
        normalA = -miter_normal

    local_normalA = safe_transform_vector(timberA.orientation.matrix.T, normalA)
    local_offsetA = safe_dot_product(intersection_point, normalA) - safe_dot_product(normalA, timberA.get_bottom_position_global())

    dot_B = safe_dot_product(normB, miter_normal)
    if safe_compare(dot_B, 0, Comparison.GT):
        normalB = miter_normal
    else:
        normalB = -miter_normal

    local_normalB = safe_transform_vector(timberB.orientation.matrix.T, normalB)
    local_offsetB = safe_dot_product(intersection_point, normalB) - safe_dot_product(normalB, timberB.get_bottom_position_global())

    miter_half_plane_A = HalfSpace(
        normal=local_normalA, offset=local_offsetA, label=CutCSGLabel("miter_plane"))
    miter_half_plane_B = HalfSpace(
        normal=local_normalB, offset=local_offsetB, label=CutCSGLabel("miter_plane"))

    # ========================================================================
    # Step 9: Combine cuts and return Joint
    # ========================================================================

    # For A fingers: SUBTRACT from timberA's half plane, ADD to timberB's half plane
    # For B fingers: SUBTRACT from timberB's half plane, ADD to timberA's half plane

    # TimberA negative_csg:
    # - Start with miter_half_plane_A
    # - Subtract A fingers (they protrude, so remove them from the half plane cut)
    # - Add B fingers (they create voids)
    # - Add keys (they create voids)
    if A_fingers_in_timberA:
        # A fingers subtract from half plane
        miter_cut_A_with_fingers = Difference(
            miter_half_plane_A, A_fingers_in_timberA, label=CutCSGLabel("miter_waste"))
    else:
        miter_cut_A_with_fingers = miter_half_plane_A

    # Collect all voids for timberA: B fingers and keys
    voids_in_A = []
    if B_fingers_in_timberA:
        voids_in_A.extend(B_fingers_in_timberA)
    if keys_in_timberA:
        voids_in_A.extend(keys_in_timberA)

    if voids_in_A:
        negative_csg_A = CSGUnion([miter_cut_A_with_fingers] + voids_in_A)
    else:
        negative_csg_A = miter_cut_A_with_fingers

    # TimberB negative_csg:
    # - Start with miter_half_plane_B
    # - Subtract B fingers (they protrude)
    # - Add A fingers (they create voids)
    # - Add keys (they create voids)
    if B_fingers_in_timberB:
        # B fingers subtract from half plane
        miter_cut_B_with_fingers = Difference(
            miter_half_plane_B, B_fingers_in_timberB, label=CutCSGLabel("miter_waste"))
    else:
        miter_cut_B_with_fingers = miter_half_plane_B

    # Collect all voids for timberB: A fingers and keys
    voids_in_B = []
    if A_fingers_in_timberB:
        voids_in_B.extend(A_fingers_in_timberB)
    if keys_in_timberB:
        voids_in_B.extend(keys_in_timberB)

    if voids_in_B:
        negative_csg_B = CSGUnion([miter_cut_B_with_fingers] + voids_in_B)
    else:
        negative_csg_B = miter_cut_B_with_fingers

    rough_end_cut_A_z = (
        rough_end_cut_A.offset
        if timberA_end == TimberEnd.TOP
        else -rough_end_cut_A.offset
    )
    rough_end_cut_B_z = (
        rough_end_cut_B.offset
        if timberB_end == TimberEnd.TOP
        else -rough_end_cut_B.offset
    )

    # Assembly: with the keys removed, each half pulls back along its own
    # axis, sliding its lap fingers out of the other member. The keys lock
    # exactly this motion, so the timbers slide at suborder 1.
    cutA = Cutting(
        timber=timberA,
        maybe_top_end_cut_distance_from_bottom=rough_end_cut_A_z if timberA_end == TimberEnd.TOP else None,
        maybe_bottom_end_cut_distance_from_bottom=rough_end_cut_A_z if timberA_end == TimberEnd.BOTTOM else None,
        negative_csg=negative_csg_A,
        label=CutCSGLabel("mitered_lap_cut"),
        assembly_freedom=AssemblyFreedom.translation(
            -directionA, freed_after=timberB.get_size_in_direction_3d(directionA)
        ),
        assembly_ordering=Ordering(0, 0),
    )

    cutB = Cutting(
        timber=timberB,
        maybe_top_end_cut_distance_from_bottom=rough_end_cut_B_z if timberB_end == TimberEnd.TOP else None,
        maybe_bottom_end_cut_distance_from_bottom=rough_end_cut_B_z if timberB_end == TimberEnd.BOTTOM else None,
        negative_csg=negative_csg_B,
        label=CutCSGLabel("mitered_lap_cut"),
        assembly_freedom=AssemblyFreedom.translation(
            -directionB, freed_after=timberA.get_size_in_direction_3d(directionB)
        ),
        assembly_ordering=Ordering(0, 0),
    )

    # Create CutTimber objects
    cut_timberA = cutA
    cut_timberB = cutB


    # Create jointAccessories dict with key wedges
    joint_accessories = {}
    for i, wedge in enumerate(key_wedges):
        joint_accessories[f"key_{i}"] = wedge

    # Return Joint
    return Joint(
        cuttings={
            timberA.ticket.path: cut_timberA,
            timberB.ticket.path: cut_timberB
        },
        ticket=JointTicket(joint_type="mitered_and_keyed_lap"),
        jointAccessories=joint_accessories,
    )