Skip to content

kumiki.joints.workshop.free

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

kumiki.joints.workshop.free

Kumiki - Free joint construction functions

FreeDovetailShape dataclass

origin instance-attribute

origin: Transform

width instance-attribute

width: Numeric

depth instance-attribute

depth: Numeric

dovetail_angle instance-attribute

dovetail_angle: Numeric

forward_length class-attribute instance-attribute

forward_length: Optional[Numeric] = None

backward_length class-attribute instance-attribute

backward_length: Optional[Numeric] = None

taper_angle class-attribute instance-attribute

taper_angle: Numeric = scalar(0)

__post_init__

__post_init__()
Source code in kumiki/joints/workshop/free/free_dovetail_joint.py
def __post_init__(self):
    assert safe_compare(self.width, 0, Comparison.GT), "a dovetail needs a positive width"
    assert safe_compare(self.depth, 0, Comparison.GT), "a dovetail needs a positive depth"
    assert safe_compare(self.dovetail_angle, 0, Comparison.GT), \
        "a dovetail's angle must be positive: it widens away from its neck, toward +y"
    if not safe_zero_test(self.taper_angle):
        assert self.forward_length is not None and self.backward_length is not None, \
            "a tapered dovetail needs finite forward and backward lengths"
        assert safe_compare(self._half_width_at(self.forward_length), 0, Comparison.GT) \
            and safe_compare(self._half_width_at(-self.backward_length), 0, Comparison.GT), \
            "the taper closes the dovetail to nothing within its length"

profile_at

profile_at(z: Numeric) -> List[V2]

The dovetail's cross section at z, in its own x/y: small width at y=0, flaring to y=depth.

Source code in kumiki/joints/workshop/free/free_dovetail_joint.py
def profile_at(self, z: Numeric) -> List[V2]:
    """The dovetail's cross section at `z`, in its own x/y: small width at y=0, flaring to y=depth."""
    half = self._half_width_at(z)
    flare = self.depth * tan(self.dovetail_angle)
    return [create_v2(-half, scalar(0)), create_v2(half, scalar(0)),
            create_v2(half + flare, self.depth), create_v2(-half - flare, self.depth)]

csg

csg() -> CutCSG

The dovetail as a solid, in its own frame (origin's local space).

Source code in kumiki/joints/workshop/free/free_dovetail_joint.py
def csg(self) -> CutCSG:
    """The dovetail as a solid, in its own frame (`origin`'s local space)."""
    start = None if self.backward_length is None else -self.backward_length
    if safe_zero_test(self.taper_angle):
        return ConvexPolygonExtrusion(points=self.profile_at(scalar(0)), start_distance=start,
                                      end_distance=self.forward_length, feature_overrides=_DOVETAIL_FEATURES,
                                      label=CutCSGLabel("dovetail"))
    assert start is not None and self.forward_length is not None
    return ConvexPolygonSimpleLoft(bottom_points=self.profile_at(start),
                                   top_points=self.profile_at(self.forward_length),
                                   bottom_points_z_pos=start, top_points_z_pos=self.forward_length,
                                   feature_overrides=_DOVETAIL_FEATURES, label=CutCSGLabel("dovetail"))

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/free_house_joint.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:
        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):
            housed_prism_in_housing = adopt_csg(
                housed_timber.timber.transform,
                housing_timber.transform,
                housed_timber.get_extended_rough_csg_with_perfect_features_local(),
            )

            with warnings.catch_warnings():
                warnings.simplefilter("ignore", UserWarning)
                housed_aabb = housed_prism_in_housing.get_aabb()

            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

            def _is_outside_housing_cross_section(csg_in_housing_local: CutCSG) -> bool:
                with warnings.catch_warnings():
                    warnings.simplefilter("ignore", UserWarning)
                    aabb = csg_in_housing_local.get_aabb()
                if aabb.is_empty:
                    return True
                if any(v is None for v in (aabb.min_x, aabb.max_x, aabb.min_y, aabb.max_y)):
                    return False
                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
                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 safe_compare(cx_min, cx_max, Comparison.GT) or safe_compare(cy_min, cy_max, Comparison.GT):
                    return True
                return (
                    safe_compare(cx_min, half_w, Comparison.GT)
                    or safe_compare(cx_max, -half_w, Comparison.LT)
                    or safe_compare(cy_min, half_h, Comparison.GT)
                    or safe_compare(cy_max, -half_h, Comparison.LT)
                )

            def _prune_csg(csg: CutCSG) -> Optional[CutCSG]:
                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

            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)

            return Difference(
                housed_prism_in_housing, relevant_cuts,
                label=CutCSGLabel("housed_timber_body"),
            ) if relevant_cuts else housed_prism_in_housing

        return adopt_csg(
            housed_timber.transform,
            housing_timber.transform,
            housed_timber.get_actual_csg_local(),
        )

    # Each housed timber's body and cuts, which its own tree already names.
    housed_negative_csgs = [strip_features(_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,
        )

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

cut_free_dovetail_joint

cut_free_dovetail_joint(dovetail_timber: TimberLike, receiving_timber: TimberLike, dovetail: FreeDovetailShape) -> Joint

A dovetail of any shape and placement: the receiving timber gets the socket, the dovetail timber the tongue.

The tongue's neck is the dovetail's narrow face, at its origin, and it widens toward its tip along +y -- so the dovetail timber should lie on the -y side of the dovetail shape origin, and it tapers towards +y. Between the dovetail's ends, everything of the dovetail timber past the narrow face in +y is cut away except the tongue. That face is the joint's shoulder.

Source code in kumiki/joints/workshop/free/free_dovetail_joint.py
def cut_free_dovetail_joint(dovetail_timber: TimberLike, receiving_timber: TimberLike,
                            dovetail: FreeDovetailShape) -> Joint:
    """A dovetail of any shape and placement: the receiving timber gets the socket, the dovetail timber the tongue.

    The tongue's neck is the dovetail's narrow face, at its origin, and it widens toward its tip
    along +y -- so the dovetail timber should lie on the -y side of the dovetail shape origin, and it tapers towards +y.
    Between the dovetail's ends, everything of the dovetail timber past the narrow face in +y is cut away except the tongue. That face is the joint's shoulder.
    """
    # create a convex polygon extrusion from `dovetail`
    dovetail_csg = dovetail.csg()

    # remove the extrusion CSG from receiving timber
    socket = adopt_csg(dovetail.origin, receiving_timber.transform, dovetail_csg)

    # create a half space coincident the narrow (-y) face of the dovetail, its neck, covering the +y side
    region: CutCSG = HalfSpace(normal=create_v3(0, 1, 0), offset=scalar(0),
                               feature_overrides=[shoulder_override(HALF_SPACE_PLANE, name="shoulder")],
                               label=CutCSGLabel("shoulder"))

    # TODO use Difference not intersection, Difference is more efficient
    # then crop that half space with the forward backward ends of the dovetail shape forming a prism
    # that's infinite in 3 directions
    if dovetail.forward_length is not None:
        region = Intersection(left=region, right=HalfSpace(
            normal=create_v3(0, 0, -1), offset=-dovetail.forward_length, label=CutCSGLabel("forward_end")))
    if dovetail.backward_length is not None:
        region = Intersection(left=region, right=HalfSpace(
            normal=create_v3(0, 0, 1), offset=-dovetail.backward_length, label=CutCSGLabel("backward_end")))

    # remove the dovetail shape from the shape above
    waste = Difference(region, [dovetail_csg], label=CutCSGLabel("tongue_waste"))

    # remove the resulting shape from the dovetail timber
    tongue_cut = adopt_csg(dovetail.origin, dovetail_timber.transform, waste)

    # return the joint
    return Joint(
        cuttings={
            "dovetail_timber": Cutting(timber=dovetail_timber, negative_csg=tongue_cut,
                                       label=CutCSGLabel("free_dovetail")),
            "receiving_timber": Cutting(timber=receiving_timber, negative_csg=socket,
                                        label=CutCSGLabel("free_dovetail")),
        },
        ticket=JointTicket(joint_type="free_dovetail"),
        jointAccessories={},
    )