Skip to content

kumiki.joints.workshop.free_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.cut_free_house_joint -- you do not need to import from the submodule path shown in the heading above.

kumiki.joints.workshop.free_joints

Kumiki - Free joint construction functions Contains functions for creating joints with flexible geometry matching.

cut_free_house_joint

cut_free_house_joint(housing_timber: TimberLike, housed_timbers: list[Union[TimberLike, CutTimber]]) -> Joint

Creates a generic house joint where the housing timber is cut to receive one or more housed timbers. Each housed timber may be a CutTimber, in which case the housing cut follows that CutTimber's actual remaining body.

Parameters:

Name Type Description Default
housing_timber TimberLike

Timber that will be cut to house the housed timbers

required
housed_timbers list[Union[TimberLike, CutTimber]]

Timbers that will be housed

required

Returns:

Type Description
Joint

Joint with the cut housing timber keyed "housing_timber", and each housed timber

Joint

unmodified, keyed "housed_timber_1", "housed_timber_2", ... in input order. No

Joint

assembly freedoms are set (the connections are treated as rigid).

Source code in kumiki/joints/workshop/free_joints.py
def cut_free_house_joint(
    housing_timber: TimberLike,
    housed_timbers: list[Union[TimberLike, CutTimber]],
) -> Joint:
    """
    Creates a generic house joint where the housing timber is cut to receive one or more housed timbers.
    Each housed timber may be a CutTimber, in which case the housing cut follows that CutTimber's
    actual remaining body.

    Args:
        housing_timber: Timber that will be cut to house the housed timbers
        housed_timbers: Timbers that will be housed

    Returns:
        Joint with the cut housing timber keyed "housing_timber", and each housed timber
        unmodified, keyed "housed_timber_1", "housed_timber_2", ... in input order. No
        assembly freedoms are set (the connections are treated as rigid).
    """
    assert len(housed_timbers) > 0, "housed_timbers must contain at least one timber"

    def _compute_housed_body_in_housing_local(housed_timber: Union[TimberLike, CutTimber]) -> CutCSG:
        # if housed_timber is not perfect, output a warning indicating that cuts will be dependent on imperfect size
        # TODO flag joints or even CSG features (using tickets) that have cuts measured from imperfect features!!
        underlying = housed_timber.timber if isinstance(housed_timber, CutTimber) else housed_timber
        if not underlying.is_perfect_timber():
            warnings.warn(
                f"cut_free_house_joint: housed_timber (type {type(underlying).__name__}) is not a perfect "
                f"timber. The housing cut is based on its actual geometry and may produce unexpected results.",
                stacklevel=2,
            )

        if isinstance(housed_timber, CutTimber):
            # if housed_timber is a CutTimber
            # The volume to remove from the housing_timber is the CutTimber's actual body shape:
            #   housed_prism - (cuts on the housed_timber that intersect the housing_timber)
            # This correctly models the relief cut matching the housed piece including any mortises in it.

            # Housed timber's actual prism in housing_timber's local space. Uses the
            # EXTENDED (semi-infinite at any end with a cut) body, not the raw
            # un-extended actual CSG: the housed timber's own un-extended box may fall
            # short of the housing (e.g. a miter cut trims material back from an end
            # that was never drawn out to meet its neighbor), so cutting the raw box
            # by the miter/end-cut half-space would chop the tip off short instead of
            # producing the full pointed corner. Extending first and then subtracting
            # the cut (below) matches how CutTimber.render_timber_with_cuts_csg_local
            # already renders this same timber for display.
            housed_prism_in_housing = adopt_csg(
                housed_timber.timber.transform,
                housing_timber.transform,
                housed_timber._extended_timber_without_cuts_csg_local(),
            )

            # AABB of the housed_timber's prism in housing_timber's local space (used for pruning below)
            # TODO this is rather pointless, the AABB is almost always infinite as we use the extended timber
            # if you want to do this properly, get the RectangularPrism instead.
            with warnings.catch_warnings():
                warnings.simplefilter("ignore", UserWarning)
                housed_aabb = housed_prism_in_housing.get_aabb()

            # Housing_timber cross-section half-extents (in its own local space).
            half_w = housing_timber.size[0] / scalar(2)
            half_h = housing_timber.size[1] / scalar(2)

            def _take_max(a: Numeric, b: Numeric) -> Numeric:
                return a if safe_compare(a, b, Comparison.GE) else b

            def _take_min(a: Numeric, b: Numeric) -> Numeric:
                return a if safe_compare(a, b, Comparison.LE) else b

            # TODO TEST THIS, add some examples to test this
            def _is_outside_housing_cross_section(csg_in_housing_local: CutCSG) -> bool:
                # find the four long face planes of the AABB of the housing_timber
                # check whether CutTimber's CSG geometry lives entirely on the external side of the 4 planes
                # specifically, for each CSG leaf feature in the CutTimber's negative_csg, see if that feature
                # INTERSECTED with the housed_timbers actual AABB is entirely contained in the external side of the plane
                # if this is the case, that means that CSG feature does not actually affect the housed timber
                # and can be removed from the tree
                with warnings.catch_warnings():
                    warnings.simplefilter("ignore", UserWarning)
                    aabb = csg_in_housing_local.get_aabb()
                # Empty (e.g. EmptyCSG) -> trivially doesn't affect anything, safe to prune
                if aabb.is_empty:
                    return True
                # Unbounded (e.g. HalfSpace) -> conservatively keep
                if any(v is None for v in (aabb.min_x, aabb.max_x, aabb.min_y, aabb.max_y)):
                    return False
                # After the check above all four x/y bounds are non-None
                assert aabb.min_x is not None and aabb.max_x is not None
                assert aabb.min_y is not None and aabb.max_y is not None
                # Clip the leaf's AABB to the housed_timber's AABB
                cx_min: Numeric = _take_max(aabb.min_x, housed_aabb.min_x) if housed_aabb.min_x is not None else aabb.min_x
                cx_max: Numeric = _take_min(aabb.max_x, housed_aabb.max_x) if housed_aabb.max_x is not None else aabb.max_x
                cy_min: Numeric = _take_max(aabb.min_y, housed_aabb.min_y) if housed_aabb.min_y is not None else aabb.min_y
                cy_max: Numeric = _take_min(aabb.max_y, housed_aabb.max_y) if housed_aabb.max_y is not None else aabb.max_y
                # If the clip is empty, the feature does not overlap with the housed_timber at all
                if safe_compare(cx_min, cx_max, Comparison.GT) or safe_compare(cy_min, cy_max, Comparison.GT):
                    return True
                # Check if the clipped AABB is entirely beyond one of the four housing_timber long-face planes
                return (
                    safe_compare(cx_min, half_w, Comparison.GT)     # right of right face
                    or safe_compare(cx_max, -half_w, Comparison.LT) # left of left face
                    or safe_compare(cy_min, half_h, Comparison.GT)  # above front face
                    or safe_compare(cy_max, -half_h, Comparison.LT) # below back face
                )

            def _prune_csg(csg: CutCSG) -> Optional[CutCSG]:
                # generate the pruned CSG tree of the housed_timber as the negative_csg for the housing timber's cut
                # Recurse into SolidUnion to prune children individually; treat all other nodes atomically.
                if isinstance(csg, SolidUnion):
                    kept = []
                    for child in csg.children:
                        pruned = _prune_csg(child)
                        if pruned is not None:
                            kept.append(pruned)
                    if not kept:
                        return None
                    return kept[0] if len(kept) == 1 else SolidUnion(children=kept, label=csg.label)
                return None if _is_outside_housing_cross_section(csg) else csg

            # Collect and prune each cut's negative CSG, transformed to housing_timber's local space
            relevant_cuts = []
            for cut in housed_timber.cuts:
                cut_csg = cut.get_negative_csg_local()
                if cut_csg is None:
                    continue
                neg_in_housing = adopt_csg(
                    housed_timber.timber.transform,
                    housing_timber.transform,
                    cut_csg,
                )
                pruned = _prune_csg(neg_in_housing)
                if pruned is not None:
                    relevant_cuts.append(pruned)

            # negative_csg = housed_prism minus the relevant cuts = the actual body of the housed_timber
            return Difference(
                housed_prism_in_housing, relevant_cuts,
                label=CutCSGLabel("housed_timber_body"),
            ) if relevant_cuts else housed_prism_in_housing

        # if housed_timber is a TimberLike, use its actual CSG as the negative_csg for the housing timber's cut
        return adopt_csg(
            housed_timber.transform,
            housing_timber.transform,
            housed_timber.get_actual_csg_local(),
        )

    housed_negative_csgs = [_compute_housed_body_in_housing_local(housed_timber) for housed_timber in housed_timbers]
    housing_negative_csg = (
        housed_negative_csgs[0] if len(housed_negative_csgs) == 1
        else SolidUnion(children=housed_negative_csgs, label=CutCSGLabel("housed_timbers"))
    )
    cut_housing = Cutting(
        timber=housing_timber,
        negative_csg=housing_negative_csg,
        label=CutCSGLabel("housing_cut"),
    )

    cuttings: dict[str, Cutting] = {"housing_timber": cut_housing}
    for i, housed_timber in enumerate(housed_timbers, start=1):
        cuttings[f"housed_timber_{i}"] = Cutting(
            timber=housed_timber.timber if isinstance(housed_timber, CutTimber) else housed_timber,
        )

    # Assembly: no freedoms are set — the housing is derived from arbitrary
    # CSG bodies, so there is no reliable single escape direction to author
    # here. The connections are treated as rigid.
    return Joint(
        cuttings=cuttings,
        ticket=JointTicket(joint_type="free_house"),
        jointAccessories={},
    )