Skip to content

kumiki.joints.workshop.shavings

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

kumiki.joints.workshop.shavings

Joint shavings helpers - re-exports from all shavings submodules.

draw_gooseneck_polygon module-attribute

draw_gooseneck_polygon = draw_gooseneck_polygon_CONVEX

CSGUnion module-attribute

CSGUnion = SolidUnion

CSGDifference module-attribute

CSGDifference = Difference

OFFSET_TEST_POINT module-attribute

OFFSET_TEST_POINT = scalar(1, 1000)

SomeTimberFace module-attribute

SomeTimberFace = Union[TimberFace, TimberEnd, TimberLongFace]

BlockLike module-attribute

BlockLike = Union[Timber, Board]

PTW_FACE_PREFIX module-attribute

PTW_FACE_PREFIX = 'ptw.'

ROUGH_FACE_PREFIX module-attribute

ROUGH_FACE_PREFIX = 'rough.'

FeatureKey module-attribute

FeatureKey = Tuple[FeatureCategory, int]

START_CAP module-attribute

START_CAP: FeatureKey = (FeatureCategory.CAP, 0)

END_CAP module-attribute

END_CAP: FeatureKey = (FeatureCategory.CAP, 1)

ExtrusionFeatureKey module-attribute

ExtrusionFeatureKey = Union[int, ExtrusionCap]

FEATURE_FACE_TOLERANCE module-attribute

FEATURE_FACE_TOLERANCE = scalar('5e-4')

FEATURE_EDGE_TOLERANCE module-attribute

FEATURE_EDGE_TOLERANCE = scalar('2e-3')

FEATURE_POINT_TOLERANCE module-attribute

FEATURE_POINT_TOLERANCE = scalar('4e-3')

DEFAULT_FEATURE_TEST_TOLERANCES module-attribute

DEFAULT_FEATURE_TEST_TOLERANCES = FeatureTestTolerances()

FEATURE_GROUP_PAIRS module-attribute

FEATURE_GROUP_PAIRS: dict = {FeatureGroup.A: frozenset({FeatureGroup.B1, FeatureGroup.B2}), FeatureGroup.B1: frozenset({FeatureGroup.A}), FeatureGroup.B2: frozenset({FeatureGroup.A, FeatureGroup.B2}), FeatureGroup.C: frozenset({FeatureGroup.C}), FeatureGroup.NONE: frozenset()}

LocatedGeometry module-attribute

LocatedGeometry = Union[Point, Line, Plane]

Profiles module-attribute

Profiles = List[Profile]

EdgeOrCenterline module-attribute

EdgeOrCenterline = Union[TimberEdge, TimberCenterline]

LocatedTimberFeature module-attribute

LocatedTimberFeature = Union['Point', 'Line', 'Plane', 'UnsignedPlane', 'LineOnPlane', 'Space']

TimberLike module-attribute

Expr module-attribute

Expr = float

Float module-attribute

Float = float

Integer module-attribute

Integer = int

S module-attribute

S = float

sympify module-attribute

sympify = float

oo module-attribute

oo = math.inf

Abs module-attribute

Abs = abs

Min module-attribute

Min = min

Max module-attribute

Max = max

pi module-attribute

pi = math.pi

Numeric module-attribute

Numeric = Union[float, int]

V2 module-attribute

V2 = Matrix

V3 module-attribute

V3 = Matrix

Direction3D module-attribute

Direction3D = Matrix

EPSILON_GENERIC module-attribute

EPSILON_GENERIC = scalar('1e-8')

safe_prune module-attribute

safe_prune = prune

numeric_prune module-attribute

numeric_prune = prune

safe_norm module-attribute

safe_norm = giraffe_norm

numeric_norm module-attribute

numeric_norm = giraffe_norm

safe_det module-attribute

safe_det = giraffe_det

numeric_det module-attribute

numeric_det = giraffe_det

safe_simplify module-attribute

safe_simplify = giraffe_simplify

safe_compare module-attribute

safe_compare = giraffe_compare

numeric_compare module-attribute

numeric_compare = giraffe_compare

safe_dot_product module-attribute

safe_dot_product = giraffe_dot_product

numeric_dot_product module-attribute

numeric_dot_product = giraffe_dot_product

safe_transform_vector module-attribute

safe_transform_vector = giraffe_transform_vector

numeric_transform_vector module-attribute

numeric_transform_vector = giraffe_transform_vector

safe_normalize_vector module-attribute

safe_normalize_vector = giraffe_normalize_vector

numeric_normalize_vector module-attribute

numeric_normalize_vector = giraffe_normalize_vector

safe_magnitude module-attribute

safe_magnitude = giraffe_magnitude

numeric_magnitude module-attribute

numeric_magnitude = giraffe_magnitude

INCH_TO_METER module-attribute

INCH_TO_METER = scalar(254, 10000)

FOOT_TO_METER module-attribute

FOOT_TO_METER = scalar(3048, 10000)

SHAKU_TO_METER module-attribute

SHAKU_TO_METER = scalar(10, 33)

Profile module-attribute

Profile = List[V2]

IMPERFECT_TIMBER_WARNING module-attribute

IMPERFECT_TIMBER_WARNING = 'timber is imperfect (does not match perfect timber within), this joint currently does not supporting maknig relief cuts beyond the perfect timber within so the joint may not actually fit'

Ticket dataclass

Ticket(path: str = UNNAMED_TICKET_PATH)

Bases: ABC

Base ticket shared by all ticket categories.

The category is represented by the concrete subclass rather than an enum field.

hierarchical identifier using '/' as separator.

e.g. "posts/frontleft", "door/boards/1" The last segment is the display name; preceding segments are folder names.

path class-attribute instance-attribute

path: str = UNNAMED_TICKET_PATH

kumiki_id class-attribute instance-attribute

kumiki_id: KumikiId = field(default_factory=_next_kumiki_id, init=False, compare=False, repr=False)

get_name

get_name() -> str

Return the display name: the last segment of the path.

Source code in kumiki/ticket.py
def get_name(self) -> str:
    """Return the display name: the last segment of the path."""
    return self.path.rsplit("/", 1)[-1]

TimberTicket dataclass

TimberTicket(path: str = UNNAMED_TICKET_PATH, material: Optional[str] = None, reference_faces: Optional[tuple[str, ...]] = None, tags: tuple[TimberTag, ...] = ())

Bases: Ticket

Ticket metadata for physical timber members.

material class-attribute instance-attribute

material: Optional[str] = None

reference_faces class-attribute instance-attribute

reference_faces: Optional[tuple[str, ...]] = None

tags class-attribute instance-attribute

tags: tuple[TimberTag, ...] = ()

__post_init__

__post_init__() -> None
Source code in kumiki/ticket.py
def __post_init__(self) -> None:
    object.__setattr__(self, "tags", normalize_timber_tags(self.tags))

with_tags

with_tags(*tags: Union[TimberTag, str]) -> Self

Return a copy of this ticket carrying these tags as well as its own.

Source code in kumiki/ticket.py
def with_tags(self, *tags: Union[TimberTag, str]) -> Self:
    """Return a copy of this ticket carrying these tags as well as its own."""
    return self._replace_tags((*self.tags, *tags))

with_member

with_member(member: Union[Member, str]) -> Self

Return a copy in this member role, replacing whatever role it had.

with_tags cannot do this: a second role is an error, not an addition.

Source code in kumiki/ticket.py
def with_member(self, member: Union[Member, str]) -> Self:
    """Return a copy in this member role, replacing whatever role it had.

    with_tags cannot do this: a second role is an error, not an addition.
    """
    kept = tuple(tag for tag in self.tags if not isinstance(tag, MemberTag))
    return self._replace_tags((*kept, MemberTag(member)))

AccessoryTicket dataclass

AccessoryTicket(path: str = UNNAMED_TICKET_PATH)

Bases: Ticket

Ticket metadata for accessories (pegs, wedges, hardware, etc.).

JointTicket dataclass

JointTicket(path: str = UNNAMED_TICKET_PATH, joint_type: Optional[str] = None, tags: tuple = ())

Bases: Ticket

Concept ticket metadata for joints.

joint_type class-attribute instance-attribute

joint_type: Optional[str] = None

tags class-attribute instance-attribute

tags: tuple = ()

__post_init__

__post_init__() -> None
Source code in kumiki/ticket.py
def __post_init__(self) -> None:
    if self.tags:
        warnings.warn(
            f"JointTicket({self.path!r}) was given tags {tuple(self.tags)!r}. Joints do "
            "not carry tags yet, so these are discarded. Move them to the timbers the "
            "joint is cut on, or drop them."
        )
        object.__setattr__(self, "tags", ())

Drawing dataclass

Drawing(name: str, timber_paths: Tuple[TimberPath, ...] = (), drawing_id: Optional[DrawingId] = None, measurements: Mapping[str, Tuple[Measure, ...]] = dict())

A drawing the frame asks for: a name, and which timbers it is of.

Timbers are named by path, the same name they carry everywhere else, and by path alone -- which of two timbers sharing a path is not a question a name can answer, and a drawing of "the front left post" should not have to know whether one was made twice. A path naming no timber is not an error either: a drawing of a timber a later edit removed is worth keeping and showing as empty, rather than failing to raise the frame it belongs to.

drawing_id is what an override in the drawings file names, so it has to survive editing the code around it. It defaults to the name, which is stable as long as the name is.

name instance-attribute

name: str

timber_paths class-attribute instance-attribute

timber_paths: Tuple[TimberPath, ...] = ()

drawing_id class-attribute instance-attribute

drawing_id: Optional[DrawingId] = None

measurements class-attribute instance-attribute

measurements: Mapping[str, Tuple[Measure, ...]] = field(default_factory=dict)

__post_init__

__post_init__()
Source code in kumiki/drawing.py
def __post_init__(self):
    object.__setattr__(self, 'timber_paths', tuple(
        TimberPath(path) if isinstance(path, str) else path
        for path in (self.timber_paths or ())
    ))
    if not self.drawing_id:
        object.__setattr__(self, 'drawing_id', DrawingId(self.name))
    elif isinstance(self.drawing_id, str):
        object.__setattr__(self, 'drawing_id', DrawingId(self.drawing_id))
    object.__setattr__(self, 'measurements', {
        str(viewport): tuple(measures)
        for viewport, measures in dict(self.measurements or {}).items()
    })

AssemblyFreedom dataclass

AssemblyFreedom(translations: Tuple[TranslationDof, ...] = (), rotations: Tuple[RotationDof, ...] = ())

The freedom shape for ONE member within ONE joint.

Currently a set of independent half/full intervals from 0 in R6. Future richer shapes (e.g. requires twisting while moving, or move-then-twist sequences) should extend this class rather than being encoded by callers.

translations class-attribute instance-attribute

translations: Tuple[TranslationDof, ...] = ()

rotations class-attribute instance-attribute

rotations: Tuple[RotationDof, ...] = ()

translation staticmethod

translation(direction: Direction3D, freed_after: Numeric) -> AssemblyFreedom

A single half-interval translational freedom along direction.

Source code in kumiki/assembly.py
@staticmethod
def translation(direction: Direction3D, freed_after: Numeric) -> "AssemblyFreedom":
    """A single half-interval translational freedom along ``direction``."""
    return AssemblyFreedom(
        translations=(TranslationDof(direction=safe_normalize_vector(direction), freed_after=freed_after),),
    )

bidirectional_translation staticmethod

bidirectional_translation(direction: Direction3D, freed_after: Numeric) -> AssemblyFreedom

A full-interval translational freedom: two opposite half DOFs.

Source code in kumiki/assembly.py
@staticmethod
def bidirectional_translation(direction: Direction3D, freed_after: Numeric) -> "AssemblyFreedom":
    """A full-interval translational freedom: two opposite half DOFs."""
    unit = safe_normalize_vector(direction)
    return AssemblyFreedom(
        translations=(
            TranslationDof(direction=unit, freed_after=freed_after),
            TranslationDof(direction=-unit, freed_after=freed_after),
        ),
    )

combine staticmethod

Union of the DOFs of two freedoms (a member that can escape either way).

Source code in kumiki/assembly.py
@staticmethod
def combine(f1: "AssemblyFreedom", f2: "AssemblyFreedom") -> "AssemblyFreedom":
    """Union of the DOFs of two freedoms (a member that can escape either way)."""
    return AssemblyFreedom(
        translations=f1.translations + f2.translations,
        rotations=f1.rotations + f2.rotations,
    )

AssemblyJoint dataclass

AssemblyJoint(name: str, members: Mapping[int, JointMemberSpec])

One joint in the assembly graph.

members must contain an entry for EVERY member participating in the joint.

name instance-attribute

name: str

members instance-attribute

members: Mapping[int, JointMemberSpec]

AssemblyMember dataclass

AssemblyMember(key: int, name: str, position: V3, bbox: Optional[BoundingBox] = None)

One movable body in the assembly graph (a timber or an accessory).

key instance-attribute

key: int

name instance-attribute

name: str

position instance-attribute

position: V3

bbox class-attribute instance-attribute

bbox: Optional[BoundingBox] = None

AssemblySolution dataclass

AssemblySolution(steps: Tuple[AssemblyStep, ...], warnings: Tuple[str, ...], failure: Optional[AssemblyFailure] = None)

steps instance-attribute

steps: Tuple[AssemblyStep, ...]

warnings instance-attribute

warnings: Tuple[str, ...]

failure class-attribute instance-attribute

failure: Optional[AssemblyFailure] = None

JointMemberSpec dataclass

JointMemberSpec(freedom: Optional[AssemblyFreedom] = None, ordering: Ordering = Ordering())

One member's participation in one joint.

A None freedom means "unspecified" and the connection is treated as rigid (the member is dragged along whenever the joint moves).

freedom class-attribute instance-attribute

freedom: Optional[AssemblyFreedom] = None

ordering class-attribute instance-attribute

ordering: Ordering = Ordering()

Ordering dataclass

Ordering(order: int = 0, suborder: int = 0)

Extraction position: compared lexicographically; smaller = out earlier.

suborder expresses sequencing required WITHIN a joint (locking accessories at -1 pop before members at 0) and is authored by the joint cut functions; order is the frame-level plan set via Joint.with_order.

order class-attribute instance-attribute

order: int = 0

suborder class-attribute instance-attribute

suborder: int = 0

label

label() -> str

Human-readable form: "2" or "2.1" when a suborder is present.

Source code in kumiki/assembly.py
def label(self) -> str:
    """Human-readable form: "2" or "2.1" when a suborder is present."""
    return str(self.order) if self.suborder == 0 else f"{self.order}.{self.suborder}"

AssemblyBoundingBox dataclass

AssemblyBoundingBox(min_x: float, max_x: float, min_y: float, max_y: float, min_z: float, max_z: float)

Axis-aligned box in GLOBAL space; used only by the Phase 4 clear-out.

min_x instance-attribute

min_x: float

max_x instance-attribute

max_x: float

min_y instance-attribute

min_y: float

max_y instance-attribute

max_y: float

min_z instance-attribute

min_z: float

max_z instance-attribute

max_z: float

TimberFeature

Bases: Enum

TOP_FACE class-attribute instance-attribute

TOP_FACE = 1

BOTTOM_FACE class-attribute instance-attribute

BOTTOM_FACE = 2

RIGHT_FACE class-attribute instance-attribute

RIGHT_FACE = 3

FRONT_FACE class-attribute instance-attribute

FRONT_FACE = 4

LEFT_FACE class-attribute instance-attribute

LEFT_FACE = 5

BACK_FACE class-attribute instance-attribute

BACK_FACE = 6

CENTERLINE class-attribute instance-attribute

CENTERLINE = 7

RIGHT_FRONT_EDGE class-attribute instance-attribute

RIGHT_FRONT_EDGE = 8

FRONT_LEFT_EDGE class-attribute instance-attribute

FRONT_LEFT_EDGE = 9

LEFT_BACK_EDGE class-attribute instance-attribute

LEFT_BACK_EDGE = 10

BACK_RIGHT_EDGE class-attribute instance-attribute

BACK_RIGHT_EDGE = 11

BOTTOM_RIGHT_EDGE class-attribute instance-attribute

BOTTOM_RIGHT_EDGE = 12

BOTTOM_FRONT_EDGE class-attribute instance-attribute

BOTTOM_FRONT_EDGE = 13

BOTTOM_LEFT_EDGE class-attribute instance-attribute

BOTTOM_LEFT_EDGE = 14

BOTTOM_BACK_EDGE class-attribute instance-attribute

BOTTOM_BACK_EDGE = 15

TOP_RIGHT_EDGE class-attribute instance-attribute

TOP_RIGHT_EDGE = 16

TOP_FRONT_EDGE class-attribute instance-attribute

TOP_FRONT_EDGE = 17

TOP_LEFT_EDGE class-attribute instance-attribute

TOP_LEFT_EDGE = 18

TOP_BACK_EDGE class-attribute instance-attribute

TOP_BACK_EDGE = 19

BOT_RIGHT_FRONT class-attribute instance-attribute

BOT_RIGHT_FRONT = 20

BOT_FRONT_LEFT class-attribute instance-attribute

BOT_FRONT_LEFT = 21

BOT_LEFT_BACK class-attribute instance-attribute

BOT_LEFT_BACK = 22

BOT_BACK_RIGHT class-attribute instance-attribute

BOT_BACK_RIGHT = 23

TOP_RIGHT_FRONT class-attribute instance-attribute

TOP_RIGHT_FRONT = 24

TOP_FRONT_LEFT class-attribute instance-attribute

TOP_FRONT_LEFT = 25

TOP_LEFT_BACK class-attribute instance-attribute

TOP_LEFT_BACK = 26

TOP_BACK_RIGHT class-attribute instance-attribute

TOP_BACK_RIGHT = 27

to property

Convert to TimberFeature for further conversions. This is a no-op.

feature

feature() -> TimberFeature

Convert to TimberFeature. This is a no-op.

Source code in kumiki/timber.py
def feature(self) -> 'TimberFeature':
    """Convert to TimberFeature. This is a no-op."""
    return self

face

face() -> TimberFace

Convert to TimberFace. Values 1-6 map to faces.

Source code in kumiki/timber.py
def face(self) -> 'TimberFace':
    """Convert to TimberFace. Values 1-6 map to faces."""
    if self.value not in range(1, 7):
        raise ValueError(f"Cannot convert {self} (value={self.value}) to TimberFace. Only values 1-6 are valid faces.")
    return TimberFace(self.value)

end

end() -> TimberEnd

Convert to TimberEnd. Values 1-2 map to ends.

Source code in kumiki/timber.py
def end(self) -> 'TimberEnd':
    """Convert to TimberEnd. Values 1-2 map to ends."""
    if self.value not in range(1, 3):
        raise ValueError(f"Cannot convert {self} (value={self.value}) to TimberEnd. Only values 1-2 are valid ends.")
    return TimberEnd(self.value)

long_face

long_face() -> TimberLongFace

Convert to TimberLongFace. Values 3-6 map to long faces.

Source code in kumiki/timber.py
def long_face(self) -> 'TimberLongFace':
    """Convert to TimberLongFace. Values 3-6 map to long faces."""
    if self.value not in range(3, 7):
        raise ValueError(f"Cannot convert {self} (value={self.value}) to TimberLongFace. Only values 3-6 are valid long faces.")
    return TimberLongFace(self.value)

edge

edge() -> TimberEdge

Convert to TimberEdge. Values 8-19 map to edges.

Source code in kumiki/timber.py
def edge(self) -> 'TimberEdge':
    """Convert to TimberEdge. Values 8-19 map to edges."""
    if self.value not in range(8, 20):
        raise ValueError(f"Cannot convert {self} (value={self.value}) to TimberEdge. Only values 8-19 are valid edges.")
    return TimberEdge(self.value)

centerline

centerline() -> TimberCenterline

Convert to TimberCenterline. Value 7 maps to CENTERLINE.

Source code in kumiki/timber.py
def centerline(self) -> 'TimberCenterline':
    """Convert to TimberCenterline. Value 7 maps to CENTERLINE."""
    if self.value != 7:
        raise ValueError(f"Cannot convert {self} (value={self.value}) to TimberCenterline. Only value 7 is valid.")
    return TimberCenterline(self.value)

long_edge

long_edge() -> TimberLongEdge

Convert to TimberLongEdge. Values 8-11 map to long edges.

Source code in kumiki/timber.py
def long_edge(self) -> 'TimberLongEdge':
    """Convert to TimberLongEdge. Values 8-11 map to long edges."""
    if self.value not in range(8, 12):
        raise ValueError(f"Cannot convert {self} (value={self.value}) to TimberLongEdge. Only values 8-11 are valid long edges.")
    return TimberLongEdge(self.value)

short_edge

short_edge() -> TimberShortEdge

Convert to TimberShortEdge. Values 12-19 map to short edges.

Source code in kumiki/timber.py
def short_edge(self) -> 'TimberShortEdge':
    """Convert to TimberShortEdge. Values 12-19 map to short edges."""
    if self.value not in range(12, 20):
        raise ValueError(f"Cannot convert {self} (value={self.value}) to TimberShortEdge. Only values 12-19 are valid short edges.")
    return TimberShortEdge(self.value)

corner

corner() -> TimberCorner

Convert to TimberCorner. Values 20-27 map to corners.

Source code in kumiki/timber.py
def corner(self) -> 'TimberCorner':
    """Convert to TimberCorner. Values 20-27 map to corners."""
    if self.value not in range(20, 28):
        raise ValueError(f"Cannot convert {self} (value={self.value}) to TimberCorner. Only values 20-27 are valid corners.")
    return TimberCorner(self.value)

TimberCorner

Bases: Enum

BOT_RIGHT_FRONT class-attribute instance-attribute

BOT_RIGHT_FRONT = 20

BOT_FRONT_LEFT class-attribute instance-attribute

BOT_FRONT_LEFT = 21

BOT_LEFT_BACK class-attribute instance-attribute

BOT_LEFT_BACK = 22

BOT_BACK_RIGHT class-attribute instance-attribute

BOT_BACK_RIGHT = 23

TOP_RIGHT_FRONT class-attribute instance-attribute

TOP_RIGHT_FRONT = 24

TOP_FRONT_LEFT class-attribute instance-attribute

TOP_FRONT_LEFT = 25

TOP_LEFT_BACK class-attribute instance-attribute

TOP_LEFT_BACK = 26

TOP_BACK_RIGHT class-attribute instance-attribute

TOP_BACK_RIGHT = 27

TimberEdge

Bases: Enum

RIGHT_FRONT class-attribute instance-attribute

RIGHT_FRONT = 8

FRONT_LEFT class-attribute instance-attribute

FRONT_LEFT = 9

LEFT_BACK class-attribute instance-attribute

LEFT_BACK = 10

BACK_RIGHT class-attribute instance-attribute

BACK_RIGHT = 11

BOTTOM_RIGHT class-attribute instance-attribute

BOTTOM_RIGHT = 12

BOTTOM_FRONT class-attribute instance-attribute

BOTTOM_FRONT = 13

BOTTOM_LEFT class-attribute instance-attribute

BOTTOM_LEFT = 14

BOTTOM_BACK class-attribute instance-attribute

BOTTOM_BACK = 15

TOP_RIGHT class-attribute instance-attribute

TOP_RIGHT = 16

TOP_FRONT class-attribute instance-attribute

TOP_FRONT = 17

TOP_LEFT class-attribute instance-attribute

TOP_LEFT = 18

TOP_BACK class-attribute instance-attribute

TOP_BACK = 19

to property

Convert to TimberFeature for further conversions.

canonical_line_from_corner

canonical_line_from_corner() -> Tuple[TimberCorner, TimberFace]

Returns canonical way to express a line from an edge. The line is defined by starting from the TimberCorner and pointing in the direction of the returned TimberFace's outward normal.

For long edges the line starts at the bottom corner and points toward TOP. For short edges the direction follows cross(long_face_normal, end_outward).

Source code in kumiki/timber.py
def canonical_line_from_corner(self) -> Tuple['TimberCorner', 'TimberFace']:
    """Returns canonical way to express a line from an edge.
    The line is defined by starting from the TimberCorner and pointing
    in the direction of the returned TimberFace's outward normal.

    For long edges the line starts at the bottom corner and points toward TOP.
    For short edges the direction follows cross(long_face_normal, end_outward).
    """
    _map = {
        TimberEdge.RIGHT_FRONT: (TimberCorner.BOT_RIGHT_FRONT, TimberFace.TOP),
        TimberEdge.FRONT_LEFT:  (TimberCorner.BOT_FRONT_LEFT,  TimberFace.TOP),
        TimberEdge.LEFT_BACK:   (TimberCorner.BOT_LEFT_BACK,   TimberFace.TOP),
        TimberEdge.BACK_RIGHT:  (TimberCorner.BOT_BACK_RIGHT,  TimberFace.TOP),

        TimberEdge.BOTTOM_RIGHT: (TimberCorner.BOT_BACK_RIGHT,  TimberFace.FRONT),
        TimberEdge.BOTTOM_FRONT: (TimberCorner.BOT_RIGHT_FRONT, TimberFace.LEFT),
        TimberEdge.BOTTOM_LEFT:  (TimberCorner.BOT_FRONT_LEFT,  TimberFace.BACK),
        TimberEdge.BOTTOM_BACK:  (TimberCorner.BOT_LEFT_BACK,   TimberFace.RIGHT),

        TimberEdge.TOP_RIGHT: (TimberCorner.TOP_RIGHT_FRONT, TimberFace.BACK),
        TimberEdge.TOP_FRONT: (TimberCorner.TOP_FRONT_LEFT,  TimberFace.RIGHT),
        TimberEdge.TOP_LEFT:  (TimberCorner.TOP_LEFT_BACK,   TimberFace.FRONT),
        TimberEdge.TOP_BACK:  (TimberCorner.TOP_BACK_RIGHT,  TimberFace.LEFT),
    }
    return _map[self]

long_edge

long_edge() -> TimberLongEdge

Convert to TimberLongEdge. Values 8-11 map to long edges.

Source code in kumiki/timber.py
def long_edge(self) -> 'TimberLongEdge':
    """Convert to TimberLongEdge. Values 8-11 map to long edges."""
    if self.value not in range(8, 12):
        raise ValueError(f"Cannot convert {self} (value={self.value}) to TimberLongEdge. Only values 8-11 are valid long edges.")
    return TimberLongEdge(self.value)

short_edge

short_edge() -> TimberShortEdge

Convert to TimberShortEdge. Values 12-19 map to short edges.

Source code in kumiki/timber.py
def short_edge(self) -> 'TimberShortEdge':
    """Convert to TimberShortEdge. Values 12-19 map to short edges."""
    if self.value not in range(12, 20):
        raise ValueError(f"Cannot convert {self} (value={self.value}) to TimberShortEdge. Only values 12-19 are valid short edges.")
    return TimberShortEdge(self.value)

TimberLongEdge

Bases: Enum

RIGHT_FRONT class-attribute instance-attribute

RIGHT_FRONT = 8

FRONT_LEFT class-attribute instance-attribute

FRONT_LEFT = 9

LEFT_BACK class-attribute instance-attribute

LEFT_BACK = 10

BACK_RIGHT class-attribute instance-attribute

BACK_RIGHT = 11

to property

Convert to TimberFeature for further conversions.

TimberShortEdge

Bases: Enum

BOTTOM_RIGHT class-attribute instance-attribute

BOTTOM_RIGHT = 12

BOTTOM_FRONT class-attribute instance-attribute

BOTTOM_FRONT = 13

BOTTOM_LEFT class-attribute instance-attribute

BOTTOM_LEFT = 14

BOTTOM_BACK class-attribute instance-attribute

BOTTOM_BACK = 15

TOP_RIGHT class-attribute instance-attribute

TOP_RIGHT = 16

TOP_FRONT class-attribute instance-attribute

TOP_FRONT = 17

TOP_LEFT class-attribute instance-attribute

TOP_LEFT = 18

TOP_BACK class-attribute instance-attribute

TOP_BACK = 19

to property

Convert to TimberFeature for further conversions.

end property

end: TimberEnd

Get the TimberEnd associated with this short edge.

long_face property

long_face: TimberLongFace

Get the TimberLongFace associated with this short edge.

PerfectTimberWithin dataclass

PerfectTimberWithin(length: Numeric, size: V2, transform: Transform, ticket: TimberTicket = TimberTicket())

Bases: ABC

Base class for all timber types in the timber framing system (immutable)

This is an abstract base class (ABC) to prevent direct instantiation. All timbers contain a perfect rectangular timber within their rough bounding box.

Note: Use create_timber() factory function to construct timber instances from length_direction and width_direction vectors. Subclasses are frozen to ensure immutability after construction.

Alternatively, if you already have a Transform object, you can construct a timber directly by passing: Timber(length, size, transform, ticket)

Attributes:

Name Type Description
length Numeric

Length of the timber along its centerline axis

size V2

Cross-sectional size (width, height) of the perfect timber within

transform Transform

Position and orientation in global coordinates

ticket TimberTicket

Ticket for this timber (used for rendering/debugging)

length instance-attribute

length: Numeric

size instance-attribute

size: V2

transform instance-attribute

transform: Transform

ticket class-attribute instance-attribute

ticket: TimberTicket = field(default_factory=TimberTicket)

orientation property

orientation: Orientation

Get the orientation from the transform.

__post_init__

__post_init__()
Source code in kumiki/timber.py
def __post_init__(self):
    if self.ticket.reference_faces is not None:
        self._validate_reference_faces()

get_orientation_global

get_orientation_global() -> Orientation

Get the orientation from the transform.

Source code in kumiki/timber.py
def get_orientation_global(self) -> Orientation:
    """Get the orientation from the transform."""
    return self.orientation

get_bottom_position_global

get_bottom_position_global() -> V3

Get the bottom position (center of bottom cross-section) in global coordinates from the transform.

Source code in kumiki/timber.py
def get_bottom_position_global(self) -> V3:
    """Get the bottom position (center of bottom cross-section) in global coordinates from the transform."""
    return self.transform.position

get_length_direction_global

get_length_direction_global() -> Direction3D

Get the length direction vector in global coordinates from the orientation matrix

Source code in kumiki/timber.py
def get_length_direction_global(self) -> Direction3D:
    """Get the length direction vector in global coordinates from the orientation matrix"""
    # Length direction is the 3rd column (index 2) of the rotation matrix
    # The +length direction is the +Z direction
    return Matrix([
        self.orientation.matrix[0, 2],
        self.orientation.matrix[1, 2],
        self.orientation.matrix[2, 2]
    ])

get_width_direction_global

get_width_direction_global() -> Direction3D

Get the width direction vector in global coordinates from the orientation matrix

Source code in kumiki/timber.py
def get_width_direction_global(self) -> Direction3D:
    """Get the width direction vector in global coordinates from the orientation matrix"""
    # Width direction is the 1st column (index 0) of the rotation matrix
    # The +width direction is the +X direction
    return Matrix([
        self.orientation.matrix[0, 0],
        self.orientation.matrix[1, 0],
        self.orientation.matrix[2, 0]
    ])

get_height_direction_global

get_height_direction_global() -> Direction3D

Get the height direction vector in global coordinates from the orientation matrix

Source code in kumiki/timber.py
def get_height_direction_global(self) -> Direction3D:
    """Get the height direction vector in global coordinates from the orientation matrix"""
    # Height direction is the 2nd column (index 1) of the rotation matrix
    # The +height direction is the +Y direction
    return Matrix([
        self.orientation.matrix[0, 1],
        self.orientation.matrix[1, 1],
        self.orientation.matrix[2, 1]
    ])

get_face_direction_global

get_face_direction_global(face: SomeTimberFace) -> Direction3D

Get the world direction vector for a specific face of this timber.

Parameters:

Name Type Description Default
face SomeTimberFace

The face to get the direction for (can be TimberFace, TimberEnd, or TimberLongFace)

required

Returns:

Type Description
Direction3D

Direction vector pointing outward from the specified face in world coordinates

Source code in kumiki/timber.py
def get_face_direction_global(self, face: SomeTimberFace) -> Direction3D:
    """
    Get the world direction vector for a specific face of this timber.

    Args:
        face: The face to get the direction for (can be TimberFace, TimberEnd, or TimberLongFace)

    Returns:
        Direction vector pointing outward from the specified face in world coordinates
    """
    # Convert to TimberFace
    face = face.to.face()

    if face == TimberFace.TOP:
        return self.get_length_direction_global()
    elif face == TimberFace.BOTTOM:
        return -self.get_length_direction_global()
    elif face == TimberFace.RIGHT:
        return self.get_width_direction_global()
    elif face == TimberFace.LEFT:
        return -self.get_width_direction_global()
    elif face == TimberFace.FRONT:
        return self.get_height_direction_global()
    else:  # BACK
        return -self.get_height_direction_global()

get_corner_position_global

get_corner_position_global(corner: TimberCorner) -> V3

Get the position of a corner in global coordinates.

Source code in kumiki/timber.py
def get_corner_position_global(self, corner: TimberCorner) -> V3:
    """Get the position of a corner in global coordinates."""
    _corner_to_faces = {
        TimberCorner.BOT_RIGHT_FRONT: (TimberFace.BOTTOM, TimberFace.RIGHT, TimberFace.FRONT),
        TimberCorner.BOT_FRONT_LEFT:  (TimberFace.BOTTOM, TimberFace.FRONT, TimberFace.LEFT),
        TimberCorner.BOT_LEFT_BACK:   (TimberFace.BOTTOM, TimberFace.LEFT,  TimberFace.BACK),
        TimberCorner.BOT_BACK_RIGHT:  (TimberFace.BOTTOM, TimberFace.BACK,  TimberFace.RIGHT),
        TimberCorner.TOP_RIGHT_FRONT: (TimberFace.TOP,    TimberFace.RIGHT, TimberFace.FRONT),
        TimberCorner.TOP_FRONT_LEFT:  (TimberFace.TOP,    TimberFace.FRONT, TimberFace.LEFT),
        TimberCorner.TOP_LEFT_BACK:   (TimberFace.TOP,    TimberFace.LEFT,  TimberFace.BACK),
        TimberCorner.TOP_BACK_RIGHT:  (TimberFace.TOP,    TimberFace.BACK,  TimberFace.RIGHT),
    }
    faces = _corner_to_faces[corner]
    timber_center = self.get_bottom_position_global() + self.get_length_direction_global() * self.length / 2
    position = timber_center
    for face in faces:
        position = position + self.get_face_direction_global(face) * self.get_size_in_face_normal_axis(face) / 2
    return position

get_size_index_in_long_face_normal_axis

get_size_index_in_long_face_normal_axis(face: TimberLongFace) -> int

Get the index of the size in the direction normal to the specified face.

Parameters:

Name Type Description Default
face TimberLongFace

The long face to get the size index for (RIGHT/LEFT or FRONT/BACK)

required

Returns:

Type Description
int

Index into self.size: 0 (width) for RIGHT/LEFT, 1 (height) for FRONT/BACK

Source code in kumiki/timber.py
def get_size_index_in_long_face_normal_axis(self, face: TimberLongFace) -> int:
    """
    Get the index of the size in the direction normal to the specified face.

    Args:
        face: The long face to get the size index for (RIGHT/LEFT or FRONT/BACK)

    Returns:
        Index into self.size: 0 (width) for RIGHT/LEFT, 1 (height) for FRONT/BACK
    """
    assert isinstance(face, TimberLongFace), f"expected TimberLongFace, got {type(face).__name__}"
    if face == TimberLongFace.RIGHT or face == TimberLongFace.LEFT:
        return 0
    elif face == TimberLongFace.FRONT or face == TimberLongFace.BACK:
        return 1
    else:
        raise ValueError(f"Unknown face: {face}")

get_size_in_face_normal_axis

get_size_in_face_normal_axis(face: SomeTimberFace) -> Numeric

Get the size of the timber in the direction normal to the specified face.

Parameters:

Name Type Description Default
face SomeTimberFace

The face to get the size for (can be TimberFace, TimberEnd, or TimberLongFace)

required

Returns:

Type Description
Numeric

The timber's extent along the axis normal to the given face: self.length for

Numeric

TOP/BOTTOM, self.size[0] (width) for RIGHT/LEFT, self.size[1] (height) for FRONT/BACK

Source code in kumiki/timber.py
def get_size_in_face_normal_axis(self, face: SomeTimberFace) -> Numeric:
    """
    Get the size of the timber in the direction normal to the specified face.

    Args:
        face: The face to get the size for (can be TimberFace, TimberEnd, or TimberLongFace)

    Returns:
        The timber's extent along the axis normal to the given face: self.length for
        TOP/BOTTOM, self.size[0] (width) for RIGHT/LEFT, self.size[1] (height) for FRONT/BACK
    """
    # Convert to TimberFace
    face = face.to.face()

    if face == TimberFace.TOP or face == TimberFace.BOTTOM:
        return self.length
    elif face == TimberFace.RIGHT or face == TimberFace.LEFT:
        return self.size[0]
    else:  # FRONT or BACK
        return self.size[1]

get_rough_size_in_face_normal_axis

get_rough_size_in_face_normal_axis(face: SomeTimberFace) -> Numeric

Get the full rough size of the timber in the direction normal to the specified face.

For long faces this returns the sum of the two half-sizes (e.g. right + left for RIGHT or LEFT). For end faces (TOP/BOTTOM) this returns the length.

Parameters:

Name Type Description Default
face SomeTimberFace

The face to get the size for (can be TimberFace, TimberEnd, or TimberLongFace)

required
Source code in kumiki/timber.py
def get_rough_size_in_face_normal_axis(self, face: SomeTimberFace) -> Numeric:
    """
    Get the full rough size of the timber in the direction normal to the specified face.

    For long faces this returns the sum of the two half-sizes (e.g. right + left for
    RIGHT or LEFT). For end faces (TOP/BOTTOM) this returns the length.

    Args:
        face: The face to get the size for (can be TimberFace, TimberEnd, or TimberLongFace)
    """
    face = face.to.face()

    if face == TimberFace.TOP or face == TimberFace.BOTTOM:
        return self.length

    width_halves, height_halves = self.get_rough_half_sizes()
    if face == TimberFace.RIGHT or face == TimberFace.LEFT:
        return width_halves[0] + width_halves[1]
    else:  # FRONT or BACK
        return height_halves[0] + height_halves[1]

get_half_rough_size_in_face_normal_axis

get_half_rough_size_in_face_normal_axis(face: SomeTimberFace) -> Numeric

Get the rough half-size of the timber from the centerline to the specified face.

Parameters:

Name Type Description Default
face SomeTimberFace

A long face (RIGHT, LEFT, FRONT, or BACK). TOP/BOTTOM will raise ValueError since length has no asymmetry concept.

required

Returns:

Type Description
Numeric

The half-size from centerline to the specified face.

Source code in kumiki/timber.py
def get_half_rough_size_in_face_normal_axis(self, face: SomeTimberFace) -> Numeric:
    """
    Get the rough half-size of the timber from the centerline to the specified face.

    Args:
        face: A long face (RIGHT, LEFT, FRONT, or BACK). TOP/BOTTOM will raise ValueError
              since length has no asymmetry concept.

    Returns:
        The half-size from centerline to the specified face.
    """
    face = face.to.face()
    width_halves, height_halves = self.get_rough_half_sizes()

    if face == TimberFace.RIGHT:
        return width_halves[0]
    elif face == TimberFace.LEFT:
        return width_halves[1]
    elif face == TimberFace.FRONT:
        return height_halves[0]
    elif face == TimberFace.BACK:
        return height_halves[1]
    else:
        raise ValueError(f"get_half_rough_size_in_face_normal_axis does not support end faces (got {face})")

get_nominal_size_in_face_normal_axis

get_nominal_size_in_face_normal_axis(face: SomeTimberFace) -> Numeric
Source code in kumiki/timber.py
@deprecated("use get_rough_size_in_face_normal_axis instead")
def get_nominal_size_in_face_normal_axis(self, face: SomeTimberFace) -> Numeric:
    return self.get_rough_size_in_face_normal_axis(face)

get_half_nominal_size_in_face_normal_axis

get_half_nominal_size_in_face_normal_axis(face: SomeTimberFace) -> Numeric
Source code in kumiki/timber.py
@deprecated("use get_half_rough_size_in_face_normal_axis instead")
def get_half_nominal_size_in_face_normal_axis(self, face: SomeTimberFace) -> Numeric:
    return self.get_half_rough_size_in_face_normal_axis(face)

get_size_in_direction_2d

get_size_in_direction_2d(direction: V2) -> Numeric

Get the size of the timber's cross-section measured along an arbitrary 2D direction.

The direction is in the timber's local cross-section plane where x is the width axis and y is the height axis. Returns the total extent (support width) of the rectangular cross-section projected onto that direction.

For axis-aligned directions this matches get_size_in_face_normal_axis.

Parameters:

Name Type Description Default
direction V2

A 2D direction vector (x=width, y=height) in local cross-section space. Does not need to be normalized.

required

Returns:

Type Description
Numeric

The size of the cross-section measured along the given direction.

Source code in kumiki/timber.py
def get_size_in_direction_2d(self, direction: V2) -> Numeric:
    """
    Get the size of the timber's cross-section measured along an arbitrary 2D direction.

    The direction is in the timber's local cross-section plane where x is the width
    axis and y is the height axis. Returns the total extent (support width) of the
    rectangular cross-section projected onto that direction.

    For axis-aligned directions this matches get_size_in_face_normal_axis.

    Args:
        direction: A 2D direction vector (x=width, y=height) in local cross-section space.
                   Does not need to be normalized.

    Returns:
        The size of the cross-section measured along the given direction.
    """
    d = safe_normalize_vector(direction)
    return self.size[0] * Abs(d[0]) + self.size[1] * Abs(d[1])

get_size_in_direction_3d

get_size_in_direction_3d(direction: Direction3D) -> Numeric

Get the size of the timber measured along an arbitrary 3D direction in global space.

Transforms the direction into the timber's local frame and computes the total extent (support width) of the rectangular prism projected onto that direction.

For axis-aligned directions this matches get_size_in_face_normal_axis.

Parameters:

Name Type Description Default
direction Direction3D

A 3D direction vector in global coordinates. Does not need to be normalized.

required

Returns:

Type Description
Numeric

The size of the timber measured along the given direction.

Source code in kumiki/timber.py
def get_size_in_direction_3d(self, direction: Direction3D) -> Numeric:
    """
    Get the size of the timber measured along an arbitrary 3D direction in global space.

    Transforms the direction into the timber's local frame and computes the total
    extent (support width) of the rectangular prism projected onto that direction.

    For axis-aligned directions this matches get_size_in_face_normal_axis.

    Args:
        direction: A 3D direction vector in global coordinates.
                   Does not need to be normalized.

    Returns:
        The size of the timber measured along the given direction.
    """
    d_global = safe_normalize_vector(direction)
    # Rotate to local frame (transpose of rotation matrix, no translation for directions)
    d_local = safe_transform_vector(self.orientation.matrix.T, d_global)
    return self.size[0] * Abs(d_local[0]) + self.size[1] * Abs(d_local[1]) + self.length * Abs(d_local[2])

get_closest_oriented_face_from_global_direction

get_closest_oriented_face_from_global_direction(target_direction: Direction3D) -> TimberFace

Find which face of this timber best aligns with the target direction.

The target_direction should point "outwards" from the desired face (not into it).

Parameters:

Name Type Description Default
target_direction Direction3D

Direction vector to match against

required

Returns:

Type Description
TimberFace

The TimberFace that best aligns with the target direction

Source code in kumiki/timber.py
def get_closest_oriented_face_from_global_direction(self, target_direction: Direction3D) -> TimberFace:
    """
    Find which face of this timber best aligns with the target direction.

    The target_direction should point "outwards" from the desired face (not into it).

    Args:
        target_direction: Direction vector to match against

    Returns:
        The TimberFace that best aligns with the target direction
    """
    faces = [
        TimberFace.TOP, TimberFace.BOTTOM, TimberFace.RIGHT,
        TimberFace.LEFT, TimberFace.FRONT, TimberFace.BACK,
    ]
    return self._get_closest_oriented_face_from_faces(faces, target_direction)

get_closest_oriented_long_face_from_global_direction

get_closest_oriented_long_face_from_global_direction(target_direction: Direction3D) -> TimberLongFace

Find which long face of this timber best aligns with the target direction.

The target_direction should point "outwards" from the desired face (not into it).

Parameters:

Name Type Description Default
target_direction Direction3D

Direction vector to match against

required

Returns:

Type Description
TimberLongFace

The TimberLongFace that best aligns with the target direction

Source code in kumiki/timber.py
def get_closest_oriented_long_face_from_global_direction(self, target_direction: Direction3D) -> TimberLongFace:
    """
    Find which long face of this timber best aligns with the target direction.

    The target_direction should point "outwards" from the desired face (not into it).

    Args:
        target_direction: Direction vector to match against

    Returns:
        The TimberLongFace that best aligns with the target direction
    """
    faces = [TimberFace.RIGHT, TimberFace.LEFT, TimberFace.FRONT, TimberFace.BACK]
    return self._get_closest_oriented_face_from_faces(faces, target_direction).to.long_face()

get_closest_oriented_end_face_from_global_direction

get_closest_oriented_end_face_from_global_direction(target_direction: Direction3D) -> TimberEnd

Find which end face of this timber best aligns with the target direction.

The target_direction should point "outwards" from the desired end face (not into it).

Returns:

Type Description
TimberEnd

The TimberEnd that best aligns with the target direction

Source code in kumiki/timber.py
def get_closest_oriented_end_face_from_global_direction(self, target_direction: Direction3D) -> TimberEnd:
    """
    Find which end face of this timber best aligns with the target direction.

    The target_direction should point "outwards" from the desired end face (not into it).

    Returns:
        The TimberEnd that best aligns with the target direction
    """
    faces = [TimberFace.TOP, TimberFace.BOTTOM]
    return self._get_closest_oriented_face_from_faces(faces, target_direction).to.end()

get_inside_face_from_footprint

get_inside_face_from_footprint(footprint: Footprint) -> TimberFace

Get the inside face of this timber relative to the footprint.

This method finds which face of the timber is oriented toward the interior of the footprint by: 1. Finding the nearest boundary of the footprint to the timber's centerline 2. Getting the inward normal of that boundary 3. Finding which timber face best aligns with that inward direction

Parameters:

Name Type Description Default
footprint Footprint

The footprint to determine inside/outside orientation

required

Returns:

Type Description
TimberFace

The TimberFace that points toward the inside of the footprint

Source code in kumiki/timber.py
def get_inside_face_from_footprint(self, footprint: Footprint) -> TimberFace:
    """
    Get the inside face of this timber relative to the footprint.

    This method finds which face of the timber is oriented toward the interior
    of the footprint by:
    1. Finding the nearest boundary of the footprint to the timber's centerline
    2. Getting the inward normal of that boundary
    3. Finding which timber face best aligns with that inward direction

    Args:
        footprint: The footprint to determine inside/outside orientation

    Returns:
        The TimberFace that points toward the inside of the footprint
    """
    from .measuring import locate_top_center_position

    # Project timber's centerline onto XY plane for footprint comparison
    bottom_2d = create_v2(self.get_bottom_position_global()[0], self.get_bottom_position_global()[1])
    top_position = locate_top_center_position(self).position
    top_2d = create_v2(top_position[0], top_position[1])

    # Find nearest boundary to timber's centerline
    boundary_idx, boundary_side, distance = footprint.nearest_boundary_from_line(bottom_2d, top_2d)

    # Get the inward normal of that boundary
    inward_normal = footprint.get_inward_normal(boundary_idx)

    # Find which face of the timber aligns with the inward direction
    return self.get_closest_oriented_face_from_global_direction(inward_normal)

get_outside_face_from_footprint

get_outside_face_from_footprint(footprint: Footprint) -> TimberFace

Get the outside face of this timber relative to the footprint.

This method finds which face of the timber is oriented toward the exterior of the footprint by: 1. Finding the nearest boundary of the footprint to the timber's centerline 2. Getting the inward normal of that boundary 3. Finding which timber face best aligns with the opposite (outward) direction

Parameters:

Name Type Description Default
footprint Footprint

The footprint to determine inside/outside orientation

required

Returns:

Type Description
TimberFace

The TimberFace that points toward the outside of the footprint

Source code in kumiki/timber.py
def get_outside_face_from_footprint(self, footprint: Footprint) -> TimberFace:
    """
    Get the outside face of this timber relative to the footprint.

    This method finds which face of the timber is oriented toward the exterior
    of the footprint by:
    1. Finding the nearest boundary of the footprint to the timber's centerline
    2. Getting the inward normal of that boundary
    3. Finding which timber face best aligns with the opposite (outward) direction

    Args:
        footprint: The footprint to determine inside/outside orientation

    Returns:
        The TimberFace that points toward the outside of the footprint
    """
    from .measuring import locate_top_center_position

    # Project timber's centerline onto XY plane for footprint comparison
    bottom_2d = create_v2(self.get_bottom_position_global()[0], self.get_bottom_position_global()[1])
    top_position = locate_top_center_position(self).position
    top_2d = create_v2(top_position[0], top_position[1])

    # Find nearest boundary to timber's centerline
    boundary_idx, boundary_side, distance = footprint.nearest_boundary_from_line(bottom_2d, top_2d)

    # Get the inward normal of that boundary
    inward_normal = footprint.get_inward_normal(boundary_idx)

    # Find which face of the timber aligns with the outward direction (negative of inward)
    outward_normal = -inward_normal
    return self.get_closest_oriented_face_from_global_direction(outward_normal)

get_transform_matrix

get_transform_matrix() -> Matrix

Get the 4x4 transformation matrix for this timber

Source code in kumiki/timber.py
def get_transform_matrix(self) -> Matrix:
    """Get the 4x4 transformation matrix for this timber"""
    # Create 4x4 transformation matrix
    transform = Matrix([
        [self.orientation.matrix[0,0], self.orientation.matrix[0,1], self.orientation.matrix[0,2], self.get_bottom_position_global()[0]],
        [self.orientation.matrix[1,0], self.orientation.matrix[1,1], self.orientation.matrix[1,2], self.get_bottom_position_global()[1]],
        [self.orientation.matrix[2,0], self.orientation.matrix[2,1], self.orientation.matrix[2,2], self.get_bottom_position_global()[2]],
        [0, 0, 0, 1]
    ])
    return transform

project_global_point_onto_timber_face_global

project_global_point_onto_timber_face_global(global_point: V3, face: SomeTimberFace) -> V3

Project a point from global coordinates onto the timber's face and return result in global coordinates.

Parameters:

Name Type Description Default
global_point V3

The point to project in global coordinates (3x1 Matrix)

required
face SomeTimberFace

The face to project onto (can be TimberFace, TimberEnd, or TimberLongFace)

required
Source code in kumiki/timber.py
def project_global_point_onto_timber_face_global(self, global_point: V3, face: SomeTimberFace) -> V3:
    """
    Project a point from global coordinates onto the timber's face and return result in global coordinates.

    Args:
        global_point: The point to project in global coordinates (3x1 Matrix)
        face: The face to project onto (can be TimberFace, TimberEnd, or TimberLongFace)
    """
    # Convert to TimberFace
    face = face.to.face()

    # Convert global point to local coordinates
    local_point = self.transform.global_to_local(global_point)

    # project the 0,0 point onto the face
    face_zero_local = face.get_direction() * self.get_size_in_face_normal_axis(face) / 2
    local_point_face_component = (local_point-face_zero_local).dot(face.get_direction()) * face.get_direction()
    local_point_projected = local_point - local_point_face_component
    return self.transform.local_to_global(local_point_projected)

get_perfect_size

get_perfect_size() -> V2

Returns the perfect cross sectional size of the timber.

The perfect size is the cross sectional size of the perfect timber within.

Source code in kumiki/timber.py
@final
def get_perfect_size(self) -> V2:
    """
    Returns the perfect cross sectional size of the timber.

    The perfect size is the cross sectional size of the perfect timber within.
    """
    return self.size

can_be_extended_for_joints

can_be_extended_for_joints() -> bool

Returns True if the timber can be extended when cutting joints.

Returns:

Type Description
bool

True if the timber can be extended when cutting joints.

Source code in kumiki/timber.py
def can_be_extended_for_joints(self) -> bool:
    """
    Returns True if the timber can be extended when cutting joints.

    Returns:
        True if the timber can be extended when cutting joints.
    """
    return True

get_rough_half_sizes abstractmethod

get_rough_half_sizes() -> Tuple[V2, V2]

Returns the rough half-sizes of the timber measured from the centerline.

The rough bounding box is defined by four half-sizes measured from the centerline in each direction. This allows the rough timber to be non-coaxial with the perfect timber within (useful for square rule layout).

Returns:

Type Description
Tuple[V2, V2]

Tuple of two V2s: - width_halves: V2(right_half, left_half) — half-sizes in the width dimension - height_halves: V2(front_half, back_half) — half-sizes in the height dimension

Source code in kumiki/timber.py
@abstractmethod
def get_rough_half_sizes(self) -> Tuple[V2, V2]:
    """
    Returns the rough half-sizes of the timber measured from the centerline.

    The rough bounding box is defined by four half-sizes measured from the
    centerline in each direction. This allows the rough timber to be non-coaxial
    with the perfect timber within (useful for square rule layout).

    Returns:
        Tuple of two V2s:
          - width_halves: V2(right_half, left_half) — half-sizes in the width dimension
          - height_halves: V2(front_half, back_half) — half-sizes in the height dimension
    """
    pass

get_nominal_half_sizes

get_nominal_half_sizes() -> Tuple[V2, V2]
Source code in kumiki/timber.py
@deprecated("use get_rough_half_sizes instead")
def get_nominal_half_sizes(self) -> Tuple[V2, V2]:
    return self.get_rough_half_sizes()

get_rough_size

get_rough_size() -> V2

Returns the rough cross sectional size of the timber.

The rough size is the total cross sectional size defined by the rough half-sizes. For a perfect timber, this matches the perfect size. For an imperfect timber, this may differ and represents the intended bounding box for joint layout and intersection tests.

Source code in kumiki/timber.py
def get_rough_size(self) -> V2:
    """
    Returns the rough cross sectional size of the timber.

    The rough size is the total cross sectional size defined by the rough half-sizes.
    For a perfect timber, this matches the perfect size. For an imperfect timber, this
    may differ and represents the intended bounding box for joint layout and intersection tests.
    """
    width_halves, height_halves = self.get_rough_half_sizes()
    total_w = width_halves[0] + width_halves[1]
    total_h = height_halves[0] + height_halves[1]
    return create_v2(total_w, total_h)

get_nominal_size

get_nominal_size() -> V2
Source code in kumiki/timber.py
@deprecated("use get_rough_size instead")
def get_nominal_size(self) -> V2:
    return self.get_rough_size()

get_perfect_timber_within_csg_local

get_perfect_timber_within_csg_local() -> RectangularPrism

Returns the perfect rectangular prism CSG in local coordinates.

This represents the perfect timber within as a CSG object -- the idealized, finished-dimension bounding box (self.size), not the rough/as-sawn stock boundary. All timber types have a perfect rectangular prism that bounds their actual geometry.

Returns:

Type Description
RectangularPrism

RectangularPrism in local coordinates (relative to timber's bottom position)

Source code in kumiki/timber.py
def get_perfect_timber_within_csg_local(self) -> RectangularPrism:
    """
    Returns the perfect rectangular prism CSG in local coordinates.

    This represents the perfect timber within as a CSG object -- the idealized,
    finished-dimension bounding box (self.size), not the rough/as-sawn stock
    boundary. All timber types have a perfect rectangular prism that bounds
    their actual geometry.

    Returns:
        RectangularPrism in local coordinates (relative to timber's bottom position)
    """
    return RectangularPrism(
        size=self.size,
        transform=Transform.identity(),
        start_distance=scalar(0),
        end_distance=self.length,
        _features=_ptw_face_tags(),
        label=self.csg_label("perfect"),
    )

csg_label_name classmethod

csg_label_name() -> str

What this kind of timber is called in a CSG label.

Derived from the class name -- "board", "round_timber" -- so a new timber type names itself without anyone remembering to add it here.

Source code in kumiki/timber.py
@classmethod
def csg_label_name(cls) -> str:
    """What this kind of timber is called in a CSG label.

    Derived from the class name -- "board", "round_timber" -- so a new
    timber type names itself without anyone remembering to add it here.
    """
    return re.sub(r"(?<!^)(?=[A-Z])", "_", cls.__name__).lower()

csg_label classmethod

csg_label(*qualifiers: str) -> CutCSGLabel

Label for one of this timber's own CSG shapes.

A classmethod so the name follows the derived class -- a Board's rough extended prism reads "board (rough, extended)", not "timber (...)".

Source code in kumiki/timber.py
@classmethod
def csg_label(cls, *qualifiers: str) -> CutCSGLabel:
    """Label for one of this timber's own CSG shapes.

    A classmethod so the name follows the derived class -- a Board's rough
    extended prism reads "board (rough, extended)", not "timber (...)".
    """
    if not qualifiers:
        return CutCSGLabel(cls.csg_label_name())
    return CutCSGLabel(f"{cls.csg_label_name()} ({', '.join(qualifiers)})")

get_actual_csg_local

get_actual_csg_local() -> CutCSG

Returns the actual CSG geometry for this timber.

For the base PerfectTimberWithin class, this returns the perfect rectangular prism. Subclasses override this to return different geometries (cylinder, mesh, etc.).

Returns:

Type Description
CutCSG

CutCSG representing the actual geometry in local coordinates

Source code in kumiki/timber.py
def get_actual_csg_local(self) -> CutCSG:
    """
    Returns the actual CSG geometry for this timber.

    For the base PerfectTimberWithin class, this returns the perfect rectangular
    prism. Subclasses override this to return different geometries (cylinder, mesh, etc.).

    Returns:
        CutCSG representing the actual geometry in local coordinates
    """
    # The base timber's rough shape is its perfect one, but it is still
    # the rough CSG in the tree, so it is named as such.
    return dataclass_replace(
        self.get_perfect_timber_within_csg_local(),
        label=self.csg_label("rough"),
    )

get_extended_actual_csg_local

get_extended_actual_csg_local(extend_bot: bool, extend_top: bool) -> CutCSG

Returns the actual CSG geometry extended to infinity at specified ends.

For the base PerfectTimberWithin class, this returns a rectangular prism using the perfect timber within size, optionally extended to infinity.

Parameters:

Name Type Description Default
extend_bot bool

If True, extend to -infinity at bottom (z=0)

required
extend_top bool

If True, extend to +infinity at top (z=length)

required

Returns:

Type Description
CutCSG

CutCSG representing the extended geometry in local coordinates

Source code in kumiki/timber.py
def get_extended_actual_csg_local(self, extend_bot: bool, extend_top: bool) -> CutCSG:
    """
    Returns the actual CSG geometry extended to infinity at specified ends.

    For the base PerfectTimberWithin class, this returns a rectangular prism
    using the perfect timber within size, optionally extended to infinity.

    Args:
        extend_bot: If True, extend to -infinity at bottom (z=0)
        extend_top: If True, extend to +infinity at top (z=length)

    Returns:
        CutCSG representing the extended geometry in local coordinates
    """
    return _create_extended_rectangular_prism(
        face_tags=_rough_face_tags(),
        size=self.get_perfect_size(),
        length=self.length,
        extend_bot=extend_bot,
        extend_top=extend_top,
        label=self.csg_label("rough", "extended"),
    )

get_extended_perfect_csg_local

get_extended_perfect_csg_local(extend_bot: bool, extend_top: bool) -> CutCSG

Returns the PERFECT (finished-dimension) CSG geometry extended to infinity at specified ends -- always self.get_perfect_size(), regardless of any rough/actual sizing a subclass's get_extended_actual_csg_local may use instead. Unlike get_extended_actual_csg_local, this is not overridden per-subclass: every timber type's perfect timber within is a rectangular prism (see get_perfect_timber_within_csg_local), so one implementation suffices for all of them.

Parameters:

Name Type Description Default
extend_bot bool

If True, extend to -infinity at bottom (z=0)

required
extend_top bool

If True, extend to +infinity at top (z=length)

required

Returns:

Type Description
CutCSG

CutCSG representing the extended geometry in local coordinates

Source code in kumiki/timber.py
@final
def get_extended_perfect_csg_local(self, extend_bot: bool, extend_top: bool) -> CutCSG:
    """
    Returns the PERFECT (finished-dimension) CSG geometry extended to infinity at
    specified ends -- always self.get_perfect_size(), regardless of any rough/actual
    sizing a subclass's get_extended_actual_csg_local may use instead. Unlike
    get_extended_actual_csg_local, this is not overridden per-subclass: every timber
    type's perfect timber within is a rectangular prism (see
    get_perfect_timber_within_csg_local), so one implementation suffices for all of them.

    Args:
        extend_bot: If True, extend to -infinity at bottom (z=0)
        extend_top: If True, extend to +infinity at top (z=length)

    Returns:
        CutCSG representing the extended geometry in local coordinates
    """
    return _create_extended_rectangular_prism(
        face_tags=_ptw_face_tags(),
        size=self.get_perfect_size(),
        length=self.length,
        extend_bot=extend_bot,
        extend_top=extend_top,
        label=self.csg_label("perfect", "extended"),
    )

is_face_perfect

is_face_perfect(face: TimberFace) -> bool

Check if the specified face of the timber is perfect (matches the perfect timber within).

Parameters:

Name Type Description Default
face TimberFace

The TimberFace to check

required
Source code in kumiki/timber.py
def is_face_perfect(self, face: TimberFace) -> bool:
    """
    Check if the specified face of the timber is perfect (matches the perfect timber within).

    Args:
        face: The TimberFace to check
    """
    width_halves, height_halves = self.get_rough_half_sizes()
    w_half = self.size[0] / scalar(2)
    h_half = self.size[1] / scalar(2)

    if face == TimberFace.TOP or face == TimberFace.BOTTOM:
        return True  # Length is always perfect
    elif face == TimberFace.RIGHT:
        return safe_equality_test(width_halves[0], w_half)
    elif face == TimberFace.LEFT:
        return safe_equality_test(width_halves[1], w_half)
    elif face == TimberFace.FRONT:
        return safe_equality_test(height_halves[0], h_half)
    elif face == TimberFace.BACK:
        return safe_equality_test(height_halves[1], h_half)
    else:
        raise ValueError(f"Face {face} is not a long face; only RIGHT, LEFT, FRONT, BACK are valid for this check.")

is_perfect_timber

is_perfect_timber() -> bool

Check if this timber's actual geometry matches its rough bounding box.

Returns True when the rough half-sizes are symmetric and equal to half the perfect timber within size.

Returns:

Type Description
bool

True if the timber is a perfect timber, False otherwise

Source code in kumiki/timber.py
def is_perfect_timber(self) -> bool:
    """
    Check if this timber's actual geometry matches its rough bounding box.

    Returns True when the rough half-sizes are symmetric and equal to half
    the perfect timber within size.

    Returns:
        True if the timber is a perfect timber, False otherwise
    """
    width_halves, height_halves = self.get_rough_half_sizes()
    w_half = self.size[0] / scalar(2)
    h_half = self.size[1] / scalar(2)
    return (safe_equality_test(width_halves[0], w_half) and
            safe_equality_test(width_halves[1], w_half) and
            safe_equality_test(height_halves[0], h_half) and
            safe_equality_test(height_halves[1], h_half))

get_imperfect_fringe_csg_local

get_imperfect_fringe_csg_local() -> CutCSG

Returns the CSG (local coordinates) of the region where this timber's actual geometry sticks out beyond its perfect-timber-within boundary, i.e. actual minus perfect.

Source code in kumiki/timber.py
def get_imperfect_fringe_csg_local(self) -> CutCSG:
    """
    Returns the CSG (local coordinates) of the region where this timber's actual
    geometry sticks out beyond its perfect-timber-within boundary, i.e. actual
    minus perfect.
    """
    if self.is_perfect_timber():
        return EmptyCSG()
    return Difference(
        base=self.get_extended_actual_csg_local(extend_bot=False, extend_top=False),
        subtract=[self.get_perfect_timber_within_csg_local()],
    )

Timber dataclass

Timber(length: Numeric, size: V2, transform: Transform, ticket: TimberTicket = TimberTicket(), rough_half_sizes: Optional[Tuple[V2, V2]] = None)

Bases: PerfectTimberWithin

Rectangular timber which may or may not be perfect.

Inherits all attributes and methods from PerfectTimberWithin
  • length: Length of the timber
  • size: Cross-sectional size (width, height)
  • transform: Position and orientation
  • name: Optional name

rough_half_sizes class-attribute instance-attribute

rough_half_sizes: Optional[Tuple[V2, V2]] = None

from_perfect_timber_within staticmethod

from_perfect_timber_within(perfect_timber: PerfectTimberWithin, rough_half_sizes: Optional[Tuple[V2, V2]] = None) -> Timber

Create a Timber instance from a PerfectTimberWithin instance.

Parameters:

Name Type Description Default
perfect_timber PerfectTimberWithin

An instance of PerfectTimberWithin

required
rough_half_sizes Optional[Tuple[V2, V2]]

Optional asymmetric half-sizes from centerline

None
Source code in kumiki/timber.py
@staticmethod
def from_perfect_timber_within(perfect_timber: PerfectTimberWithin, rough_half_sizes: Optional[Tuple[V2, V2]] = None) -> 'Timber':
    """
    Create a Timber instance from a PerfectTimberWithin instance.

    Args:
        perfect_timber: An instance of PerfectTimberWithin
        rough_half_sizes: Optional asymmetric half-sizes from centerline
    """
    return Timber(
        length=perfect_timber.length,
        size=perfect_timber.size,
        transform=perfect_timber.transform,
        ticket=perfect_timber.ticket,
        rough_half_sizes=rough_half_sizes
    )

get_rough_half_sizes

get_rough_half_sizes() -> Tuple[V2, V2]

Returns the rough half-sizes of the timber.

If rough_half_sizes is set, returns that. Otherwise returns symmetric half-sizes derived from the perfect timber within size.

Returns:

Type Description
Tuple[V2, V2]

Tuple of (V2(right_half, left_half), V2(front_half, back_half))

Source code in kumiki/timber.py
def get_rough_half_sizes(self) -> Tuple[V2, V2]:
    """
    Returns the rough half-sizes of the timber.

    If rough_half_sizes is set, returns that. Otherwise returns symmetric
    half-sizes derived from the perfect timber within size.

    Returns:
        Tuple of (V2(right_half, left_half), V2(front_half, back_half))
    """
    if self.rough_half_sizes is not None:
        return self.rough_half_sizes
    w_half = self.size[0] / scalar(2)
    h_half = self.size[1] / scalar(2)
    return (create_v2(w_half, w_half), create_v2(h_half, h_half))

get_actual_csg_local

get_actual_csg_local() -> CutCSG

Returns the actual CSG geometry for this timber.

For Timber, this returns a rectangular prism using the rough half-sizes, offset from the centerline when the half-sizes are asymmetric.

Returns:

Type Description
CutCSG

RectangularPrism representing the actual geometry in local coordinates

Source code in kumiki/timber.py
def get_actual_csg_local(self) -> CutCSG:
    """
    Returns the actual CSG geometry for this timber.

    For Timber, this returns a rectangular prism using the rough half-sizes,
    offset from the centerline when the half-sizes are asymmetric.

    Returns:
        RectangularPrism representing the actual geometry in local coordinates
    """
    rough_size, offset = _get_rough_size_and_offset(self)
    return RectangularPrism(
        size=rough_size,
        transform=Transform(position=offset, orientation=Orientation.identity()),
        start_distance=scalar(0),
        end_distance=self.length,
        _features=_rough_face_tags(),
        label=self.csg_label("rough"),
    )

get_extended_actual_csg_local

get_extended_actual_csg_local(extend_bot: bool, extend_top: bool) -> CutCSG

Returns the actual CSG geometry extended to infinity at specified ends.

For Timber, this returns a rectangular prism using the rough half-sizes, offset from the centerline when the half-sizes are asymmetric.

Parameters:

Name Type Description Default
extend_bot bool

If True, extend to -infinity at bottom (z=0)

required
extend_top bool

If True, extend to +infinity at top (z=length)

required

Returns:

Type Description
CutCSG

CutCSG representing the extended geometry in local coordinates

Source code in kumiki/timber.py
def get_extended_actual_csg_local(self, extend_bot: bool, extend_top: bool) -> CutCSG:
    """
    Returns the actual CSG geometry extended to infinity at specified ends.

    For Timber, this returns a rectangular prism using the rough half-sizes,
    offset from the centerline when the half-sizes are asymmetric.

    Args:
        extend_bot: If True, extend to -infinity at bottom (z=0)
        extend_top: If True, extend to +infinity at top (z=length)

    Returns:
        CutCSG representing the extended geometry in local coordinates
    """
    rough_size, offset = _get_rough_size_and_offset(self)
    return RectangularPrism(
        size=rough_size,
        transform=Transform(position=offset, orientation=Orientation.identity()),
        start_distance=None if extend_bot else scalar(0),
        end_distance=None if extend_top else self.length,
        _features=_rough_face_tags(),
        label=self.csg_label("rough", "extended"),
    )

Board dataclass

Board(length: Numeric, size: V2, transform: Transform, ticket: TimberTicket = TimberTicket())

Bases: PerfectTimberWithin

Boards are perfect timbers with board-specific semantics

Boards are structurally identical to perfect timbers but carry additional semantics: - the "length" of the board runs in the Z direction so the TOP and BOTTOM faces are referred to as the "ends" of the board - the "width" of the board runs in the X direction so the LEFT and RIGHT faces are referred to as the "sides" of the board - the "thickness" of the board runs in the Y direction so the FRONT and BACK faces are the same as the "faces" of the board

Like timbers, we assume the grain is always running in the length direction.

Note that you can end cut along the length direction but not in the other directions so you must ensure the board dimensions are large enough to incorporate the cuts

get_rough_half_sizes

get_rough_half_sizes() -> Tuple[V2, V2]

Returns the rough half-sizes of the board.

For Board, these are symmetric halves of the perfect timber within size.

Returns:

Type Description
Tuple[V2, V2]

Tuple of (V2(right_half, left_half), V2(front_half, back_half))

Source code in kumiki/timber.py
def get_rough_half_sizes(self) -> Tuple[V2, V2]:
    """
    Returns the rough half-sizes of the board.

    For Board, these are symmetric halves of the perfect timber within size.

    Returns:
        Tuple of (V2(right_half, left_half), V2(front_half, back_half))
    """
    w_half = self.size[0] / scalar(2)
    h_half = self.size[1] / scalar(2)
    return (create_v2(w_half, w_half), create_v2(h_half, h_half))

get_extended_actual_csg_local

get_extended_actual_csg_local(extend_bot: bool, extend_top: bool) -> CutCSG

Returns the actual CSG geometry extended to infinity at specified ends.

For Board, this returns a rectangular prism using the perfect timber within size.

Parameters:

Name Type Description Default
extend_bot bool

If True, extend to -infinity at bottom (z=0)

required
extend_top bool

If True, extend to +infinity at top (z=length)

required

Returns:

Type Description
CutCSG

CutCSG representing the extended geometry in local coordinates

Source code in kumiki/timber.py
def get_extended_actual_csg_local(self, extend_bot: bool, extend_top: bool) -> CutCSG:
    """
    Returns the actual CSG geometry extended to infinity at specified ends.

    For Board, this returns a rectangular prism using the perfect timber within size.

    Args:
        extend_bot: If True, extend to -infinity at bottom (z=0)
        extend_top: If True, extend to +infinity at top (z=length)

    Returns:
        CutCSG representing the extended geometry in local coordinates
    """
    return _create_extended_rectangular_prism(
        face_tags=_rough_face_tags(),
        size=self.get_perfect_size(),
        length=self.length,
        extend_bot=extend_bot,
        extend_top=extend_top,
        label=self.csg_label("rough", "extended"),
    )

RoundTimber dataclass

RoundTimber(length: Numeric, size: V2, transform: Transform, ticket: TimberTicket = TimberTicket(), *, diameter: Numeric)

Bases: PerfectTimberWithin

Cylindrical timber (e.g., logs, poles)

Round timbers have a circular cross-section centered on the centerline. The rough bounding box is a square that contains the circle, but the actual geometry is a cylinder.

diameter class-attribute instance-attribute

diameter: Numeric = field(kw_only=True)

is_perfect_timber

is_perfect_timber() -> bool

Round timber has a perfect cylindrical geometry, so it is perfect.

Source code in kumiki/timber.py
def is_perfect_timber(self) -> bool:
    """Round timber has a perfect cylindrical geometry, so it is perfect."""
    return True

from_perfect_timber_within staticmethod

from_perfect_timber_within(perfect_timber: PerfectTimberWithin, diameter: Optional[Numeric] = None) -> RoundTimber

Create a Timber instance from a PerfectTimberWithin instance.

Parameters:

Name Type Description Default
perfect_timber PerfectTimberWithin

An instance of PerfectTimberWithin

required
diameter Optional[Numeric]

Optional diameter for the round timber, if None, then the diagonal of the perfect_timber.size is used to compute the diameter.

None
Source code in kumiki/timber.py
@staticmethod
def from_perfect_timber_within(perfect_timber: PerfectTimberWithin, diameter: Optional[Numeric] = None) -> 'RoundTimber':
    """
    Create a Timber instance from a PerfectTimberWithin instance.

    Args:
        perfect_timber: An instance of PerfectTimberWithin
        diameter: Optional diameter for the round timber, if None, then the diagonal of the perfect_timber.size is used to compute the diameter.
    """
    if diameter is None:
        diameter = sqrt(perfect_timber.size[0]**2 + perfect_timber.size[1]**2)
    return RoundTimber(
        length=perfect_timber.length,
        size=perfect_timber.size,
        transform=perfect_timber.transform,
        ticket=perfect_timber.ticket,
        diameter=diameter
    )

get_rough_half_sizes

get_rough_half_sizes() -> Tuple[V2, V2]

Returns the rough half-sizes of the round timber.

For round timbers, this is a symmetric square bounding box using the diameter.

Returns:

Type Description
Tuple[V2, V2]

Tuple of (V2(d/2, d/2), V2(d/2, d/2))

Source code in kumiki/timber.py
def get_rough_half_sizes(self) -> Tuple[V2, V2]:
    """
    Returns the rough half-sizes of the round timber.

    For round timbers, this is a symmetric square bounding box using the diameter.

    Returns:
        Tuple of (V2(d/2, d/2), V2(d/2, d/2))
    """
    half_d = self.diameter / scalar(2)
    return (create_v2(half_d, half_d), create_v2(half_d, half_d))

get_actual_csg_local

get_actual_csg_local() -> CutCSG

Returns the actual CSG geometry for this timber.

For RoundTimber, this returns a Cylinder with the specified diameter.

Returns:

Type Description
CutCSG

Cylinder representing the actual geometry in local coordinates

Source code in kumiki/timber.py
def get_actual_csg_local(self) -> CutCSG:
    """
    Returns the actual CSG geometry for this timber.

    For RoundTimber, this returns a Cylinder with the specified diameter.

    Returns:
        Cylinder representing the actual geometry in local coordinates
    """
    return Cylinder(
        radius=self.diameter / scalar(2),
        axis_direction=create_v3(scalar(0), scalar(0), scalar(1)),  # Local Z-axis
        position=create_v3(scalar(0), scalar(0), scalar(0)),  # Origin in local coords
        start_distance=scalar(0),
        end_distance=self.length,
        label=self.csg_label("rough"),
    )

get_extended_actual_csg_local

get_extended_actual_csg_local(extend_bot: bool, extend_top: bool) -> CutCSG

Returns the actual CSG geometry extended to infinity at specified ends.

For RoundTimber, this returns a Cylinder optionally extended to infinity.

Parameters:

Name Type Description Default
extend_bot bool

If True, extend to -infinity at bottom (z=0)

required
extend_top bool

If True, extend to +infinity at top (z=length)

required

Returns:

Type Description
CutCSG

Cylinder representing the extended geometry in local coordinates

Source code in kumiki/timber.py
def get_extended_actual_csg_local(self, extend_bot: bool, extend_top: bool) -> CutCSG:
    """
    Returns the actual CSG geometry extended to infinity at specified ends.

    For RoundTimber, this returns a Cylinder optionally extended to infinity.

    Args:
        extend_bot: If True, extend to -infinity at bottom (z=0)
        extend_top: If True, extend to +infinity at top (z=length)

    Returns:
        Cylinder representing the extended geometry in local coordinates
    """
    return Cylinder(
        radius=self.diameter / scalar(2),
        axis_direction=create_v3(scalar(0), scalar(0), scalar(1)),  # Local Z-axis
        position=create_v3(scalar(0), scalar(0), scalar(0)),  # Origin in local coords
        start_distance=None if extend_bot else scalar(0),
        end_distance=None if extend_top else self.length,
        label=self.csg_label("rough", "extended"),
    )

MeshTimber dataclass

MeshTimber(length: Numeric, size: V2, transform: Transform, ticket: TimberTicket = TimberTicket())

Bases: PerfectTimberWithin

Timber represented by an arbitrary mesh geometry

This timber type uses a mesh CSG to represent complex or irregular timber geometries that cannot be represented by simple primitives.

TODO: Add mesh_csg field and override get_actual_csg_local()

get_rough_half_sizes

get_rough_half_sizes() -> Tuple[V2, V2]
Source code in kumiki/timber.py
def get_rough_half_sizes(self) -> Tuple[V2, V2]:
    w_half = self.size[0] / scalar(2)
    h_half = self.size[1] / scalar(2)
    return (create_v2(w_half, w_half), create_v2(h_half, h_half))

can_be_extended_for_joints

can_be_extended_for_joints() -> bool
Source code in kumiki/timber.py
def can_be_extended_for_joints(self) -> bool:
    return False

get_extended_actual_csg_local

get_extended_actual_csg_local(extend_bot: bool, extend_top: bool) -> CutCSG

Returns the actual CSG geometry extended to infinity at specified ends.

For MeshTimber, this returns a rectangular prism using the perfect timber within size (the bounding box). Note: MeshTimber cannot be extended for joints.

Parameters:

Name Type Description Default
extend_bot bool

If True, extend to -infinity at bottom (z=0)

required
extend_top bool

If True, extend to +infinity at top (z=length)

required

Returns:

Type Description
CutCSG

CutCSG representing the extended geometry in local coordinates

Source code in kumiki/timber.py
def get_extended_actual_csg_local(self, extend_bot: bool, extend_top: bool) -> CutCSG:
    """
    Returns the actual CSG geometry extended to infinity at specified ends.

    For MeshTimber, this returns a rectangular prism using the perfect timber within size
    (the bounding box). Note: MeshTimber cannot be extended for joints.

    Args:
        extend_bot: If True, extend to -infinity at bottom (z=0)
        extend_top: If True, extend to +infinity at top (z=length)

    Returns:
        CutCSG representing the extended geometry in local coordinates
    """
    return _create_extended_rectangular_prism(
        face_tags=_rough_face_tags(),
        size=self.get_perfect_size(),
        length=self.length,
        extend_bot=extend_bot,
        extend_top=extend_top,
        label=self.csg_label("rough", "extended"),
    )

RegularPolygonTimber dataclass

RegularPolygonTimber(length: Numeric, size: V2, transform: Transform, ticket: TimberTicket = TimberTicket(), *, num_sides: int)

Bases: PerfectTimberWithin

Timber with regular polygonal cross-section

This timber type has a polygonal (non-rectangular) cross-section that is extruded along the length axis. Examples include hexagonal or octagonal timbers.

The polygon is inscribed in a circle with radius equal to half the minimum dimension of the rough bounding box.

num_sides class-attribute instance-attribute

num_sides: int = field(kw_only=True)

is_perfect_timber

is_perfect_timber() -> bool

Polygonal timber has a perfect regular geometry, so it is perfect.

Source code in kumiki/timber.py
def is_perfect_timber(self) -> bool:
    """Polygonal timber has a perfect regular geometry, so it is perfect."""
    return True

get_rough_half_sizes

get_rough_half_sizes() -> Tuple[V2, V2]

Returns the rough half-sizes of the polygon timber.

For polygon extrusion timbers, these are symmetric halves of the rectangular bounding box.

Returns:

Type Description
Tuple[V2, V2]

Tuple of (V2(w/2, w/2), V2(h/2, h/2))

Source code in kumiki/timber.py
def get_rough_half_sizes(self) -> Tuple[V2, V2]:
    """
    Returns the rough half-sizes of the polygon timber.

    For polygon extrusion timbers, these are symmetric halves of the rectangular bounding box.

    Returns:
        Tuple of (V2(w/2, w/2), V2(h/2, h/2))
    """
    w_half = self.size[0] / scalar(2)
    h_half = self.size[1] / scalar(2)
    return (create_v2(w_half, w_half), create_v2(h_half, h_half))

get_actual_csg_local

get_actual_csg_local() -> CutCSG

Returns the actual CSG geometry for this timber.

For RegularPolygonTimber, this returns a ConvexPolygonExtrusion with the specified number of sides.

Returns:

Type Description
CutCSG

ConvexPolygonExtrusion representing the actual geometry in local coordinates

Source code in kumiki/timber.py
def get_actual_csg_local(self) -> CutCSG:
    """
    Returns the actual CSG geometry for this timber.

    For RegularPolygonTimber, this returns a ConvexPolygonExtrusion with the specified number of sides.

    Returns:
        ConvexPolygonExtrusion representing the actual geometry in local coordinates
    """
    return ConvexPolygonExtrusion(
        points=self._compute_polygon_vertices(),
        transform=Transform.identity(),
        start_distance=scalar(0),
        end_distance=self.length,
        label=self.csg_label("rough"),
    )

get_extended_actual_csg_local

get_extended_actual_csg_local(extend_bot: bool, extend_top: bool) -> CutCSG

Returns the actual CSG geometry extended to infinity at specified ends.

For RegularPolygonTimber, this returns a ConvexPolygonExtrusion optionally extended to infinity.

Parameters:

Name Type Description Default
extend_bot bool

If True, extend to -infinity at bottom (z=0)

required
extend_top bool

If True, extend to +infinity at top (z=length)

required

Returns:

Type Description
CutCSG

ConvexPolygonExtrusion representing the extended geometry in local coordinates

Source code in kumiki/timber.py
def get_extended_actual_csg_local(self, extend_bot: bool, extend_top: bool) -> CutCSG:
    """
    Returns the actual CSG geometry extended to infinity at specified ends.

    For RegularPolygonTimber, this returns a ConvexPolygonExtrusion optionally extended to infinity.

    Args:
        extend_bot: If True, extend to -infinity at bottom (z=0)
        extend_top: If True, extend to +infinity at top (z=length)

    Returns:
        ConvexPolygonExtrusion representing the extended geometry in local coordinates
    """
    return ConvexPolygonExtrusion(
        points=self._compute_polygon_vertices(),
        transform=Transform.identity(),
        start_distance=None if extend_bot else scalar(0),
        end_distance=None if extend_top else self.length,
        label=self.csg_label("rough", "extended"),
    )

CutTimber

CutTimber(timber: PerfectTimberWithin, cuts: Optional[List[Cutting]] = None, joints: Optional[List[Joint]] = None)

A timber with cuts applied to it.

Create a CutTimber from a Timber.

Parameters:

Name Type Description Default
timber PerfectTimberWithin

The timber to be cut

required
cuts Optional[List[Cutting]]

List of cuts to apply (default: empty list)

None
joints Optional[List[Joint]]

Joints this timber participates in (default: empty list). Populated by the from_joints constructors. Anything asking "which joint produced this cut?" reads it, so a CutTimber built by hand simply cannot answer that -- which is the honest outcome, since by hand there is no joint to name.

None
Source code in kumiki/timber.py
def __init__(
    self,
    timber: PerfectTimberWithin,
    cuts: Optional[List['Cutting']] = None,
    joints: Optional[List['Joint']] = None,
):
    """
    Create a CutTimber from a Timber.

    Args:
        timber: The timber to be cut
        cuts: List of cuts to apply (default: empty list)
        joints: Joints this timber participates in (default: empty list).
            Populated by the from_joints constructors. Anything asking
            "which joint produced this cut?" reads it, so a CutTimber built
            by hand simply cannot answer that -- which is the honest
            outcome, since by hand there is no joint to name.
    """
    self.timber = timber
    self.cuts = cuts if cuts is not None else []
    self.joints = joints if joints is not None else []

timber instance-attribute

timber: PerfectTimberWithin = timber

cuts instance-attribute

cuts: List[Cutting] = cuts if cuts is not None else []

joints instance-attribute

joints: List[Joint] = joints if joints is not None else []

name property

name: str

Get the name from the underlying timber's ticket.

resolve_joint_path

resolve_joint_path(path: JointPath) -> List[ResolvedJointPath]

Which of this timber's joints a name refers to.

A list, for the same reason Frame.resolve_timber_path returns one: two identical joints on one timber -- both ends of a brace -- share a name, and pretending a name means one joint quietly picks whichever was cut first. Where it does match several the reference stops being stable, so it warns.

Counted over this timber's cuts, in order, which is what the cut labels and the CSG paths below them are numbered by.

Source code in kumiki/timber.py
def resolve_joint_path(self, path: 'JointPath') -> List['ResolvedJointPath']:
    """Which of this timber's joints a name refers to.

    A list, for the same reason Frame.resolve_timber_path returns one: two
    identical joints on one timber -- both ends of a brace -- share a name,
    and pretending a name means one joint quietly picks whichever was cut
    first. Where it does match several the reference stops being stable, so
    it warns.

    Counted over this timber's cuts, in order, which is what the cut labels
    and the CSG paths below them are numbered by.
    """
    from .identity import ResolvedJointPath

    wanted = str(path)
    matches = [
        ResolvedJointPath(path=wanted, occurrence=occurrence)
        for occurrence, _ in enumerate(
            cut for cut in (self.cuts or [])
            if getattr(getattr(cut, "label", None), "name", None) == wanted
        )
    ]
    if len(matches) > 1:
        warnings.warn(
            f"{len(matches)} joints on this timber share the name {wanted!r}. They can "
            "only be told apart by the order they were cut in, so adding another before "
            "them will move anything that refers to them -- a drawing, or a measurement."
        )
    return matches

from_joints classmethod

from_joints(timber: PerfectTimberWithin, joints: List[Joint]) -> CutTimber

Build a CutTimber for timber by collecting every Cutting across joints whose Cutting.timber is this exact timber (matched by identity -- the same matching Frame.from_joints uses to merge cuttings for a timber across the whole frame).

Useful when a joint function needs "this timber's actual body so far" (e.g. cut_free_house_joint's housed_timbers) but the timber has cuts from more than one joint (e.g. a corner miter plus a roundover decoration): rather than manually picking which Joint.cuttings key belongs to which timber (easy to mix up -- see cuttings["timberA"] vs cuttings["timberB"]), this collects every relevant cutting automatically, in the order joints are given.

Parameters:

Name Type Description Default
timber PerfectTimberWithin

The timber to build a CutTimber for

required
joints List[Joint]

Joints to search for cuttings on timber. Joints that don't involve timber at all contribute nothing.

required

Returns:

Type Description
CutTimber

CutTimber wrapping timber with all matching cuts, in joint order.

Source code in kumiki/timber.py
@classmethod
def from_joints(cls, timber: PerfectTimberWithin, joints: List['Joint']) -> 'CutTimber':
    """
    Build a CutTimber for `timber` by collecting every Cutting across `joints`
    whose Cutting.timber is this exact timber (matched by identity -- the same
    matching Frame.from_joints uses to merge cuttings for a timber across the
    whole frame).

    Useful when a joint function needs "this timber's actual body so far" (e.g.
    cut_free_house_joint's housed_timbers) but the timber has cuts from more than
    one joint (e.g. a corner miter plus a roundover decoration): rather than
    manually picking which Joint.cuttings key belongs to which timber (easy to
    mix up -- see cuttings["timberA"] vs cuttings["timberB"]), this collects
    every relevant cutting automatically, in the order `joints` are given.

    Args:
        timber: The timber to build a CutTimber for
        joints: Joints to search for cuttings on `timber`. Joints that don't
            involve `timber` at all contribute nothing.

    Returns:
        CutTimber wrapping `timber` with all matching cuts, in joint order.
    """
    cuts = [
        cutting
        for joint in joints
        for cutting in joint.cuttings.values()
        if cutting.timber is timber
    ]
    contributing = _joints_touching_timber(joints, timber)
    return cls(timber, cuts=cuts, joints=contributing)

render_timber_with_cuts_csg_local

render_timber_with_cuts_csg_local() -> CutCSG

Returns a CSG representation of the timber with all cuts applied.

Returns:

Type Description
CutCSG

Difference CSG representing the timber with all cuts subtracted

Source code in kumiki/timber.py
def render_timber_with_cuts_csg_local(self) -> CutCSG:
    """
    Returns a CSG representation of the timber with all cuts applied.


    Returns:
        Difference CSG representing the timber with all cuts subtracted
    """
    # Start with the timber prism (possibly with infinite ends where cuts exist)
    starting_csg = self._extended_timber_without_cuts_csg_local()

    # If there are no cuts, just return the starting CSG
    if not self.cuts:
        return starting_csg

    # Collect all the negative CSGs (volumes to be removed) from the cuts.
    # A cut that removes nothing contributes no node.
    negative_csgs = [
        csg for csg in (cut.get_negative_csg_local() for cut in self.cuts)
        if csg is not None
    ]
    if not negative_csgs:
        return starting_csg

    # Return the difference: timber - all cuts
    return Difference(starting_csg, negative_csgs)

get_perfect_timber_within_bounding_box_prism

get_perfect_timber_within_bounding_box_prism() -> RectangularPrism

Get the bounding box prism for this timber cropped based on its end cuts if any, otherwise the original perfet timber within box is produced. The bounding box is aligned with the timber's orientation.

Uses PerfectTimberWithin size to determine the cross-sectional size of the bounding box. Uses the end cuts (maybe_top_end_cut and maybe_bottom_end_cut) to determine the extent of the timber along its length. For skewed end cuts, finds where the plane intersects the four long edges of the timber and takes the max/min.

Returns:

Name Type Description
RectangularPrism RectangularPrism

The bounding box for the cut timber in global coordinates

Source code in kumiki/timber.py
def get_perfect_timber_within_bounding_box_prism(self) -> RectangularPrism:
    """
    Get the bounding box prism for this timber cropped based on its end cuts if any, otherwise the original perfet timber within box is produced.
    The bounding box is aligned with the timber's orientation.

    Uses PerfectTimberWithin size to determine the cross-sectional size of the bounding box.
    Uses the end cuts (maybe_top_end_cut and maybe_bottom_end_cut) to determine
    the extent of the timber along its length. For skewed end cuts, finds where
    the plane intersects the four long edges of the timber and takes the max/min.

    Returns:
        RectangularPrism: The bounding box for the cut timber in global coordinates
    """
    return self._bounding_box_prism_for_cross_section(self.timber.size)

get_rough_bounding_box_prism

get_rough_bounding_box_prism() -> RectangularPrism

Get the bounding box prism for this timber's ROUGH (as-sawn) cross-section, cropped in length the same way as get_perfect_timber_within_bounding_box_prism (the most restrictive end cut across every Cutting on this timber -- the frame's aggregated outer length trims, not each joint's own internal cut geometry).

Unlike the perfect-timber-within box, the rough box may be off-center from the timber's centerline (see get_rough_half_sizes -- e.g. for square-rule layout) and is generally larger than the perfect/finished size.

Returns:

Name Type Description
RectangularPrism RectangularPrism

The rough bounding box for the cut timber, in global coordinates

Source code in kumiki/timber.py
def get_rough_bounding_box_prism(self) -> RectangularPrism:
    """
    Get the bounding box prism for this timber's ROUGH (as-sawn) cross-section,
    cropped in length the same way as get_perfect_timber_within_bounding_box_prism
    (the most restrictive end cut across every Cutting on this timber -- the
    frame's aggregated outer length trims, not each joint's own internal cut
    geometry).

    Unlike the perfect-timber-within box, the rough box may be off-center from
    the timber's centerline (see get_rough_half_sizes -- e.g. for square-rule
    layout) and is generally larger than the perfect/finished size.

    Returns:
        RectangularPrism: The rough bounding box for the cut timber, in global coordinates
    """
    rough_size, offset = _get_rough_size_and_offset(self.timber)
    return self._bounding_box_prism_for_cross_section(rough_size, offset[0], offset[1])

get_bounding_box_prism

get_bounding_box_prism() -> RectangularPrism
Source code in kumiki/timber.py
@deprecated("use get_perfect_timber_within_bounding_box_prism instead")
def get_bounding_box_prism(self) -> RectangularPrism:
    return self.get_perfect_timber_within_bounding_box_prism()

DEPRECATED_approximate_bounding_prism

DEPRECATED_approximate_bounding_prism() -> RectangularPrism

TODO someday we want a fully analytical solution for this, but for now this is sufficient for our needs.

Get the bounding box prism for this timber including all its cuts. The bounding box is aligned with the timber's orientation.

Uses a hybrid approach: analytical methods for simple cases (HalfSpace cuts), and sampling for complex CSG operations. Works with all CSG types and orientations.

Returns:

Name Type Description
RectangularPrism RectangularPrism

The bounding box for the cut timber in global coordinates

Source code in kumiki/timber.py
@deprecated("use get_perfect_timber_within_bounding_box_prism instead")
def DEPRECATED_approximate_bounding_prism(self) -> RectangularPrism:
    """
    TODO someday we want a fully analytical solution for this, but for now this is sufficient for our needs.

    Get the bounding box prism for this timber including all its cuts.
    The bounding box is aligned with the timber's orientation.

    Uses a hybrid approach: analytical methods for simple cases (HalfSpace cuts),
    and sampling for complex CSG operations. Works with all CSG types and orientations.

    Returns:
        RectangularPrism: The bounding box for the cut timber in global coordinates
    """

    # Start with the timber's original bounds (in local coordinates)
    min_z = scalar(0)
    max_z = self.timber.length

    # Length direction in local coordinates (always +Z)
    length_direction_local = Matrix([scalar(0), scalar(0), scalar(1)])

    # Try analytical approach first for simple HalfSpace cuts
    can_use_analytical = True
    for cut in self.cuts:
        csg = cut.get_negative_csg_local()
        if csg is None:
            continue

        # A cutting always wraps what it removes in a SolidUnion of its
        # own, so look through that to the pieces doing the removing.
        components = list(csg.children) if isinstance(csg, SolidUnion) else [csg]

        for half_space in components:
            # Check if it's a simple HalfSpace
            if not isinstance(half_space, HalfSpace):
                # Complex CSG - need sampling
                can_use_analytical = False
                break

            dot_product = safe_dot_product(half_space.normal, length_direction_local)
            if not safe_equality_test(Abs(dot_product), 1):
                # HalfSpace not aligned with length - need sampling
                can_use_analytical = False
                break

            # HalfSpace aligned with length direction.
            # HalfSpace contains points where (p · normal) >= offset
            # When subtracted, remaining points are where (p · normal) < offset
            if safe_compare(dot_product, 0, Comparison.GT):
                # Normal points in +Z direction
                # Subtraction removes points with Z >= offset
                max_z = Min(max_z, half_space.offset)
            else:
                # Normal points in -Z direction
                # Subtraction removes points with Z <= -offset
                min_z = Max(min_z, -half_space.offset)

        if not can_use_analytical:
            break

    if can_use_analytical:
        # All cuts were simple aligned HalfSpaces, we're done
        return RectangularPrism(
            size=self.timber.size,
            transform=Transform(
                position=self.timber.get_bottom_position_global(),
                orientation=self.timber.orientation
            ),
            start_distance=min_z,
            end_distance=max_z
        )

    # Fall back to sampling for complex cases
    cut_csg = self.render_timber_with_cuts_csg_local()

    # Use fewer samples for speed, using float arithmetic
    num_length_samples = 50
    num_cross_section_samples = 5

    # Get timber half-sizes
    half_width = self.timber.size[0] / 2
    half_height = self.timber.size[1] / 2

    # Find actual min Z (bottom bound)
    for i in range(num_length_samples + 1):
        z_float = float(min_z) + (float(max_z) - float(min_z)) * (i / num_length_samples)
        z = scalar(int(z_float * 1000), 1000)  # Round to 3 decimal places for speed

        # Sample points in the cross-section
        found_point_at_z = False
        for ix in range(-num_cross_section_samples, num_cross_section_samples + 1):
            if found_point_at_z:
                break
            for iy in range(-num_cross_section_samples, num_cross_section_samples + 1):
                x = half_width * scalar(ix, num_cross_section_samples)
                y = half_height * scalar(iy, num_cross_section_samples)

                test_point = Matrix([x, y, z])
                if cut_csg.contains_point(test_point):
                    found_point_at_z = True
                    min_z = z
                    break

        if found_point_at_z:
            break

    # Find actual max Z (top bound)
    for i in range(num_length_samples + 1):
        z_float = float(max_z) - (float(max_z) - float(min_z)) * (i / num_length_samples)
        z = scalar(int(z_float * 1000), 1000)  # Round to 3 decimal places for speed

        # Sample points in the cross-section
        found_point_at_z = False
        for ix in range(-num_cross_section_samples, num_cross_section_samples + 1):
            if found_point_at_z:
                break
            for iy in range(-num_cross_section_samples, num_cross_section_samples + 1):
                x = half_width * scalar(ix, num_cross_section_samples)
                y = half_height * scalar(iy, num_cross_section_samples)

                test_point = Matrix([x, y, z])
                if cut_csg.contains_point(test_point):
                    found_point_at_z = True
                    max_z = z
                    break

        if found_point_at_z:
            break

    # Create the bounding box prism in global coordinates
    return RectangularPrism(
        size=self.timber.size,
        transform=Transform(
            position=self.timber.get_bottom_position_global(),
            orientation=self.timber.orientation
        ),
        start_distance=min_z,
        end_distance=max_z
    )

Accessory dataclass

Accessory(*, ticket: AccessoryTicket = AccessoryTicket(), assembly_freedom: Optional[AssemblyFreedom] = None, assembly_ordering: Ordering = Ordering())

Bases: ABC

Base class for joint accessories like wedges, drawbores, etc.

ticket class-attribute instance-attribute

ticket: AccessoryTicket = field(default_factory=AccessoryTicket, kw_only=True)

assembly_freedom class-attribute instance-attribute

assembly_freedom: Optional[AssemblyFreedom] = field(default=None, kw_only=True)

assembly_ordering class-attribute instance-attribute

assembly_ordering: Ordering = field(default=Ordering(), kw_only=True)

get_csg_local abstractmethod

get_csg_local() -> CutCSG

Generate CSG representation of the accessory in local space.

The local space is defined by the accessory's orientation and position, where the CSG is generated at the origin with identity orientation.

Returns:

Name Type Description
CutCSG CutCSG

The CSG representation of the accessory in local space

Source code in kumiki/timber.py
@abstractmethod
def get_csg_local(self) -> CutCSG:
    """
    Generate CSG representation of the accessory in local space.

    The local space is defined by the accessory's orientation and position,
    where the CSG is generated at the origin with identity orientation.

    Returns:
        CutCSG: The CSG representation of the accessory in local space
    """
    pass

PegShape

Bases: Enum

Shape of a peg.

SQUARE class-attribute instance-attribute

SQUARE = 'square'

ROUND class-attribute instance-attribute

ROUND = 'round'

Peg dataclass

Peg(transform: Transform, size: Numeric, shape: PegShape, forward_length: Numeric, stickout_length: Numeric, *, ticket: AccessoryTicket = AccessoryTicket(), assembly_freedom: Optional[AssemblyFreedom] = None, assembly_ordering: Ordering = Ordering())

Bases: Accessory

Represents a peg used in timber joinery (e.g., draw bore pegs, komisen).

The peg is stored in GLOBAL SPACE with absolute position and orientation. In identity orientation, the peg points in the +Z direction, with the insertion end at the origin.

By convention, the origin of the peg is on the mortise face that the peg is going into. This is why there are 2 lengths parameters, one for how deep the peg goes past the mortise face, and one for how far the peg sticks out of the mortise face.

Attributes:

Name Type Description
transform Transform

Transform (position and orientation) of the peg in global space

size Numeric

Size/diameter of the peg (for square pegs, this is the side length)

shape PegShape

Shape of the peg (SQUARE or ROUND)

forward_length Numeric

How far the peg reaches in the forward direction (into the mortise)

stickout_length Numeric

How far the peg "sticks out" in the back direction (outside the mortise)

transform instance-attribute

transform: Transform

size instance-attribute

size: Numeric

shape instance-attribute

shape: PegShape

forward_length instance-attribute

forward_length: Numeric

stickout_length instance-attribute

stickout_length: Numeric

get_csg_local

get_csg_local() -> CutCSG

Generate CSG representation of the peg in local space.

The peg is centered at the origin with identity orientation, extending from -stickout_length to forward_length along the Z axis.

Returns:

Name Type Description
CutCSG CutCSG

The CSG representation of the peg

Source code in kumiki/timber.py
def get_csg_local(self) -> CutCSG:
    """
    Generate CSG representation of the peg in local space.

    The peg is centered at the origin with identity orientation,
    extending from -stickout_length to forward_length along the Z axis.

    Returns:
        CutCSG: The CSG representation of the peg
    """
    if self.shape == PegShape.SQUARE:
        # Square peg - use RectangularPrism with square cross-section
        return RectangularPrism(
            size=create_v2(self.size, self.size),
            transform=Transform.identity(),
            start_distance=-self.stickout_length,
            end_distance=self.forward_length
        )
    else:  # PegShape.ROUND
        # Round peg - use Cylinder
        radius = self.size / scalar(2)
        return Cylinder(
            axis_direction=create_v3(scalar(0), scalar(0), scalar(1)),
            radius=radius,
            position=create_v3(scalar(0), scalar(0), scalar(0)),
            start_distance=-self.stickout_length,
            end_distance=self.forward_length
        )

WedgeShape dataclass

WedgeShape(base_width: Numeric, tip_width: Numeric, height: Numeric, length: Numeric)

Specification for wedge dimensions.

base_width instance-attribute

base_width: Numeric

tip_width instance-attribute

tip_width: Numeric

height instance-attribute

height: Numeric

length instance-attribute

length: Numeric

Wedge dataclass

Wedge(transform: Transform, base_width: Numeric, tip_width: Numeric, height: Numeric, length: Numeric, stickout_length: Numeric = scalar(0), *, ticket: AccessoryTicket = AccessoryTicket(), assembly_freedom: Optional[AssemblyFreedom] = None, assembly_ordering: Ordering = Ordering())

Bases: Accessory

Represents a wedge used in timber joinery (e.g., wedged tenons).

The wedge is stored in local space of a timber. In identity orientation, the pointy end of the wedge goes in the length direction of the timber.

The profile of the wedge (trapezoidal shape) is in the Y axis (height in Y). The width of the wedge is in the X axis. The origin (0,0) is at the bottom center of the longer side of the triangle.

Visual representation (looking at wedge from the side): +z _ <- tip width
/ \ \ / \ \ +y -x /
____\ <- base width ↑ origin

transform instance-attribute

transform: Transform

base_width instance-attribute

base_width: Numeric

tip_width instance-attribute

tip_width: Numeric

height instance-attribute

height: Numeric

length instance-attribute

length: Numeric

stickout_length class-attribute instance-attribute

stickout_length: Numeric = scalar(0)

width property

width: Numeric

Alias for base_width for convenience.

get_csg_local

get_csg_local() -> CutCSG

Generate CSG representation of the wedge in local space.

The wedge is created using a polyline extrusion (ConvexPolygonExtrusion) with a trapezoidal profile in the XZ plane. The base is at z=0 with base_width, and the tip is at z=length with tip_width. The extrusion extends along Y from -height/2 to height/2.

The polygon profile is a trapezoid in the XZ plane: - Base at z=0 with width = base_width (centered at x=0) - Tip at z=length with width = tip_width (centered at x=0)

The transform is rotated so that +Y goes to +Z (rotation around X axis by +90°).

Returns:

Name Type Description
CutCSG CutCSG

The CSG representation of the wedge

Source code in kumiki/timber.py
def get_csg_local(self) -> CutCSG:
    """
    Generate CSG representation of the wedge in local space.

    The wedge is created using a polyline extrusion (ConvexPolygonExtrusion)
    with a trapezoidal profile in the XZ plane. The base is at z=0 with base_width,
    and the tip is at z=length with tip_width. The extrusion extends along Y
    from -height/2 to height/2.

    The polygon profile is a trapezoid in the XZ plane:
    - Base at z=0 with width = base_width (centered at x=0)
    - Tip at z=length with width = tip_width (centered at x=0)

    The transform is rotated so that +Y goes to +Z (rotation around X axis by +90°).

    Returns:
        CutCSG: The CSG representation of the wedge
    """
    # Create trapezoid polygon in XZ plane
    # Points are (x, z) where x is X coordinate and y (2D) is Z coordinate
    # Ordered counter-clockwise when viewed from +Y
    half_base_width = self.base_width / scalar(2)
    half_tip_width = self.tip_width / scalar(2)

    # Calculate width at stickout position (z = -stickout_length)
    # The taper goes from base_width at z=0 to tip_width at z=length
    # Linear interpolation: width(z) = base_width + (tip_width - base_width) * z / length
    has_stickout = safe_compare(self.stickout_length, 0, Comparison.GT)
    if has_stickout:
        # Width at z = -stickout_length
        stickout_width = self.base_width + (self.tip_width - self.base_width) * (-self.stickout_length) / self.length
        half_stickout_width = stickout_width / scalar(2)
        base_z = -self.stickout_length
    else:
        half_stickout_width = half_base_width
        base_z = scalar(0)

    trapezoid_points = [
        create_v2(-half_stickout_width, base_z),      # Bottom-left (base with stickout)
        create_v2(half_stickout_width, base_z),       # Bottom-right (base with stickout)
        create_v2(half_tip_width, self.length),       # Top-right (tip)
        create_v2(-half_tip_width, self.length)        # Top-left (tip)
    ]

    # Rotate transform so that +Y goes to +Z
    # This rotates around X axis by +90° (pi/2 radians)
    x_axis = create_v3(scalar(1), scalar(0), scalar(0))
    rotation_orientation = Orientation.from_axis_angle(x_axis, radians(pi / scalar(2)))

    wedge_transform = Transform(
        position=create_v3(scalar(0), scalar(0), scalar(0)),
        orientation=rotation_orientation
    )

    # Extrusion extends along Y from -height/2 to height/2
    half_height = self.height / scalar(2)

    return ConvexPolygonExtrusion(
        points=trapezoid_points,
        transform=wedge_transform,
        start_distance=-half_height,
        end_distance=half_height
    )

CSGAccessory dataclass

CSGAccessory(transform: Transform, positive_csg: CutCSG, *, ticket: AccessoryTicket = AccessoryTicket(), assembly_freedom: Optional[AssemblyFreedom] = None, assembly_ordering: Ordering = Ordering())

Bases: Accessory

Generic accessory represented as local-space positive CSG plus a global transform.

transform instance-attribute

transform: Transform

positive_csg instance-attribute

positive_csg: CutCSG

get_csg_local

get_csg_local() -> CutCSG
Source code in kumiki/timber.py
def get_csg_local(self) -> CutCSG:
    return self.positive_csg

Sticker dataclass

Sticker(transform: Transform, size: Numeric = inches(1), *, ticket: AccessoryTicket = AccessoryTicket(), assembly_freedom: Optional[AssemblyFreedom] = None, assembly_ordering: Ordering = Ordering())

Bases: Accessory

Just a marking used for debugging (ball at center + shaft in local +Z).

transform instance-attribute

transform: Transform

size class-attribute instance-attribute

size: Numeric = inches(1)

get_csg_local

get_csg_local() -> CutCSG
Source code in kumiki/timber.py
def get_csg_local(self) -> CutCSG:
    # Ball diameter = size, shaft diameter = size/2, shaft length = 2*size
    ball_radius = self.size / scalar(2)
    shaft_radius = self.size / scalar(4)
    shaft_length = self.size * scalar(2)
    axis_z = create_v3(scalar(0), scalar(0), scalar(1))
    origin = create_v3(scalar(0), scalar(0), scalar(0))
    ball = Cylinder(
        position=origin,
        axis_direction=axis_z,
        radius=ball_radius,
        start_distance=-ball_radius,
        end_distance=ball_radius,
    )
    shaft_position = axis_z * ball_radius
    shaft = Cylinder(
        position=shaft_position,
        axis_direction=axis_z,
        radius=shaft_radius,
        start_distance=scalar(0),
        end_distance=shaft_length,
    )
    return SolidUnion(children=[ball, shaft])

Joint dataclass

Joint(cuttings: Dict[str, Cutting], ticket: JointTicket, jointAccessories: Dict[str, Accessory] = dict())

cuttings instance-attribute

cuttings: Dict[str, Cutting]

ticket instance-attribute

ticket: JointTicket

jointAccessories class-attribute instance-attribute

jointAccessories: Dict[str, Accessory] = field(default_factory=dict)

__post_init__

__post_init__() -> None
Source code in kumiki/timber.py
def __post_init__(self) -> None:
    # A cutting that removes nothing contributes no CSG node, so a joint
    # whose cuttings all remove nothing would cut no timber at all.
    assert any(
        cutting.get_negative_csg_local() is not None
        for cutting in self.cuttings.values()
    ), f"Joint '{self.ticket.path}' has no cutting that removes anything"

is_decorative

is_decorative() -> bool
Source code in kumiki/timber.py
def is_decorative(self) -> bool:
    return len(self.cuttings) == 1

with_order

with_order(order: Union[int, Mapping[str, int], Iterable[Tuple[Union[str, PerfectTimberWithin, Accessory], int]]]) -> Joint

Return a copy of this joint with assembly order(s) assigned.

Assembly freedoms and suborders are authored by the cut functions; the order is the frame-level plan and is assigned here, after cutting (smaller order = extracted earlier during disassembly).

with_order(n): sets order=n on every cutting and accessory, keeping their suborders, so intra-joint sequencing (peg pops before the tenon slides) is preserved within step n.

with_order({key: n, ...}) or with_order([(member, n), ...]): sets Ordering(n, 0) on each named member — referenced by cutting/accessory string key, or by the timber / accessory object itself (a timber reference applies to every cutting holding it; use the pair-list form for object references, which are unhashable). Unnamed members keep their current ordering. Raises ValueError for unknown references, or when the new orderings break the strict precedence the cut function expressed via suborders (any member pair previously strictly ordered must remain strictly ordered).

Assign orders BEFORE building the Frame: this rebuilds the member objects (dataclasses.replace, preserving timber references), so a Frame built earlier would still hold the previous orderings.

Source code in kumiki/timber.py
def with_order(
    self,
    order: Union[
        int,
        Mapping[str, int],
        Iterable[Tuple[Union[str, "PerfectTimberWithin", "Accessory"], int]],
    ],
) -> "Joint":
    """Return a copy of this joint with assembly order(s) assigned.

    Assembly freedoms and suborders are authored by the cut functions;
    the order is the frame-level plan and is assigned here, after cutting
    (smaller order = extracted earlier during disassembly).

    with_order(n): sets order=n on every cutting and accessory, keeping
    their suborders, so intra-joint sequencing (peg pops before the tenon
    slides) is preserved within step n.

    with_order({key: n, ...}) or with_order([(member, n), ...]): sets
    Ordering(n, 0) on each named member — referenced by cutting/accessory
    string key, or by the timber / accessory object itself (a timber
    reference applies to every cutting holding it; use the pair-list form
    for object references, which are unhashable). Unnamed members keep
    their current ordering. Raises ValueError for unknown references, or
    when the new orderings break the strict precedence the cut function
    expressed via suborders (any member pair previously strictly ordered
    must remain strictly ordered).

    Assign orders BEFORE building the Frame: this rebuilds the member
    objects (dataclasses.replace, preserving timber references), so a
    Frame built earlier would still hold the previous orderings.
    """
    if isinstance(order, int):
        new_cuttings = {
            key: replace(cutting, assembly_ordering=Ordering(order, cutting.assembly_ordering.suborder))
            for key, cutting in self.cuttings.items()
        }
        new_accessories = {
            key: replace(accessory, assembly_ordering=Ordering(order, accessory.assembly_ordering.suborder))
            for key, accessory in self.jointAccessories.items()
        }
        return Joint(cuttings=new_cuttings, ticket=self.ticket, jointAccessories=new_accessories)

    # Per-member form. Members are addressed as ("cutting"|"accessory", key).
    def resolve(reference) -> List[Tuple[str, str]]:
        if isinstance(reference, str):
            if reference in self.cuttings:
                return [("cutting", reference)]
            if reference in self.jointAccessories:
                return [("accessory", reference)]
            raise ValueError(
                f"with_order: unknown member key '{reference}'; this joint has cuttings "
                f"{sorted(self.cuttings)} and accessories {sorted(self.jointAccessories)}"
            )
        timber_matches = [("cutting", key) for key, cutting in self.cuttings.items() if cutting.timber is reference]
        if timber_matches:
            return timber_matches
        accessory_matches = [("accessory", key) for key, accessory in self.jointAccessories.items() if accessory is reference]
        if accessory_matches:
            return accessory_matches
        reference_name = getattr(getattr(reference, "ticket", None), "path", repr(type(reference)))
        raise ValueError(f"with_order: '{reference_name}' is not a timber or accessory of this joint")

    old_orderings: Dict[Tuple[str, str], Ordering] = {
        ("cutting", key): cutting.assembly_ordering for key, cutting in self.cuttings.items()
    }
    old_orderings.update(
        (("accessory", key), accessory.assembly_ordering) for key, accessory in self.jointAccessories.items()
    )

    order_pairs: List[Tuple[Union[str, "PerfectTimberWithin", "Accessory"], int]]
    if isinstance(order, Mapping):
        order_pairs = [(str(key), int(value)) for key, value in cast(Mapping[str, int], order).items()]
    else:
        order_pairs = [(reference, int(member_order)) for reference, member_order in order]
    new_orderings = dict(old_orderings)
    for reference, member_order in order_pairs:
        for member_id in resolve(reference):
            new_orderings[member_id] = Ordering(member_order, 0)

    # The cut function's suborders express required sequencing; explicit
    # per-member orders must not invert or collapse it.
    member_ids = list(old_orderings)
    for first in member_ids:
        for second in member_ids:
            if old_orderings[first] < old_orderings[second] and not new_orderings[first] < new_orderings[second]:
                raise ValueError(
                    f"with_order: '{first[1]}' must be extracted before '{second[1]}' "
                    f"(orderings {old_orderings[first].label()} < {old_orderings[second].label()}), "
                    f"but the new orders place them at {new_orderings[first].label()} "
                    f"vs {new_orderings[second].label()}"
                )

    new_cuttings = {
        key: replace(cutting, assembly_ordering=new_orderings[("cutting", key)])
        for key, cutting in self.cuttings.items()
    }
    new_accessories = {
        key: replace(accessory, assembly_ordering=new_orderings[("accessory", key)])
        for key, accessory in self.jointAccessories.items()
    }
    return Joint(cuttings=new_cuttings, ticket=self.ticket, jointAccessories=new_accessories)

Frame dataclass

Frame(cut_timbers: List[CutTimber], accessories: List[Accessory] = list(), name: Optional[str] = None, source_joints: Optional[List] = None, footprints: List[Footprint] = list(), drawings: List[Drawing] = list())

Represents a complete timber frame structure with all cut timbers and accessories.

In traditional timber framing, a 'frame' is the complete structure ready for raising. This class encapsulates all the timbers that have been cut with their joints, plus any accessories like pegs, wedges, or drawbores.

Attributes:

Name Type Description
cut_timbers List[CutTimber]

List of CutTimber objects representing all timbers in the frame

accessories List[Accessory]

List of Accessory objects (already in global space)

name Optional[str]

Optional name for this frame (e.g., "Oscar's Shed", "Main Frame")

cut_timbers instance-attribute

cut_timbers: List[CutTimber]

accessories class-attribute instance-attribute

accessories: List[Accessory] = field(default_factory=list)

name class-attribute instance-attribute

name: Optional[str] = None

source_joints class-attribute instance-attribute

source_joints: Optional[List] = field(default=None, compare=False, hash=False, repr=False)

footprints class-attribute instance-attribute

footprints: List[Footprint] = field(default_factory=list)

drawings class-attribute instance-attribute

drawings: List[Drawing] = field(default_factory=list)

resolve_timber_path

resolve_timber_path(path: TimberPath) -> List[ResolvedTimberPath]

Which timbers a name refers to, in this frame.

A list, because a name may match several: paths are not required to be unique, and pretending one always means one timber would quietly pick whichever came first. Where it does match several, the reference stops being stable -- each is then told apart by the order the frame built them, so inserting another above them moves every reference below. That is worth saying out loud rather than discovering later, so it warns.

Source code in kumiki/timber.py
def resolve_timber_path(self, path: 'TimberPath') -> List['ResolvedTimberPath']:
    """Which timbers a name refers to, in this frame.

    A list, because a name may match several: paths are not required to be
    unique, and pretending one always means one timber would quietly pick
    whichever came first. Where it does match several, the reference stops
    being stable -- each is then told apart by the order the frame built
    them, so inserting another above them moves every reference below. That
    is worth saying out loud rather than discovering later, so it warns.
    """
    from .identity import ResolvedTimberPath

    wanted = str(path)
    matches = [
        ResolvedTimberPath(path=wanted, occurrence=occurrence)
        for occurrence, _ in enumerate(
            cut for cut in self.cut_timbers
            if _timber_path_of(cut.timber) == wanted
        )
    ]
    if len(matches) > 1:
        warnings.warn(
            f"{len(matches)} timbers share the path {wanted!r}. They can only be told "
            "apart by the order they were built in, so adding another above them will "
            "move anything that refers to them -- a drawing, or a measurement. Give "
            "them distinct ticket paths to keep those references stable."
        )
    return matches

timber_paths

timber_paths() -> List[TimberPath]

Every name in the frame, in order, duplicates included.

Source code in kumiki/timber.py
def timber_paths(self) -> List['TimberPath']:
    """Every name in the frame, in order, duplicates included."""
    from .identity import TimberPath

    return [TimberPath(_timber_path_of(cut.timber)) for cut in self.cut_timbers]

from_joints classmethod

from_joints(joints: List[Joint], additional_unjointed_timbers: Optional[List[PerfectTimberWithin]] = None, name: Optional[str] = None) -> Frame

Create a Frame from a list of joints and optional additional unjointed timbers.

This constructor extracts all cut timbers and accessories from the joints, and combines cut timbers that share the same underlying timber reference.

Parameters:

Name Type Description Default
joints List[Joint]

List of Joint objects

required
additional_unjointed_timbers Optional[List[PerfectTimberWithin]]

Optional list of PerfectTimberWithin objects that don't participate in any joints (default: empty list)

None
name Optional[str]

Optional name for the frame

None

Returns:

Name Type Description
Frame Frame

A new Frame object with merged cut timbers and collected accessories

Raises:

Type Description
ValueError

If two timbers with the same name but same underlying timber have different references (indicates a bug)

Source code in kumiki/timber.py
@classmethod
def from_joints(cls, joints: List[Joint],
                additional_unjointed_timbers: Optional[List[PerfectTimberWithin]] = None,
                name: Optional[str] = None) -> 'Frame':
    """
    Create a Frame from a list of joints and optional additional unjointed timbers.

    This constructor extracts all cut timbers and accessories from the joints,
    and combines cut timbers that share the same underlying timber reference.

    Args:
        joints: List of Joint objects
        additional_unjointed_timbers: Optional list of PerfectTimberWithin objects that don't
                                     participate in any joints (default: empty list)
        name: Optional name for the frame

    Returns:
        Frame: A new Frame object with merged cut timbers and collected accessories

    Raises:
        ValueError: If two timbers with the same name but same underlying timber 
                   have different references (indicates a bug)

    Warnings:
        Prints a warning if two timbers with the same name have different underlying 
        timber references and the underlying timbers are actually different.
    """
    import warnings

    if additional_unjointed_timbers is None:
        additional_unjointed_timbers = []

    # Dictionary to group Cutting objects by their underlying Timber reference (identity)
    # Key: id(timber), Value: List of Cutting objects
    timber_ref_to_cuttings: Dict[int, List[Cutting]] = {}
    timber_ref_to_timber: Dict[int, PerfectTimberWithin] = {}

    # Extract cuttings from all joints
    for joint in joints:
        for cutting in joint.cuttings.values():
            timber_id = id(cutting.timber)
            timber_ref_to_timber[timber_id] = cutting.timber
            if timber_id not in timber_ref_to_cuttings:
                timber_ref_to_cuttings[timber_id] = []
            timber_ref_to_cuttings[timber_id].append(cutting)

    # Check for name conflicts
    # Build a mapping from name to list of timber references
    name_to_timber_refs: Dict[str, List[PerfectTimberWithin]] = {}
    for timber_id, timber in timber_ref_to_timber.items():
        timber_name = timber.ticket.path
        if timber_name is not None:
            if timber_name not in name_to_timber_refs:
                name_to_timber_refs[timber_name] = []
            # Only add if not already in the list (check by identity)
            if not any(t is timber for t in name_to_timber_refs[timber_name]):
                name_to_timber_refs[timber_name].append(timber)

    # Check for conflicts
    for timber_name, timber_refs in name_to_timber_refs.items():
        if len(timber_refs) > 1:
            # Multiple timbers with the same name
            # Check if the underlying timbers are actually different
            for i in range(len(timber_refs)):
                for j in range(i + 1, len(timber_refs)):
                    timber_i = timber_refs[i]
                    timber_j = timber_refs[j]

                    # Compare using structural equality (==)
                    if timber_i == timber_j:
                        # Same timber data but different references - this is a bug
                        raise ValueError(
                            f"Error: Found two timber references with the same name '{timber_name}' "
                            f"that have identical underlying timber data. This indicates a bug "
                            f"where the same timber was created multiple times instead of reusing "
                            f"the same reference."
                        )
                    else:
                        # Different timber data with the same name - just a warning
                        warnings.warn(
                            f"Warning: Found multiple timbers with the same name '{timber_name}' "
                            f"but different properties (length, size, position, or orientation). "
                            f"This may indicate an error in timber naming. "
                            f"Timber 1: length={timber_i.length}, size={timber_i.size}, "
                            f"position={timber_i.get_bottom_position_global()}. "
                            f"Timber 2: length={timber_j.length}, size={timber_j.size}, "
                            f"position={timber_j.get_bottom_position_global()}."
                        )

    # Merge cut timbers with the same underlying timber reference
    merged_cut_timbers: List[CutTimber] = []
    for timber_id, cutting_list in timber_ref_to_cuttings.items():
        timber = timber_ref_to_timber[timber_id]

        # Collect all cuts from all joints for this timber
        all_cuts: List[Cutting] = []
        all_cuts.extend(cutting_list)

        # Create a single merged CutTimber
        merged_cut_timber = CutTimber(
            timber,
            cuts=all_cuts,
            joints=_joints_touching_timber(joints, timber),
        )
        merged_cut_timbers.append(merged_cut_timber)

    # Add additional unjointed timbers as CutTimbers with no cuts
    for timber in additional_unjointed_timbers:
        merged_cut_timbers.append(CutTimber(timber, cuts=[], joints=[]))

    # Collect all accessories from all joints
    all_accessories: List[Accessory] = []
    for joint in joints:
        all_accessories.extend(joint.jointAccessories.values())

    # Create and return the Frame
    return cls(
        cut_timbers=merged_cut_timbers,
        accessories=all_accessories,
        name=name,
        source_joints=list(joints),
    )

get_bounding_box

get_bounding_box() -> tuple[V3, V3]

Get the axis-aligned bounding box for the entire frame in global coordinates.

This computes the bounding box by getting the bounding prism for each cut timber and finding the global min/max coordinates that enclose all of them.

Returns:

Type Description
tuple[V3, V3]

tuple[V3, V3]: (min_corner, max_corner) where each is a 3x1 Matrix representing the minimum and maximum corners of the axis-aligned bounding box in global coordinates

Raises:

Type Description
ValueError

If the frame contains no cut timbers

Source code in kumiki/timber.py
def get_bounding_box(self) -> tuple[V3, V3]:
    """
    Get the axis-aligned bounding box for the entire frame in global coordinates.

    This computes the bounding box by getting the bounding prism for each cut timber
    and finding the global min/max coordinates that enclose all of them.

    Returns:
        tuple[V3, V3]: (min_corner, max_corner) where each is a 3x1 Matrix representing
                      the minimum and maximum corners of the axis-aligned bounding box
                      in global coordinates

    Raises:
        ValueError: If the frame contains no cut timbers
    """
    if not self.cut_timbers:
        raise ValueError("Cannot compute bounding box for empty frame (no cut timbers)")

    # Get bounding prism for each cut timber
    bounding_prisms = [ct.get_perfect_timber_within_bounding_box_prism() for ct in self.cut_timbers]

    # For each prism, we need to find its 8 corners and track global min/max
    # Initialize with infinities
    min_x = None
    min_y = None
    min_z = None
    max_x = None
    max_y = None
    max_z = None

    for prism in bounding_prisms:
        # Get the 8 corners of the rectangular prism
        # The prism is defined by its size (width, height) in the XY plane
        # and start_distance/end_distance along the Z axis

        half_width = prism.size[0] / 2
        half_height = prism.size[1] / 2

        # Generate 8 corners in local coordinates
        # (±half_width, ±half_height, start_distance or end_distance)
        local_corners = []
        for x_sign in [-1, 1]:
            for y_sign in [-1, 1]:
                for z_val in [prism.start_distance, prism.end_distance]:
                    local_corner = Matrix([
                        x_sign * half_width,
                        y_sign * half_height,
                        z_val
                    ])
                    local_corners.append(local_corner)

        # Transform each corner to global coordinates
        for local_corner in local_corners:
            global_corner = prism.transform.position + safe_transform_vector(prism.transform.orientation.matrix, local_corner)

            # Update min/max for each axis
            if min_x is None:
                min_x = global_corner[0]
                max_x = global_corner[0]
                min_y = global_corner[1]
                max_y = global_corner[1]
                min_z = global_corner[2]
                max_z = global_corner[2]
            else:
                min_x = min(min_x, global_corner[0])
                max_x = max(max_x, global_corner[0])
                min_y = min(min_y, global_corner[1])
                max_y = max(max_y, global_corner[1])
                min_z = min(min_z, global_corner[2])
                max_z = max(max_z, global_corner[2])

    min_corner = Matrix([min_x, min_y, min_z])
    max_corner = Matrix([max_x, max_y, max_z])

    return (min_corner, max_corner)

KumikiArrangementError

Bases: ValueError

Raised when a timber arrangement or joint parameter fails a validation check.

Unlike AssertionError, this survives python -O and is safe for callers to catch specifically when handling invalid joint/arrangement configurations.

Footprint dataclass

A support class representing the footprint of the structure in the XY plane

corners instance-attribute

corners: Tuple[V2, ...]

__post_init__

__post_init__()

Validate corners. Args: corners: Tuple of points defining the corners, last point connects to first

Source code in kumiki/footprint.py
def __post_init__(self):
    """
    Validate corners.
    Args:
        corners: Tuple of points defining the corners, last point connects to first
    """
    # Convert list to tuple if necessary
    if isinstance(self.corners, list):
        object.__setattr__(self, 'corners', tuple(self.corners))

sides

sides() -> List[Tuple[V2, V2]]

Returns a list of sides (line segments) connecting consecutive corners.

Returns:

Type Description
List[Tuple[V2, V2]]

List of tuples, each containing two points (start, end) representing a side

Source code in kumiki/footprint.py
def sides(self) -> List[Tuple[V2, V2]]:
    """
    Returns a list of sides (line segments) connecting consecutive corners.

    Returns:
        List of tuples, each containing two points (start, end) representing a side
    """
    result = []
    for i in range(len(self.corners)):
        start = self.corners[i]
        end = self.corners[(i + 1) % len(self.corners)]
        result.append((start, end))
    return result

is_valid

is_valid() -> bool

Checks if the footprint is valid. A valid footprint has at least 3 corners and no intersecting sides.

Returns:

Type Description
bool

True if valid, False otherwise

Source code in kumiki/footprint.py
def is_valid(self) -> bool:
    """
    Checks if the footprint is valid.
    A valid footprint has at least 3 corners and no intersecting sides.

    Returns:
        True if valid, False otherwise
    """
    # Check minimum number of corners
    if len(self.corners) < 3:
        return False

    # Check for self-intersecting sides
    sides = self.sides()
    n = len(sides)

    for i in range(n):
        for j in range(i + 2, n):
            # Don't check adjacent sides (they share a point)
            if j == (i + n - 1) % n:
                continue

            # Check if side i intersects with side j
            if self._segments_intersect(sides[i], sides[j]):
                return False

    return True

contains_point

contains_point(point: V2) -> bool

Check if a point is contained within the footprint boundary using ray casting algorithm.

Parameters:

Name Type Description Default
point V2

2D point to check

required

Returns:

Type Description
bool

True if point is inside or on the boundary, False otherwise

Source code in kumiki/footprint.py
def contains_point(self, point: V2) -> bool:
    """
    Check if a point is contained within the footprint boundary using ray casting algorithm.

    Args:
        point: 2D point to check

    Returns:
        True if point is inside or on the boundary, False otherwise
    """
    x, y = point[0], point[1]
    n = len(self.corners)
    inside = False

    p1x, p1y = self.corners[0][0], self.corners[0][1]

    for i in range(1, n + 1):
        p2x, p2y = self.corners[i % n][0], self.corners[i % n][1]

        if y > min(p1y, p2y):
            if y <= max(p1y, p2y):
                if x <= max(p1x, p2x):
                    if p1y != p2y:
                        xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
                    if p1x == p2x or x <= xinters:
                        inside = not inside

        p1x, p1y = p2x, p2y

    return inside

nearest_corner

nearest_corner(point: V2) -> Tuple[int, V2]

Find the nearest corner to a given point.

Parameters:

Name Type Description Default
point V2

2D point to measure from

required

Returns:

Type Description
Tuple[int, V2]

Tuple of (index, corner) where index is the corner index and corner is the V2 point

Source code in kumiki/footprint.py
def nearest_corner(self, point: V2) -> Tuple[int, V2]:
    """
    Find the nearest corner to a given point.

    Args:
        point: 2D point to measure from

    Returns:
        Tuple of (index, corner) where index is the corner index and corner is the V2 point
    """
    if not self.corners:
        raise ValueError("Footprint has no corners")

    min_distance = None
    nearest_idx = 0

    for i, corner in enumerate(self.corners):
        dx = point[0] - corner[0]
        dy = point[1] - corner[1]
        distance = (dx * dx + dy * dy) ** 0.5

        if min_distance is None or distance < min_distance:
            min_distance = distance
            nearest_idx = i

    return nearest_idx, self.corners[nearest_idx]

nearest_boundary

nearest_boundary(point: V2) -> Tuple[int, Tuple[V2, V2], Numeric]

Find the nearest side (line segment) to a given point.

Parameters:

Name Type Description Default
point V2

2D point to measure from

required

Returns:

Type Description
Tuple[int, Tuple[V2, V2], Numeric]

Tuple of (index, side, distance) where: - index is the side index - side is a tuple (start_corner, end_corner) - distance is the perpendicular distance to the side

Source code in kumiki/footprint.py
def nearest_boundary(self, point: V2) -> Tuple[int, Tuple[V2, V2], Numeric]:
    """
    Find the nearest side (line segment) to a given point.

    Args:
        point: 2D point to measure from

    Returns:
        Tuple of (index, side, distance) where:
            - index is the side index
            - side is a tuple (start_corner, end_corner)
            - distance is the perpendicular distance to the side
    """
    if len(self.corners) < 2:
        raise ValueError("Footprint must have at least 2 corners")

    sides = self.sides()
    min_distance = None
    nearest_idx = 0

    px, py = point[0], point[1]

    for i, (start, end) in enumerate(sides):
        # Calculate distance from point to line segment
        x1, y1 = start[0], start[1]
        x2, y2 = end[0], end[1]

        # Vector from start to end
        dx = x2 - x1
        dy = y2 - y1

        # If the segment has zero length, distance is to the point
        if dx == 0 and dy == 0:
            distance = ((px - x1) ** 2 + (py - y1) ** 2) ** 0.5
        else:
            # Parameter t of the projection of point onto the line
            t = max(0, min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy)))

            # Closest point on the segment
            closest_x = x1 + t * dx
            closest_y = y1 + t * dy

            # Distance to closest point
            distance = ((px - closest_x) ** 2 + (py - closest_y) ** 2) ** 0.5

        if min_distance is None or distance < min_distance:
            min_distance = distance
            nearest_idx = i

    assert min_distance is not None, "min_distance should not be None after iterating through sides"
    return nearest_idx, sides[nearest_idx], min_distance

get_inward_normal

get_inward_normal(side_index: int) -> Direction3D

Get the inward-pointing normal vector for a boundary side.

The inward normal is perpendicular to the boundary side and points toward the interior of the footprint.

Parameters:

Name Type Description Default
side_index int

Index of the boundary side

required

Returns:

Type Description
Direction3D

Direction3D representing the normalized 3D inward normal vector

Source code in kumiki/footprint.py
def get_inward_normal(self, side_index: int) -> Direction3D:
    """
    Get the inward-pointing normal vector for a boundary side.

    The inward normal is perpendicular to the boundary side and points toward
    the interior of the footprint.

    Args:
        side_index: Index of the boundary side

    Returns:
        Direction3D representing the normalized 3D inward normal vector
    """
    if side_index < 0 or side_index >= len(self.corners):
        raise ValueError(f"Invalid side_index: {side_index}")

    # Get the start and end points of the side
    start = self.corners[side_index]
    end = self.corners[(side_index + 1) % len(self.corners)]

    # Calculate direction vector along the side
    dx = end[0] - start[0]
    dy = end[1] - start[1]

    # Normalize the direction
    length = sqrt(dx * dx + dy * dy)
    if safe_zero_test(length):
        raise ValueError(f"Side {side_index} has zero length")

    dx = dx / length
    dy = dy / length

    # Calculate perpendicular vector (left perpendicular in 2D)
    # For direction (dx, dy), left perpendicular is (-dy, dx)
    left_perp_x = -dy
    left_perp_y = dx

    # Test if this perpendicular points inward by checking if a point
    # slightly offset in this direction is inside the polygon
    midpoint_x = (start[0] + end[0]) / scalar(2)
    midpoint_y = (start[1] + end[1]) / scalar(2)

    # Create a test point offset slightly in the perpendicular direction
    offset = scalar(1, 1000)  # Small offset for testing
    test_x = midpoint_x + left_perp_x * offset
    test_y = midpoint_y + left_perp_y * offset

    # Create a test point vector
    test_point = Matrix([test_x, test_y])

    # Check if test point is inside
    if self.contains_point(test_point):
        # Left perpendicular points inward
        return create_v3(left_perp_x, left_perp_y, scalar(0))
    else:
        # Right perpendicular points inward
        return create_v3(dy, -dx, scalar(0))

nearest_boundary_from_line

nearest_boundary_from_line(line_start: V2, line_end: V2) -> Tuple[int, Tuple[V2, V2], Numeric]

Find the nearest boundary side to a given line segment.

Parameters:

Name Type Description Default
line_start V2

Start point of the line segment (2D)

required
line_end V2

End point of the line segment (2D)

required

Returns:

Type Description
Tuple[int, Tuple[V2, V2], Numeric]

Tuple of (index, side, distance) where: - index is the side index - side is a tuple (start_corner, end_corner) - distance is the minimum distance between the line segment and the boundary

Source code in kumiki/footprint.py
def nearest_boundary_from_line(self, line_start: V2, line_end: V2) -> Tuple[int, Tuple[V2, V2], Numeric]:
    """
    Find the nearest boundary side to a given line segment.

    Args:
        line_start: Start point of the line segment (2D)
        line_end: End point of the line segment (2D)

    Returns:
        Tuple of (index, side, distance) where:
            - index is the side index
            - side is a tuple (start_corner, end_corner)
            - distance is the minimum distance between the line segment and the boundary
    """
    if len(self.corners) < 2:
        raise ValueError("Footprint must have at least 2 corners")

    sides = self.sides()
    min_distance = None
    nearest_idx = 0

    for i, (start, end) in enumerate(sides):
        distance = _segment_to_segment_distance(line_start, line_end, start, end)

        if min_distance is None or distance < min_distance:
            min_distance = distance
            nearest_idx = i

    assert min_distance is not None, "min_distance should not be None after iterating through sides"
    return nearest_idx, sides[nearest_idx], min_distance

FootprintLocation

Bases: Enum

INSIDE class-attribute instance-attribute

INSIDE = 1

CENTER class-attribute instance-attribute

CENTER = 2

OUTSIDE class-attribute instance-attribute

OUTSIDE = 3

Line dataclass

Line(direction: Direction3D, point: V3)

Represents an oriented, infinite line with origin in 3D space.

direction instance-attribute

direction: Direction3D

point instance-attribute

point: V3

__repr__

__repr__() -> str
Source code in kumiki/geometry.py
def __repr__(self) -> str:
    return f"Line(direction={self.direction}, point={self.point})"

Point dataclass

Point(position: V3)

Represents a point in 3D space.

position instance-attribute

position: V3

__repr__

__repr__() -> str
Source code in kumiki/geometry.py
def __repr__(self) -> str:
    return f"Point(position={self.position})"

BoundingBox dataclass

Axis-aligned bounding box (AABB) for a CSG object.

Each bound is Optional[Numeric] where None means unbounded in that direction.

When is_empty is True, the CSG object contains no points at all (e.g. EmptyCSG, or a union/intersection that reduces to nothing). The bound fields are meaningless in this case (by convention all set to 0) and must not be treated as a real zero-size box at the origin — check is_empty first.

min_x instance-attribute

min_x: Optional[Numeric]

min_y instance-attribute

min_y: Optional[Numeric]

min_z instance-attribute

min_z: Optional[Numeric]

max_x instance-attribute

max_x: Optional[Numeric]

max_y instance-attribute

max_y: Optional[Numeric]

max_z instance-attribute

max_z: Optional[Numeric]

is_empty class-attribute instance-attribute

is_empty: bool = False

FeatureCategory

Bases: Enum

What kind of place on a primitive's boundary a default feature names.

One vocabulary across every primitive for simplicity. OK to add primitive specific keys here rather than reuse.

Some will be paired with an index, others may be one offs (index 0)

CAP class-attribute instance-attribute

CAP = 0

SIDE class-attribute instance-attribute

SIDE = 1

ARRIS class-attribute instance-attribute

ARRIS = 2

CORNER class-attribute instance-attribute

CORNER = 3

PrismFace

Bases: Enum

Face of a RectangularPrism, indices match TimberFace.

TOP class-attribute instance-attribute

TOP = 1

BOTTOM class-attribute instance-attribute

BOTTOM = 2

RIGHT class-attribute instance-attribute

RIGHT = 3

FRONT class-attribute instance-attribute

FRONT = 4

LEFT class-attribute instance-attribute

LEFT = 5

BACK class-attribute instance-attribute

BACK = 6

ExtrusionCap

Bases: Enum

Which flat end of an extrusion-like primitive a feature is on.

TOP class-attribute instance-attribute

TOP = 1

BOTTOM class-attribute instance-attribute

BOTTOM = 2

CylinderPart

Bases: Enum

Which surface of a Cylinder a feature is on.

BARREL is the curved lateral surface. Unlike a prism's four sides it is a single feature, not four -- there is no non-arbitrary way to cut it up, and nothing in joinery wants to reference "a quarter of a peg hole wall".

TOP class-attribute instance-attribute

TOP = 1

BOTTOM class-attribute instance-attribute

BOTTOM = 2

BARREL class-attribute instance-attribute

BARREL = 3

CSGFeatureType

Bases: Enum

What kind of geometry a feature names.

The three cases measurement cares about: measuring between two features dispatches on this pair (two parallel faces measure like two parallel planes, a point and a face measure a projected distance, and so on).

Everything nameable on a primitive today is a FACE. EDGE arrives with features derived from intersecting face pairs; POINT with their vertices.

FACE class-attribute instance-attribute

FACE = 1

EDGE class-attribute instance-attribute

EDGE = 2

POINT class-attribute instance-attribute

POINT = 3

FeatureTestTolerances dataclass

How close a point must be to count as on a feature, per feature type.

Not epsilons: an epsilon absorbs float error, while these absorb the gap between meshed and analytic geometry and the imprecision of a human click. They are several orders of magnitude larger than EPSILON_GENERIC and are chosen, not derived.

One tolerance does not fit all three, and the reason is about how features get selected rather than about the geometry:

  • a FACE you click directly, so the only slack needed is the gap between the analytic surface and the triangulated mesh a raycast actually hits;
  • an EDGE or a POINT you cannot click exactly at all. Selecting one means snapping to it, the way any CAD package works, so they want considerably more room -- and a caller driving this from a viewport usually wants to derive theirs from screen space, or a line is unhittable zoomed out and greedy zoomed in.

This replaces the earlier pair of eps / snap_eps parameters, which keyed the wider tolerance off real instead. Type is the better key: a real derived edge is just as unclickable as a non-real centre axis.

face class-attribute instance-attribute

edge class-attribute instance-attribute

point class-attribute instance-attribute

for_type

for_type(feature_type: CSGFeatureType) -> Numeric

The test tolerance for a feature of feature_type.

Source code in kumiki/cutcsg.py
def for_type(self, feature_type: 'CSGFeatureType') -> Numeric:
    """The test tolerance for a feature of *feature_type*."""
    if feature_type == CSGFeatureType.EDGE:
        return self.edge
    if feature_type == CSGFeatureType.POINT:
        return self.point
    return self.face

__mul__

__mul__(factor: Numeric) -> FeatureTestTolerances

Scale every tolerance by factor.

The reason this exists is camera zoom. Selecting an edge or a point is a snap, and how much slack a snap needs is a screen-space question: a fixed 2mm is a comfortable target zoomed in and an invisible one zoomed out. A viewport can hold one FeatureTestTolerances describing the tolerances at some reference zoom and scale it by world-units-per-pixel per query.

Source code in kumiki/cutcsg.py
def __mul__(self, factor: Numeric) -> 'FeatureTestTolerances':
    """Scale every tolerance by *factor*.

    The reason this exists is camera zoom. Selecting an edge or a point is
    a snap, and how much slack a snap needs is a screen-space question: a
    fixed 2mm is a comfortable target zoomed in and an invisible one zoomed
    out. A viewport can hold one FeatureTestTolerances describing the tolerances
    at some reference zoom and scale it by world-units-per-pixel per query.
    """
    if safe_compare(factor, 0, Comparison.LE):
        raise ValueError(f"feature test tolerances must scale by a positive factor, got {factor}")
    return FeatureTestTolerances(
        face=self.face * factor,
        edge=self.edge * factor,
        point=self.point * factor,
    )

__rmul__

__rmul__(factor: Numeric) -> FeatureTestTolerances
Source code in kumiki/cutcsg.py
def __rmul__(self, factor: Numeric) -> 'FeatureTestTolerances':
    return self.__mul__(factor)

__truediv__

__truediv__(divisor: Numeric) -> FeatureTestTolerances
Source code in kumiki/cutcsg.py
def __truediv__(self, divisor: Numeric) -> 'FeatureTestTolerances':
    if safe_compare(divisor, 0, Comparison.LE):
        raise ValueError(f"feature test tolerances must divide by a positive factor, got {divisor}")
    return self.__mul__(scalar(1) / divisor)

uniform staticmethod

uniform(eps: Numeric) -> FeatureTestTolerances

The same tolerance for every feature type.

Source code in kumiki/cutcsg.py
@staticmethod
def uniform(eps: Numeric) -> 'FeatureTestTolerances':
    """The same tolerance for every feature type."""
    return FeatureTestTolerances(face=eps, edge=eps, point=eps)

exact staticmethod

Analytic tolerance, for geometry that was never triangulated.

Source code in kumiki/cutcsg.py
@staticmethod
def exact() -> 'FeatureTestTolerances':
    """Analytic tolerance, for geometry that was never triangulated."""
    return FeatureTestTolerances.uniform(EPSILON_GENERIC)

FeatureGroup

Bases: Enum

Which other features a feature is allowed to form an edge with.

Deriving edges from every pair of faces in a CSG tree produces mostly nonsense -- a tenon cheek and the far end of the timber do not meet. Groups make the useful pairs declarable instead of searched for:

A  intersects with B1 and B2
B1 intersects with A only
B2 intersects with A, and with itself
C  intersects with itself only

NONE is the exception to the scheme: it meets nothing, not even itself, and is how a feature says it forms no edges at all. Some geometry is worth naming and pointing at without every face of it turning into an arris.

Defaults today: a timber's perfect-timber-within and rough faces are B2, and every named joint feature is A -- so joint geometry meets the timber body, and the body meets itself, the latter being the timber's own four long arrises, which drawing generation needs. B1 and C are defined but unused until something needs them.

A consequence of the body meeting itself: relief geometry embeds the MATING timber's rough body to scribe against, and its faces carry the same reserved rough.* names (see timber.ROUGH_FACE_PREFIX). Two timbers' faces then pair into an edge that reads as one timber's -- rough.back x rough.back -- since the name says nothing about whose body it is.

A class-attribute instance-attribute

A = 1

B1 class-attribute instance-attribute

B1 = 2

B2 class-attribute instance-attribute

B2 = 3

C class-attribute instance-attribute

C = 4

NONE class-attribute instance-attribute

NONE = 5

FeatureMarkingStatus

Bases: Enum

Whether a feature has to appear on a drawing.

TODO integrate: declared and carried on every feature, but nothing reads it yet -- the drawing generator does not consult it when deciding what to mark.

OPTIONAL class-attribute instance-attribute

OPTIONAL = 0

ALWAYS_MARK class-attribute instance-attribute

ALWAYS_MARK = 1

NEVER_MARK class-attribute instance-attribute

NEVER_MARK = 2

FeatureMarkingSpec dataclass

How a feature should be marked, when that differs from the default.

mark_relative_to names the feature a dimension should be measured from, which is how a drawing says "38mm from the shoulder" rather than giving an absolute position. None leaves that to whatever generates the drawing.

TODO integrate: nothing sets marking_override and nothing reads it, so a joint cannot yet say how it wants to be dimensioned.

mark class-attribute instance-attribute

mark_relative_to class-attribute instance-attribute

mark_relative_to: Optional[str] = None

FeaturePurpose

Bases: Enum

What purpose the feature serves.

TODO integrate: ROUGH_RELIEF is never set and never tested against, so relief geometry is still indistinguishable from a joint's real surfaces everywhere it matters -- picking, measuring and drawing alike.

NOT_SPECIFIED class-attribute instance-attribute

NOT_SPECIFIED = 0

ROUGH_RELIEF class-attribute instance-attribute

ROUGH_RELIEF = 1

FeatureProperties dataclass

Metadata every feature carries, independent of how it is identified.

Parameters:

Name Type Description Default
group

which other features this one may form an edge with. NONE by default, so a feature pairs with nothing unless someone says it should. Deriving an edge is the expensive, noisy thing the feature system does -- every pairing is a line that has to be worth selecting -- so it is opted into rather than out of. Today the only pairing anyone wants is a shoulder plane against the timber's own prism.

required
priority

lower wins when several features claim the same point.

required
real

False for a feature that names no actual surface (a bore's centre axis, a reference plane). Real features can be cropped away by the CSG tree and so are tested against the triangulated result first; non-real ones are unaffected by boolean operations.

required
marking_override

how to mark this feature on a drawing, when the default for its kind is not what is wanted. None means the default. TODO integrate: carried, never read.

required
purpose

what the feature is for, where that is worth recording -- relief geometry is not a feature of the joint the way a tenon cheek is. TODO integrate: carried, never read.

required

group class-attribute instance-attribute

priority class-attribute instance-attribute

priority: int = 0

real class-attribute instance-attribute

real: bool = True

marking_override class-attribute instance-attribute

marking_override: Optional[FeatureMarkingSpec] = None

purpose class-attribute instance-attribute

CSGFeatureExtent dataclass

Roughly where a feature is, for placing annotations against it.

Separate from locate(): that gives the unbounded geometry a measurement is computed on, this says where to actually draw the thing. Approximate is fine -- a dimension line only needs somewhere sensible to attach.

Parameters:

Name Type Description Default
anchor

a representative point -- a face's centre, an edge's midpoint, or the point itself.

required
ends

for an edge, its two endpoints.

required
aabb

for a face, a rough bounding box.

required

anchor instance-attribute

anchor: V3

ends class-attribute instance-attribute

ends: Optional[Tuple[V3, V3]] = None

aabb class-attribute instance-attribute

aabb: Optional[BoundingBox] = None

CSGFeature dataclass

Bases: ABC

A named region of a CutCSG's boundary -- a face today, edges and points later.

A feature is stored on the primitive it belongs to and does NOT hold a reference back to it: the owner is passed in to every method that needs geometry. That keeps a feature constructible before its owner exists (which it must be, to be passed to the owner's constructor) and means there is one feature type rather than a stored declaration plus a resolved copy.

Because a feature alone does not know where it lives, queries hand back a OwnedFeatureHit pairing it with the primitive that matched.

Subclasses say how the feature is identified: by an enum member for the simple per-primitive cases, or by an arbitrary predicate for ProgrammableCSGFeature.

name instance-attribute

name: str

properties class-attribute instance-attribute

properties: FeatureProperties = field(default_factory=FeatureProperties)

group property

group: FeatureGroup

real property

real: bool

priority property

priority: int

feature_key

feature_key() -> Optional[FeatureKey]

Which default slot this feature occupies, or None if it has none.

This is what lets an authored feature REPLACE the default at the same place rather than sit alongside it. None is the honest answer for anything with no fixed place on a primitive -- a ProgrammableCSGFeature matching a formula, or a derived edge, which exists only as the product of two hits and never occupies a slot of its own.

Source code in kumiki/cutcsg.py
def feature_key(self) -> Optional['FeatureKey']:
    """Which default slot this feature occupies, or None if it has none.

    This is what lets an authored feature REPLACE the default at the same
    place rather than sit alongside it. None is the honest answer for
    anything with no fixed place on a primitive -- a ProgrammableCSGFeature
    matching a formula, or a derived edge, which exists only as the product
    of two hits and never occupies a slot of its own.
    """
    return None

feature_type abstractmethod

feature_type() -> CSGFeatureType

What kind of geometry this feature names.

A method rather than a field so it cannot be set to something the feature is not: a face feature has no way to claim it is an edge. Subclasses that name one kind by construction return a constant; only a feature whose kind genuinely varies stores one.

Kept off FeatureProperties deliberately -- this says what the feature is, while properties say how it should be treated.

Source code in kumiki/cutcsg.py
@abstractmethod
def feature_type(self) -> CSGFeatureType:
    """What kind of geometry this feature names.

    A method rather than a field so it cannot be set to something the
    feature is not: a face feature has no way to claim it is an edge.
    Subclasses that name one kind by construction return a constant; only
    a feature whose kind genuinely varies stores one.

    Kept off FeatureProperties deliberately -- this says what the feature
    *is*, while properties say how it should be treated.
    """
    ...

locate

locate(owner: CutCSG) -> Optional[LocatedGeometry]

The unbounded geometry this feature lies on, in the owner's space.

A Plane for a planar face, a Line for an edge, a Point for a vertex.

None when the feature names a surface that is not one of those -- a cylinder's barrel, a lofted side, an extrusion side that follows a curved path segment. Those are perfectly good features to select and highlight; there is just no single plane to measure against, so measurement has to decline rather than invent one.

Note the space: the CSG tree is timber-local, so this is too. Anything comparing features across timbers has to lift both through the timber transform first.

Source code in kumiki/cutcsg.py
def locate(self, owner: 'CutCSG') -> Optional[LocatedGeometry]:
    """The unbounded geometry this feature lies on, in the owner's space.

    A Plane for a planar face, a Line for an edge, a Point for a vertex.

    None when the feature names a surface that is not one of those -- a
    cylinder's barrel, a lofted side, an extrusion side that follows a
    curved path segment. Those are perfectly good features to select and
    highlight; there is just no single plane to measure against, so
    measurement has to decline rather than invent one.

    Note the space: the CSG tree is timber-local, so this is too. Anything
    comparing features across timbers has to lift both through the timber
    transform first.
    """
    return None

get_extent

get_extent(owner: CutCSG) -> Optional[CSGFeatureExtent]

Roughly where this feature sits, for placing annotations.

None when the feature has no bounded extent at all (a half-space's plane), or when it is not worked out for this shape yet.

Source code in kumiki/cutcsg.py
def get_extent(self, owner: 'CutCSG') -> Optional[CSGFeatureExtent]:
    """Roughly where this feature sits, for placing annotations.

    None when the feature has no bounded extent at all (a half-space's
    plane), or when it is not worked out for this shape yet.
    """
    return None

test_point_unbounded abstractmethod

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool

Whether point lies on this feature's SURFACE, unbounded.

Unbounded is in the name because it is half a test and reads as a whole one. A face feature answers for the face's whole PLANE: the RIGHT face of a prism is x == half_width and nothing about y or z, so it says yes a metre off the end of the timber. An edge built from two of these says yes all the way along its line.

Primitive level, and deliberately so: no root node is involved, so this cannot know what the rest of the tree did to owner. The other half comes from collect_feature_hits, which gates every real feature on is_point_on_boundary of the node declaring it, and from each enclosing compound node, which gates again on its own boundary. On the plane AND on the boundary means on the face, so a point query IS bounded -- by composition, at every level of the tree.

CONSIDERED: folding the bound in here, so this stands on its own. Decided against, for now:

  • it would not remove the gate. A face buried inside a sibling union is still on its own primitive's boundary, so the compound levels have to keep checking regardless.
  • it costs more. The gate is computed once per node and shared by every feature that node declares; bounding each feature separately does the same work per feature -- ten times over for a prism with six faces and four arrises, for the same answer.
  • it duplicates the primitive's own extent inside every feature sitting on it, which is the kind of thing that drifts apart.
  • it fixes nothing that is broken. The bug this looks like it would fix -- a highlight running past the end of an edge -- is an EXTENT question, and no point test answers that however well bounded. That is what crop_line_to_segments_on_csg is for.

What it would buy is safety for a caller that uses this on its own, which today means one: the mesh-vertex fallback in kigumi's runner, already marked for deletion. If that stops being the only one, revisit.

ALSO CONSIDERED: two more optional arguments, a surface normal and a line, so a caller that knows more about the point can say so. Several features can claim one point -- two coincident parallel faces, or the several that meet at a corner -- and a normal would tell them apart where the point alone cannot.

Not done, because the caller cannot honestly supply either one for the case that wants them most. Selecting an EDGE is the case: you would get the edge's own line only by clicking exactly on a triangle edge of the mesh, which is the one thing a human click never does. Every other click lands on a triangle's face, so what is actually available is that triangle's normal and a line lying on ONE of the two faces forming the edge -- which is not the edge, and answers a slightly different question with total confidence. Better nothing than that.

Worth revisiting if a picker ever hands back the analytic surface it hit rather than the triangle, since then both arguments mean what they say.

Source code in kumiki/cutcsg.py
@abstractmethod
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    """Whether *point* lies on this feature's SURFACE, unbounded.

    Unbounded is in the name because it is half a test and reads as a whole
    one. A face feature answers for the face's whole PLANE: the RIGHT face
    of a prism is x == half_width and nothing about y or z, so it says yes
    a metre off the end of the timber. An edge built from two of these says
    yes all the way along its line.

    Primitive level, and deliberately so: no root node is involved, so this
    cannot know what the rest of the tree did to *owner*. The other half
    comes from collect_feature_hits, which gates every real feature on
    is_point_on_boundary of the node declaring it, and from each enclosing
    compound node, which gates again on its own boundary. On the plane AND
    on the boundary means on the face, so a point query IS bounded -- by
    composition, at every level of the tree.

    CONSIDERED: folding the bound in here, so this stands on its own.
    Decided against, for now:

    - it would not remove the gate. A face buried inside a sibling union is
      still on its own primitive's boundary, so the compound levels have to
      keep checking regardless.
    - it costs more. The gate is computed once per node and shared by every
      feature that node declares; bounding each feature separately does the
      same work per feature -- ten times over for a prism with six faces and
      four arrises, for the same answer.
    - it duplicates the primitive's own extent inside every feature sitting
      on it, which is the kind of thing that drifts apart.
    - it fixes nothing that is broken. The bug this looks like it would fix
      -- a highlight running past the end of an edge -- is an EXTENT
      question, and no point test answers that however well bounded. That
      is what crop_line_to_segments_on_csg is for.

    What it would buy is safety for a caller that uses this on its own,
    which today means one: the mesh-vertex fallback in kigumi's runner,
    already marked for deletion. If that stops being the only one, revisit.

    ALSO CONSIDERED: two more optional arguments, a surface normal and a
    line, so a caller that knows more about the point can say so. Several
    features can claim one point -- two coincident parallel faces, or the
    several that meet at a corner -- and a normal would tell them apart
    where the point alone cannot.

    Not done, because the caller cannot honestly supply either one for the
    case that wants them most. Selecting an EDGE is the case: you would get
    the edge's own line only by clicking exactly on a triangle edge of the
    mesh, which is the one thing a human click never does. Every other click
    lands on a triangle's face, so what is actually available is that
    triangle's normal and a line lying on ONE of the two faces forming the
    edge -- which is not the edge, and answers a slightly different question
    with total confidence. Better nothing than that.

    Worth revisiting if a picker ever hands back the analytic surface it hit
    rather than the triangle, since then both arguments mean what they say.
    """
    ...

ProgrammableCSGFeature dataclass

Bases: CSGFeature

A feature identified by an arbitrary predicate rather than an enum member.

The escape hatch for anything the simple per-primitive classes cannot name: a formula-defined region, half of a face, an edge derived from two other features. Works on any primitive, since the owner is just an argument.

The predicate is called only for points already known to be on the owner's boundary, and receives the same eps the query was made with.

predicate class-attribute instance-attribute

predicate: Optional[Callable[[CutCSG, V3, Optional[Numeric]], bool]] = None

declared_type class-attribute instance-attribute

feature_type

feature_type() -> CSGFeatureType
Source code in kumiki/cutcsg.py
def feature_type(self) -> CSGFeatureType:
    return self.declared_type

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/cutcsg.py
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    if self.predicate is None:
        return False
    return self.predicate(owner, point, test_tolerance)

DerivedEdgeFeature dataclass

Bases: CSGFeature

The edge where two face features meet.

Built rather than authored: joints declare faces, and the edges between them fall out of which faces are allowed to meet (see FeatureGroup). Use derive() rather than constructing directly -- it applies the group rules, rejects pairs that form no edge, and names the result deterministically.

The two parents generally live on different primitives (a tenon cheek and the timber body, say), so each is carried with its own owner. The owner passed to this feature's own methods is the compound node that contains both, and is unused here -- the geometry comes from the parents.

a class-attribute instance-attribute

a: Optional[OwnedFeatureHit] = None

b class-attribute instance-attribute

b: Optional[OwnedFeatureHit] = None

feature_type

feature_type() -> CSGFeatureType
Source code in kumiki/cutcsg.py
def feature_type(self) -> CSGFeatureType:
    return CSGFeatureType.EDGE

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/cutcsg.py
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    if self.a is None or self.b is None:
        return False
    return (self.a.feature.test_point_unbounded(self.a.owner, point, test_tolerance)
            and self.b.feature.test_point_unbounded(self.b.owner, point, test_tolerance))

locate

locate(owner: CutCSG) -> Optional[LocatedGeometry]
Source code in kumiki/cutcsg.py
def locate(self, owner: 'CutCSG') -> Optional[LocatedGeometry]:
    if self.a is None or self.b is None:
        return None
    # None if either parent is a surface with no plane -- a cylinder
    # barrel, a lofted side. The edge is still pickable; it just cannot be
    # measured against, the same decline locate() makes elsewhere.
    return intersect_planes(_as_plane(self.a.locate()), _as_plane(self.b.locate()))

get_extent

get_extent(owner: CutCSG) -> Optional[CSGFeatureExtent]

Where this edge sits -- only approximately, and deliberately so.

ends is None and anchor is the point on the INFINITE line closest to the origin, which need not be anywhere near the stretch of edge that actually exists. Harmless for picking, which only calls test_point_unbounded, and not good enough to hang a dimension line off.

Measurement does the cropping instead, a level up where the enclosing timber is known -- this feature cannot see it, since its owner is whichever node derived it. See cropcsg.segment_on_line, called from the runner's _feature_anchor.

Source code in kumiki/cutcsg.py
def get_extent(self, owner: 'CutCSG') -> Optional[CSGFeatureExtent]:
    """Where this edge sits -- only approximately, and deliberately so.

    `ends` is None and `anchor` is the point on the INFINITE line closest to
    the origin, which need not be anywhere near the stretch of edge that
    actually exists. Harmless for picking, which only calls
    test_point_unbounded, and
    not good enough to hang a dimension line off.

    Measurement does the cropping instead, a level up where the enclosing
    timber is known -- this feature cannot see it, since its owner is
    whichever node derived it. See cropcsg.segment_on_line, called
    from the runner's _feature_anchor.
    """
    if self.a is None or self.b is None:
        return None
    line = self.locate(owner)
    if not isinstance(line, Line):
        return None
    return CSGFeatureExtent(anchor=line.point)

derive staticmethod

The edge where a and b meet, or None if they form none.

None when: either is not a face; their groups are not allowed to meet; either names a face that is not THERE; or their planes are parallel (which includes being the same plane -- coincident faces share a whole plane, not a line).

Not planar is a different thing from not there, and only the second stops an edge existing. A cylinder's barrel and a lofted side are real surfaces with no single plane, and the edge where one meets a flat face is real too -- pickable, just not measurable as a line, which is what locate() returning None means for it. The top of a prism extended to infinity is not a surface at all, and an edge against it is nothing.

It takes both questions to tell those apart, because each alone gets one of them wrong. A barrel has no plane but has an extent; a half space has a plane but no extent, being unbounded; a face that is not there has neither. So neither answer on its own means absent -- both do.

Source code in kumiki/cutcsg.py
@staticmethod
def derive(a: 'OwnedFeatureHit', b: 'OwnedFeatureHit') -> Optional['DerivedEdgeFeature']:
    """The edge where *a* and *b* meet, or None if they form none.

    None when: either is not a face; their groups are not allowed to meet;
    either names a face that is not THERE; or their planes are parallel
    (which includes being the same plane -- coincident faces share a whole
    plane, not a line).

    Not planar is a different thing from not there, and only the second
    stops an edge existing. A cylinder's barrel and a lofted side are real
    surfaces with no single plane, and the edge where one meets a flat face
    is real too -- pickable, just not measurable as a line, which is what
    locate() returning None means for it. The top of a prism extended to
    infinity is not a surface at all, and an edge against it is nothing.

    It takes both questions to tell those apart, because each alone gets
    one of them wrong. A barrel has no plane but has an extent; a half
    space has a plane but no extent, being unbounded; a face that is not
    there has neither. So neither answer on its own means absent -- both
    do.
    """
    if a.feature.feature_type() != CSGFeatureType.FACE:
        return None
    if b.feature.feature_type() != CSGFeatureType.FACE:
        return None
    if not feature_groups_intersect(a.feature.group, b.feature.group):
        return None

    # A face that is not there forms no edge. The top of a prism extended
    # to infinity is the case that turns this up -- a timber's rough stock
    # is exactly that -- and without the check the pair was accepted and
    # the edge then located to nothing, which reads downstream as "cannot
    # say" rather than "not an edge".
    for hit in (a, b):
        if (hit.feature.locate(hit.owner) is None
                and hit.feature.get_extent(hit.owner) is None):
            return None
    if planes_are_parallel(_as_plane(a.locate()), _as_plane(b.locate())):
        return None

    # Deterministic order, so the same edge gets the same identity however
    # traversal reached it.
    first, second = sorted(
        (a, b), key=lambda hit: (hit.feature.group.value, hit.feature.name))
    return DerivedEdgeFeature(
        name=f"{first.feature.name}\u00d7{second.feature.name}",
        properties=FeatureProperties(
            # An edge exists only where both its faces do.
            real=a.feature.real and b.feature.real,
            priority=max(a.feature.priority, b.feature.priority),
            # Groups govern which faces meet; nothing pairs edges yet, so
            # this is not meaningful for a derived edge and stays default.
        ),
        a=first,
        b=second,
    )

HalfSpaceFeature dataclass

Bases: CSGFeature

The entire boundary plane of a HalfSpace.

A half-space has exactly one face, so this needs no key to say which.

feature_key

feature_key() -> Optional[FeatureKey]
Source code in kumiki/cutcsg.py
def feature_key(self) -> Optional[FeatureKey]:
    # Its only surface. Not a cap: a half space has no ends to be an end of.
    return (FeatureCategory.SIDE, 0)

feature_type

feature_type() -> CSGFeatureType
Source code in kumiki/cutcsg.py
def feature_type(self) -> CSGFeatureType:
    return CSGFeatureType.FACE

locate

locate(owner: CutCSG) -> Optional[LocatedGeometry]
Source code in kumiki/cutcsg.py
def locate(self, owner: 'CutCSG') -> Optional[LocatedGeometry]:
    if not isinstance(owner, HalfSpace):
        return None
    # The solid is dot(normal, p) >= offset, so the boundary plane is
    # dot(normal, p) == offset and the outward normal points out of it.
    normal_length_sq = safe_dot_product(owner.normal, owner.normal)
    if safe_zero_test_sq(normal_length_sq):
        return None
    closest_to_origin = owner.normal * (owner.offset / normal_length_sq)
    return Plane(normal=-owner.normal, point=closest_to_origin)

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/cutcsg.py
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    return owner.is_point_on_boundary(point, eps=test_tolerance)

SimpleRectangularPrismFeature dataclass

Bases: CSGFeature

One of the six faces of a RectangularPrism, named by PrismFace.

face class-attribute instance-attribute

feature_key

feature_key() -> Optional[FeatureKey]
Source code in kumiki/cutcsg.py
def feature_key(self) -> Optional[FeatureKey]:
    if self.face in _PRISM_CAP_KEYS:
        return _PRISM_CAP_KEYS[self.face]
    return (FeatureCategory.SIDE, _PRISM_SIDE_ORDER.index(self.face))

feature_type

feature_type() -> CSGFeatureType
Source code in kumiki/cutcsg.py
def feature_type(self) -> CSGFeatureType:
    return CSGFeatureType.FACE

locate

locate(owner: CutCSG) -> Optional[LocatedGeometry]
Source code in kumiki/cutcsg.py
def locate(self, owner: 'CutCSG') -> Optional[LocatedGeometry]:
    if not isinstance(owner, RectangularPrism):
        return None
    frame = self._face_frame(owner)
    if frame is None:
        return None
    normal, centre = frame
    return Plane(normal=normal, point=centre)

get_extent

get_extent(owner: CutCSG) -> Optional[CSGFeatureExtent]
Source code in kumiki/cutcsg.py
def get_extent(self, owner: 'CutCSG') -> Optional[CSGFeatureExtent]:
    if not isinstance(owner, RectangularPrism):
        return None
    frame = self._face_frame(owner)
    if frame is None:
        return None
    _, centre = frame
    return CSGFeatureExtent(anchor=centre, aabb=owner.get_aabb())

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/cutcsg.py
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    if not isinstance(owner, RectangularPrism):
        return False
    x, y, z = owner._local_coords(point)
    half_width = owner.size[0] / 2
    half_height = owner.size[1] / 2
    if self.face == PrismFace.RIGHT:
        return safe_equality_test(x, half_width, eps=test_tolerance)
    if self.face == PrismFace.LEFT:
        return safe_equality_test(x, -half_width, eps=test_tolerance)
    if self.face == PrismFace.FRONT:
        return safe_equality_test(y, half_height, eps=test_tolerance)
    if self.face == PrismFace.BACK:
        return safe_equality_test(y, -half_height, eps=test_tolerance)
    if self.face == PrismFace.TOP:
        return owner.end_distance is not None and safe_equality_test(z, owner.end_distance, eps=test_tolerance)
    if self.face == PrismFace.BOTTOM:
        return owner.start_distance is not None and safe_equality_test(z, owner.start_distance, eps=test_tolerance)
    return False

SimpleRectangularPrismEdgeFeature dataclass

Bases: CSGFeature

An arris of a RectangularPrism, named by the two faces it lies between.

Declared rather than derived, which is the difference that matters. A derived edge exists only as the product of two face hits at a query point, so it cannot be referred to afterwards by name and its identity depends on both parents surviving. An arris a timber simply HAS is a thing to name once, and then to measure to for as long as the timber has it.

The two faces must actually meet: opposite faces are parallel and share no line, and asking for that pair gets None from locate() rather than an invented answer.

faces class-attribute instance-attribute

feature_type

feature_type() -> CSGFeatureType
Source code in kumiki/cutcsg.py
def feature_type(self) -> CSGFeatureType:
    return CSGFeatureType.EDGE

feature_key

feature_key() -> Optional[FeatureKey]

The arris between two adjacent sides, or None between a side and a cap.

The low run of ARRIS is side n against side n+1, so only an adjacent PAIR of sides has one there; a side against a cap lands further along the same run, and opposite sides never meet at all.

Source code in kumiki/cutcsg.py
def feature_key(self) -> Optional[FeatureKey]:
    """The arris between two adjacent sides, or None between a side and a cap.

    The low run of ARRIS is side n against side n+1, so only an adjacent
    PAIR of sides has one there; a side against a cap lands further along
    the same run, and opposite sides never meet at all.
    """
    first, second = self.faces
    if first in _PRISM_CAP_KEYS or second in _PRISM_CAP_KEYS:
        cap, side = ((first, second) if first in _PRISM_CAP_KEYS
                     else (second, first))
        if side in _PRISM_CAP_KEYS:
            return None  # two caps never meet
        return arris_against_cap(
            _PRISM_SIDE_ORDER.index(side), len(_PRISM_SIDE_ORDER),
            end=cap is PrismFace.TOP)
    low, high = (_PRISM_SIDE_ORDER.index(first), _PRISM_SIDE_ORDER.index(second))
    low, high = min(low, high), max(low, high)
    sides = len(_PRISM_SIDE_ORDER)
    if high - low == 1:
        return (FeatureCategory.ARRIS, low)
    if low == 0 and high == sides - 1:
        return (FeatureCategory.ARRIS, high)  # the wrap-around join
    return None  # opposite sides: parallel, no arris

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/cutcsg.py
def test_point_unbounded(self, owner: 'CutCSG', point: V3,
               test_tolerance: Optional[Numeric] = None) -> bool:
    first, second = self._sides()
    return (first.test_point_unbounded(owner, point, test_tolerance)
            and second.test_point_unbounded(owner, point, test_tolerance))

locate

locate(owner: CutCSG) -> Optional[LocatedGeometry]

The line the two faces meet in, or None if they never do.

Source code in kumiki/cutcsg.py
def locate(self, owner: 'CutCSG') -> Optional[LocatedGeometry]:
    """The line the two faces meet in, or None if they never do."""
    first, second = self._sides()
    return intersect_planes(_as_plane(first.locate(owner)), _as_plane(second.locate(owner)))

get_extent

get_extent(owner: CutCSG) -> Optional[CSGFeatureExtent]

Where the arris sits -- only approximately, as for a derived edge.

ends is None and anchor is the point on the INFINITE line closest to the origin. Cropping it to the timber is measurement's job, a level up where the enclosing solid is known; see cropcsg.segment_on_line.

Source code in kumiki/cutcsg.py
def get_extent(self, owner: 'CutCSG') -> Optional[CSGFeatureExtent]:
    """Where the arris sits -- only approximately, as for a derived edge.

    `ends` is None and `anchor` is the point on the INFINITE line closest to
    the origin. Cropping it to the timber is measurement's job, a level up
    where the enclosing solid is known; see cropcsg.segment_on_line.
    """
    line = self.locate(owner)
    if not isinstance(line, Line):
        return None
    return CSGFeatureExtent(anchor=line.point)

SimpleCylinderFeature dataclass

Bases: CSGFeature

One surface of a Cylinder: an end cap, or the barrel.

part class-attribute instance-attribute

feature_key

feature_key() -> Optional[FeatureKey]
Source code in kumiki/cutcsg.py
def feature_key(self) -> Optional[FeatureKey]:
    if self.part is CylinderPart.BOTTOM:
        return START_CAP
    if self.part is CylinderPart.TOP:
        return END_CAP
    # A cylinder is an extrusion with one side, and that side is curved.
    return (FeatureCategory.SIDE, 0)

feature_type

feature_type() -> CSGFeatureType
Source code in kumiki/cutcsg.py
def feature_type(self) -> CSGFeatureType:
    return CSGFeatureType.FACE

locate

locate(owner: CutCSG) -> Optional[LocatedGeometry]
Source code in kumiki/cutcsg.py
def locate(self, owner: 'CutCSG') -> Optional[LocatedGeometry]:
    if not isinstance(owner, Cylinder):
        return None
    # The barrel is curved: no single plane describes it, so decline rather
    # than invent one. (Its axis is a separate, non-real feature -- see D5.)
    if self.part == CylinderPart.BARREL:
        return None
    axis = safe_normalize_vector(owner.axis_direction)
    distance = owner.end_distance if self.part == CylinderPart.TOP else owner.start_distance
    if distance is None:
        return None
    sign = scalar(1) if self.part == CylinderPart.TOP else scalar(-1)
    return Plane(normal=axis * sign, point=owner.position + axis * distance)

get_extent

get_extent(owner: CutCSG) -> Optional[CSGFeatureExtent]
Source code in kumiki/cutcsg.py
def get_extent(self, owner: 'CutCSG') -> Optional[CSGFeatureExtent]:
    if not isinstance(owner, Cylinder):
        return None
    axis = safe_normalize_vector(owner.axis_direction)
    if self.part == CylinderPart.BARREL:
        mid = _finite_midpoint(owner.start_distance, owner.end_distance)
        return CSGFeatureExtent(anchor=owner.position + axis * mid)
    distance = owner.end_distance if self.part == CylinderPart.TOP else owner.start_distance
    if distance is None:
        return None
    return CSGFeatureExtent(anchor=owner.position + axis * distance)

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/cutcsg.py
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    if not isinstance(owner, Cylinder):
        return False
    axial, radial = owner._axial_and_radial(point)
    if self.part == CylinderPart.TOP:
        return owner.end_distance is not None and safe_equality_test(axial, owner.end_distance, eps=test_tolerance)
    if self.part == CylinderPart.BOTTOM:
        return owner.start_distance is not None and safe_equality_test(axial, owner.start_distance, eps=test_tolerance)
    return safe_equality_test(radial, owner.radius, eps=test_tolerance)

SimpleConvexPolygonExtrusionFeature dataclass

Bases: CSGFeature

One side face (points[key] -> points[key+1 mod n]) or end cap of a ConvexPolygonExtrusion.

key class-attribute instance-attribute

feature_key

feature_key() -> Optional[FeatureKey]
Source code in kumiki/cutcsg.py
def feature_key(self) -> Optional[FeatureKey]:
    if self.key is ExtrusionCap.BOTTOM:
        return START_CAP
    if self.key is ExtrusionCap.TOP:
        return END_CAP
    return (FeatureCategory.SIDE, int(self.key))

feature_type

feature_type() -> CSGFeatureType
Source code in kumiki/cutcsg.py
def feature_type(self) -> CSGFeatureType:
    return CSGFeatureType.FACE

locate

locate(owner: CutCSG) -> Optional[LocatedGeometry]
Source code in kumiki/cutcsg.py
def locate(self, owner: 'CutCSG') -> Optional[LocatedGeometry]:
    if not isinstance(owner, ConvexPolygonExtrusion):
        return None
    frame = self._frame(owner)
    if frame is None:
        return None
    normal, centre = frame
    return Plane(normal=normal, point=centre)

get_extent

get_extent(owner: CutCSG) -> Optional[CSGFeatureExtent]
Source code in kumiki/cutcsg.py
def get_extent(self, owner: 'CutCSG') -> Optional[CSGFeatureExtent]:
    if not isinstance(owner, ConvexPolygonExtrusion):
        return None
    frame = self._frame(owner)
    if frame is None:
        return None
    _, centre = frame
    return CSGFeatureExtent(anchor=centre, aabb=owner.get_aabb())

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/cutcsg.py
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    if not isinstance(owner, ConvexPolygonExtrusion):
        return False
    x, y, z = owner._local_coords(point)
    if self.key == ExtrusionCap.TOP:
        return owner.end_distance is not None and safe_equality_test(z, owner.end_distance, eps=test_tolerance)
    if self.key == ExtrusionCap.BOTTOM:
        return owner.start_distance is not None and safe_equality_test(z, owner.start_distance, eps=test_tolerance)
    return owner._point_on_side(self.key, x, y, eps=test_tolerance)

SimpleLoftFeature dataclass

Bases: CSGFeature

One side face or end cap of a ConvexPolygonSimpleLoft.

Side faces are ruled surfaces and are only planar in the special case of a pure per-axis taper, so a named side is a surface, not necessarily a plane. Edge derivation (which assumes planes) has to account for that.

key class-attribute instance-attribute

feature_key

feature_key() -> Optional[FeatureKey]
Source code in kumiki/cutcsg.py
def feature_key(self) -> Optional[FeatureKey]:
    if self.key is ExtrusionCap.BOTTOM:
        return START_CAP
    if self.key is ExtrusionCap.TOP:
        return END_CAP
    return (FeatureCategory.SIDE, int(self.key))

feature_type

feature_type() -> CSGFeatureType
Source code in kumiki/cutcsg.py
def feature_type(self) -> CSGFeatureType:
    return CSGFeatureType.FACE

locate

locate(owner: CutCSG) -> Optional[LocatedGeometry]
Source code in kumiki/cutcsg.py
def locate(self, owner: 'CutCSG') -> Optional[LocatedGeometry]:
    if not isinstance(owner, ConvexPolygonSimpleLoft):
        return None
    # Only the caps are reliably planar. A side is a ruled surface, planar
    # only in the special case of a pure per-axis taper -- so decline
    # rather than return a plane that is right for some lofts and wrong
    # for others. Refining this to detect the planar case is worth doing
    # when something actually needs to measure from a tapered side.
    if self.key not in (ExtrusionCap.TOP, ExtrusionCap.BOTTOM):
        return None
    orientation = owner.transform.orientation.matrix
    length_dir = safe_transform_vector(orientation, Matrix([scalar(0), scalar(0), scalar(1)]))
    is_top = self.key == ExtrusionCap.TOP
    distance = owner.end_distance if is_top else owner.start_distance
    sign = scalar(1) if is_top else scalar(-1)
    return Plane(normal=length_dir * sign, point=owner.transform.position + length_dir * distance)

get_extent

get_extent(owner: CutCSG) -> Optional[CSGFeatureExtent]
Source code in kumiki/cutcsg.py
def get_extent(self, owner: 'CutCSG') -> Optional[CSGFeatureExtent]:
    if not isinstance(owner, ConvexPolygonSimpleLoft):
        return None
    orientation = owner.transform.orientation.matrix
    if self.key in (ExtrusionCap.TOP, ExtrusionCap.BOTTOM):
        is_top = self.key == ExtrusionCap.TOP
        profile = owner.top_points if is_top else owner.bottom_points
        distance = owner.end_distance if is_top else owner.start_distance
    else:
        profile = [(b + t) / scalar(2) for b, t in zip(owner.bottom_points, owner.top_points)]
        distance = _finite_midpoint(owner.start_distance, owner.end_distance)
    if self.key in (ExtrusionCap.TOP, ExtrusionCap.BOTTOM):
        centroid_2d = sum(profile[1:], profile[0]) / scalar(len(profile))
    else:
        p1 = profile[self.key]
        p2 = profile[(self.key + 1) % len(profile)]
        centroid_2d = (p1 + p2) / scalar(2)
    local = Matrix([centroid_2d[0], centroid_2d[1], distance])
    return CSGFeatureExtent(
        anchor=owner.transform.position + safe_transform_vector(orientation, local),
        aabb=owner.get_aabb(),
    )

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/cutcsg.py
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    if not isinstance(owner, ConvexPolygonSimpleLoft):
        return False
    x, y, z = owner._local_coords(point)
    if self.key == ExtrusionCap.TOP:
        return safe_equality_test(z, owner.end_distance, eps=test_tolerance)
    if self.key == ExtrusionCap.BOTTOM:
        return safe_equality_test(z, owner.start_distance, eps=test_tolerance)
    return owner._point_on_side(self.key, x, y, z, eps=test_tolerance)

FeatureSource

Bases: Flag

Which features a query is asking for.

DEFAULTS are what a primitive names on its own, in FeatureKey slots. OVERRIDES are what an author handed it: a replacement for a default at the same key, or a feature at a slot no default occupies. BOTH is the answer to "what does this shape name", which is what nearly every caller wants.

DEFAULTS class-attribute instance-attribute

DEFAULTS = 1

OVERRIDES class-attribute instance-attribute

OVERRIDES = 2

BOTH class-attribute instance-attribute

BOTH = 3

HasFeatures dataclass

Storage for the features a primitive names on its own boundary.

A mixin rather than a field on CutCSG, because a compound node names nothing: a SolidUnion, Difference or Intersection has no surface of its own, only the surfaces its children contribute. Only the primitives that have a boundary of their own inherit this.

Six primitives carried an identical copy of the field and its accessor before this existed. That is the whole reason it exists -- feature storage is one idea, and the shapes that have features differ in their geometry, not in how they hold a list.

Two layers, not one. A primitive names its own boundary through default_features(), keyed by FeatureKey; an author overrides or adds to that through _features. An authored feature whose key matches a default REPLACES it, so naming a face does not leave the anonymous one behind to be found twice.

default_features

default_features() -> Dict[FeatureKey, CSGFeature]

What this primitive names on its own, keyed by where it sits.

Empty here: a shape opts in by overriding this. Whatever it returns must be in FeatureGroup.NONE -- see the note on default_features in RectangularPrism for why that matters more than it looks.

Source code in kumiki/cutcsg.py
def default_features(self) -> Dict['FeatureKey', 'CSGFeature']:
    """What this primitive names on its own, keyed by where it sits.

    Empty here: a shape opts in by overriding this. Whatever it returns
    must be in FeatureGroup.NONE -- see the note on default_features in
    RectangularPrism for why that matters more than it looks.
    """
    return {}

get_declared_features

get_declared_features(source: FeatureSource = BOTH) -> List[CSGFeature]

Features this node names on its own boundary, whether or not any point lies on them.

Source code in kumiki/cutcsg.py
def get_declared_features(
    self, source: 'FeatureSource' = FeatureSource.BOTH,
) -> List['CSGFeature']:
    """Features this node names on its own boundary, whether or not any
    point lies on them.
    """
    authored = list(self._features or ())
    if source is FeatureSource.OVERRIDES:
        return authored

    defaults = dict(self.default_features())
    if source is FeatureSource.DEFAULTS:
        return list(defaults.values())

    for feature in authored:
        key = feature.feature_key()
        if key is not None:
            defaults.pop(key, None)
    return authored + list(defaults.values())

OwnedFeatureHit dataclass

A feature, paired with the primitive it belongs to.

A CSGFeature holds no reference to its owner, so anything handing one around carries both. That covers two jobs with the same shape: what a query hands back, and how a DerivedEdgeFeature refers to the two parents it was built from -- which generally live on different primitives.

Anything needing the feature's geometry -- its plane, its extent -- needs the owner too, so locate and get_extent are forwarded here.

feature instance-attribute

feature: CSGFeature

owner instance-attribute

owner: CutCSG

name property

name: str

properties property

properties: FeatureProperties

feature_type

feature_type() -> CSGFeatureType
Source code in kumiki/cutcsg.py
def feature_type(self) -> CSGFeatureType:
    return self.feature.feature_type()

locate

locate() -> Optional[LocatedGeometry]
Source code in kumiki/cutcsg.py
def locate(self) -> Optional['LocatedGeometry']:
    return self.feature.locate(self.owner)

get_extent

get_extent() -> Optional[CSGFeatureExtent]
Source code in kumiki/cutcsg.py
def get_extent(self) -> Optional['CSGFeatureExtent']:
    return self.feature.get_extent(self.owner)

CSGParity

Bases: Enum

Whether a node adds material to the finished solid or takes it away.

ADDITIVE means growing that node grows the result; SUBTRACTIVE means growing it shrinks the result.

ADDITIVE class-attribute instance-attribute

ADDITIVE = 0

SUBTRACTIVE class-attribute instance-attribute

SUBTRACTIVE = 1

flipped

flipped() -> CSGParity
Source code in kumiki/cutcsg.py
def flipped(self) -> 'CSGParity':
    return CSGParity.SUBTRACTIVE if self is CSGParity.ADDITIVE else CSGParity.ADDITIVE

Cylinder dataclass

Bases: HasFeatures, CutCSG

A cylinder with circular cross-section, optionally infinite in one or both ends.

The cylinder is defined by: - A position (translation from origin) - An axis direction - A radius - Start and end distances along the axis from the position

So the center point of the radius cross section is at position and the cylinder extends out in -z by start_distance and +z by end_distance.

Use None for start_distance or end_distance to make the cylinder infinite in that direction.

Parameters:

Name Type Description Default
axis_direction

Direction of the cylinder's axis (3x1 Matrix)

required
radius

Radius of the cylinder

required
position

Position of the cylinder origin in global coordinates (3x1 Matrix, default: origin)

required
start_distance

Distance from position to start of cylinder (None = -infinite)

required
end_distance

Distance from position to end of cylinder (None = infinite)

required

axis_direction instance-attribute

axis_direction: Direction3D

radius instance-attribute

radius: Numeric

position class-attribute instance-attribute

position: V3 = field(default_factory=lambda: Matrix([scalar(0), scalar(0), scalar(0)]))

start_distance class-attribute instance-attribute

start_distance: Optional[Numeric] = None

end_distance class-attribute instance-attribute

end_distance: Optional[Numeric] = None

default_features

default_features() -> Dict[FeatureKey, CSGFeature]

Two caps and the barrel. See RectangularPrism for why the group is NONE.

No arrises: a cylinder's rims are circles, and there is no feature class for one yet. Their slots are the two arrises against the caps, which arris_against_cap names for a shape with a single side.

Source code in kumiki/cutcsg.py
def default_features(self) -> Dict[FeatureKey, CSGFeature]:
    """Two caps and the barrel. See RectangularPrism for why the group is NONE.

    No arrises: a cylinder's rims are circles, and there is no feature class
    for one yet. Their slots are the two arrises against the caps, which
    arris_against_cap names for a shape with a single side.
    """
    parts = ((START_CAP, CylinderPart.BOTTOM),
             (END_CAP, CylinderPart.TOP),
             ((FeatureCategory.SIDE, 0), CylinderPart.BARREL))
    return {
        key: SimpleCylinderFeature(name=default_feature_name(key), part=part,
                                   properties=_DEFAULT_FEATURE_PROPERTIES)
        for key, part in parts
    }

__repr__

__repr__() -> str
Source code in kumiki/cutcsg.py
def __repr__(self) -> str:
    return (f"Cylinder(axis={self.axis_direction.T}, "
            f"radius={self.radius}, "
            f"position={self.position.T}, "
            f"start={self.start_distance}, end={self.end_distance})")

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is contained within the cylinder.

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is inside or on the boundary of the cylinder, False otherwise

Source code in kumiki/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is contained within the cylinder.

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is inside or on the boundary of the cylinder, False otherwise
    """
    # Transform point to local coordinates
    local_point = point - self.position

    # Normalize axis
    axis = self.axis_direction / safe_norm(self.axis_direction)

    # Project onto axis to get axial coordinate
    axial_coord = safe_dot_product(local_point, axis)

    # Check axial bounds
    if self.start_distance is not None and safe_compare(axial_coord, self.start_distance, Comparison.LT, eps=eps):
        return False
    if self.end_distance is not None and safe_compare(axial_coord, self.end_distance, Comparison.GT, eps=eps):
        return False

    # Calculate radial distance from axis
    axial_projection = axis * axial_coord
    radial_vector = local_point - axial_projection
    radial_distance = safe_norm(radial_vector)

    # Check if within radius
    return safe_compare(radial_distance, self.radius, Comparison.LE, eps=eps)

is_point_on_boundary

is_point_on_boundary(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is on the boundary of the cylinder.

A point is on the boundary if it's either: 1. On the cylindrical surface (at radius distance from axis) 2. On one of the end caps (if finite)

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is on the boundary of the cylinder, False otherwise

Source code in kumiki/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is on the boundary of the cylinder.

    A point is on the boundary if it's either:
    1. On the cylindrical surface (at radius distance from axis)
    2. On one of the end caps (if finite)

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is on the boundary of the cylinder, False otherwise
    """
    # First check if point is contained
    if not self.contains_point(point, eps=eps):
        return False

    # Transform point to local coordinates
    local_point = point - self.position

    # Normalize axis
    axis = self.axis_direction / safe_norm(self.axis_direction)

    # Project onto axis to get axial coordinate
    axial_coord = safe_dot_product(local_point, axis)

    # Calculate radial distance from axis
    axial_projection = axis * axial_coord
    radial_vector = local_point - axial_projection
    radial_distance = safe_norm(radial_vector)

    # On cylindrical surface
    if safe_equality_test(radial_distance, self.radius, eps=eps):
        return True

    # On end caps (if finite and at the end)
    if self.start_distance is not None and safe_equality_test(axial_coord, self.start_distance, eps=eps):
        return True
    if self.end_distance is not None and safe_equality_test(axial_coord, self.end_distance, eps=eps):
        return True

    return False

get_outward_normal

get_outward_normal(point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]

Get the outward normal vector at a boundary point.

For a cylinder, the normal depends on which surface the point is on.

Parameters:

Name Type Description Default
point V3

A point on the boundary

required

Returns:

Type Description
Optional[Direction3D]

The outward normal vector at the point

Source code in kumiki/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    """
    Get the outward normal vector at a boundary point.

    For a cylinder, the normal depends on which surface the point is on.

    Args:
        point: A point on the boundary

    Returns:
        The outward normal vector at the point
    """
    # Transform point to local coordinates
    local_point = point - self.position

    # Normalize axis
    axis = self.axis_direction / safe_norm(self.axis_direction)

    # Project onto axis to get axial coordinate
    axial_coord = safe_dot_product(local_point, axis)

    # Calculate radial distance from axis
    axial_projection = axis * axial_coord
    radial_vector = local_point - axial_projection
    radial_distance = safe_norm(radial_vector)

    # Check if on cylindrical surface first (most common case)
    if safe_equality_test(radial_distance, self.radius, eps=eps):
        # Normal is the radial direction (normalized)
        if safe_zero_test(radial_distance, eps=eps):
            # Point is on the axis, which shouldn't happen for the cylindrical surface
            # This might be an edge case on the cap center
            pass
        else:
            return radial_vector / radial_distance

    # Check if on end caps
    if self.start_distance is not None and safe_equality_test(axial_coord, self.start_distance, eps=eps):
        # Bottom cap, normal points in -axis direction (outward)
        return -axis
    if self.end_distance is not None and safe_equality_test(axial_coord, self.end_distance, eps=eps):
        # Top cap, normal points in +axis direction (outward)
        return axis

    # Should not reach here if point is on boundary
    return None

get_aabb

get_aabb() -> BoundingBox
Source code in kumiki/cutcsg.py
def get_aabb(self) -> BoundingBox:
    if self.start_distance is None or self.end_distance is None:
        warnings.warn(
            "get_aabb() called on an infinite Cylinder — result is unbounded",
            UserWarning,
            stacklevel=2,
        )
        return BoundingBox(None, None, None, None, None, None)

    axis_norm = self.axis_direction / safe_norm(self.axis_direction)
    p1 = self.position + axis_norm * self.start_distance
    p2 = self.position + axis_norm * self.end_distance

    bounds = []
    for i in range(3):
        ai = axis_norm[i]
        radial_i = self.radius * sqrt(scalar(1) - ai * ai)
        lo = _numeric_min(p1[i], p2[i]) - radial_i
        hi = _numeric_max(p1[i], p2[i]) + radial_i
        bounds.append((lo, hi))

    return BoundingBox(
        bounds[0][0], bounds[1][0], bounds[2][0],
        bounds[0][1], bounds[1][1], bounds[2][1],
    )

ConvexPolygonExtrusion dataclass

Bases: HasFeatures, CutCSG

An extruded Convex Polygon shape, optionally infinite in one or both ends.

The extrusion is defined by: - A list of ordered (x,y) points in the polygon (must be convex!) - A transform (position and orientation in global coordinates) - Start and end distances along the local Z-axis from the position

The polygon is in the local XY plane at the position, and the extrusion extends out in -z by start_distance and +z by end_distance.

Use None for start_distance or end_distance to make the extrusion infinite in that direction.

Parameters:

Name Type Description Default
points

List of ordered (x,y) points in the polygon (last connects to first, must be convex)

required
transform

Transform (position and orientation) in global coordinates (default: identity)

required
start_distance

Distance from position along Z-axis to start of extrusion (None = -infinite)

required
end_distance

Distance from position along Z-axis to end of extrusion (None = infinite)

required

points instance-attribute

points: Profile

transform class-attribute instance-attribute

transform: Transform = field(default_factory=Transform.identity)

start_distance class-attribute instance-attribute

start_distance: Optional[Numeric] = None

end_distance class-attribute instance-attribute

end_distance: Optional[Numeric] = None

default_features

default_features() -> Dict[FeatureKey, CSGFeature]

Two caps and a side per edge of the profile.

No arrises yet: SimpleRectangularPrismEdgeFeature is a prism's, and an extrusion needs its own before ARRIS n can be filled in here.

Source code in kumiki/cutcsg.py
def default_features(self) -> Dict[FeatureKey, CSGFeature]:
    """Two caps and a side per edge of the profile.

    No arrises yet: SimpleRectangularPrismEdgeFeature is a prism's, and an
    extrusion needs its own before ARRIS n can be filled in here.
    """
    features: Dict[FeatureKey, CSGFeature] = {}
    for key, cap in ((START_CAP, ExtrusionCap.BOTTOM), (END_CAP, ExtrusionCap.TOP)):
        features[key] = SimpleConvexPolygonExtrusionFeature(
            name=default_feature_name(key), key=cap,
            properties=_DEFAULT_FEATURE_PROPERTIES)
    for index in range(len(self.points)):
        key = (FeatureCategory.SIDE, index)
        features[key] = SimpleConvexPolygonExtrusionFeature(
            name=default_feature_name(key), key=index,
            properties=_DEFAULT_FEATURE_PROPERTIES)
    return features

display_name classmethod

display_name() -> str
Source code in kumiki/cutcsg.py
@classmethod
def display_name(cls) -> str:
    return "extrusion"

get_bottom_position

get_bottom_position() -> V3

Get the position of the bottom of the extrusion (at start_distance). Only valid for extrusions with finite start_distance.

Returns:

Type Description
V3

The 3D position at the bottom of the extrusion

Raises:

Type Description
ValueError

If start_distance is None (infinite extrusion)

Source code in kumiki/cutcsg.py
def get_bottom_position(self) -> V3:
    """
    Get the position of the bottom of the extrusion (at start_distance).
    Only valid for extrusions with finite start_distance.

    Returns:
        The 3D position at the bottom of the extrusion

    Raises:
        ValueError: If start_distance is None (infinite extrusion)
    """
    if self.start_distance is None:
        raise ValueError("Cannot get bottom position of infinite extrusion (start_distance is None)")
    return self.transform.position - safe_transform_vector(self.transform.orientation.matrix, Matrix([scalar(0), scalar(0), self.start_distance]))

get_top_position

get_top_position() -> V3

Get the position of the top of the extrusion (at end_distance). Only valid for extrusions with finite end_distance.

Returns:

Type Description
V3

The 3D position at the top of the extrusion

Raises:

Type Description
ValueError

If end_distance is None (infinite extrusion)

Source code in kumiki/cutcsg.py
def get_top_position(self) -> V3:
    """
    Get the position of the top of the extrusion (at end_distance).
    Only valid for extrusions with finite end_distance.

    Returns:
        The 3D position at the top of the extrusion

    Raises:
        ValueError: If end_distance is None (infinite extrusion)
    """
    if self.end_distance is None:
        raise ValueError("Cannot get top position of infinite extrusion (end_distance is None)")
    return self.transform.position + safe_transform_vector(self.transform.orientation.matrix, Matrix([scalar(0), scalar(0), self.end_distance]))

__repr__

__repr__() -> str
Source code in kumiki/cutcsg.py
def __repr__(self) -> str:
    return (f"ConvexPolygonExtrusion({len(self.points)} points, "
            f"transform={self.transform}, start={self.start_distance}, end={self.end_distance})")

is_valid

is_valid() -> bool

Check if the ConvexPolygonExtrusion is valid

Checks: 1. At least 3 points 2. Valid distance configuration (if both finite, end > start) 3. Polygon is convex (all turns go the same direction)

Returns:

Type Description
bool

True if valid, False otherwise

Source code in kumiki/cutcsg.py
def is_valid(self) -> bool:
    """
    Check if the ConvexPolygonExtrusion is valid

    Checks:
    1. At least 3 points
    2. Valid distance configuration (if both finite, end > start)
    3. Polygon is convex (all turns go the same direction)

    Returns:
        True if valid, False otherwise
    """
    if len(self.points) < 3:
        return False

    # Check distance configuration
    if self.start_distance is not None and self.end_distance is not None:
        if safe_compare(self.end_distance, self.start_distance, Comparison.LE):
            return False

    # Check convexity: all cross products of consecutive edges should have the same sign
    # For a convex polygon, as we traverse the vertices, we should always turn the same way
    n = len(self.points)

    # Compute 2D cross product for each triplet of consecutive points
    def cross_product(i):
        p0, p1, p2 = self.points[i], self.points[(i + 1) % n], self.points[(i + 2) % n]
        edge1, edge2 = p1 - p0, p2 - p1
        return edge1[0] * edge2[1] - edge1[1] * edge2[0]

    # Generate all cross products and filter out zeros (collinear points)
    cross_products = [cross_product(i) for i in range(n)]
    non_zero_crosses = [cp for cp in cross_products if not safe_zero_test(cp)]

    # Reject if all collinear, otherwise check all turns go the same direction
    return (len(non_zero_crosses) > 0 and
            (all(safe_compare(cp, 0, Comparison.GT) for cp in non_zero_crosses) or
             all(safe_compare(cp, 0, Comparison.LT) for cp in non_zero_crosses)))

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is contained within the extruded polygon.

A point is inside if: 1. Its Z coordinate (in local space) is between start_distance and end_distance 2. Its XY coordinates (in local space) are inside the convex polygon

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is inside or on the boundary, False otherwise

Source code in kumiki/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is contained within the extruded polygon.

    A point is inside if:
    1. Its Z coordinate (in local space) is between start_distance and end_distance
    2. Its XY coordinates (in local space) are inside the convex polygon

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is inside or on the boundary, False otherwise
    """
    # Transform point to local coordinates
    local_point = point - self.transform.position
    local_coords = safe_transform_vector(self.transform.orientation.invert().matrix, local_point)

    x_coord = local_coords[0]
    y_coord = local_coords[1]
    z_coord = local_coords[2]

    # Check Z bounds (use safe_compare for tolerance with Float vs Integer)
    if self.start_distance is not None and safe_compare(z_coord - self.start_distance, 0, Comparison.LT, eps=eps):
        return False
    if self.end_distance is not None and safe_compare(z_coord - self.end_distance, 0, Comparison.GT, eps=eps):
        return False

    # Check if (x_coord, y_coord) is inside the convex polygon
    # For a convex polygon, a point is inside if it's on the correct side
    # of all edges
    point_2d = Matrix([x_coord, y_coord])

    for i in range(len(self.points)):
        p1 = self.points[i]
        p2 = self.points[(i + 1) % len(self.points)]

        # Edge vector from p1 to p2
        edge = p2 - p1

        # Vector from p1 to test point
        to_point = point_2d - p1

        # Cross product in 2D: edge × to_point
        # If polygon vertices are ordered counter-clockwise, 
        # cross product should be >= 0 for point to be inside
        cross = edge[0] * to_point[1] - edge[1] * to_point[0]

        # Use safe_compare with tolerance to handle Float vs Integer comparisons
        if safe_compare(cross, 0, Comparison.LT, eps=eps):
            return False

    return True

is_point_on_boundary

is_point_on_boundary(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is on the boundary of the extruded polygon.

A point is on the boundary if it's contained and either: 1. On the top or bottom face (z = start_distance or z = end_distance, if finite) 2. On one of the side faces (on an edge of the polygon)

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is on the boundary, False otherwise

Source code in kumiki/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is on the boundary of the extruded polygon.

    A point is on the boundary if it's contained and either:
    1. On the top or bottom face (z = start_distance or z = end_distance, if finite)
    2. On one of the side faces (on an edge of the polygon)

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is on the boundary, False otherwise
    """
    # First check if point is contained
    if not self.contains_point(point, eps=eps):
        return False

    # Transform point to local coordinates
    local_point = point - self.transform.position
    local_coords = safe_transform_vector(self.transform.orientation.invert().matrix, local_point)

    x_coord = local_coords[0]
    y_coord = local_coords[1]
    z_coord = local_coords[2]

    # Check if on top or bottom face (if finite)
    if self.start_distance is not None and safe_zero_test(z_coord - self.start_distance, eps=eps):
        return True
    if self.end_distance is not None and safe_zero_test(z_coord - self.end_distance, eps=eps):
        return True

    # Check if on a vertical edge (point is at a vertex XY coordinate)
    point_2d = Matrix([x_coord, y_coord])
    for vertex_2d in self.points:
        distance_sq = (point_2d[0] - vertex_2d[0])**2 + (point_2d[1] - vertex_2d[1])**2
        if safe_zero_test_sq(distance_sq, eps):
            return True  # Point is on a vertical edge

    # Check if on any horizontal edge of the polygon (side face at this z)
    for i in range(len(self.points)):
        p1 = self.points[i]
        p2 = self.points[(i + 1) % len(self.points)]

        # Check if point is on the line segment from p1 to p2
        # Use parametric form: p = p1 + t*(p2-p1), where 0 <= t <= 1
        edge = p2 - p1
        to_point = point_2d - p1

        # If edge is zero-length, skip it
        edge_length_sq = edge[0]**2 + edge[1]**2
        # Degeneracy is a property of the polygon, not of how close the
        # caller clicked, so this takes no query tolerance.
        if safe_zero_test_sq(edge_length_sq):
            continue

        # Project to_point onto edge
        t = (to_point[0] * edge[0] + to_point[1] * edge[1]) / edge_length_sq

        # Check if projection is on the segment [0, 1]
        t_in_range = safe_compare(t, 0, Comparison.GE, eps=eps) and safe_compare(t - scalar(1), 0, Comparison.LE, eps=eps)

        if t_in_range:
            closest_point = p1 + edge * t
            distance_sq = (point_2d[0] - closest_point[0])**2 + (point_2d[1] - closest_point[1])**2
            if safe_zero_test_sq(distance_sq, eps):
                return True

    return False

get_outward_normal

get_outward_normal(point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]

Get the outward normal vector at a boundary point.

For a convex polygon extrusion, the normal depends on which surface.

Parameters:

Name Type Description Default
point V3

A point on the boundary

required

Returns:

Type Description
Optional[Direction3D]

The outward normal vector at the point

Source code in kumiki/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    """
    Get the outward normal vector at a boundary point.

    For a convex polygon extrusion, the normal depends on which surface.

    Args:
        point: A point on the boundary

    Returns:
        The outward normal vector at the point
    """
    # Transform point to local coordinates
    local_point = point - self.transform.position
    local_coords = safe_transform_vector(self.transform.orientation.invert().matrix, local_point)

    x_coord = local_coords[0]
    y_coord = local_coords[1]
    z_coord = local_coords[2]

    # Check if on top face
    if self.end_distance is not None and safe_equality_test(z_coord, self.end_distance, eps=eps):
        # Top face, normal points in +Z direction in local coords
        local_normal = Matrix([scalar(0), scalar(0), scalar(1)])
        return safe_transform_vector(self.transform.orientation.matrix, local_normal)

    # Check if on bottom face
    if self.start_distance is not None and safe_equality_test(z_coord, self.start_distance, eps=eps):
        # Bottom face, normal points in -Z direction in local coords
        local_normal = Matrix([scalar(0), scalar(0), scalar(-1)])
        return safe_transform_vector(self.transform.orientation.matrix, local_normal)

    # Otherwise, point is on a side face (edge of polygon extruded)
    # Find which edge it's on and compute the normal
    point_2d = Matrix([x_coord, y_coord])

    for i in range(len(self.points)):
        p1 = self.points[i]
        p2 = self.points[(i + 1) % len(self.points)]

        # Check if point is on the line segment from p1 to p2
        edge = p2 - p1
        to_point = point_2d - p1

        edge_length_sq = edge[0]**2 + edge[1]**2
        # Degeneracy is a property of the polygon, not of how close the
        # caller clicked, so this takes no query tolerance.
        if safe_zero_test_sq(edge_length_sq):
            continue

        t = (to_point[0] * edge[0] + to_point[1] * edge[1]) / edge_length_sq

        if safe_compare(t, 0, Comparison.GE, eps=eps) and safe_compare(t, 1, Comparison.LE, eps=eps):
            closest_point = p1 + edge * t
            distance_sq = (point_2d[0] - closest_point[0])**2 + (point_2d[1] - closest_point[1])**2
            if safe_zero_test_sq(distance_sq, eps):
                # Point is on this edge
                # Normal is perpendicular to edge (in 2D), pointing outward
                # Left perpendicular of (dx, dy) is (-dy, dx)
                edge_normal_2d = Matrix([-edge[1], edge[0]])
                edge_normal_2d = edge_normal_2d / sqrt(edge_normal_2d[0]**2 + edge_normal_2d[1]**2)

                # Check if this normal points outward (away from polygon center)
                # Calculate polygon center
                center_x = sum(p[0] for p in self.points) / len(self.points)
                center_y = sum(p[1] for p in self.points) / len(self.points)
                center = Matrix([center_x, center_y])

                # Vector from center to point on edge
                to_edge = closest_point - center

                # If dot product is negative, flip the normal
                if safe_compare(edge_normal_2d[0] * to_edge[0] + edge_normal_2d[1] * to_edge[1], 0, Comparison.LT, eps=eps):
                    edge_normal_2d = -edge_normal_2d

                # Convert to 3D local normal (no Z component for side faces)
                local_normal = Matrix([edge_normal_2d[0], edge_normal_2d[1], 0])

                # Transform to global coordinates
                return safe_transform_vector(self.transform.orientation.matrix, local_normal)

    return None

get_aabb

get_aabb() -> BoundingBox
Source code in kumiki/cutcsg.py
def get_aabb(self) -> BoundingBox:
    if self.start_distance is None or self.end_distance is None:
        warnings.warn(
            "get_aabb() called on an infinite ConvexPolygonExtrusion — result is unbounded",
            UserWarning,
            stacklevel=2,
        )
        return BoundingBox(None, None, None, None, None, None)

    corners_global = [
        self.transform.local_to_global(Matrix([pt[0], pt[1], z]))
        for pt in self.points
        for z in (self.start_distance, self.end_distance)
    ]

    xs = [p[0] for p in corners_global]
    ys = [p[1] for p in corners_global]
    zs = [p[2] for p in corners_global]
    return BoundingBox(
        _numeric_min(*xs), _numeric_min(*ys), _numeric_min(*zs),
        _numeric_max(*xs), _numeric_max(*ys), _numeric_max(*zs),
    )

StickoutReference

Bases: Enum

Defines how stickout is measured relative to timber connection points.

Stickout measured from centerline of the timber (default)

joined timber | | |||===== created timber | |

Stickout measured from inside face of the timber

joined timber | | | |===== created timber | |

Stickout measured from outside face of the timber

joined timber | | |====== created timber | |

CENTER_LINE class-attribute instance-attribute

CENTER_LINE = 1

INSIDE class-attribute instance-attribute

INSIDE = 2

OUTSIDE class-attribute instance-attribute

OUTSIDE = 3

Stickout

Defines how much a timber extends beyond connection points.

For symmetric stickout, set stickout1 = stickout2. For asymmetric stickout, use different values. Default is no stickout (0, 0) from CENTER_LINE.

StickoutReference modes:

Stickout measured from centerline of the joined timber

joined timber | | |||===== created timber | |

Stickout measured from inside face of the joined timber

joined timber | | | |===== created timber | |

Stickout measured from outside face of the joined timber

joined timber | | |====== created timber | |

Parameters:

Name Type Description Default
stickout1

Extension beyond the first connection point (default: 0)

required
stickout2

Extension beyond the second connection point (default: 0)

required
stickoutReference1

How stickout1 is measured (default: CENTER_LINE)

required
stickoutReference2

How stickout2 is measured (default: CENTER_LINE)

required

Examples:

Symmetric stickout from centerline

s = Stickout.symmetric(scalar(1, 5)) # Both sides extend 0.2m from centerline

No stickout

s = Stickout.nostickout() # Both sides are 0

Asymmetric stickout

s = Stickout(scalar(1, 10), scalar(2, 5)) # Left extends 0.1m, right extends 0.4m from centerline

Stickout from outside faces

s = Stickout(scalar(1, 10), scalar(1, 5), StickoutReference.OUTSIDE, StickoutReference.OUTSIDE)

stickout1 class-attribute instance-attribute

stickout1: Numeric = scalar(0)

stickout2 class-attribute instance-attribute

stickout2: Numeric = scalar(0)

stickoutReference1 class-attribute instance-attribute

stickoutReference1: Optional[StickoutReference] = None

stickoutReference2 class-attribute instance-attribute

stickoutReference2: Optional[StickoutReference] = None

__post_init__

__post_init__()

Set default stickout references if not provided.

Source code in kumiki/construction.py
def __post_init__(self):
    """Set default stickout references if not provided."""
    if self.stickoutReference1 is None:
        object.__setattr__(self, 'stickoutReference1', StickoutReference.CENTER_LINE)
    if self.stickoutReference2 is None:
        object.__setattr__(self, 'stickoutReference2', StickoutReference.CENTER_LINE)

symmetric classmethod

symmetric(value: Numeric, reference: Optional[StickoutReference] = None) -> Stickout

Create a symmetric stickout where both sides extend by the same amount.

Parameters:

Name Type Description Default
value Numeric

The stickout distance for both sides

required
reference Optional[StickoutReference]

How stickout is measured (default: CENTER_LINE)

None

Returns:

Type Description
Stickout

Stickout instance with stickout1 = stickout2 = value

Source code in kumiki/construction.py
@classmethod
def symmetric(cls, value: Numeric, reference: Optional['StickoutReference'] = None) -> 'Stickout':
    """
    Create a symmetric stickout where both sides extend by the same amount.

    Args:
        value: The stickout distance for both sides
        reference: How stickout is measured (default: CENTER_LINE)

    Returns:
        Stickout instance with stickout1 = stickout2 = value
    """
    if reference is None:
        reference = StickoutReference.CENTER_LINE
    return cls(value, value, reference, reference)

nostickout classmethod

nostickout() -> Stickout

Create a stickout with no extension on either side.

Returns:

Type Description
Stickout

Stickout instance with stickout1 = stickout2 = 0

Source code in kumiki/construction.py
@classmethod
def nostickout(cls) -> 'Stickout':
    """
    Create a stickout with no extension on either side.

    Returns:
        Stickout instance with stickout1 = stickout2 = 0
    """
    return cls(scalar(0), scalar(0))

DoubleButtJointTimberArrangement

Two butt timbers meeting a single receiving timber.

Arrangements: - Opposing: butt_timber_1 and butt_timber_2 point in opposite cardinal directions (antiparallel, like spokes from either side of the receiving timber). - Orthogonal: butt_timber_1 and butt_timber_2 point in perpendicular cardinal directions (90° apart, like an L at the receiving timber).

butt_timber_1 instance-attribute

butt_timber_1: TimberLike

butt_timber_2 instance-attribute

butt_timber_2: TimberLike

receiving_timber instance-attribute

receiving_timber: TimberLike

butt_timber_1_end instance-attribute

butt_timber_1_end: TimberEnd

butt_timber_2_end instance-attribute

butt_timber_2_end: TimberEnd

front_face_on_butt_timber_1 class-attribute instance-attribute

front_face_on_butt_timber_1: Optional[TimberLongFace] = None

check_face_aligned

check_face_aligned() -> Optional[str]

Return None if all timbers are face-aligned with the receiving timber, else an error message.

Source code in kumiki/construction.py
def check_face_aligned(self) -> Optional[str]:
    """Return None if all timbers are face-aligned with the receiving timber, else an error message."""
    if not are_timbers_face_aligned(self.butt_timber_1, self.receiving_timber):
        return "butt_timber_1 must be face-aligned with receiving_timber"
    if not are_timbers_face_aligned(self.butt_timber_2, self.receiving_timber):
        return "butt_timber_2 must be face-aligned with receiving_timber"
    return None

check_face_aligned_cardinal_and_opposing_butts

check_face_aligned_cardinal_and_opposing_butts() -> Optional[str]

Return None if: - all timbers are face-aligned, - each butt timber's length direction is orthogonal to the receiving timber (cardinal), - butt_timber_1 and butt_timber_2 are in different cardinal directions, and - the pair approaches from opposite directions (antiparallel), accounting for which end of each timber is used.

Source code in kumiki/construction.py
def check_face_aligned_cardinal_and_opposing_butts(self) -> Optional[str]:
    """Return None if:
    - all timbers are face-aligned,
    - each butt timber's length direction is orthogonal to the receiving timber (cardinal),
    - butt_timber_1 and butt_timber_2 are in different cardinal directions, and
    - the pair approaches from opposite directions (antiparallel), accounting for which end of each timber is used.
    """
    err = self.check_face_aligned()
    if err is not None:
        return err
    recv_len = self.receiving_timber.get_length_direction_global()
    dir1 = self.butt_timber_1.get_length_direction_global()
    dir2 = self.butt_timber_2.get_length_direction_global()
    if not _are_directions_perpendicular(dir1, recv_len):
        return "butt_timber_1 length direction must be orthogonal to receiving_timber length direction"
    if not _are_directions_perpendicular(dir2, recv_len):
        return "butt_timber_2 length direction must be orthogonal to receiving_timber length direction"

    if self.front_face_on_butt_timber_1 is not None:
        joint_plane_normal = safe_normalize_vector(cross_product(dir1, recv_len))
        butt_1_face_normal = self.butt_timber_1.get_face_direction_global(
            self.front_face_on_butt_timber_1
        )
        if not are_vectors_parallel(butt_1_face_normal, joint_plane_normal):
            return (
                "front_face_on_butt_timber_1 must be parallel to the joint plane "
                "(its normal must be parallel to the joint-plane normal)"
            )


    # Calculate effective approach directions based on which end of each timber is used
    # If end == TOP, the timber approaches from the -direction
    # If end == BOTTOM, the timber approaches from the +direction
    approach_dir1 = -dir1 if self.butt_timber_1_end == TimberEnd.TOP else dir1
    approach_dir2 = -dir2 if self.butt_timber_2_end == TimberEnd.TOP else dir2

    # Pair must approach from opposite directions (antiparallel)
    if not safe_equality_test(approach_dir1.dot(approach_dir2), -1):
        return "butt_timber_1 and butt_timber_2 must approach from opposite directions (antiparallel)"
    return None

check_face_aligned_and_orthogonal_butts

check_face_aligned_and_orthogonal_butts() -> Optional[str]

Return None if all timbers are face-aligned and the two butt timbers are orthogonal to each other (length directions perpendicular), else an error message.

Source code in kumiki/construction.py
def check_face_aligned_and_orthogonal_butts(self) -> Optional[str]:
    """Return None if all timbers are face-aligned and the two butt timbers are orthogonal
    to each other (length directions perpendicular), else an error message."""
    err = self.check_face_aligned()
    if err is not None:
        return err
    if not are_timbers_orthogonal(self.butt_timber_1, self.butt_timber_2):
        return "butt_timber_1 and butt_timber_2 must be orthogonal to each other"
    return None

check_perfection

check_perfection() -> Optional[str]

Return None if all timbers are perfect, else an error message.

Source code in kumiki/construction.py
def check_perfection(self) -> Optional[str]:
    """Return None if all timbers are perfect, else an error message."""
    if not self.butt_timber_1.is_perfect_timber():
        return "butt_timber_1 must be perfect"
    if not self.butt_timber_2.is_perfect_timber():
        return "butt_timber_2 must be perfect"
    if not self.receiving_timber.is_perfect_timber():
        return "receiving_timber must be perfect"
    return None

TripleButtJointTimberArrangement

Three butt timbers meeting a single receiving timber.

main_butt_timber_1 and main_butt_timber_2 form an opposing pair (antiparallel). awk_timber is the third butt timber pointing in a third cardinal direction.

main_butt_timber_1 instance-attribute

main_butt_timber_1: TimberLike

main_butt_timber_2 instance-attribute

main_butt_timber_2: TimberLike

awk_timber instance-attribute

awk_timber: TimberLike

receiving_timber instance-attribute

receiving_timber: TimberLike

main_butt_timber_1_end instance-attribute

main_butt_timber_1_end: TimberEnd

main_butt_timber_2_end instance-attribute

main_butt_timber_2_end: TimberEnd

awk_timber_end instance-attribute

awk_timber_end: TimberEnd

check_face_aligned

check_face_aligned() -> Optional[str]

Return None if all butt timbers are face-aligned with the receiving timber, else an error message.

Source code in kumiki/construction.py
def check_face_aligned(self) -> Optional[str]:
    """Return None if all butt timbers are face-aligned with the receiving timber, else an error message."""
    if not are_timbers_face_aligned(self.main_butt_timber_1, self.receiving_timber):
        return "main_butt_timber_1 must be face-aligned with receiving_timber"
    if not are_timbers_face_aligned(self.main_butt_timber_2, self.receiving_timber):
        return "main_butt_timber_2 must be face-aligned with receiving_timber"
    if not are_timbers_face_aligned(self.awk_timber, self.receiving_timber):
        return "awk_timber must be face-aligned with receiving_timber"
    return None

check_face_aligned_cardinal_and_opposing_butts

check_face_aligned_cardinal_and_opposing_butts() -> Optional[str]

Return None if: - all timbers are face-aligned, - each butt timber's length direction is orthogonal to the receiving timber (cardinal), - all three butt timbers are in different cardinal directions, and - main_butt_timber_1 and main_butt_timber_2 are antiparallel (pointing towards each other).

Source code in kumiki/construction.py
def check_face_aligned_cardinal_and_opposing_butts(self) -> Optional[str]:
    """Return None if:
    - all timbers are face-aligned,
    - each butt timber's length direction is orthogonal to the receiving timber (cardinal),
    - all three butt timbers are in different cardinal directions, and
    - main_butt_timber_1 and main_butt_timber_2 are antiparallel (pointing towards each other).
    """
    err = self.check_face_aligned()
    if err is not None:
        return err
    recv_len = self.receiving_timber.get_length_direction_global()
    dir_main1 = self.main_butt_timber_1.get_length_direction_global()
    dir_main2 = self.main_butt_timber_2.get_length_direction_global()
    dir_awk = self.awk_timber.get_length_direction_global()
    butt_dirs = [
        ("main_butt_timber_1", dir_main1),
        ("main_butt_timber_2", dir_main2),
        ("awk_timber", dir_awk),
    ]
    for name, d in butt_dirs:
        if not _are_directions_perpendicular(d, recv_len):
            return f"{name} length direction must be orthogonal to receiving_timber length direction"
    # All three must be in different cardinal directions (no two share the same direction)
    pairs = [(butt_dirs[i][0], butt_dirs[i][1], butt_dirs[j][0], butt_dirs[j][1])
             for i in range(len(butt_dirs)) for j in range(i + 1, len(butt_dirs))]
    for name_i, dir_i, name_j, dir_j in pairs:
        if safe_equality_test(dir_i.dot(dir_j), 1):
            return f"{name_i} and {name_j} must point in different cardinal directions"
    # Main pair must be antiparallel (pointing towards each other)
    if not safe_equality_test(dir_main1.dot(dir_main2), -1):
        return "main_butt_timber_1 and main_butt_timber_2 must be antiparallel (pointing towards each other)"
    return None

check_perfection

check_perfection() -> Optional[str]

Return None if all timbers are perfect, else an error message.

Source code in kumiki/construction.py
def check_perfection(self) -> Optional[str]:
    """Return None if all timbers are perfect, else an error message."""
    if not self.main_butt_timber_1.is_perfect_timber():
        return "main_butt_timber_1 must be perfect"
    if not self.main_butt_timber_2.is_perfect_timber():
        return "main_butt_timber_2 must be perfect"
    if not self.awk_timber.is_perfect_timber():
        return "awk_timber must be perfect"
    if not self.receiving_timber.is_perfect_timber():
        return "receiving_timber must be perfect"
    return None

QuadrupleButtJointTimberArrangement

Four butt timbers meeting a single receiving timber, covering all four cardinal directions.

main_butt_timber_1 and main_butt_timber_2 form one opposing pair (antiparallel). awk_1 and awk_2 form the second opposing pair (antiparallel, on the perpendicular axis).

main_butt_timber_1 instance-attribute

main_butt_timber_1: TimberLike

main_butt_timber_2 instance-attribute

main_butt_timber_2: TimberLike

awk_1 instance-attribute

awk_1: TimberLike

awk_2 instance-attribute

awk_2: TimberLike

receiving_timber instance-attribute

receiving_timber: TimberLike

main_butt_timber_1_end instance-attribute

main_butt_timber_1_end: TimberEnd

main_butt_timber_2_end instance-attribute

main_butt_timber_2_end: TimberEnd

awk_1_end instance-attribute

awk_1_end: TimberEnd

awk_2_end instance-attribute

awk_2_end: TimberEnd

check_face_aligned

check_face_aligned() -> Optional[str]

Return None if all butt timbers are face-aligned with the receiving timber, else an error message.

Source code in kumiki/construction.py
def check_face_aligned(self) -> Optional[str]:
    """Return None if all butt timbers are face-aligned with the receiving timber, else an error message."""
    if not are_timbers_face_aligned(self.main_butt_timber_1, self.receiving_timber):
        return "main_butt_timber_1 must be face-aligned with receiving_timber"
    if not are_timbers_face_aligned(self.main_butt_timber_2, self.receiving_timber):
        return "main_butt_timber_2 must be face-aligned with receiving_timber"
    if not are_timbers_face_aligned(self.awk_1, self.receiving_timber):
        return "awk_1 must be face-aligned with receiving_timber"
    if not are_timbers_face_aligned(self.awk_2, self.receiving_timber):
        return "awk_2 must be face-aligned with receiving_timber"
    return None

check_face_aligned_cardinal_and_opposing_butts

check_face_aligned_cardinal_and_opposing_butts() -> Optional[str]

Return None if: - all timbers are face-aligned, - each butt timber's length direction is orthogonal to the receiving timber (cardinal), - all four butt timbers are in different cardinal directions, and - main_butt_timber_1/main_butt_timber_2 are antiparallel and awk_1/awk_2 are antiparallel.

Source code in kumiki/construction.py
def check_face_aligned_cardinal_and_opposing_butts(self) -> Optional[str]:
    """Return None if:
    - all timbers are face-aligned,
    - each butt timber's length direction is orthogonal to the receiving timber (cardinal),
    - all four butt timbers are in different cardinal directions, and
    - main_butt_timber_1/main_butt_timber_2 are antiparallel and awk_1/awk_2 are antiparallel.
    """
    err = self.check_face_aligned()
    if err is not None:
        return err
    recv_len = self.receiving_timber.get_length_direction_global()
    dir_main1 = self.main_butt_timber_1.get_length_direction_global()
    dir_main2 = self.main_butt_timber_2.get_length_direction_global()
    dir_awk1 = self.awk_1.get_length_direction_global()
    dir_awk2 = self.awk_2.get_length_direction_global()
    butt_dirs = [
        ("main_butt_timber_1", dir_main1),
        ("main_butt_timber_2", dir_main2),
        ("awk_1", dir_awk1),
        ("awk_2", dir_awk2),
    ]
    for name, d in butt_dirs:
        if not _are_directions_perpendicular(d, recv_len):
            return f"{name} length direction must be orthogonal to receiving_timber length direction"
    # All four must be in different cardinal directions (no two share the same direction)
    pairs = [(butt_dirs[i][0], butt_dirs[i][1], butt_dirs[j][0], butt_dirs[j][1])
             for i in range(len(butt_dirs)) for j in range(i + 1, len(butt_dirs))]
    for name_i, dir_i, name_j, dir_j in pairs:
        if safe_equality_test(dir_i.dot(dir_j), 1):
            return f"{name_i} and {name_j} must point in different cardinal directions"
    # Main pair must be antiparallel
    if not safe_equality_test(dir_main1.dot(dir_main2), -1):
        return "main_butt_timber_1 and main_butt_timber_2 must be antiparallel (pointing towards each other)"
    # Awk pair must be antiparallel
    if not safe_equality_test(dir_awk1.dot(dir_awk2), -1):
        return "awk_1 and awk_2 must be antiparallel (pointing towards each other)"
    return None

check_perfection

check_perfection() -> Optional[str]

Return None if all timbers are perfect, else an error message.

Source code in kumiki/construction.py
def check_perfection(self) -> Optional[str]:
    """Return None if all timbers are perfect, else an error message."""
    if not self.main_butt_timber_1.is_perfect_timber():
        return "main_butt_timber_1 must be perfect"
    if not self.main_butt_timber_2.is_perfect_timber():
        return "main_butt_timber_2 must be perfect"
    if not self.awk_1.is_perfect_timber():
        return "awk_1 must be perfect"
    if not self.awk_2.is_perfect_timber():
        return "awk_2 must be perfect"
    if not self.receiving_timber.is_perfect_timber():
        return "receiving_timber must be perfect"
    return None

CrossCapJointTimberArrangement

A butting post timber "capped" by two crossed timbers.

post_timber instance-attribute

post_timber: TimberLike

post_timber_end instance-attribute

post_timber_end: TimberEnd

cross_timber_1 instance-attribute

cross_timber_1: TimberLike

cross_timber_2 instance-attribute

cross_timber_2: TimberLike

check_face_aligned_and_orthogonal

check_face_aligned_and_orthogonal() -> Optional[str]
Source code in kumiki/construction.py
def check_face_aligned_and_orthogonal(self) -> Optional[str]:
    if not are_timbers_face_aligned(self.cross_timber_1, self.post_timber):
        return "cross_timber_1 must be face-aligned with post_timber"
    if not are_timbers_face_aligned(self.cross_timber_2, self.post_timber):
        return "cross_timber_2 must be face-aligned with post_timber"
    if not are_timbers_face_aligned(self.cross_timber_1, self.cross_timber_2):
        return "cross_timber_1 and cross_timber_2 must be face-aligned"
    if not are_timbers_orthogonal(self.cross_timber_1, self.cross_timber_2):
        return "cross_timber_1 and cross_timber_2 must be orthogonal"
    if not are_timbers_orthogonal(self.cross_timber_1, self.post_timber):
        return "cross_timber_1 must be orthogonal to post_timber"
    if not are_timbers_orthogonal(self.cross_timber_2, self.post_timber):
        return "cross_timber_2 must be orthogonal to post_timber"
    return None

check_perfection

check_perfection() -> Optional[str]

Return None if all timbers are perfect, else an error message.

Source code in kumiki/construction.py
def check_perfection(self) -> Optional[str]:
    """Return None if all timbers are perfect, else an error message."""
    if not self.post_timber.is_perfect_timber():
        return "post_timber must be perfect"
    if not self.cross_timber_1.is_perfect_timber():
        return "cross_timber_1 must be perfect"
    if not self.cross_timber_2.is_perfect_timber():
        return "cross_timber_2 must be perfect"
    return None

SpliceJointTimberArrangement

timber1 instance-attribute

timber1: TimberLike

timber2 instance-attribute

timber2: TimberLike

timber1_end instance-attribute

timber1_end: TimberEnd

timber2_end instance-attribute

timber2_end: TimberEnd

front_face_on_timber1 class-attribute instance-attribute

front_face_on_timber1: Optional[TimberLongFace] = None

check_face_aligned_and_parallel_axis

check_face_aligned_and_parallel_axis() -> Optional[str]

Return None if timbers are face-aligned and have parallel length axes, else an error message.

Source code in kumiki/construction.py
def check_face_aligned_and_parallel_axis(self) -> Optional[str]:
    """Return None if timbers are face-aligned and have parallel length axes, else an error message."""
    if not are_timbers_face_aligned(self.timber1, self.timber2):
        return "Timbers must be face-aligned"
    if not are_timbers_parallel(self.timber1, self.timber2):
        return "Timbers must have parallel length axes"
    return None

check_perfection

check_perfection() -> Optional[str]

Return None if both timbers are perfect, else an error message.

Source code in kumiki/construction.py
def check_perfection(self) -> Optional[str]:
    """Return None if both timbers are perfect, else an error message."""
    if not self.timber1.is_perfect_timber():
        return "timber1 must be perfect"
    if not self.timber2.is_perfect_timber():
        return "timber2 must be perfect"
    return None

CornerJointTimberArrangement

timber1 instance-attribute

timber1: TimberLike

timber2 instance-attribute

timber2: TimberLike

timber1_end instance-attribute

timber1_end: TimberEnd

timber2_end instance-attribute

timber2_end: TimberEnd

front_face_on_timber1 class-attribute instance-attribute

front_face_on_timber1: Optional[TimberLongFace] = None

compute_normalized_timber_cross_product

compute_normalized_timber_cross_product() -> Direction3D

Compute the normalized cross product of timber1 and timber2 length directions.

Source code in kumiki/construction.py
def compute_normalized_timber_cross_product(self) -> Direction3D:
    """Compute the normalized cross product of timber1 and timber2 length directions."""
    return safe_normalize_vector(cross_product(self.timber1.get_face_direction_global(self.timber1_end), self.timber2.get_face_direction_global(self.timber2_end)))

is_timber2_left_of_timber1

is_timber2_left_of_timber1() -> bool

returns true if timber2 is to the left of timber1 when looking down the length of timber1 and standing on the front face of timber1

Source code in kumiki/construction.py
def is_timber2_left_of_timber1(self) -> bool:
    """returns true if timber2 is to the left of timber1 when looking down the length of timber1 and standing on the front face of timber1"""
    assert self.front_face_on_timber1 is not None, "front_face_on_timber1 must be specified to determine left/right orientation"
    cross_product_vector = cross_product(self.timber1.get_face_direction_global(self.timber1_end), self.timber2.get_face_direction_global(self.timber2_end))
    return cross_product_vector.dot(self.timber1.get_face_direction_global(self.front_face_on_timber1)) > 0

check_plane_aligned

check_plane_aligned() -> Optional[str]

Return None if timbers are plane-aligned and front face is in plane, else an error message.

Source code in kumiki/construction.py
def check_plane_aligned(self) -> Optional[str]:
    """Return None if timbers are plane-aligned and front face is in plane, else an error message."""
    if not are_timbers_plane_aligned(self.timber1, self.timber2):
        return "Timbers must be plane-aligned"
    if self.front_face_on_timber1 is not None and not are_vectors_parallel(
        self.timber1.get_face_direction_global(self.front_face_on_timber1),
        self.compute_normalized_timber_cross_product(),
    ):
        return "front_face_on_timber1 must point in the aligned plane normal"
    return None

check_face_aligned_and_orthogonal

check_face_aligned_and_orthogonal() -> Optional[str]

Return None if timbers are face-aligned and orthogonal, else an error message.

Source code in kumiki/construction.py
def check_face_aligned_and_orthogonal(self) -> Optional[str]:
    """Return None if timbers are face-aligned and orthogonal, else an error message."""
    if not are_timbers_face_aligned(self.timber1, self.timber2):
        return "Timbers must be face-aligned"
    if not are_timbers_orthogonal(self.timber1, self.timber2):
        return "Timbers must be orthogonal"
    return None

check_perfection

check_perfection() -> Optional[str]

Return None if both timbers are perfect, else an error message.

Source code in kumiki/construction.py
def check_perfection(self) -> Optional[str]:
    """Return None if both timbers are perfect, else an error message."""
    if not self.timber1.is_perfect_timber():
        return "timber1 must be perfect"
    if not self.timber2.is_perfect_timber():
        return "timber2 must be perfect"
    return None

CrossJointTimberArrangement

timber1 instance-attribute

timber1: TimberLike

timber2 instance-attribute

timber2: TimberLike

front_face_on_timber1 class-attribute instance-attribute

front_face_on_timber1: Optional[TimberLongFace] = None

compute_normalized_timber_cross_product

compute_normalized_timber_cross_product() -> Direction3D

Compute the normalized cross product of timber1 and timber2 length directions.

Source code in kumiki/construction.py
def compute_normalized_timber_cross_product(self) -> Direction3D:
    """Compute the normalized cross product of timber1 and timber2 length directions."""
    return safe_normalize_vector(cross_product(self.timber1.get_length_direction_global(), self.timber2.get_length_direction_global()))

check_plane_aligned

check_plane_aligned() -> Optional[str]

Return None if timbers are plane-aligned and front face is in plane, else an error message.

Source code in kumiki/construction.py
def check_plane_aligned(self) -> Optional[str]:
    """Return None if timbers are plane-aligned and front face is in plane, else an error message."""
    if not are_timbers_plane_aligned(self.timber1, self.timber2):
        return "Timbers must be plane-aligned"
    if self.front_face_on_timber1 is not None and not are_vectors_parallel(
        self.timber1.get_face_direction_global(self.front_face_on_timber1),
        self.compute_normalized_timber_cross_product(),
    ):
        return "front_face_on_timber1 must point in the aligned plane normal"
    return None

check_face_aligned_and_orthogonal

check_face_aligned_and_orthogonal() -> Optional[str]

Return None if timbers are face-aligned and orthogonal, else an error message.

Source code in kumiki/construction.py
def check_face_aligned_and_orthogonal(self) -> Optional[str]:
    """Return None if timbers are face-aligned and orthogonal, else an error message."""
    if not are_timbers_face_aligned(self.timber1, self.timber2):
        return "Timbers must be face-aligned"
    if not are_timbers_orthogonal(self.timber1, self.timber2):
        return "Timbers must be orthogonal"
    return None

check_perfection

check_perfection() -> Optional[str]

Return None if both timbers are perfect, else an error message.

Source code in kumiki/construction.py
def check_perfection(self) -> Optional[str]:
    """Return None if both timbers are perfect, else an error message."""
    if not self.timber1.is_perfect_timber():
        return "timber1 must be perfect"
    if not self.timber2.is_perfect_timber():
        return "timber2 must be perfect"
    return None

BraceJointTimberArrangement

timber1 instance-attribute

timber1: TimberLike

timber2 instance-attribute

timber2: TimberLike

brace_timber instance-attribute

brace_timber: TimberLike

timber1_end instance-attribute

timber1_end: TimberEnd

timber2_end instance-attribute

timber2_end: TimberEnd

front_face_on_timber1 class-attribute instance-attribute

front_face_on_timber1: Optional[TimberLongFace] = None

check_perfection

check_perfection() -> Optional[str]

Return None if all timbers are perfect, else an error message.

Source code in kumiki/construction.py
def check_perfection(self) -> Optional[str]:
    """Return None if all timbers are perfect, else an error message."""
    if not self.timber1.is_perfect_timber():
        return "timber1 must be perfect"
    if not self.timber2.is_perfect_timber():
        return "timber2 must be perfect"
    if not self.brace_timber.is_perfect_timber():
        return "brace_timber must be perfect"
    return None

ButtJointBoardArrangement

butt_timber instance-attribute

butt_timber: TimberLike

receiving_timber instance-attribute

receiving_timber: TimberLike

butt_timber_face instance-attribute

butt_timber_face: TimberFace

front_face_on_butt_timber class-attribute instance-attribute

front_face_on_butt_timber: Optional[TimberFace] = None

__post_init__

__post_init__()
Source code in kumiki/construction.py
def __post_init__(self):
    if self.front_face_on_butt_timber is not None:
        assert self.butt_timber_face.is_perpendicular(self.front_face_on_butt_timber), "front_face_on_butt_timber must be an orthogonal face to butt_timber_face"

check_orthogonal

check_orthogonal() -> Optional[str]

Return None if timbers are orthogonal, else an error message. Timbers are orthogonal in this arrangement if butt_timber_face is parallel to some face on the receiving_timber

Source code in kumiki/construction.py
def check_orthogonal(self) -> Optional[str]:
    """Return None if timbers are orthogonal, else an error message.
    Timbers are orthogonal in this arrangement if butt_timber_face is parallel to some face on the receiving_timber
    """
    butt_face_direction = self.butt_timber.get_face_direction_global(self.butt_timber_face)
    receiving_axis_directions = [
        self.receiving_timber.get_length_direction_global(),
        self.receiving_timber.get_width_direction_global(),
        self.receiving_timber.get_height_direction_global(),
    ]
    for axis_direction in receiving_axis_directions:
        if safe_equality_test(Abs(numeric_dot_product(butt_face_direction, axis_direction)), 1):
            return None
    return "butt_timber_face must be parallel to some face on receiving_timber"

check_face_aligned

check_face_aligned() -> Optional[str]

Return None if timbers are face-aligned, else an error message.

Source code in kumiki/construction.py
def check_face_aligned(self) -> Optional[str]:
    """Return None if timbers are face-aligned, else an error message."""
    if not are_timbers_face_aligned(self.butt_timber, self.receiving_timber):
        return "Timbers must be face-aligned"
    return None

check_perfection

check_perfection() -> Optional[str]

Return None if both timbers are perfect, else an error message.

Source code in kumiki/construction.py
def check_perfection(self) -> Optional[str]:
    """Return None if both timbers are perfect, else an error message."""
    if not self.butt_timber.is_perfect_timber():
        return "butt_timber must be perfect"
    if not self.receiving_timber.is_perfect_timber():
        return "receiving_timber must be perfect"
    return None

PanelBoardArrangement

boards instance-attribute

boards: List[Board]

check_parallal_coplanar_and_same_thickness

check_parallal_coplanar_and_same_thickness() -> Optional[str]

Return None if all boards are parallel (same orientation), share the same thickness, and are coplanar (all measured against boards[0]), else an error message describing the first violation found.

Source code in kumiki/construction.py
def check_parallal_coplanar_and_same_thickness(self) -> Optional[str]:
    """Return None if all boards are parallel (same orientation), share the
    same thickness, and are coplanar (all measured against boards[0]), else
    an error message describing the first violation found.
    """
    if not self.boards:
        return "boards must not be empty"

    ref = self.boards[0]
    board_thickness = ref.size[1]

    for i, b in enumerate(self.boards[1:], start=1):
        for r in range(3):
            for c in range(3):
                if not safe_equality_test(
                    b.transform.orientation.matrix[r, c],
                    ref.transform.orientation.matrix[r, c],
                ):
                    return (
                        f"all boards must have the same orientation "
                        f"(board {i} differs from board 0 at [{r},{c}])"
                    )
        if not safe_equality_test(b.size[1], board_thickness):
            return (
                f"all boards must have the same thickness "
                f"(board {i} has {b.size[1]}, board 0 has {board_thickness})"
            )
        pos_in_ref_local = ref.transform.global_to_local(b.transform.position)
        if not safe_equality_test(pos_in_ref_local[1], scalar(0)):
            return (
                f"board {i} is not coplanar with board 0 "
                f"(Y offset = {pos_in_ref_local[1]} in ref local frame)"
            )
    return None

ExtendedTimberArrangement

Just a list of timbers, with no real restriction until check functions are called...

timbers instance-attribute

timbers: List[TimberLike]

check_plane_aligned

check_plane_aligned() -> Optional[str]
Source code in kumiki/construction.py
def check_plane_aligned(self) -> Optional[str]:
    # TODO
    pass

check_parallel

check_parallel() -> Optional[str]

Return None if all timbers have parallel length directions (measured against timbers[0]), else an error message.

Source code in kumiki/construction.py
def check_parallel(self) -> Optional[str]:
    """Return None if all timbers have parallel length directions
    (measured against timbers[0]), else an error message.
    """
    if not self.timbers:
        return "timbers must not be empty"

    ref = self.timbers[0]
    for i, t in enumerate(self.timbers[1:], start=1):
        if not are_timbers_parallel(ref, t):
            return f"all timbers must be parallel (timber {i} is not parallel to timber 0)"
    return None

check_face_aligned

check_face_aligned() -> Optional[str]

Return None if all timbers are face-aligned (measured against timbers[0]), else an error message.

Source code in kumiki/construction.py
def check_face_aligned(self) -> Optional[str]:
    """Return None if all timbers are face-aligned (measured against
    timbers[0]), else an error message.
    """
    if not self.timbers:
        return "timbers must not be empty"

    ref = self.timbers[0]
    for i, t in enumerate(self.timbers[1:], start=1):
        if not are_timbers_face_aligned(ref, t):
            return f"all timbers must be face-aligned (timber {i} is not face-aligned with timber 0)"
    return None

check_coaxial_face_aligned_and_same_size

check_coaxial_face_aligned_and_same_size() -> Optional[str]

Return None if all timbers share the same centerline (coaxial), are face-aligned, and have the same cross-sectional size, else an error message describing the first violation found (measured against timbers[0]).

"Same size" is checked along matching GLOBAL directions rather than local (width, height) indices: a face-aligned timber may be rotated 90 degrees about the shared centerline relative to timbers[0], in which case its local width/height are swapped relative to global space even though its physical cross-section matches.

Source code in kumiki/construction.py
def check_coaxial_face_aligned_and_same_size(self) -> Optional[str]:
    """Return None if all timbers share the same centerline (coaxial),
    are face-aligned, and have the same cross-sectional size, else an
    error message describing the first violation found (measured
    against timbers[0]).

    "Same size" is checked along matching GLOBAL directions rather than
    local (width, height) indices: a face-aligned timber may be rotated
    90 degrees about the shared centerline relative to timbers[0], in
    which case its local width/height are swapped relative to global
    space even though its physical cross-section matches.
    """
    if not self.timbers:
        return "timbers must not be empty"

    ref = self.timbers[0]
    ref_length_dir = ref.get_length_direction_global()
    ref_width_dir = ref.get_width_direction_global()
    ref_height_dir = ref.get_height_direction_global()
    ref_position = ref.get_bottom_position_global()

    for i, t in enumerate(self.timbers[1:], start=1):
        if not are_timbers_parallel(ref, t):
            return f"all timbers must be coaxial (timber {i} is not even parallel to timber 0)"

        offset = t.get_bottom_position_global() - ref_position
        perpendicular_offset = offset - ref_length_dir * safe_dot_product(offset, ref_length_dir)
        if not safe_zero_test(safe_dot_product(perpendicular_offset, perpendicular_offset)):
            return f"all timbers must be coaxial (timber {i}'s centerline does not lie on timber 0's centerline)"

        if not are_timbers_face_aligned(ref, t):
            return f"all timbers must be face-aligned (timber {i} is not face-aligned with timber 0)"

        if not safe_equality_test(t.get_size_in_direction_3d(ref_width_dir), ref.size[0]):
            return (
                f"all timbers must have the same cross-sectional size "
                f"(timber {i}'s size does not match timber 0 along timber 0's width direction)"
            )
        if not safe_equality_test(t.get_size_in_direction_3d(ref_height_dir), ref.size[1]):
            return (
                f"all timbers must have the same cross-sectional size "
                f"(timber {i}'s size does not match timber 0 along timber 0's height direction)"
            )
    return None

LineOnPlane dataclass

LineOnPlane(normal: Direction3D, point_on_line: V3, line_direction: Direction3D)

A line lying in a plane, with a direction to measure in.

Three things, not two: the line itself (a point on it and its direction), and the plane's normal, which says which way is positive for anything measured from the line. What locate_edge_on_face gives back -- an edge as seen ON one of the faces that meets there, which is how a mark gets squared across a piece.

Called LineOnPlane once, which named the wrong thing: a half plane is a 2D REGION bounded by a line, and this is the line and an orientation, with no region at all.

normal instance-attribute

normal: Direction3D

point_on_line instance-attribute

point_on_line: V3

line_direction instance-attribute

line_direction: Direction3D

__repr__

__repr__() -> str
Source code in kumiki/geometry.py
def __repr__(self) -> str:
    return (f"LineOnPlane(normal={self.normal}, point_on_line={self.point_on_line}, "
            f"line_direction={self.line_direction})")

Space dataclass

Space(transform: Transform)

Represents an ORIENTED 3D space.

transform instance-attribute

transform: Transform

__repr__

__repr__() -> str
Source code in kumiki/geometry.py
def __repr__(self) -> str:
    return f"Space(transform={self.transform})"

UnsignedPlane dataclass

UnsignedPlane(normal: Direction3D, point: V3)

Bases: Plane

Same as Plane but the sign on the normal should be ignored.

normal instance-attribute

normal: Direction3D

point instance-attribute

point: V3

__repr__

__repr__() -> str
Source code in kumiki/geometry.py
def __repr__(self) -> str:
    return f"UnsignedPlane(normal={self.normal}, point={self.point})"

from_transform_and_direction staticmethod

from_transform_and_direction(transform: Transform, direction: Direction3D) -> UnsignedPlane

Create an unsigned plane from a transform and a direction.

Parameters:

Name Type Description Default
transform Transform

Transform defining the position and orientation

required
direction Direction3D

Direction in the transform's local coordinate system

required

Returns:

Type Description
UnsignedPlane

UnsignedPlane with normal in global coordinates and point at transform position

Source code in kumiki/geometry.py
@staticmethod
def from_transform_and_direction(transform: Transform, direction: Direction3D) -> 'UnsignedPlane':
    """
    Create an unsigned plane from a transform and a direction.

    Args:
        transform: Transform defining the position and orientation
        direction: Direction in the transform's local coordinate system

    Returns:
        UnsignedPlane with normal in global coordinates and point at transform position
    """
    return UnsignedPlane(safe_transform_vector(transform.orientation.matrix, direction), transform.position)

Marking dataclass

Bases: ABC

locate abstractmethod

locate() -> Union[UnsignedPlane, Plane, Line, Point, LineOnPlane, Space]
Source code in kumiki/measuring.py
@abstractmethod
def locate(self) -> Union[UnsignedPlane, Plane, Line, Point, LineOnPlane, Space]:
    pass

DistanceFromFace dataclass

Bases: Marking

Represents a distance from a face on a timber with + being AWAY from the face.

distance instance-attribute

distance: Numeric

timber instance-attribute

face instance-attribute

locate

locate() -> UnsignedPlane

Convert the distance from a face to an unsigned plane.

Source code in kumiki/measuring.py
def locate(self) -> UnsignedPlane:
    """
    Convert the distance from a face to an unsigned plane.
    """
    return locate_into_face(self.distance, self.face, self.timber)

DistanceFromPointIntoFace dataclass

Bases: Marking

Represents a distance from a point into a face on a timber with + being INTO the timber (that is the negative face normal direction is the + axis of the measurement) If the point is not supplied, the center of the face is used.

distance instance-attribute

distance: Numeric

timber instance-attribute

face instance-attribute

face: TimberFace

point class-attribute instance-attribute

point: Optional[V3] = None

locate

locate() -> Point

Convert the distance from a point into a face to a Point

Returns:

Type Description
Point

Point at the specified distance from the starting point

Source code in kumiki/measuring.py
def locate(self) -> Point:
    """
    Convert the distance from a point into a face to a Point

    Returns:
        Point at the specified distance from the starting point
    """
    # Determine the starting point (either provided point or face center)
    if self.point is not None:
        starting_point = self.point
    else:
        starting_point = get_center_point_on_face_global(self.face, self.timber)

    # Get the face normal (pointing OUT of the timber)
    face_normal = self.timber.get_face_direction_global(self.face)

    # Direction AWAY from the face is -face_normal
    away_direction = -face_normal

    # Calculate the line position by offsetting from the starting point
    # Positive distance means away from the face
    line_point = starting_point + away_direction * self.distance

    # The line direction is perpendicular to the face (away from it)
    #return Line(away_direction, line_point)
    return Point(line_point)

DistanceFromLongEdgeOnFace dataclass

Bases: Marking

Represents a distance from a long edge on a timber with + being onto the face from the edge.

distance instance-attribute

distance: Numeric

timber instance-attribute

timber: Timber

edge instance-attribute

face instance-attribute

face: TimberFace

locate

locate() -> Line

Convert the distance from a long edge to a line on the specified face.

Returns a line parallel to the edge, on the given face, at the specified distance from the edge. The distance is measured along the face plane, perpendicular to the edge direction. Positive distance means moving in the direction of the "other" face's normal (the face that defines the edge together with self.face).

Returns:

Type Description
Line

Line parallel to the edge at the specified distance on the face

Source code in kumiki/measuring.py
def locate(self) -> Line:
    """
    Convert the distance from a long edge to a line on the specified face.

    Returns a line parallel to the edge, on the given face, at the specified distance from the edge.
    The distance is measured along the face plane, perpendicular to the edge direction.
    Positive distance means moving in the direction of the "other" face's normal (the face that
    defines the edge together with self.face).

    Returns:
        Line parallel to the edge at the specified distance on the face
    """
    # Get the edge line
    edge_line = locate_long_edge(self.timber, self.edge)

    # Check that the face is adjacent to the edge
    # Long faces are RIGHT, FRONT, LEFT, BACK (not TOP or BOTTOM)
    LONG_FACES = {TimberFace.RIGHT, TimberFace.FRONT, TimberFace.LEFT, TimberFace.BACK}

    assert self.face in LONG_FACES, \
        f"Face must be a long face (RIGHT, FRONT, LEFT, BACK), got {self.face}"

    # Edge to faces mapping
    edge_to_faces = {
        TimberLongEdge.RIGHT_FRONT: (TimberFace.RIGHT, TimberFace.FRONT),
        TimberLongEdge.FRONT_LEFT: (TimberFace.FRONT, TimberFace.LEFT),
        TimberLongEdge.LEFT_BACK: (TimberFace.LEFT, TimberFace.BACK),
        TimberLongEdge.BACK_RIGHT: (TimberFace.BACK, TimberFace.RIGHT),
    }

    # The face must be one of the two faces that define the edge
    if self.edge not in edge_to_faces:
        raise ValueError(f"Unknown edge: {self.edge}")

    face1, face2 = edge_to_faces[self.edge]
    assert self.face == face1 or self.face == face2, \
        f"Face {self.face} is not adjacent to edge {self.edge}. Adjacent faces are {face1} and {face2}"

    # Calculate the direction to move on the face, parallel to the face plane
    # This is perpendicular to both the edge direction and the face normal
    # The "other" face defines this direction
    other_face = face1 if self.face == face2 else face2

    # The offset direction is the normal of the other face (parallel to our face plane)
    # Positive distance means moving in the direction of the other face's normal
    offset_direction = self.timber.get_face_direction_global(other_face)

    # Calculate the new line position by offsetting from the edge
    # Positive distance means moving in the offset direction (onto the face from the edge)
    new_point = edge_line.point + offset_direction * self.distance

    # Return a line parallel to the edge at the new position
    return Line(edge_line.direction, new_point)

PointFromCornerInFaceDirection dataclass

Bases: Marking

Point on an edge in a given direction.

timber instance-attribute

timber: Timber

corner instance-attribute

corner: TimberCorner

face instance-attribute

face: TimberFace

distance instance-attribute

distance: Numeric

locate

locate() -> Point
Source code in kumiki/measuring.py
def locate(self) -> Point:
    _corner_to_faces = {
        TimberCorner.BOT_RIGHT_FRONT: (TimberFace.BOTTOM, TimberFace.RIGHT, TimberFace.FRONT),
        TimberCorner.BOT_FRONT_LEFT:  (TimberFace.BOTTOM, TimberFace.FRONT, TimberFace.LEFT),
        TimberCorner.BOT_LEFT_BACK:   (TimberFace.BOTTOM, TimberFace.LEFT,  TimberFace.BACK),
        TimberCorner.BOT_BACK_RIGHT:  (TimberFace.BOTTOM, TimberFace.BACK,  TimberFace.RIGHT),
        TimberCorner.TOP_RIGHT_FRONT: (TimberFace.TOP,    TimberFace.RIGHT, TimberFace.FRONT),
        TimberCorner.TOP_FRONT_LEFT:  (TimberFace.TOP,    TimberFace.FRONT, TimberFace.LEFT),
        TimberCorner.TOP_LEFT_BACK:   (TimberFace.TOP,    TimberFace.LEFT,  TimberFace.BACK),
        TimberCorner.TOP_BACK_RIGHT:  (TimberFace.TOP,    TimberFace.BACK,  TimberFace.RIGHT),
    }
    corner_faces = _corner_to_faces[self.corner]
    assert self.face not in corner_faces, (
        f"Face {self.face} defines corner {self.corner} and points away from the timber. "
        f"Use the opposite face ({self.face.get_opposite_face()}) to point inward."
    )
    return Point(self.timber.get_corner_position_global(self.corner) + self.timber.get_face_direction_global(self.face) * self.distance)

DistanceFromCornerAlongEdge dataclass

Bases: Marking

Distance along a timber edge from a reference end (corner) to an intersection or closest point. Positive means into the timber from the end.

distance instance-attribute

distance: Numeric

timber instance-attribute

edge instance-attribute

end instance-attribute

end: TimberEnd

locate

locate() -> Point
Source code in kumiki/measuring.py
def locate(self) -> Point:
    edge_line = locate_edge(self.timber, self.edge)
    if self.end == TimberEnd.TOP:
        end_position = edge_line.point + edge_line.direction * (self.timber.length / scalar(2))
        into_direction = -self.timber.get_length_direction_global()
    else:
        end_position = edge_line.point - edge_line.direction * (self.timber.length / scalar(2))
        into_direction = self.timber.get_length_direction_global()
    return Point(end_position + into_direction * self.distance)

PlaneFromEdgeInDirection dataclass

Bases: Marking

Plane with normal direction and distance from an edge in direction.

timber instance-attribute

edge instance-attribute

direction instance-attribute

direction: Direction3D

distance instance-attribute

distance: Numeric

locate

locate() -> Plane
Source code in kumiki/measuring.py
def locate(self) -> Plane:
    return locate_plane_from_edge_in_direction(self.timber, self.edge, self.direction, self.distance)

MarkingSpace dataclass

Bases: Marking

Represents a space to mark in.

timber instance-attribute

timber: Timber

local_transform instance-attribute

local_transform: Transform

locate

locate() -> Space
Source code in kumiki/measuring.py
def locate(self) -> Space:
    return Space(self.timber.transform * self.local_transform)

ButtJointShoulderResult

Result of computing a butt joint shoulder plane and its associated marking space.

Attributes:

Name Type Description
shoulder_plane Plane

The shoulder plane (normal points from mortise centerline toward tenon).

butt_direction Direction3D

Direction the butt timber is pointing into the receiving timber.

marking_space Space

Located where tenon centerline intersects the shoulder plane, oriented with: +X = shoulder_plane.normal (from mortise centerline toward tenon) +Y = caller-provided up_direction (orthogonalized) +Z = derived via right-hand rule

shoulder_plane instance-attribute

shoulder_plane: Plane

butt_direction instance-attribute

butt_direction: Direction3D

marking_space instance-attribute

marking_space: Space

ButtJointCSGParts

Representation of the geometry components that make up a butt joint. They are combined by unioning the positive parts, then differencing the negative parts.

positive_receiving_csg class-attribute instance-attribute

positive_receiving_csg: Optional[CutCSG] = None

negative_receiving_csg class-attribute instance-attribute

negative_receiving_csg: Optional[CutCSG] = None

positive_butt_csg class-attribute instance-attribute

positive_butt_csg: Optional[CutCSG] = None

negative_butt_csg class-attribute instance-attribute

negative_butt_csg: Optional[CutCSG] = None

DovetailTenonGeometeryResult

Bases: NamedTuple

Result of computing the geometry for a dovetail tenon.

Attributes:

Name Type Description
tenon_csg

CSG representing the tenon shape to be cut from the butt timber.

mortise_csg

CSG representing the mortise shape to be cut from the receiving timber.

tenon_negative_csg instance-attribute

tenon_negative_csg: CutCSG

mortise_negative_csg instance-attribute

mortise_negative_csg: CutCSG

wedge_accessory_csg class-attribute instance-attribute

wedge_accessory_csg: Optional[CSGAccessory] = None

DovetailTenonWedgeAccessoryParameters

Bases: NamedTuple

Parameters for an optional wedge accessory for a dovetail tenon.

__
| \ wedge_tip_stickout

| ____________________ <- wedge_small_height measured at this line | | | | ___ | \ wedge_back_extra_length |_

Attributes:

Name Type Description
wedge_from_receiving_timber_side bool

If true, the wedge is designed to be cut from the receiving timber and inserted from that side. If false, the wedge is designed to be cut from the tenon timber and inserted from the tenon side. We must have tenon_depth + receiving_timber_extra_depth > the matching width on the receivingtimber for this to work

wedge_angle Numeric

The angle of the wedge taper. 0 means a rectangular wedge, X means a wedge with an X angle taper.

wedge_extra_height Numeric

Extra height added to the wedge. The small height of the wedge is calculated as dovetail_depth + wedge_extra_height. The reason for this is that a minimum size of dovetail_depth is required for the joint to physically assemble.

wedge_from_receiving_timber_side class-attribute instance-attribute

wedge_from_receiving_timber_side: bool = False

wedge_angle class-attribute instance-attribute

wedge_angle: Numeric = degrees(10)

wedge_extra_height class-attribute instance-attribute

wedge_extra_height: Numeric = 0

wedge_tip_stickout class-attribute instance-attribute

wedge_tip_stickout: Numeric = 0

wedge_back_extra_length class-attribute instance-attribute

wedge_back_extra_length: Numeric = 0

TuskTenonGeometryResult

Bases: NamedTuple

Result of computing the geometry for a tusk tenon's crosswise locking key.

Attributes:

Name Type Description
tenon_hole_negative_csg CutCSG

The crosswise hole cut through the (through-)tenon that the tusk key slides into.

mortise_clearance_negative_csg Optional[CutCSG]

Extra clearance cut into the receiving (mortise) timber, only present when its rough stock still surrounds the tenon at the tusk hole's position (None otherwise).

tusk_accessory_csg CSGAccessory

The tusk key itself.

tenon_hole_negative_csg instance-attribute

tenon_hole_negative_csg: CutCSG

mortise_clearance_negative_csg instance-attribute

mortise_clearance_negative_csg: Optional[CutCSG]

tusk_accessory_csg instance-attribute

tusk_accessory_csg: CSGAccessory

PegPositionSpace

Bases: Enum

Which timber's coordinate space to use when interpreting peg positions and orientations.

TENON class-attribute instance-attribute

TENON = 1

MORTISE class-attribute instance-attribute

MORTISE = 2

BUTT class-attribute instance-attribute

BUTT = 1

RECEIVING class-attribute instance-attribute

RECEIVING = 2

SimplePegParameters

Parameters for simple pegs in mortise and tenon joints.

Attributes:

Name Type Description
shape PegShape

Shape specification for the peg (from PegShape enum)

peg_positions List[Tuple[Numeric, Numeric]]

List of (distance_from_shoulder, distance_from_centerline) tuples - First value: distance along length axis measured from shoulder of tenon - Second value: distance in perpendicular axis measured from center

peg_position_space Tuple[PegPositionSpace, PegPositionSpace]

Controls which timber's coordinate system is used to interpret each component of peg_positions. A tuple of (shoulder_axis_space, lateral_axis_space). - shoulder_axis_space (first element): controls distance_from_shoulder direction. TENON = along tenon length axis. MORTISE = along mortise length axis. - lateral_axis_space (second element): controls distance_from_centerline direction. TENON = perpendicular to peg face normal and tenon length axis. MORTISE = along mortise length axis.

size Numeric

Peg diameter (for round pegs) or side length (for square pegs)

depth Optional[Numeric]

Depth measured from mortise face where peg goes in (None means all the way through the mortise timber)

tenon_hole_offset Numeric

Offset distance of the hole in the tenon towards the shoulder so that the peg tightens the joint up. You should usually set this to 1-2mm

peg_orientation Tuple[PegPositionSpace, Numeric]

Controls which timber's face axes the peg cross-section is aligned to, plus an optional CCW rotation around the drill axis. A tuple of (space, ccw_rotation_angle). - space: TENON = align peg Y axis with the tenon length axis. MORTISE = align peg Y axis with the mortise length axis. - ccw_rotation_angle: counter-clockwise rotation (in radians) around the drill axis applied on top of the face-aligned basis. 0 = no rotation.

stickout_length Optional[Numeric]

Length the peg protrudes beyond the mortise entry face. If None, the peg sticks out by half its depth.

shape instance-attribute

shape: PegShape

peg_positions instance-attribute

peg_positions: List[Tuple[Numeric, Numeric]]

size instance-attribute

size: Numeric

depth class-attribute instance-attribute

depth: Optional[Numeric] = None

tenon_hole_offset class-attribute instance-attribute

tenon_hole_offset: Numeric = scalar(0)

stickout_length class-attribute instance-attribute

stickout_length: Optional[Numeric] = None

peg_position_space class-attribute instance-attribute

peg_orientation class-attribute instance-attribute

peg_orientation: Tuple[PegPositionSpace, Numeric] = (PegPositionSpace.TENON, scalar(0))

PegPositionResult

Computed geometry for a single peg, all positions and orientations in global space.

Attributes:

Name Type Description
tenon_face_position_global V3

Center of the peg hole on the tenon face (no draw-bore offset).

tenon_face_position_with_offset_global V3

Center of the peg hole on the tenon face, shifted toward the shoulder by tenon_hole_offset for draw-bore tightening.

mortise_entry_position_global V3

Center of the peg hole on the mortise entry face.

orientation_global Orientation

Orientation of the peg (Z-axis = drill direction into the timber).

peg_depth Numeric

Depth of the peg hole (full chord through the mortise, or explicit depth).

stickout_length Numeric

Length the peg protrudes beyond the mortise entry face.

tenon_face_position_global instance-attribute

tenon_face_position_global: V3

tenon_face_position_with_offset_global instance-attribute

tenon_face_position_with_offset_global: V3

mortise_entry_position_global instance-attribute

mortise_entry_position_global: V3

orientation_global instance-attribute

orientation_global: Orientation

peg_depth instance-attribute

peg_depth: Numeric

stickout_length instance-attribute

stickout_length: Numeric

TimberFace

Bases: Enum

TOP class-attribute instance-attribute

TOP = 1

BOTTOM class-attribute instance-attribute

BOTTOM = 2

RIGHT class-attribute instance-attribute

RIGHT = 3

FRONT class-attribute instance-attribute

FRONT = 4

LEFT class-attribute instance-attribute

LEFT = 5

BACK class-attribute instance-attribute

BACK = 6

to property

Convert to TimberFeature for further conversions.

get_direction

get_direction() -> Direction3D

Get the direction vector for this face in world coordinates.

Source code in kumiki/timber.py
def get_direction(self) -> Direction3D:
    """Get the direction vector for this face in world coordinates."""
    if self == TimberFace.TOP:
        return create_v3(scalar(0), scalar(0), scalar(1))
    elif self == TimberFace.BOTTOM:
        return create_v3(scalar(0), scalar(0), scalar(-1))
    elif self == TimberFace.RIGHT:
        return create_v3(scalar(1), scalar(0), scalar(0))
    elif self == TimberFace.LEFT:
        return create_v3(scalar(-1), scalar(0), scalar(0))
    elif self == TimberFace.FRONT:
        return create_v3(scalar(0), scalar(1), scalar(0))
    else:  # BACK
        return create_v3(scalar(0), scalar(-1), scalar(0))

is_perpendicular

is_perpendicular(other: TimberFace) -> bool

Check if two faces are perpendicular to each other.

Perpendicular face pairs (orthogonal axes): - X-axis faces (RIGHT, LEFT) <-> Y-axis faces (FRONT, BACK) - X-axis faces (RIGHT, LEFT) <-> Z-axis faces (TOP, BOTTOM) - Y-axis faces (FRONT, BACK) <-> Z-axis faces (TOP, BOTTOM)

Source code in kumiki/timber.py
def is_perpendicular(self, other: 'TimberFace') -> bool:
    """
    Check if two faces are perpendicular to each other.

    Perpendicular face pairs (orthogonal axes):
    - X-axis faces (RIGHT, LEFT) <-> Y-axis faces (FRONT, BACK)
    - X-axis faces (RIGHT, LEFT) <-> Z-axis faces (TOP, BOTTOM)
    - Y-axis faces (FRONT, BACK) <-> Z-axis faces (TOP, BOTTOM)
    """
    # Define axis groups
    x_faces = {TimberFace.RIGHT, TimberFace.LEFT}
    y_faces = {TimberFace.FRONT, TimberFace.BACK}
    z_faces = {TimberFace.TOP, TimberFace.BOTTOM}

    # Two faces are perpendicular if they are on different axes
    self_in_x = self in x_faces
    self_in_y = self in y_faces
    self_in_z = self in z_faces

    other_in_x = other in x_faces
    other_in_y = other in y_faces
    other_in_z = other in z_faces

    # Perpendicular if on different axes
    return (self_in_x and (other_in_y or other_in_z)) or \
           (self_in_y and (other_in_x or other_in_z)) or \
           (self_in_z and (other_in_x or other_in_y))

get_opposite_face

get_opposite_face() -> TimberFace

Get the opposite face (the face on the opposite side of the timber).

Opposite pairs: - TOP <-> BOTTOM - RIGHT <-> LEFT - FRONT <-> BACK

Source code in kumiki/timber.py
def get_opposite_face(self) -> 'TimberFace':
    """
    Get the opposite face (the face on the opposite side of the timber).

    Opposite pairs:
    - TOP <-> BOTTOM
    - RIGHT <-> LEFT
    - FRONT <-> BACK
    """
    if self == TimberFace.TOP:
        return TimberFace.BOTTOM
    elif self == TimberFace.BOTTOM:
        return TimberFace.TOP
    elif self == TimberFace.RIGHT:
        return TimberFace.LEFT
    elif self == TimberFace.LEFT:
        return TimberFace.RIGHT
    elif self == TimberFace.FRONT:
        return TimberFace.BACK
    else:  # BACK
        return TimberFace.FRONT

rotate_about

rotate_about(face: TimberFace) -> TimberFace

Rotate this face by 90 degrees about face's outward-normal axis (a quarter turn using the right-hand rule around that normal).

If this face IS the rotation axis (self == face or self == face.get_opposite_face()), it lies on the axis and is unaffected by the rotation, so it is returned unchanged.

Source code in kumiki/timber.py
def rotate_about(self, face: 'TimberFace') -> 'TimberFace':
    """
    Rotate this face by 90 degrees about `face`'s outward-normal axis
    (a quarter turn using the right-hand rule around that normal).

    If this face IS the rotation axis (self == face or self ==
    face.get_opposite_face()), it lies on the axis and is unaffected by
    the rotation, so it is returned unchanged.
    """
    if self == face or self == face.get_opposite_face():
        return self

    # Each cycle lists the 4 faces perpendicular to the rotation axis, in
    # the order a right-hand rotation about that axis's outward normal
    # maps them (self -> next element, wrapping around).
    cycles = {
        TimberFace.TOP: [TimberFace.RIGHT, TimberFace.FRONT, TimberFace.LEFT, TimberFace.BACK],
        TimberFace.BOTTOM: [TimberFace.RIGHT, TimberFace.BACK, TimberFace.LEFT, TimberFace.FRONT],
        TimberFace.RIGHT: [TimberFace.FRONT, TimberFace.TOP, TimberFace.BACK, TimberFace.BOTTOM],
        TimberFace.LEFT: [TimberFace.FRONT, TimberFace.BOTTOM, TimberFace.BACK, TimberFace.TOP],
        TimberFace.FRONT: [TimberFace.RIGHT, TimberFace.BOTTOM, TimberFace.LEFT, TimberFace.TOP],
        TimberFace.BACK: [TimberFace.RIGHT, TimberFace.TOP, TimberFace.LEFT, TimberFace.BOTTOM],
    }
    cycle = cycles[face]
    index = cycle.index(self)
    return cycle[(index + 1) % len(cycle)]

TimberEnd

Bases: Enum

TOP class-attribute instance-attribute

TOP = 1

BOTTOM class-attribute instance-attribute

BOTTOM = 2

to property

Convert to TimberFeature for further conversions.

TimberLongFace

Bases: Enum

RIGHT class-attribute instance-attribute

RIGHT = 3

FRONT class-attribute instance-attribute

FRONT = 4

LEFT class-attribute instance-attribute

LEFT = 5

BACK class-attribute instance-attribute

BACK = 6

to property

Convert to TimberFeature for further conversions.

is_perpendicular

is_perpendicular(other: TimberLongFace) -> bool

Check if two long faces are perpendicular to each other.

Perpendicular face pairs: - RIGHT <-> FRONT, RIGHT <-> BACK - LEFT <-> FRONT, LEFT <-> BACK

Source code in kumiki/timber.py
def is_perpendicular(self, other: 'TimberLongFace') -> bool:
    """
    Check if two long faces are perpendicular to each other.

    Perpendicular face pairs:
    - RIGHT <-> FRONT, RIGHT <-> BACK
    - LEFT <-> FRONT, LEFT <-> BACK
    """
    return self.to.face().is_perpendicular(other.to.face())

rotate_right

rotate_right() -> TimberLongFace

Rotate the long face right (90 degrees clockwise).

Source code in kumiki/timber.py
def rotate_right(self) -> 'TimberLongFace':
    """Rotate the long face right (90 degrees clockwise)."""
    # Map from 3-6 to 0-3, rotate, then map back to 3-6
    return TimberLongFace((self.value - 3 + 1) % 4 + 3)

rotate_left

rotate_left() -> TimberLongFace

Rotate the long face left (90 degrees counter-clockwise).

Source code in kumiki/timber.py
def rotate_left(self) -> 'TimberLongFace':
    """Rotate the long face left (90 degrees counter-clockwise)."""
    # Map from 3-6 to 0-3, rotate, then map back to 3-6
    return TimberLongFace((self.value - 3 - 1) % 4 + 3)

TimberCenterline

Bases: Enum

CENTERLINE class-attribute instance-attribute

CENTERLINE = 7

to property

Convert to TimberFeature for further conversions.

Cutting dataclass

Cutting(timber: PerfectTimberWithin, maybe_top_end_cut_distance_from_bottom: Optional[Numeric] = None, maybe_bottom_end_cut_distance_from_bottom: Optional[Numeric] = None, negative_csg: Optional[CutCSG] = None, label: CutCSGLabel = NoLabel(), assembly_freedom: Optional[AssemblyFreedom] = None, assembly_ordering: Ordering = Ordering())

A set of cuts on a timber (to create a joint, for example), defined by a CSG object representing the volume to be removed.

The CSG object represents the volume to be REMOVED from the timber (negative CSG), in LOCAL coordinates (relative to timber.bottom_position).

timber instance-attribute

maybe_top_end_cut_distance_from_bottom class-attribute instance-attribute

maybe_top_end_cut_distance_from_bottom: Optional[Numeric] = None

maybe_bottom_end_cut_distance_from_bottom class-attribute instance-attribute

maybe_bottom_end_cut_distance_from_bottom: Optional[Numeric] = None

negative_csg class-attribute instance-attribute

negative_csg: Optional[CutCSG] = None

label class-attribute instance-attribute

label: CutCSGLabel = field(default_factory=CutCSGLabel.NoLabel)

assembly_freedom class-attribute instance-attribute

assembly_freedom: Optional[AssemblyFreedom] = None

assembly_ordering class-attribute instance-attribute

assembly_ordering: Ordering = Ordering()

get_maybe_top_end_cut

get_maybe_top_end_cut() -> Optional[HalfSpace]

Return the top end cut HalfSpace derived from distance metadata.

Source code in kumiki/timber.py
def get_maybe_top_end_cut(self) -> Optional[HalfSpace]:
    """Return the top end cut HalfSpace derived from distance metadata."""
    if self.maybe_top_end_cut_distance_from_bottom is not None:
        return HalfSpace(
            normal=create_v3(scalar(0), scalar(0), scalar(1)),
            offset=self.maybe_top_end_cut_distance_from_bottom,
            label=CutCSGLabel("top_end_cut"),
        )
    return None

get_maybe_bottom_end_cut

get_maybe_bottom_end_cut() -> Optional[HalfSpace]

Return the bottom end cut HalfSpace derived from distance metadata.

Source code in kumiki/timber.py
def get_maybe_bottom_end_cut(self) -> Optional[HalfSpace]:
    """Return the bottom end cut HalfSpace derived from distance metadata."""
    if self.maybe_bottom_end_cut_distance_from_bottom is not None:
        return HalfSpace(
            normal=create_v3(scalar(0), scalar(0), scalar(-1)),
            offset=-self.maybe_bottom_end_cut_distance_from_bottom,
            label=CutCSGLabel("bottom_end_cut"),
        )
    return None

get_negative_csg_local

get_negative_csg_local() -> Optional[CutCSG]

Get the complete negative CSG including end cuts.

Returns the union of negative_csg with any end cuts that are defined, or None when this cutting removes nothing at all.

Source code in kumiki/timber.py
def get_negative_csg_local(self) -> Optional[CutCSG]:
    """
    Get the complete negative CSG including end cuts.

    Returns the union of negative_csg with any end cuts that are defined,
    or None when this cutting removes nothing at all.
    """
    csg_components = []

    # negative_csg and the end-cut metadata can describe the same plane.
    # Both are kept: subtracting a plane twice removes the same material,
    # and a search resolves to the first match -- so the joint's own cut,
    # which goes in first, is the one that answers for the plane, and the
    # generated end cut trails behind it.
    if self.negative_csg is not None:
        csg_components.append(self.negative_csg)

    top_end_cut = self.get_maybe_top_end_cut()
    bottom_end_cut = self.get_maybe_bottom_end_cut()
    if top_end_cut is not None:
        csg_components.append(top_end_cut)
    if bottom_end_cut is not None:
        csg_components.append(bottom_end_cut)

    # A cutting that removes nothing has no node and no label; the timber
    # is then just its own CSG. Returning EmptyCSG instead would put a
    # subtract-nothing node in every such tree.
    if len(csg_components) == 0:
        return None

    # Always one SolidUnion, named or not, one piece or several: the
    # cutting owns this node, so the tree has the same shape either way.
    return SolidUnion(csg_components, label=self.label)

make_end_cut_distance_from_bottom staticmethod

make_end_cut_distance_from_bottom(timber: PerfectTimberWithin, end: TimberEnd, distance_from_end_to_cut: Numeric) -> Numeric

Convert distance-from-end to cut-plane distance from timber bottom.

Source code in kumiki/timber.py
@staticmethod
def make_end_cut_distance_from_bottom(
    timber: PerfectTimberWithin,
    end: TimberEnd,
    distance_from_end_to_cut: Numeric,
) -> Numeric:
    """Convert distance-from-end to cut-plane distance from timber bottom."""
    assert isinstance(end, TimberEnd), f"expected TimberEnd, got {type(end).__name__}"
    if end == TimberEnd.TOP:
        return timber.length - distance_from_end_to_cut
    return distance_from_end_to_cut

Matrix

Matrix(data)

Immutable: _data is set once at construction and never written to again (enforced both by omitting __setitem__ and by marking the underlying numpy buffer read-only), matching the frozen dataclasses (Transform/Orientation/Axis) that hold Matrix-typed fields elsewhere in this module -- without this, some_frozen_transform.position[0] = 5 would silently succeed despite the dataclass being frozen.

Source code in kumiki/rule.py
def __init__(self, data):
    if isinstance(data, Matrix):
        arr = np.array(data._data, dtype=float, copy=True)
    elif isinstance(data, np.ndarray):
        arr = np.array(data, dtype=float)
        arr = arr.reshape(-1, 1) if arr.ndim == 1 else arr
    else:
        data = list(data)
        if len(data) > 0 and isinstance(data[0], (list, tuple)):
            arr = np.array([[float(v) for v in row] for row in data], dtype=float)
        else:
            arr = np.array([float(v) for v in data], dtype=float).reshape(-1, 1)
    arr.setflags(write=False)
    self._data = arr

__slots__ class-attribute instance-attribute

__slots__ = ('_data',)

shape property

shape: Tuple[int, int]

rows property

rows: int

cols property

cols: int

T property

T: Matrix

eye classmethod

eye(n: int) -> Matrix
Source code in kumiki/rule.py
@classmethod
def eye(cls, n: int) -> 'Matrix':
    return cls._wrap(np.eye(n, dtype=float))

zeros classmethod

zeros(rows: int, cols: Optional[int] = None) -> Matrix
Source code in kumiki/rule.py
@classmethod
def zeros(cls, rows: int, cols: Optional[int] = None) -> 'Matrix':
    return cls._wrap(np.zeros((rows, cols if cols is not None else rows), dtype=float))

det

det() -> float
Source code in kumiki/rule.py
def det(self) -> float:
    return float(np.linalg.det(self._data))

dot

dot(other: Matrix) -> float
Source code in kumiki/rule.py
def dot(self, other: 'Matrix') -> float:
    other_data = other._data if isinstance(other, Matrix) else np.asarray(other, dtype=float)
    return float(np.dot(self._data.flatten(), other_data.flatten()))

cross

cross(other: Matrix) -> Matrix
Source code in kumiki/rule.py
def cross(self, other: 'Matrix') -> 'Matrix':
    other_data = other._data if isinstance(other, Matrix) else np.asarray(other, dtype=float)
    result = np.cross(self._data.flatten(), other_data.flatten())
    return Matrix._wrap(result.reshape(-1, 1))

equals

equals(other: Matrix, tolerance: Optional[float] = None) -> bool

Elementwise approximate equality (tolerates float noise from trig/sqrt).

Source code in kumiki/rule.py
def equals(self, other: 'Matrix', tolerance: Optional[float] = None) -> bool:
    """Elementwise approximate equality (tolerates float noise from trig/sqrt)."""
    if not isinstance(other, Matrix) or self._data.shape != other._data.shape:
        return False
    tol = EPSILON_GENERIC if tolerance is None else tolerance
    return bool(np.all(np.abs(self._data - other._data) < tol))

norm

norm() -> float
Source code in kumiki/rule.py
def norm(self) -> float:
    return float(np.linalg.norm(self._data))

tolist

tolist() -> list
Source code in kumiki/rule.py
def tolist(self) -> list:
    return self._data.tolist()

__getitem__

__getitem__(key)
Source code in kumiki/rule.py
def __getitem__(self, key):
    if isinstance(key, tuple):
        result = self._data[key]
    else:
        result = self._data.flat[key]
    if isinstance(result, np.ndarray):
        if result.ndim == 1:
            # A row-slice (int row, slice col) -> keep as a row vector;
            # anything else (col-slice, or a flat slice) -> column vector,
            # matching sympy's Matrix slicing shapes.
            if isinstance(key, tuple) and isinstance(key[0], int):
                result = result.reshape(1, -1)
            else:
                result = result.reshape(-1, 1)
        return Matrix._wrap(result)
    return float(result)

__iter__

__iter__()
Source code in kumiki/rule.py
def __iter__(self):
    return iter(self._data.flatten().tolist())

__len__

__len__() -> int
Source code in kumiki/rule.py
def __len__(self) -> int:
    return int(self._data.size)

__mul__

__mul__(other)
Source code in kumiki/rule.py
def __mul__(self, other):
    if isinstance(other, Matrix):
        return Matrix._wrap(self._data @ other._data)
    return Matrix._wrap(self._data * float(other))

__rmul__

__rmul__(other)
Source code in kumiki/rule.py
def __rmul__(self, other):
    return Matrix._wrap(self._data * float(other))

__truediv__

__truediv__(other)
Source code in kumiki/rule.py
def __truediv__(self, other):
    return Matrix._wrap(self._data / float(other))

__add__

__add__(other)
Source code in kumiki/rule.py
def __add__(self, other):
    other_data = other._data if isinstance(other, Matrix) else other
    return Matrix._wrap(self._data + other_data)

__radd__

__radd__(other)
Source code in kumiki/rule.py
def __radd__(self, other):
    return self.__add__(other)

__sub__

__sub__(other)
Source code in kumiki/rule.py
def __sub__(self, other):
    other_data = other._data if isinstance(other, Matrix) else other
    return Matrix._wrap(self._data - other_data)

__rsub__

__rsub__(other)
Source code in kumiki/rule.py
def __rsub__(self, other):
    other_data = other._data if isinstance(other, Matrix) else other
    return Matrix._wrap(other_data - self._data)

__neg__

__neg__()
Source code in kumiki/rule.py
def __neg__(self):
    return Matrix._wrap(-self._data)

__eq__

__eq__(other)
Source code in kumiki/rule.py
def __eq__(self, other):
    if not isinstance(other, Matrix):
        return NotImplemented
    return self._data.shape == other._data.shape and bool(np.array_equal(self._data, other._data))

__repr__

__repr__() -> str
Source code in kumiki/rule.py
def __repr__(self) -> str:
    return f"Matrix({self._data.tolist()!r})"

Axis dataclass

Axis(position: V3, direction: Direction3D)

position instance-attribute

position: V3

direction instance-attribute

direction: Direction3D

Transform dataclass

Transform(position: V3, orientation: Orientation)

Represents a 3D transformation with position and orientation. Encapsulates both translation and rotation for objects in 3D space.

position instance-attribute

position: V3

orientation instance-attribute

orientation: Orientation

identity classmethod

identity() -> Transform

Create an identity transform at origin with identity orientation.

Source code in kumiki/rule.py
@classmethod
def identity(cls) -> 'Transform':
    """Create an identity transform at origin with identity orientation."""
    return cls(
        position=create_v3(scalar(0), scalar(0), scalar(0)),
        orientation=Orientation.identity()
    )

local_to_global

local_to_global(local_point: V3) -> V3

Convert a point from local coordinates to global world coordinates.

Parameters:

Name Type Description Default
local_point V3

A point in local coordinates

required

Returns:

Type Description
V3

The same point in global world coordinates

Source code in kumiki/rule.py
def local_to_global(self, local_point: V3) -> V3:
    """
    Convert a point from local coordinates to global world coordinates.

    Args:
        local_point: A point in local coordinates

    Returns:
        The same point in global world coordinates
    """
    # Rotate to global frame, then translate to position
    # global = R * local + position
    return safe_transform_vector(self.orientation.matrix, local_point) + self.position

global_to_local

global_to_local(global_point: V3) -> V3

Convert a point from global world coordinates to local coordinates.

Parameters:

Name Type Description Default
global_point V3

A point in global world coordinates

required

Returns:

Type Description
V3

The same point in local coordinates

Source code in kumiki/rule.py
def global_to_local(self, global_point: V3) -> V3:
    """
    Convert a point from global world coordinates to local coordinates.

    Args:
        global_point: A point in global world coordinates

    Returns:
        The same point in local coordinates
    """
    # Translate to origin, then rotate to local frame
    # local = R^T * (global - position)
    translated = global_point - self.position
    return safe_transform_vector(self.orientation.matrix.T, translated)

numeric_local_to_global

numeric_local_to_global(local_point: V3) -> V3

Convert local to global using numeric (Float) math. For hot paths like CSG.

Source code in kumiki/rule.py
def numeric_local_to_global(self, local_point: V3) -> V3:
    """Convert local to global using numeric (Float) math. For hot paths like CSG."""
    return numeric_transform_vector(self.orientation.matrix, local_point) + self.position

numeric_global_to_local

numeric_global_to_local(global_point: V3) -> V3

Convert global to local using numeric (Float) math. For hot paths like CSG.

Source code in kumiki/rule.py
def numeric_global_to_local(self, global_point: V3) -> V3:
    """Convert global to local using numeric (Float) math. For hot paths like CSG."""
    translated = global_point - self.position
    return numeric_transform_vector(self.orientation.matrix.T, translated)

to_global_transform

to_global_transform(old_parent: Transform) -> Transform

Convert this transform to global coordinates relative to a parent transform.

Source code in kumiki/rule.py
def to_global_transform(self, old_parent: 'Transform') -> 'Transform':
    """
    Convert this transform to global coordinates relative to a parent transform.
    """
    return old_parent * self

invert

invert() -> Transform

Return the inverse of this transform.

For a transform T that converts local to global (global = T * local), the inverse converts global to local (local = T^-1 * global).

Source code in kumiki/rule.py
def invert(self) -> 'Transform':
    """
    Return the inverse of this transform.

    For a transform T that converts local to global (global = T * local),
    the inverse converts global to local (local = T^-1 * global).
    """
    # Invert the orientation (transpose for rotation matrices)
    inv_orientation = self.orientation.invert()
    # Transform the position by the inverted orientation and negate
    inv_position = -safe_transform_vector(inv_orientation.matrix, self.position)
    return Transform(position=inv_position, orientation=inv_orientation)

__mul__

__mul__(other: Transform) -> Transform

Compose two transforms: result = self * other.

This applies other first, then self. Equivalent to: global = self.local_to_global(other.local_to_global(local))

Source code in kumiki/rule.py
def __mul__(self, other: 'Transform') -> 'Transform':
    """
    Compose two transforms: result = self * other.

    This applies other first, then self.
    Equivalent to: global = self.local_to_global(other.local_to_global(local))
    """
    new_orientation = self.orientation * other.orientation
    new_position = safe_transform_vector(self.orientation.matrix, other.position) + self.position
    return Transform(position=new_position, orientation=new_orientation)

to_local_transform

to_local_transform(new_parent: Transform) -> Transform

Convert this transform to local coordinates relative to a parent transform.

Source code in kumiki/rule.py
def to_local_transform(self, new_parent: 'Transform') -> 'Transform':
    """
    Convert this transform to local coordinates relative to a parent transform.
    """
    return new_parent.invert() * self

rotate_around_axis

rotate_around_axis(axis: Axis, radians: Numeric) -> Transform

Rotate this transform counterclockwise around an axis and return the new transform.

The axis can be positioned anywhere in space (not just through the origin). Uses Rodrigues' rotation formula after translating to make the axis pass through origin.

Parameters:

Name Type Description Default
axis Axis

Axis with position and direction to rotate around

required
radians Numeric

Angle to rotate in radians (counterclockwise when looking along axis direction)

required

Returns:

Type Description
Transform

New Transform with rotated position and orientation

Source code in kumiki/rule.py
def rotate_around_axis(self, axis: Axis, radians: Numeric) -> 'Transform':
    """
    Rotate this transform counterclockwise around an axis and return the new transform.

    The axis can be positioned anywhere in space (not just through the origin).
    Uses Rodrigues' rotation formula after translating to make the axis pass through origin.

    Args:
        axis: Axis with position and direction to rotate around
        radians: Angle to rotate in radians (counterclockwise when looking along axis direction)

    Returns:
        New Transform with rotated position and orientation
    """
    # Normalize the axis direction
    axis_normalized = safe_normalize_vector(axis.direction)
    kx, ky, kz = axis_normalized[0], axis_normalized[1], axis_normalized[2]

    # Rodrigues' rotation formula for rotation matrix around axis k by angle θ:
    # R = I + sin(θ)K + (1 - cos(θ))K²
    # where K is the skew-symmetric cross-product matrix of k

    # K = [[0, -kz, ky], [kz, 0, -kx], [-ky, kx, 0]]
    K = Matrix([
        [scalar(0), -kz, ky],
        [kz, scalar(0), -kx],
        [-ky, kx, scalar(0)]
    ])

    # K² = K * K
    K_squared = K * K

    # R = I + sin(θ)K + (1 - cos(θ))K²
    I = eye(3)
    rotation_matrix = I + sin(radians) * K + (scalar(1) - cos(radians)) * K_squared

    # To rotate around an axis not through origin:
    # 1. Translate so axis passes through origin
    # 2. Rotate
    # 3. Translate back

    # Translate position relative to axis position
    position_relative = self.position - axis.position

    # Apply rotation to the relative position
    rotated_relative = rotation_matrix * position_relative

    # Translate back
    new_position = rotated_relative + axis.position

    # Apply rotation to orientation (orientation is independent of translation)
    new_orientation_matrix = rotation_matrix * self.orientation.matrix
    new_orientation = Orientation(new_orientation_matrix)

    return Transform(position=new_position, orientation=new_orientation)

Comparison

Bases: Enum

Enum for safe comparison operations

GT class-attribute instance-attribute

GT = '>'

LT class-attribute instance-attribute

LT = '<'

GE class-attribute instance-attribute

GE = '>='

LE class-attribute instance-attribute

LE = '<='

EQ class-attribute instance-attribute

EQ = '=='

NE class-attribute instance-attribute

NE = '!='

Orientation dataclass

Orientation(matrix: Matrix = (lambda: eye(3))())

Represents a 3D rotation using a 3x3 rotation matrix. I guess we never slerp and don't care about memory usage so apparently we're using matrices to implement this class.

matrix class-attribute instance-attribute

matrix: Matrix = field(default_factory=lambda: Matrix.eye(3))

__post_init__

__post_init__()

Convert to Matrix and validate that the matrix is 3x3.

Source code in kumiki/rule.py
def __post_init__(self):
    """Convert to Matrix and validate that the matrix is 3x3."""
    # Convert to Matrix if necessary (handles list/tuple inputs)
    if not isinstance(self.matrix, Matrix):
        object.__setattr__(self, 'matrix', Matrix(self.matrix))

    if self.matrix.shape != (3, 3):
        raise ValueError("Rotation matrix must be 3x3")

multiply

multiply(other: Orientation) -> Orientation

Multiply this orientation with another orientation. Returns a new Orientation representing the combined rotation.

Source code in kumiki/rule.py
def multiply(self, other: 'Orientation') -> 'Orientation':
    """
    Multiply this orientation with another orientation.
    Returns a new Orientation representing the combined rotation.
    """
    if not isinstance(other, Orientation):
        raise TypeError("Can only multiply with another Orientation")
    return Orientation(safe_transform_vector(self.matrix, other.matrix))

invert

invert() -> Orientation

Return the inverse of this orientation. For rotation matrices, the inverse is the transpose.

Source code in kumiki/rule.py
def invert(self) -> 'Orientation':
    """
    Return the inverse of this orientation.
    For rotation matrices, the inverse is the transpose.
    """
    return Orientation(self.matrix.T)

flip

flip(flip_x: bool = False, flip_y: bool = False, flip_z: bool = False) -> Orientation

Return the orientation with the given axes flipped.

Source code in kumiki/rule.py
def flip(self, flip_x: bool = False, flip_y: bool = False, flip_z: bool = False) -> 'Orientation':
    """
    Return the orientation with the given axes flipped.
    """
    # Matrix is frozen, so mutate a raw numpy buffer here and wrap it
    # fresh at the end rather than assigning into an existing Matrix.
    arr = self.matrix._data.copy()
    if flip_x:
        arr[0, :] = -arr[0, :]
    if flip_y:
        arr[:, 0] = -arr[:, 0]
    if flip_z:
        arr[:, 2] = -arr[:, 2]
    return Orientation(Matrix._wrap(arr))

__mul__

__mul__(other: Orientation) -> Orientation

Allow using * operator for multiplication

Source code in kumiki/rule.py
def __mul__(self, other: 'Orientation') -> 'Orientation':
    """Allow using * operator for multiplication"""
    return self.multiply(other)

__repr__

__repr__() -> str
Source code in kumiki/rule.py
def __repr__(self) -> str:
    return f"Orientation(\n{self.matrix}\n)"

rotate_right classmethod

rotate_right() -> Orientation

Rotate right: +X axis rotates to -Y axis (clockwise around Z)

Source code in kumiki/rule.py
@classmethod
def rotate_right(cls) -> 'Orientation':
    """Rotate right: +X axis rotates to -Y axis (clockwise around Z)"""
    matrix = Matrix([
        [0, 1, 0],
        [-1, 0, 0],
        [0, 0, 1]
    ])
    return cls(matrix)

rotate_left classmethod

rotate_left() -> Orientation

Rotate left: +X axis rotates to +Y axis (counterclockwise around Z)

Source code in kumiki/rule.py
@classmethod
def rotate_left(cls) -> 'Orientation':
    """Rotate left: +X axis rotates to +Y axis (counterclockwise around Z)"""
    matrix = Matrix([
        [0, -1, 0],
        [1, 0, 0],
        [0, 0, 1]
    ])
    return cls(matrix)

from_angle_axis classmethod

from_angle_axis(radians: Numeric, axis: Direction3D) -> Orientation

Create an orientation from an angle-axis rotation (Rodrigues' formula).

Source code in kumiki/rule.py
@classmethod
def from_angle_axis(cls, radians: Numeric, axis: Direction3D) -> 'Orientation':
    """Create an orientation from an angle-axis rotation (Rodrigues' formula)."""
    k = safe_normalize_vector(axis)
    kx, ky, kz = k[0], k[1], k[2]
    K = Matrix([
        [scalar(0), -kz, ky],
        [kz, scalar(0), -kx],
        [-ky, kx, scalar(0)]
    ])
    R = eye(3) + sin(radians) * K + (scalar(1) - cos(radians)) * K * K
    return cls(R)

identity staticmethod

identity() -> Orientation

Identity orientation - facing east (+X)

Source code in kumiki/rule.py
@staticmethod
def identity() -> 'Orientation':
    """Identity orientation - facing east (+X)"""
    return Orientation()

from_z_and_y staticmethod

from_z_and_y(z_direction: Direction3D, y_direction: Direction3D) -> Orientation

Create an Orientation from z and y direction vectors. Computes x = y × z to complete the right-handed coordinate system.

Source code in kumiki/rule.py
@staticmethod
def from_z_and_y(z_direction: Direction3D, y_direction: Direction3D) -> 'Orientation':
    """
    Create an Orientation from z and y direction vectors.
    Computes x = y × z to complete the right-handed coordinate system.
    """
    x_direction = cross_product(y_direction, z_direction)
    return Orientation(Matrix([
        [x_direction[0], y_direction[0], z_direction[0]],
        [x_direction[1], y_direction[1], z_direction[1]],
        [x_direction[2], y_direction[2], z_direction[2]]
    ]))

from_z_and_x staticmethod

from_z_and_x(z_direction: Direction3D, x_direction: Direction3D) -> Orientation

Create an Orientation from z and x direction vectors. Computes y = z × x to complete the right-handed coordinate system.

Source code in kumiki/rule.py
@staticmethod
def from_z_and_x(z_direction: Direction3D, x_direction: Direction3D) -> 'Orientation':
    """
    Create an Orientation from z and x direction vectors.
    Computes y = z × x to complete the right-handed coordinate system.
    """
    y_direction = cross_product(z_direction, x_direction)
    return Orientation(Matrix([
        [x_direction[0], y_direction[0], z_direction[0]],
        [x_direction[1], y_direction[1], z_direction[1]],
        [x_direction[2], y_direction[2], z_direction[2]]
    ]))

from_x_and_y staticmethod

from_x_and_y(x_direction: Direction3D, y_direction: Direction3D) -> Orientation

Create an Orientation from x and y direction vectors. Computes z = x × y to complete the right-handed coordinate system.

Source code in kumiki/rule.py
@staticmethod
def from_x_and_y(x_direction: Direction3D, y_direction: Direction3D) -> 'Orientation':
    """
    Create an Orientation from x and y direction vectors.
    Computes z = x × y to complete the right-handed coordinate system.
    """
    z_direction = cross_product(x_direction, y_direction)
    return Orientation(Matrix([
        [x_direction[0], y_direction[0], z_direction[0]],
        [x_direction[1], y_direction[1], z_direction[1]],
        [x_direction[2], y_direction[2], z_direction[2]]
    ]))

from_axis_angle staticmethod

from_axis_angle(axis: Direction3D, radians: Numeric) -> Orientation

Create an Orientation representing a rotation around an axis by an angle. Uses Rodrigues' rotation formula.

Parameters:

Name Type Description Default
axis Direction3D

Direction vector to rotate around (will be normalized)

required
radians Numeric

Angle to rotate in radians

required

Returns:

Type Description
Orientation

Orientation object representing the rotation

Source code in kumiki/rule.py
@staticmethod
def from_axis_angle(axis: Direction3D, radians: Numeric) -> 'Orientation':
    """
    Create an Orientation representing a rotation around an axis by an angle.
    Uses Rodrigues' rotation formula.

    Args:
        axis: Direction vector to rotate around (will be normalized)
        radians: Angle to rotate in radians

    Returns:
        Orientation object representing the rotation
    """
    # Normalize the axis
    axis_normalized = safe_normalize_vector(axis)
    kx, ky, kz = axis_normalized[0], axis_normalized[1], axis_normalized[2]

    # Rodrigues' rotation formula: R = I + sin(θ)K + (1 - cos(θ))K²
    # where K is the skew-symmetric cross-product matrix of k
    K = Matrix([
        [scalar(0), -kz, ky],
        [kz, scalar(0), -kx],
        [-ky, kx, scalar(0)]
    ])
    K_squared = K * K
    I = Matrix.eye(3)
    rotation_matrix = I + sin(radians) * K + (scalar(1) - cos(radians)) * K_squared

    return Orientation(rotation_matrix)

from_euleryZYX staticmethod

from_euleryZYX(yaw: Numeric, pitch: Numeric, roll: Numeric) -> Orientation

Create an Orientation from Euler angles using ZYX rotation sequence.

Parameters:

Name Type Description Default
yaw Numeric

Rotation around Z-axis (radians)

required
pitch Numeric

Rotation around Y-axis (radians)

required
roll Numeric

Rotation around X-axis (radians)

required

Returns:

Type Description
Orientation

Orientation object with combined rotation matrix

The rotation sequence is: 1. Yaw (Z-axis rotation) 2. Pitch (Y-axis rotation) 3. Roll (X-axis rotation)

Source code in kumiki/rule.py
@staticmethod
def from_euleryZYX(yaw: Numeric, pitch: Numeric, roll: Numeric) -> 'Orientation':
    """
    Create an Orientation from Euler angles using ZYX rotation sequence.

    Args:
        yaw: Rotation around Z-axis (radians)
        pitch: Rotation around Y-axis (radians)
        roll: Rotation around X-axis (radians)

    Returns:
        Orientation object with combined rotation matrix

    The rotation sequence is:
    1. Yaw (Z-axis rotation)
    2. Pitch (Y-axis rotation)
    3. Roll (X-axis rotation)
    """
    # Individual rotation matrices
    Rz = Matrix([
        [cos(yaw), -sin(yaw), 0],
        [sin(yaw), cos(yaw), 0],
        [0, 0, 1]
    ])

    Ry = Matrix([
        [cos(pitch), 0, sin(pitch)],
        [0, 1, 0],
        [-sin(pitch), 0, cos(pitch)]
    ])

    Rx = Matrix([
        [1, 0, 0],
        [0, cos(roll), -sin(roll)],
        [0, sin(roll), cos(roll)]
    ])

    # Combined rotation: R = Rz * Ry * Rx
    combined_matrix = Rz * Ry * Rx
    return Orientation(combined_matrix)

facing_west staticmethod

facing_west() -> Orientation

Horizontal timber with top face up. This is the IDENTITY orientation.

  • Length: +X (local) = -X (west) in global
  • Width: +Y (local) = -Y (south) in global
  • Facing: +Z (up)
Source code in kumiki/rule.py
@staticmethod
def facing_west() -> 'Orientation':
    """
    Horizontal timber with top face up.
    This is the IDENTITY orientation.

    - Length: +X (local) = -X (west) in global
    - Width: +Y (local) = -Y (south) in global
    - Facing: +Z (up)
    """
    return Orientation()  # Identity matrix

facing_east staticmethod

facing_east() -> Orientation

Horizontal timber with top face up. 180° rotation around Z axis from facing_west.

  • Length: +X (local) = +X (east) in global
  • Width: +Y (local) = +Y (north) in global
  • Facing: +Z (up)
Source code in kumiki/rule.py
@staticmethod
def facing_east() -> 'Orientation':
    """
    Horizontal timber with top face up.
    180° rotation around Z axis from facing_west.

    - Length: +X (local) = +X (east) in global
    - Width: +Y (local) = +Y (north) in global
    - Facing: +Z (up)
    """
    matrix = Matrix([
        [-1, 0, 0],
        [0, -1, 0],
        [0, 0, 1]
    ])
    return Orientation(matrix)

facing_north staticmethod

facing_north() -> Orientation

Horizontal timber with top face up. 90° counterclockwise rotation around Z axis from facing_west.

  • Length: +X (local) = +Y (north) in global
  • Width: +Y (local) = -X (west) in global
  • Facing: +Z (up)
Source code in kumiki/rule.py
@staticmethod
def facing_north() -> 'Orientation':
    """
    Horizontal timber with top face up.
    90° counterclockwise rotation around Z axis from facing_west.

    - Length: +X (local) = +Y (north) in global
    - Width: +Y (local) = -X (west) in global
    - Facing: +Z (up)
    """
    matrix = Matrix([
        [0, -1, 0],
        [1, 0, 0],
        [0, 0, 1]
    ])
    return Orientation(matrix)

facing_south staticmethod

facing_south() -> Orientation

Horizontal timber with top face up. 90° clockwise rotation around Z axis from facing_west.

  • Length: +X (local) = -Y (south) in global
  • Width: +Y (local) = +X (east) in global
  • Facing: +Z (up)
Source code in kumiki/rule.py
@staticmethod
def facing_south() -> 'Orientation':
    """
    Horizontal timber with top face up.
    90° clockwise rotation around Z axis from facing_west.

    - Length: +X (local) = -Y (south) in global
    - Width: +Y (local) = +X (east) in global
    - Facing: +Z (up)
    """
    matrix = Matrix([
        [0, 1, 0],
        [-1, 0, 0],
        [0, 0, 1]
    ])
    return Orientation(matrix)

pointing_up staticmethod

pointing_up() -> Orientation

Vertical timber with LENGTH pointing upward (+Z). This is the same as pointing_forward.

  • Length (local +X) → +Z (up) in global
  • Width (local +Y) → +Y (north) in global
  • Facing (local +Z) → -X (west) in global
Source code in kumiki/rule.py
@staticmethod
def pointing_up() -> 'Orientation':
    """
    Vertical timber with LENGTH pointing upward (+Z).
    This is the same as pointing_forward.

    - Length (local +X) → +Z (up) in global
    - Width (local +Y) → +Y (north) in global
    - Facing (local +Z) → -X (west) in global
    """
    matrix = Matrix([
        [0, 0, -1],
        [0, 1, 0],
        [1, 0, 0]
    ])
    return Orientation(matrix)

pointing_down staticmethod

pointing_down() -> Orientation

Vertical timber with LENGTH pointing downward (-Z).

  • Length (local +X) → -Z (down) in global
  • Width (local +Y) → +Y (north) in global
  • Facing (local +Z) → +X (east) in global
Source code in kumiki/rule.py
@staticmethod
def pointing_down() -> 'Orientation':
    """
    Vertical timber with LENGTH pointing downward (-Z).

    - Length (local +X) → -Z (down) in global
    - Width (local +Y) → +Y (north) in global
    - Facing (local +Z) → +X (east) in global
    """
    matrix = Matrix([
        [0, 0, 1],
        [0, 1, 0],
        [-1, 0, 0]
    ])
    return Orientation(matrix)

pointing_forward staticmethod

pointing_forward() -> Orientation

Vertical timber with LENGTH pointing upward (+Z). Identical to pointing_up.

  • Length (local +X) → +Z (up) in global
  • Width (local +Y) → +Y (north) in global
  • Facing (local +Z) → -X (west) in global
Source code in kumiki/rule.py
@staticmethod
def pointing_forward() -> 'Orientation':
    """
    Vertical timber with LENGTH pointing upward (+Z).
    Identical to pointing_up.

    - Length (local +X) → +Z (up) in global
    - Width (local +Y) → +Y (north) in global
    - Facing (local +Z) → -X (west) in global
    """
    matrix = Matrix([
        [0, 0, -1],
        [0, 1, 0],
        [1, 0, 0]
    ])
    return Orientation(matrix)

pointing_backward staticmethod

pointing_backward() -> Orientation

Vertical timber with LENGTH pointing upward (+Z), rotated 180° from pointing_forward.

  • Length (local +X) → +Z (up) in global
  • Width (local +Y) → -Y (south) in global
  • Facing (local +Z) → +X (east) in global
Source code in kumiki/rule.py
@staticmethod
def pointing_backward() -> 'Orientation':
    """
    Vertical timber with LENGTH pointing upward (+Z), rotated 180° from pointing_forward.

    - Length (local +X) → +Z (up) in global
    - Width (local +Y) → -Y (south) in global
    - Facing (local +Z) → +X (east) in global
    """
    matrix = Matrix([
        [0, 0, 1],
        [0, -1, 0],
        [1, 0, 0]
    ])
    return Orientation(matrix)

pointing_left staticmethod

pointing_left() -> Orientation

Vertical timber with LENGTH pointing upward (+Z), rotated 90° CCW from pointing_forward.

  • Length (local +X) → +Z (up) in global
  • Width (local +Y) → -X (west) in global
  • Facing (local +Z) → -Y (south) in global
Source code in kumiki/rule.py
@staticmethod
def pointing_left() -> 'Orientation':
    """
    Vertical timber with LENGTH pointing upward (+Z), rotated 90° CCW from pointing_forward.

    - Length (local +X) → +Z (up) in global
    - Width (local +Y) → -X (west) in global
    - Facing (local +Z) → -Y (south) in global
    """
    matrix = Matrix([
        [0, -1, 0],
        [0, 0, -1],
        [1, 0, 0]
    ])
    return Orientation(matrix)

pointing_right staticmethod

pointing_right() -> Orientation

Vertical timber with LENGTH pointing upward (+Z), rotated 90° CW from pointing_forward.

  • Length (local +X) → +Z (up) in global
  • Width (local +Y) → +X (east) in global
  • Facing (local +Z) → +Y (north) in global
Source code in kumiki/rule.py
@staticmethod
def pointing_right() -> 'Orientation':
    """
    Vertical timber with LENGTH pointing upward (+Z), rotated 90° CW from pointing_forward.

    - Length (local +X) → +Z (up) in global
    - Width (local +Y) → +X (east) in global
    - Facing (local +Z) → +Y (north) in global
    """
    matrix = Matrix([
        [0, 1, 0],
        [0, 0, 1],
        [1, 0, 0]
    ])
    return Orientation(matrix)

Plane dataclass

Plane(normal: Direction3D, point: V3)

Represents an oriented, infinite plane with origin in 3D space.

normal instance-attribute

normal: Direction3D

point instance-attribute

point: V3

__repr__

__repr__() -> str
Source code in kumiki/geometry.py
def __repr__(self) -> str:
    return f"Plane(normal={self.normal}, point={self.point})"

from_transform_and_direction staticmethod

from_transform_and_direction(transform: Transform, direction: Direction3D) -> Plane

Create a plane from a transform and a direction.

Parameters:

Name Type Description Default
transform Transform

Transform defining the position and orientation

required
direction Direction3D

Direction in the transform's local coordinate system

required

Returns:

Type Description
Plane

Plane with normal in global coordinates and point at transform position

Source code in kumiki/geometry.py
@staticmethod
def from_transform_and_direction(transform: Transform, direction: Direction3D) -> 'Plane':
    """
    Create a plane from a transform and a direction.

    Args:
        transform: Transform defining the position and orientation
        direction: Direction in the transform's local coordinate system

    Returns:
        Plane with normal in global coordinates and point at transform position
    """
    return Plane(safe_transform_vector(transform.orientation.matrix, direction), transform.position)

CutCSGLabel dataclass

The name a CSG node carries, if anyone gave it one.

A wrapper rather than a bare Optional[str] so that what a label carries can grow -- provenance, namespacing, whatever naming turns out to need -- without revisiting every node that constructs one.

An unnamed node gets NoLabel() rather than None, so csg.label is always a CutCSGLabel and reading it never needs a None check first. Test for a name with the label's truthiness or is_labeled(); read it with .name.

name class-attribute instance-attribute

name: Optional[str] = None

NoLabel staticmethod

NoLabel() -> CutCSGLabel

The label of a node nobody named. The default for CutCSG.label.

Source code in kumiki/cutcsg.py
@staticmethod
def NoLabel() -> 'CutCSGLabel':
    """The label of a node nobody named. The default for CutCSG.label."""
    return CutCSGLabel()

is_labeled

is_labeled() -> bool

True if this node was given a name.

Source code in kumiki/cutcsg.py
def is_labeled(self) -> bool:
    """True if this node was given a name."""
    return self.name is not None

__bool__

__bool__() -> bool
Source code in kumiki/cutcsg.py
def __bool__(self) -> bool:
    return self.is_labeled()

__repr__

__repr__() -> str
Source code in kumiki/cutcsg.py
def __repr__(self) -> str:
    return f"CutCSGLabel({self.name!r})" if self.name is not None else "NoLabel"

CutCSG dataclass

Bases: ABC

Base class for all CSG operations.

label class-attribute instance-attribute

label: CutCSGLabel = field(default_factory=CutCSGLabel.NoLabel, kw_only=True)

__repr__ abstractmethod

__repr__() -> str

String representation for debugging.

Source code in kumiki/cutcsg.py
@abstractmethod
def __repr__(self) -> str:
    """String representation for debugging."""
    pass

display_name classmethod

display_name() -> str

What this kind of CSG is called where a person reads it.

Derived from the class name -- "path extrusion" -- so a new CSG type names itself; subclasses override where a shorter word is the one people actually use ("union", not "solid union").

Distinct from the class name, which stays the machine-readable kind: the viewer keys structural decisions off that and must not follow wording changes.

Source code in kumiki/cutcsg.py
@classmethod
def display_name(cls) -> str:
    """What this kind of CSG is called where a person reads it.

    Derived from the class name -- "path extrusion" -- so a new CSG type
    names itself; subclasses override where a shorter word is the one
    people actually use ("union", not "solid union").

    Distinct from the class name, which stays the machine-readable kind:
    the viewer keys structural decisions off that and must not follow
    wording changes.
    """
    return re.sub(r"(?<!^)(?=[A-Z])", " ", cls.__name__).lower()

get_declared_features

get_declared_features(source: FeatureSource = BOTH) -> List[CSGFeature]

Features this node names on its own boundary, whether or not any point lies on them.

Empty by default, and it stays empty for the compound nodes: a SolidUnion, Difference or Intersection has no surface of its own to name, only the surfaces its children contribute. The primitives that do have a boundary get this from HasFeatures instead.

Source code in kumiki/cutcsg.py
def get_declared_features(
    self, source: 'FeatureSource' = FeatureSource.BOTH,
) -> List[CSGFeature]:
    """Features this node names on its own boundary, whether or not any
    point lies on them.

    Empty by default, and it stays empty for the compound nodes: a
    SolidUnion, Difference or Intersection has no surface of its own to
    name, only the surfaces its children contribute. The primitives that do
    have a boundary get this from HasFeatures instead.
    """
    return []

collect_feature_hits

collect_feature_hits(point: V3, tolerances: FeatureTestTolerances) -> List[OwnedFeatureHit]

Every declared feature in this subtree that point lies on.

Each feature is tested at the tolerance its own type calls for, right here -- a face at the face tolerance, a declared edge at the edge one. Compound nodes extend this over their children; they declare nothing themselves.

Real and non-real features are gated differently, which is the whole reason real exists:

  • A real feature names actual surface, so the point has to be on the boundary of the primitive declaring it. That gate is a surface question, hence the face tolerance whatever the feature's own type.
  • A non-real feature (a bore's centre axis, a reference plane) names nothing the CSG tree ever cut, so boolean operations cannot have removed it and the gate does not apply.

Takes a concrete FeatureTestTolerances, not an optional one: the defaulting happens once, at the public entry point, so nothing on the recursive path can quietly re-default.

Source code in kumiki/cutcsg.py
def collect_feature_hits(
    self,
    point: V3,
    tolerances: FeatureTestTolerances,
) -> List['OwnedFeatureHit']:
    """Every declared feature in this subtree that *point* lies on.

    Each feature is tested at the tolerance its own type calls for, right
    here -- a face at the face tolerance, a declared edge at the edge one.
    Compound nodes extend this over their children; they declare nothing
    themselves.

    Real and non-real features are gated differently, which is the whole
    reason `real` exists:

    - A real feature names actual surface, so the point has to be on the
      boundary of the primitive declaring it. That gate is a surface
      question, hence the face tolerance whatever the feature's own type.
    - A non-real feature (a bore's centre axis, a reference plane) names
      nothing the CSG tree ever cut, so boolean operations cannot have
      removed it and the gate does not apply.

    Takes a concrete FeatureTestTolerances, not an optional one: the
    defaulting happens once, at the public entry point, so nothing on the
    recursive path can quietly re-default.
    """
    declared = self.get_declared_features()
    if not declared:
        return []
    on_boundary: Optional[bool] = None  # computed at most once, only if needed
    hits: List['OwnedFeatureHit'] = []
    for feature in declared:
        if not feature.test_point_unbounded(
            self, point, tolerances.for_type(feature.feature_type())
        ):
            continue
        if feature.real:
            if on_boundary is None:
                on_boundary = self.is_point_on_boundary(point, eps=tolerances.face)
            if not on_boundary:
                continue
        hits.append(OwnedFeatureHit(feature=feature, owner=self))
    return hits

find_all_features

find_all_features(point: V3, test_tolerances: Optional[FeatureTestTolerances] = None) -> List[OwnedFeatureHit]

Every feature at point: those declared in this subtree, plus the edges they form with each other.

Two gathers, because "near enough to count" means a different distance depending on what is being asked. The first collects features at the tolerance each one's type calls for. The second collects faces at the EDGE tolerance and pairs them, which is what makes an edge selectable from further away than either of its faces -- a face 1.5mm off cannot claim the point itself, but it can still form an edge that is selectable there, because you cannot click exactly on a line.

Derivation happens here rather than inside collect_feature_hits, and so runs once, at whichever node the caller asked about. Putting it in the recursive gather would either recurse into itself or have every nested compound re-derive what its parent derives.

Source code in kumiki/cutcsg.py
def find_all_features(
    self,
    point: V3,
    test_tolerances: Optional[FeatureTestTolerances] = None,
) -> List['OwnedFeatureHit']:
    """Every feature at *point*: those declared in this subtree, plus the
    edges they form with each other.

    Two gathers, because "near enough to count" means a different distance
    depending on what is being asked. The first collects features at the
    tolerance each one's type calls for. The second collects faces at the
    EDGE tolerance and pairs them, which is what makes an edge selectable
    from further away than either of its faces -- a face 1.5mm off cannot
    claim the point itself, but it can still form an edge that is
    selectable there, because you cannot click exactly on a line.

    Derivation happens here rather than inside collect_feature_hits, and so runs
    once, at whichever node the caller asked about. Putting it in the
    recursive gather would either recurse into itself or have every nested
    compound re-derive what its parent derives.
    """
    tolerances = DEFAULT_FEATURE_TEST_TOLERANCES if test_tolerances is None else test_tolerances
    hits = self.collect_feature_hits(point, tolerances)
    at_edge_tolerance = self.collect_feature_hits(
        point, FeatureTestTolerances.uniform(tolerances.edge))
    faces = [
        hit for hit in at_edge_tolerance
        if hit.feature.feature_type() == CSGFeatureType.FACE
    ]
    return _sort_feature_hits(hits + derive_edge_hits(self, faces))

find_first_feature

find_first_feature(point: V3, test_tolerances: Optional[FeatureTestTolerances] = None) -> Optional[OwnedFeatureHit]

The best feature at point, or None. Uses default sorting rules.

Non-real features win outright over real ones. They are lines and points inside or alongside the solid, so anything selecting one has deliberately snapped to it, and a surface it happens to sit on should not steal the click. Priority breaks ties within each of the two.

Source code in kumiki/cutcsg.py
def find_first_feature(
    self,
    point: V3,
    test_tolerances: Optional[FeatureTestTolerances] = None,
) -> Optional['OwnedFeatureHit']:
    """The best feature at *point*, or None. Uses default sorting rules.

    Non-real features win outright over real ones. They are lines and
    points inside or alongside the solid, so anything selecting one has
    deliberately snapped to it, and a surface it happens to sit on should
    not steal the click. Priority breaks ties within each of the two.
    """
    hits = self.find_all_features(point, test_tolerances=test_tolerances)
    if not hits:
        return None
    return _sort_feature_hits(hits)[0]

contains_point abstractmethod

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is contained within the CSG object.

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is inside or on the boundary of the CSG object, False otherwise

Source code in kumiki/cutcsg.py
@abstractmethod
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is contained within the CSG object.

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is inside or on the boundary of the CSG object, False otherwise
    """
    pass

is_point_on_boundary abstractmethod

is_point_on_boundary(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is on the boundary of the CSG object.

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is on the boundary of the CSG object, False otherwise

Source code in kumiki/cutcsg.py
@abstractmethod
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is on the boundary of the CSG object.

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is on the boundary of the CSG object, False otherwise
    """
    pass

get_outward_normal abstractmethod

get_outward_normal(point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]

Get the outward normal vector at a boundary point.

This method should only be called if is_point_on_boundary(point) is True. For points not on the boundary, behavior is undefined.

Parameters:

Name Type Description Default
point V3

A point on the boundary (3x1 Matrix)

required

Returns:

Type Description
Optional[Direction3D]

The outward normal vector at the point, or None if cannot be determined

Source code in kumiki/cutcsg.py
@abstractmethod
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    """
    Get the outward normal vector at a boundary point.

    This method should only be called if is_point_on_boundary(point) is True.
    For points not on the boundary, behavior is undefined.

    Args:
        point: A point on the boundary (3x1 Matrix)

    Returns:
        The outward normal vector at the point, or None if cannot be determined
    """
    pass

get_aabb abstractmethod

get_aabb() -> BoundingBox

Return the axis-aligned bounding box (AABB) of this CSG object.

Each bound is Optional[Numeric] — None means unbounded in that direction.

Primitives with infinite extent (HalfSpace, or prisms/cylinders with start_distance or end_distance set to None) cannot produce a finite AABB. They emit a UserWarning and return a BoundingBox with all fields set to None.

Source code in kumiki/cutcsg.py
@abstractmethod
def get_aabb(self) -> 'BoundingBox':
    """
    Return the axis-aligned bounding box (AABB) of this CSG object.

    Each bound is Optional[Numeric] — None means unbounded in that direction.

    Primitives with infinite extent (HalfSpace, or prisms/cylinders with
    start_distance or end_distance set to None) cannot produce a finite AABB.
    They emit a UserWarning and return a BoundingBox with all fields set to None.
    """
    pass

EmptyCSG dataclass

Bases: CutCSG

Represents an empty solid (contains no points).

display_name classmethod

display_name() -> str
Source code in kumiki/cutcsg.py
@classmethod
def display_name(cls) -> str:
    return "empty"

__repr__

__repr__() -> str
Source code in kumiki/cutcsg.py
def __repr__(self) -> str:
    return "EmptyCSG()"

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool
Source code in kumiki/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    return False

is_point_on_boundary

is_point_on_boundary(point: V3, eps: Optional[Numeric] = None) -> bool
Source code in kumiki/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    return False

get_outward_normal

get_outward_normal(point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]
Source code in kumiki/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    return None

get_aabb

get_aabb() -> BoundingBox
Source code in kumiki/cutcsg.py
def get_aabb(self) -> 'BoundingBox':
    return BoundingBox(
        min_x=0,
        min_y=0,
        min_z=0,
        max_x=0,
        max_y=0,
        max_z=0,
        is_empty=True,
    )

HalfSpace dataclass

Bases: HasFeatures, CutCSG

An infinite half-plane defined by a normal vector and offset from origin.

The half-plane includes all points P such that: P · normal >= offset
The offset represents the signed distance from the origin along the normal direction where the plane is located. Positive offset moves the plane in the direction of the normal.

Parameters:

Name Type Description Default
normal

Normal vector pointing into the half-space (3x1 Matrix)

required
offset

Distance from origin along normal direction where plane is located (default: 0)

required

normal instance-attribute

normal: Direction3D

offset class-attribute instance-attribute

offset: Numeric = scalar(0)

default_features

default_features() -> Dict[FeatureKey, CSGFeature]

Its one surface. See RectangularPrism.default_features for the group.

Source code in kumiki/cutcsg.py
def default_features(self) -> Dict[FeatureKey, CSGFeature]:
    """Its one surface. See RectangularPrism.default_features for the group."""
    key = (FeatureCategory.SIDE, 0)
    return {key: HalfSpaceFeature(name=default_feature_name(key),
                                  properties=_DEFAULT_FEATURE_PROPERTIES)}

display_name classmethod

display_name() -> str
Source code in kumiki/cutcsg.py
@classmethod
def display_name(cls) -> str:
    return "half-space"

__repr__

__repr__() -> str
Source code in kumiki/cutcsg.py
def __repr__(self) -> str:
    return f"HalfSpace(normal={self.normal.T}, offset={self.offset})"

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is contained within the half-plane.

A point P is in the half-plane if (P · normal) >= offset

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is in the half-plane (including boundary), False otherwise

Source code in kumiki/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is contained within the half-plane.

    A point P is in the half-plane if (P · normal) >= offset

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is in the half-plane (including boundary), False otherwise
    """
    # Compute dot product: point · normal
    dot_product = safe_dot_product(point, self.normal)
    return safe_compare(dot_product, self.offset, Comparison.GE, eps=eps)

is_point_on_boundary

is_point_on_boundary(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is on the boundary of the half-plane.

A point P is on the boundary if (P · normal) == offset

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is on the boundary plane, False otherwise

Source code in kumiki/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is on the boundary of the half-plane.

    A point P is on the boundary if (P · normal) == offset

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is on the boundary plane, False otherwise
    """
    # Compute dot product: point · normal
    dot_product = safe_dot_product(point, self.normal)
    # Use safe_zero_test to handle Float vs Integer comparison with tolerance
    return safe_zero_test(dot_product - self.offset, eps=eps)

get_outward_normal

get_outward_normal(point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]

Get the outward normal vector at a boundary point.

For a HalfSpace, the outward normal is always the opposite of thenormal vector itself.

Parameters:

Name Type Description Default
point V3

A point on the boundary

required

Returns:

Type Description
Optional[Direction3D]

The outward normal vector (the HalfSpace's normal)

Source code in kumiki/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    """
    Get the outward normal vector at a boundary point.

    For a HalfSpace, the outward normal is always the opposite of thenormal vector itself.

    Args:
        point: A point on the boundary

    Returns:
        The outward normal vector (the HalfSpace's normal)
    """
    return -self.normal

get_aabb

get_aabb() -> BoundingBox
Source code in kumiki/cutcsg.py
def get_aabb(self) -> BoundingBox:
    warnings.warn(
        "get_aabb() called on HalfSpace, which has infinite extent — result is unbounded",
        UserWarning,
        stacklevel=2,
    )
    return BoundingBox(None, None, None, None, None, None)

RectangularPrism dataclass

Bases: HasFeatures, CutCSG

A prism with rectangular cross-section, optionally infinite in one or both ends. Note,they are parameterized similar to the Timber class which is atypical for such a primitive.

The prism is defined by: - A transform (position and orientation in global coordinates) - A cross-section size (width (x-axis)) x height (y-axis)) in the local XY plane - Start and end distances along the local Z-axis from the position

So the center point of the size cross section is at position and the timber extends out in -z by start_distance and +z by end_distance.

Use None for start_distance or end_distance to make the prism infinite in that direction.

The orientation matrix defines the local coordinate system where: - X-axis (first column) is the width direction (size[0]) - Y-axis (second column) is the height direction (size[1]) - Z-axis (third column) is the length/axis direction

Parameters:

Name Type Description Default
size

Cross-section dimensions [width, height] (2x1 Matrix)

required
transform

Transform (position and orientation) in global coordinates (default: identity)

required
start_distance

Distance from position along Z-axis to start of prism (None =

required
end_distance

Distance from position along Z-axis to end of prism (None = infinite)

required

size instance-attribute

size: V2

transform class-attribute instance-attribute

transform: Transform = field(default_factory=Transform.identity)

start_distance class-attribute instance-attribute

start_distance: Optional[Numeric] = None

end_distance class-attribute instance-attribute

end_distance: Optional[Numeric] = None

default_features

default_features() -> Dict[FeatureKey, CSGFeature]

Every face and arris a prism has, named without anyone asking.

ALL DEFAULTS ARE IN FeatureGroup.NONE, AND THIS IS LOAD BEARING. The group is what decides which features may pair to form a DERIVED edge, and derived edges are found by pairing every face near a query point with every other -- O(k^2) in k. Before defaults, a primitive nobody had named contributed k = 0. Putting these in a pairing group instead would set k to a dozen per primitive across the whole tree, and produce a mass of derived edges that are geometrically real and mean nothing.

A default is a thing you can SELECT and MEASURE TO, not a thing that combines. Anything wanting to combine is authored, with a group chosen on purpose -- which is what _ptw_face_tags and the joint code do.

Source code in kumiki/cutcsg.py
def default_features(self) -> Dict[FeatureKey, CSGFeature]:
    """Every face and arris a prism has, named without anyone asking.

    ALL DEFAULTS ARE IN FeatureGroup.NONE, AND THIS IS LOAD BEARING. The
    group is what decides which features may pair to form a DERIVED edge,
    and derived edges are found by pairing every face near a query point
    with every other -- O(k^2) in k. Before defaults, a primitive nobody
    had named contributed k = 0. Putting these in a pairing group instead
    would set k to a dozen per primitive across the whole tree, and produce
    a mass of derived edges that are geometrically real and mean nothing.

    A default is a thing you can SELECT and MEASURE TO, not a thing that
    combines. Anything wanting to combine is authored, with a group chosen
    on purpose -- which is what _ptw_face_tags and the joint code do.
    """
    features: Dict[FeatureKey, CSGFeature] = {}

    def named(key: FeatureKey, feature_for) -> None:
        features[key] = feature_for(default_feature_name(key))

    for face, key in _PRISM_CAP_KEYS.items():
        named(key, lambda name, face=face: SimpleRectangularPrismFeature(
            name=name, face=face, properties=_DEFAULT_FEATURE_PROPERTIES))
    for index, face in enumerate(_PRISM_SIDE_ORDER):
        named((FeatureCategory.SIDE, index),
              lambda name, face=face: SimpleRectangularPrismFeature(
                  name=name, face=face, properties=_DEFAULT_FEATURE_PROPERTIES))

    sides = len(_PRISM_SIDE_ORDER)
    for index in range(sides):
        pair = (_PRISM_SIDE_ORDER[index], _PRISM_SIDE_ORDER[(index + 1) % sides])
        named((FeatureCategory.ARRIS, index),
              lambda name, pair=pair: SimpleRectangularPrismEdgeFeature(
                  name=name, faces=pair, properties=_DEFAULT_FEATURE_PROPERTIES))
        for cap in (PrismFace.BOTTOM, PrismFace.TOP):
            ends = (cap, _PRISM_SIDE_ORDER[index])
            named(arris_against_cap(index, sides, end=cap is PrismFace.TOP),
                  lambda name, ends=ends: SimpleRectangularPrismEdgeFeature(
                      name=name, faces=ends, properties=_DEFAULT_FEATURE_PROPERTIES))
    return features

display_name classmethod

display_name() -> str
Source code in kumiki/cutcsg.py
@classmethod
def display_name(cls) -> str:
    return "prism"

get_bottom_position

get_bottom_position() -> V3

Get the position of the bottom of the prism (at start_distance). Only valid for prisms with finite start_distance.

Returns:

Type Description
V3

The 3D position at the bottom of the prism

Raises:

Type Description
ValueError

If start_distance is None (infinite prism)

Source code in kumiki/cutcsg.py
def get_bottom_position(self) -> V3:
    """
    Get the position of the bottom of the prism (at start_distance).
    Only valid for prisms with finite start_distance.

    Returns:
        The 3D position at the bottom of the prism

    Raises:
        ValueError: If start_distance is None (infinite prism)
    """
    if self.start_distance is None:
        raise ValueError("Cannot get bottom position of infinite prism (start_distance is None)")
    return self.transform.position - safe_transform_vector(self.transform.orientation.matrix, Matrix([scalar(0), scalar(0), self.start_distance]))

get_top_position

get_top_position() -> V3

Get the position of the top of the prism (at end_distance). Only valid for prisms with finite end_distance.

Returns:

Type Description
V3

The 3D position at the top of the prism

Raises:

Type Description
ValueError

If end_distance is None (infinite prism)

Source code in kumiki/cutcsg.py
def get_top_position(self) -> V3:
    """
    Get the position of the top of the prism (at end_distance).
    Only valid for prisms with finite end_distance.

    Returns:
        The 3D position at the top of the prism

    Raises:
        ValueError: If end_distance is None (infinite prism)
    """
    if self.end_distance is None:
        raise ValueError("Cannot get top position of infinite prism (end_distance is None)")
    return self.transform.position + safe_transform_vector(self.transform.orientation.matrix, Matrix([scalar(0), scalar(0), self.end_distance]))

__repr__

__repr__() -> str
Source code in kumiki/cutcsg.py
def __repr__(self) -> str:
    return (f"RectangularPrism(size={self.size.T}, transform={self.transform}, "
            f"start={self.start_distance}, end={self.end_distance})")

equals_prism

equals_prism(other: RectangularPrism) -> bool

Check if this prism equals another prism.

Uses SymPy's equals() method for numeric comparisons to handle symbolic values.

Parameters:

Name Type Description Default
other RectangularPrism

Another RectangularPrism to compare with

required

Returns:

Type Description
bool

True if all components are equal, False otherwise

Source code in kumiki/cutcsg.py
def equals_prism(self, other: 'RectangularPrism') -> bool:
    """
    Check if this prism equals another prism.

    Uses SymPy's equals() method for numeric comparisons to handle symbolic values.

    Args:
        other: Another RectangularPrism to compare with

    Returns:
        True if all components are equal, False otherwise
    """
    # Check size components
    if not safe_equality_test(self.size[0], other.size[0]) or not safe_equality_test(self.size[1], other.size[1]):
        return False

    # Check transform position
    if not (safe_equality_test(self.transform.position[0], other.transform.position[0]) and
            safe_equality_test(self.transform.position[1], other.transform.position[1]) and
            safe_equality_test(self.transform.position[2], other.transform.position[2])):
        return False

    # Check transform orientation matrix
    for i in range(3):
        for j in range(3):
            if not safe_equality_test(self.transform.orientation.matrix[i, j], other.transform.orientation.matrix[i, j]):
                return False

    # Check start_distance (handle None case)
    if self.start_distance is None and other.start_distance is None:
        pass  # Both None, equal
    elif self.start_distance is None or other.start_distance is None:
        return False  # One is None, other isn't
    elif not safe_compare(self.start_distance - other.start_distance, 0, Comparison.EQ):
        return False

    # Check end_distance (handle None case)
    if self.end_distance is None and other.end_distance is None:
        pass  # Both None, equal
    elif self.end_distance is None or other.end_distance is None:
        return False  # One is None, other isn't
    elif not safe_compare(self.end_distance - other.end_distance, 0, Comparison.EQ):
        return False

    return True

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is contained within the prism.

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is inside or on the boundary of the prism, False otherwise

Source code in kumiki/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is contained within the prism.

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is inside or on the boundary of the prism, False otherwise
    """
    x_coord, y_coord, z_coord = self._local_coords(point)

    # Check bounds in each dimension
    half_width = self.size[0] / 2
    half_height = self.size[1] / 2

    # Check width and height bounds
    if safe_compare(Abs(x_coord), half_width, Comparison.GT, eps=eps) or safe_compare(Abs(y_coord), half_height, Comparison.GT, eps=eps):
        return False

    # Check length bounds
    if self.start_distance is not None and safe_compare(z_coord, self.start_distance, Comparison.LT, eps=eps):
        return False
    if self.end_distance is not None and safe_compare(z_coord, self.end_distance, Comparison.GT, eps=eps):
        return False

    return True

is_point_on_boundary

is_point_on_boundary(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is on the boundary of the prism.

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is on the boundary of the prism, False otherwise

Source code in kumiki/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is on the boundary of the prism.

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is on the boundary of the prism, False otherwise
    """
    # First check if point is contained
    if not self.contains_point(point, eps=eps):
        return False

    x_coord, y_coord, z_coord = self._local_coords(point)

    # Check if on any face
    half_width = self.size[0] / 2
    half_height = self.size[1] / 2

    # On width faces
    if safe_equality_test(Abs(x_coord), half_width, eps=eps):
        return True

    # On height faces
    if safe_equality_test(Abs(y_coord), half_height, eps=eps):
        return True

    # On length faces (if finite)
    if self.start_distance is not None and safe_equality_test(z_coord, self.start_distance, eps=eps):
        return True
    if self.end_distance is not None and safe_equality_test(z_coord, self.end_distance, eps=eps):
        return True

    return False

get_outward_normal

get_outward_normal(point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]

Get the outward normal vector at a boundary point.

Returns the normalized outward normal for the face that contains this point. If the point is on multiple faces (edge or corner), returns one of the normals.

Parameters:

Name Type Description Default
point V3

A point on the boundary

required

Returns:

Type Description
Optional[Direction3D]

The outward normal vector at the point, or None if cannot be determined

Source code in kumiki/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    """
    Get the outward normal vector at a boundary point.

    Returns the normalized outward normal for the face that contains this point.
    If the point is on multiple faces (edge or corner), returns one of the normals.

    Args:
        point: A point on the boundary

    Returns:
        The outward normal vector at the point, or None if cannot be determined
    """
    x_coord, y_coord, z_coord = self._local_coords(point)
    width_dir, height_dir, length_dir = self._local_axes()

    half_width = self.size[0] / 2
    half_height = self.size[1] / 2

    # Check which face(s) the point is on
    # For edges/corners, we'll return one of the normals
    # Prioritize: length faces (top/bottom), then width faces, then height faces
    # This prioritization makes sense for typical CSG operations where end faces are often involved

    # TODO you should check if point is on edges and return averages instead

    # On length faces (top/bottom) - check these first
    if self.start_distance is not None and safe_equality_test(z_coord, self.start_distance, eps=eps):
        return -length_dir  # Bottom face, normal points in -length direction (outward)
    if self.end_distance is not None and safe_equality_test(z_coord, self.end_distance, eps=eps):
        return length_dir  # Top face, normal points in +length direction (outward)

    # On width faces (right/left)
    if safe_equality_test(Abs(x_coord), half_width, eps=eps):
        if safe_compare(x_coord, 0, Comparison.GT, eps=eps):
            return width_dir  # Right face, normal points in +width direction
        else:
            return -width_dir  # Left face, normal points in -width direction

    # On height faces (front/back)
    if safe_equality_test(Abs(y_coord), half_height, eps=eps):
        if safe_compare(y_coord, 0, Comparison.GT, eps=eps):
            return height_dir  # Front face, normal points in +height direction
        else:
            return -height_dir  # Back face, normal points in -height direction

    # Should not reach here if point is actually on boundary
    return None

get_aabb

get_aabb() -> BoundingBox
Source code in kumiki/cutcsg.py
def get_aabb(self) -> BoundingBox:
    if self.start_distance is None or self.end_distance is None:
        warnings.warn(
            "get_aabb() called on an infinite RectangularPrism — result is unbounded",
            UserWarning,
            stacklevel=2,
        )
        return BoundingBox(None, None, None, None, None, None)

    half_w = self.size[0] / scalar(2)
    half_h = self.size[1] / scalar(2)

    corners_global = [
        self.transform.local_to_global(Matrix([x_sign * half_w, y_sign * half_h, z]))
        for x_sign in (scalar(-1), scalar(1))
        for y_sign in (scalar(-1), scalar(1))
        for z in (self.start_distance, self.end_distance)
    ]

    xs = [p[0] for p in corners_global]
    ys = [p[1] for p in corners_global]
    zs = [p[2] for p in corners_global]
    return BoundingBox(
        _numeric_min(*xs), _numeric_min(*ys), _numeric_min(*zs),
        _numeric_max(*xs), _numeric_max(*ys), _numeric_max(*zs),
    )

SolidUnion dataclass

Bases: CutCSG

CSG union operation - combines multiple CSG objects.

The union represents the set of all points that are in ANY of the child CSG objects.

Parameters:

Name Type Description Default
children

List of CSG objects to union together

required

children instance-attribute

children: List[CutCSG]

display_name classmethod

display_name() -> str
Source code in kumiki/cutcsg.py
@classmethod
def display_name(cls) -> str:
    return "union"

__repr__

__repr__() -> str
Source code in kumiki/cutcsg.py
def __repr__(self) -> str:
    return f"SolidUnion({len(self.children)} children)"

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is contained within the union.

A point is in the union if it's in ANY of the children.

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is in any of the children, False otherwise

Source code in kumiki/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is contained within the union.

    A point is in the union if it's in ANY of the children.

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is in any of the children, False otherwise
    """
    return any(child.contains_point(point, eps=eps) for child in self.children)

is_point_on_boundary

is_point_on_boundary(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is on the boundary of the union.

A point is on the boundary if it's on the boundary of at least one child and not in the interior of any other child.

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is on the boundary of the union, False otherwise

Source code in kumiki/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is on the boundary of the union.

    A point is on the boundary if it's on the boundary of at least one child
    and not in the interior of any other child.

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is on the boundary of the union, False otherwise
    """
    # Point must be contained in the union
    if not self.contains_point(point, eps=eps):
        return False

    # Check if on boundary of any child and not strictly inside all others
    on_any_boundary = False
    for child in self.children:
        if child.contains_point(point, eps=eps):
            if child.is_point_on_boundary(point, eps=eps):
                on_any_boundary = True
            else:
                # Point is strictly inside this child, so not on union boundary
                return False

    return on_any_boundary

get_outward_normal

get_outward_normal(point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]

Get the outward normal vector at a boundary point.

For a union, we check all children that have the point on their boundary and return the average of their outward normals. The reason we do this is because this method is used to check if a point is on the boundary through Differences and using an average normal here tends to behave better on weird non-convex geometry.

Parameters:

Name Type Description Default
point V3

A point on the boundary

required

Returns:

Type Description
Optional[Direction3D]

The average outward normal vector, or None if cannot be determined

Source code in kumiki/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    """
    Get the outward normal vector at a boundary point.

    For a union, we check all children that have the point on their boundary
    and return the average of their outward normals. The reason we do this is because this method is used to check if a point is on the boundary through Differences and using an average normal here tends to behave better on weird non-convex geometry.

    Args:
        point: A point on the boundary

    Returns:
        The average outward normal vector, or None if cannot be determined
    """
    normals = []

    for child in self.children:
        if child.is_point_on_boundary(point, eps=eps):
            normal = child.get_outward_normal(point, eps=eps)
            if normal is not None:
                normals.append(normal)

    if len(normals) == 0:
        return None
    elif len(normals) == 1:
        return normals[0]
    else:
        # Average the normals
        avg_normal = normals[0]
        for n in normals[1:]:
            avg_normal = avg_normal + n
        # Normalize
        norm = safe_norm(avg_normal)
        if safe_zero_test(norm, eps=eps):
            return None
        return avg_normal / norm

collect_feature_hits

collect_feature_hits(point: V3, tolerances: FeatureTestTolerances) -> List[OwnedFeatureHit]
Source code in kumiki/cutcsg.py
def collect_feature_hits(self, point: V3, tolerances: FeatureTestTolerances) -> List['OwnedFeatureHit']:
    hits = super().collect_feature_hits(point, tolerances)
    for child in self.children:
        hits.extend(child.collect_feature_hits(point, tolerances))
    # A child's face can be buried inside a sibling, which is surface the
    # union does not have. is_point_on_boundary rejects exactly that case.
    return _drop_real_hits_off_boundary(self, hits, point, tolerances)

get_aabb

get_aabb() -> BoundingBox
Source code in kumiki/cutcsg.py
def get_aabb(self) -> BoundingBox:
    # Empty children contribute no points to the union, so they're excluded
    # before combining bounds — otherwise their degenerate zero-box would
    # incorrectly pull the union's bounds toward the origin.
    bboxes = [b for b in (child.get_aabb() for child in self.children) if not b.is_empty]
    if not bboxes:
        return BoundingBox(None, None, None, None, None, None, is_empty=True)

    def union_min(vals):
        if any(v is None for v in vals):
            return None
        return _numeric_min(*vals)

    def union_max(vals):
        if any(v is None for v in vals):
            return None
        return _numeric_max(*vals)

    return BoundingBox(
        union_min([b.min_x for b in bboxes]),
        union_min([b.min_y for b in bboxes]),
        union_min([b.min_z for b in bboxes]),
        union_max([b.max_x for b in bboxes]),
        union_max([b.max_y for b in bboxes]),
        union_max([b.max_z for b in bboxes]),
    )

Intersection dataclass

Bases: CutCSG

CSG intersection operation - keeps only points common to both child CSG objects.

Parameters:

Name Type Description Default
left

First CSG object

required
right

Second CSG object

required

left instance-attribute

left: CutCSG

right instance-attribute

right: CutCSG

__repr__

__repr__() -> str
Source code in kumiki/cutcsg.py
def __repr__(self) -> str:
    return f"Intersection(left={self.left}, right={self.right})"

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool
Source code in kumiki/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    return self.left.contains_point(point, eps=eps) and self.right.contains_point(point, eps=eps)

is_point_on_boundary

is_point_on_boundary(point: V3, eps: Optional[Numeric] = None) -> bool
Source code in kumiki/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    # Boundary of intersection = points in both solids that are on either boundary.
    if not self.contains_point(point, eps=eps):
        return False
    return self.left.is_point_on_boundary(point, eps=eps) or self.right.is_point_on_boundary(point, eps=eps)

get_outward_normal

get_outward_normal(point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]
Source code in kumiki/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    left_on_boundary = self.left.is_point_on_boundary(point, eps=eps)
    right_on_boundary = self.right.is_point_on_boundary(point, eps=eps)

    if left_on_boundary and not right_on_boundary:
        return self.left.get_outward_normal(point, eps=eps)
    if right_on_boundary and not left_on_boundary:
        return self.right.get_outward_normal(point, eps=eps)

    if left_on_boundary and right_on_boundary:
        left_normal = self.left.get_outward_normal(point, eps=eps)
        right_normal = self.right.get_outward_normal(point, eps=eps)
        if left_normal is None:
            return right_normal
        if right_normal is None:
            return left_normal
        avg_normal = left_normal + right_normal
        norm = safe_norm(avg_normal)
        if safe_zero_test(norm, eps=eps):
            return left_normal
        return avg_normal / norm

    return None

collect_feature_hits

collect_feature_hits(point: V3, tolerances: FeatureTestTolerances) -> List[OwnedFeatureHit]
Source code in kumiki/cutcsg.py
def collect_feature_hits(self, point: V3, tolerances: FeatureTestTolerances) -> List['OwnedFeatureHit']:
    hits = super().collect_feature_hits(point, tolerances)
    hits.extend(self.left.collect_feature_hits(point, tolerances))
    hits.extend(self.right.collect_feature_hits(point, tolerances))
    return _drop_real_hits_off_boundary(self, hits, point, tolerances)

get_aabb

get_aabb() -> BoundingBox
Source code in kumiki/cutcsg.py
def get_aabb(self) -> BoundingBox:
    left_bbox = self.left.get_aabb()
    right_bbox = self.right.get_aabb()

    # If either side is empty, their intersection has no points either.
    if left_bbox.is_empty or right_bbox.is_empty:
        return BoundingBox(None, None, None, None, None, None, is_empty=True)

    def intersect_min(a: Optional[Numeric], b: Optional[Numeric]) -> Optional[Numeric]:
        if a is None:
            return b
        if b is None:
            return a
        return _numeric_max(a, b)

    def intersect_max(a: Optional[Numeric], b: Optional[Numeric]) -> Optional[Numeric]:
        if a is None:
            return b
        if b is None:
            return a
        return _numeric_min(a, b)

    return BoundingBox(
        intersect_min(left_bbox.min_x, right_bbox.min_x),
        intersect_min(left_bbox.min_y, right_bbox.min_y),
        intersect_min(left_bbox.min_z, right_bbox.min_z),
        intersect_max(left_bbox.max_x, right_bbox.max_x),
        intersect_max(left_bbox.max_y, right_bbox.max_y),
        intersect_max(left_bbox.max_z, right_bbox.max_z),
    )

Difference dataclass

Bases: CutCSG

CSG difference operation - subtracts multiple CSG objects from a base object.

The difference represents: base - subtract[0] - subtract[1] - ... All points in base that are NOT in any of the subtract objects.

Parameters:

Name Type Description Default
base

The base CSG object to subtract from

required
subtract

List of CSG objects to subtract from the base

required

base instance-attribute

base: CutCSG

subtract instance-attribute

subtract: List[CutCSG]

__repr__

__repr__() -> str
Source code in kumiki/cutcsg.py
def __repr__(self) -> str:
    return f"Difference(base={self.base}, subtract={len(self.subtract)} objects)"

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is contained within the difference.

A point is in the difference if it's in the base and NOT strictly inside any subtract object. Special case: if a point is on the boundary of both base and subtract, it's excluded.

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is in base but not in any subtract objects, False otherwise

Source code in kumiki/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is contained within the difference.

    A point is in the difference if it's in the base and NOT strictly inside any subtract object.
    Special case: if a point is on the boundary of both base and subtract, it's excluded.

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is in base but not in any subtract objects, False otherwise
    """
    # Point must be in base
    if not self.base.contains_point(point, eps=eps):
        return False

    # Check if on base boundary
    on_base_boundary = self.base.is_point_on_boundary(point, eps=eps)

    # Point must not be strictly inside any subtract object
    # If point is on boundary of both base and subtract, check normals
    for sub in self.subtract:
        if sub.contains_point(point, eps=eps):
            if not sub.is_point_on_boundary(point, eps=eps):
                # Point is strictly inside a subtract object
                return False
            elif on_base_boundary:
                # Point is on boundary of both base and subtract
                # Check the outward normals
                base_normal = self.base.get_outward_normal(point, eps=eps)
                sub_normal = sub.get_outward_normal(point, eps=eps)

                if base_normal is not None and sub_normal is not None:
                    # Compute dot product of normals
                    dot_product = safe_dot_product(base_normal, sub_normal)

                    # If dot product == 1, surfaces overlap, exclude the point
                    # TODO what were really wanting to chec khere is that the surfaces are the same locally which may not be the case if the normal was on an edge with this condition. To fix this you should introduce an is_on_edge function HOWEVER this also won't work in the case of stuff like cylinders, so to fix that you probably really need a surface_derivative (curvature) function...
                    if safe_equality_test(dot_product, 1, eps=eps):
                        return False
                else:
                    # Cannot determine normals, use conservative approach: exclude
                    return False

    return True

is_point_on_boundary

is_point_on_boundary(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is on the boundary of the difference.

A point is on the boundary if: 1. It's contained in the difference (base - subtract), AND 2. Either: a. It's on the boundary of the base, OR b. It's strictly inside the base but on the boundary of at least one subtract object

Note: For case 2b, the point creates a new boundary surface (the "hole" surface). The point must be on the subtract boundary but NOT inside the subtract (i.e., on the surface of the hole facing the remaining material).

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is on the boundary of the difference, False otherwise

Source code in kumiki/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is on the boundary of the difference.

    A point is on the boundary if:
    1. It's contained in the difference (base - subtract), AND
    2. Either:
       a. It's on the boundary of the base, OR
       b. It's strictly inside the base but on the boundary of at least one subtract object

    Note: For case 2b, the point creates a new boundary surface (the "hole" surface).
    The point must be on the subtract boundary but NOT inside the subtract (i.e., on the
    surface of the hole facing the remaining material).

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is on the boundary of the difference, False otherwise
    """
    # Point must be contained in base
    if not self.base.contains_point(point, eps=eps):
        return False

    # Check if point is in any subtract region (strictly inside, not just boundary)
    in_subtract_interior = False
    on_subtract_boundary = False

    for sub in self.subtract:
        if sub.contains_point(point, eps=eps):
            if sub.is_point_on_boundary(point, eps=eps):
                on_subtract_boundary = True
            else:
                # Point is strictly inside a subtract object
                in_subtract_interior = True
                break

    # If point is strictly inside any subtract, it's not on the difference boundary
    if in_subtract_interior:
        return False

    # On a subtract's surface: the wall of the hole it made. That is this
    # solid's boundary only if the hole HAS a wall -- if there is material
    # of this difference on the near side of it. Two cases say otherwise:
    # a subtract that only touches the base, taking nothing, and a cut made
    # flush with the base's own surface, whose "wall" is the open mouth of
    # the cut with nothing behind it.
    if on_subtract_boundary:
        return self._material_outside_the_hole(point, eps=eps)

    # Otherwise, check if it's on the base boundary
    return self.base.is_point_on_boundary(point, eps=eps)

get_outward_normal

get_outward_normal(point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]

Get the outward normal vector at a boundary point.

For a difference, if the point is on the boundary of the base CSG, return that normal. Otherwise, go through the subtract CSGs and return the average of their normals (negated).

Parameters:

Name Type Description Default
point V3

A point on the boundary

required

Returns:

Type Description
Optional[Direction3D]

The outward normal vector, or None if cannot be determined

Source code in kumiki/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    """
    Get the outward normal vector at a boundary point.

    For a difference, if the point is on the boundary of the base CSG, return that normal.
    Otherwise, go through the subtract CSGs and return the average of their normals (negated).

    Args:
        point: A point on the boundary

    Returns:
        The outward normal vector, or None if cannot be determined
    """
    # If point is on base boundary, return base's normal
    if self.base.is_point_on_boundary(point, eps=eps):
        return self.base.get_outward_normal(point, eps=eps)

    # Otherwise, point must be on subtract boundary (creating a "hole")
    # The normal should point inward to the subtract (which is outward from the difference)
    # So we negate the subtract's outward normal
    normals = []
    for sub in self.subtract:
        if sub.is_point_on_boundary(point, eps=eps):
            normal = sub.get_outward_normal(point, eps=eps)
            if normal is not None:
                # Negate because we want the normal pointing into the remaining material
                normals.append(-normal)

    if len(normals) == 0:
        return None
    elif len(normals) == 1:
        return normals[0]
    else:
        # Average the normals
        avg_normal = normals[0]
        for n in normals[1:]:
            avg_normal = avg_normal + n
        # Normalize
        norm = safe_norm(avg_normal)
        if safe_zero_test(norm, eps=eps):
            return None
        return avg_normal / norm

collect_feature_hits

collect_feature_hits(point: V3, tolerances: FeatureTestTolerances) -> List[OwnedFeatureHit]
Source code in kumiki/cutcsg.py
def collect_feature_hits(self, point: V3, tolerances: FeatureTestTolerances) -> List['OwnedFeatureHit']:
    hits = super().collect_feature_hits(point, tolerances)
    hits.extend(self.base.collect_feature_hits(point, tolerances))
    for sub_csg in self.subtract:
        hits.extend(sub_csg.collect_feature_hits(point, tolerances))
    return _drop_real_hits_off_boundary(self, hits, point, tolerances)

get_aabb

get_aabb() -> BoundingBox
Source code in kumiki/cutcsg.py
def get_aabb(self) -> BoundingBox:
    bbox = self.base.get_aabb()
    if bbox.is_empty:
        return bbox
    for sub in self.subtract:
        if isinstance(sub, HalfSpace):
            bbox = _clip_bbox_by_halfspace_complement(bbox, sub)
    return bbox

ConvexPolygonSimpleLoft dataclass

Bases: HasFeatures, CutCSG

A solid formed by straight-line lofting between two convex polygons in parallel planes, connected index-to-index (vertex i of bottom_points connects by a straight line to vertex i of top_points). Generalizes ConvexPolygonExtrusion to the case where the cross-section changes shape/size/offset along the length instead of staying constant -- ConvexPolygonExtrusion is the degenerate case where bottom_points == top_points.

bottom_points and top_points must each independently be a valid convex polygon (same rules as ConvexPolygonExtrusion.is_valid()) with the SAME number of points wound in the SAME direction. Intermediate (lofted) cross-sections are NOT checked for convexity or simplicity -- if the correspondence between the two profiles is "twisted" enough (e.g. a profile rotated relative to the other), an intermediate cross-section can become non-convex or self-intersecting, which is undefined behavior for this primitive. This is safe for tapers/relief pockets where each vertex moves along a roughly-monotonic path (the common case for joinery), but this is NOT a general-purpose polygon morph.

Side faces are ruled surfaces and are only planar in the special case where the taper is a pure independent per-axis scale from one profile to the other (e.g. a rectangle-to-rectangle taper on the same axes); get_outward_normal accounts for this and is not necessarily constant across a side face.

The polygons live in the local XY plane, with bottom_points at start_distance and top_points at end_distance along the local Z-axis, matching the position/orientation conventions of RectangularPrism and ConvexPolygonExtrusion. Unlike those two, start_distance/end_distance must both be finite -- an infinite loft has no meaningful cross-section to loft towards.

Parameters:

Name Type Description Default
bottom_points

convex polygon at start_distance (local XY plane)

required
top_points

convex polygon at end_distance (local XY plane), same point count and winding direction as bottom_points

required
start_distance

distance from position along Z-axis to bottom_points

required
end_distance

distance from position along Z-axis to top_points

required
transform

Transform (position and orientation) in global coordinates (default: identity)

required

bottom_points instance-attribute

bottom_points: Profile

top_points instance-attribute

top_points: Profile

start_distance instance-attribute

start_distance: Numeric

end_distance instance-attribute

end_distance: Numeric

transform class-attribute instance-attribute

transform: Transform = field(default_factory=Transform.identity)

default_features

default_features() -> Dict[FeatureKey, CSGFeature]

Two caps and a side per edge of the profile, as an extrusion has.

Source code in kumiki/cutcsg.py
def default_features(self) -> Dict[FeatureKey, CSGFeature]:
    """Two caps and a side per edge of the profile, as an extrusion has."""
    features: Dict[FeatureKey, CSGFeature] = {}
    for key, cap in ((START_CAP, ExtrusionCap.BOTTOM), (END_CAP, ExtrusionCap.TOP)):
        features[key] = SimpleLoftFeature(
            name=default_feature_name(key), key=cap,
            properties=_DEFAULT_FEATURE_PROPERTIES)
    for index in range(len(self.bottom_points)):
        key = (FeatureCategory.SIDE, index)
        features[key] = SimpleLoftFeature(
            name=default_feature_name(key), key=index,
            properties=_DEFAULT_FEATURE_PROPERTIES)
    return features

display_name classmethod

display_name() -> str
Source code in kumiki/cutcsg.py
@classmethod
def display_name(cls) -> str:
    return "loft"

get_bottom_position

get_bottom_position() -> V3

Get the position of the bottom of the loft (at start_distance).

Source code in kumiki/cutcsg.py
def get_bottom_position(self) -> V3:
    """Get the position of the bottom of the loft (at start_distance)."""
    return self.transform.position - safe_transform_vector(self.transform.orientation.matrix, Matrix([scalar(0), scalar(0), self.start_distance]))

get_top_position

get_top_position() -> V3

Get the position of the top of the loft (at end_distance).

Source code in kumiki/cutcsg.py
def get_top_position(self) -> V3:
    """Get the position of the top of the loft (at end_distance)."""
    return self.transform.position + safe_transform_vector(self.transform.orientation.matrix, Matrix([scalar(0), scalar(0), self.end_distance]))

__repr__

__repr__() -> str
Source code in kumiki/cutcsg.py
def __repr__(self) -> str:
    return (f"ConvexPolygonSimpleLoft({len(self.bottom_points)}->{len(self.top_points)} points, "
            f"transform={self.transform}, start={self.start_distance}, end={self.end_distance})")

is_valid

is_valid() -> bool

Check if the ConvexPolygonSimpleLoft is valid.

Checks: 1. bottom_points and top_points each have at least 3 points 2. bottom_points and top_points have the same number of points 3. end_distance > start_distance 4. bottom_points and top_points are each individually convex

Does NOT check that intermediate (lofted) cross-sections stay convex or simple -- see class docstring.

Source code in kumiki/cutcsg.py
def is_valid(self) -> bool:
    """
    Check if the ConvexPolygonSimpleLoft is valid.

    Checks:
    1. bottom_points and top_points each have at least 3 points
    2. bottom_points and top_points have the same number of points
    3. end_distance > start_distance
    4. bottom_points and top_points are each individually convex

    Does NOT check that intermediate (lofted) cross-sections stay convex or
    simple -- see class docstring.
    """
    if len(self.bottom_points) < 3 or len(self.top_points) < 3:
        return False
    if len(self.bottom_points) != len(self.top_points):
        return False
    if safe_compare(self.end_distance, self.start_distance, Comparison.LE):
        return False

    def winding_sign(points: Profile) -> Optional[int]:
        """+1 for CCW-convex, -1 for CW-convex, None if not convex."""
        n = len(points)

        def cross_product_2d(i):
            p0, p1, p2 = points[i], points[(i + 1) % n], points[(i + 2) % n]
            edge1, edge2 = p1 - p0, p2 - p1
            return edge1[0] * edge2[1] - edge1[1] * edge2[0]

        cross_products = [cross_product_2d(i) for i in range(n)]
        non_zero_crosses = [cp for cp in cross_products if not safe_zero_test(cp)]
        if not non_zero_crosses:
            return None
        if all(safe_compare(cp, 0, Comparison.GT) for cp in non_zero_crosses):
            return 1
        if all(safe_compare(cp, 0, Comparison.LT) for cp in non_zero_crosses):
            return -1
        return None

    bottom_winding = winding_sign(self.bottom_points)
    top_winding = winding_sign(self.top_points)
    # Both must be individually convex AND wound the same direction -- the
    # index-to-index correspondence between bottom_points and top_points only
    # means what it's documented to mean (a straight-line loft) if they agree.
    return bottom_winding is not None and bottom_winding == top_winding

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is contained within the loft.

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is inside or on the boundary, False otherwise

Source code in kumiki/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is contained within the loft.

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is inside or on the boundary, False otherwise
    """
    x_coord, y_coord, z_coord = self._local_coords(point)

    if safe_compare(z_coord - self.start_distance, 0, Comparison.LT, eps=eps):
        return False
    if safe_compare(z_coord - self.end_distance, 0, Comparison.GT, eps=eps):
        return False

    cross_section = self._cross_section_at(self._height_fraction(z_coord))
    point_2d = Matrix([x_coord, y_coord])

    for i in range(len(cross_section)):
        p1 = cross_section[i]
        p2 = cross_section[(i + 1) % len(cross_section)]
        edge = p2 - p1
        to_point = point_2d - p1
        cross = edge[0] * to_point[1] - edge[1] * to_point[0]
        if safe_compare(cross, 0, Comparison.LT, eps=eps):
            return False

    return True

is_point_on_boundary

is_point_on_boundary(point: V3, eps: Optional[Numeric] = None) -> bool

Check if a point is on the boundary of the loft.

Parameters:

Name Type Description Default
point V3

Point to test (3x1 Matrix)

required

Returns:

Type Description
bool

True if the point is on the boundary, False otherwise

Source code in kumiki/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """
    Check if a point is on the boundary of the loft.

    Args:
        point: Point to test (3x1 Matrix)

    Returns:
        True if the point is on the boundary, False otherwise
    """
    if not self.contains_point(point, eps=eps):
        return False

    x_coord, y_coord, z_coord = self._local_coords(point)

    if safe_zero_test(z_coord - self.start_distance, eps=eps):
        return True
    if safe_zero_test(z_coord - self.end_distance, eps=eps):
        return True

    cross_section = self._cross_section_at(self._height_fraction(z_coord))
    point_2d = Matrix([x_coord, y_coord])

    # On a lofted vertex (the straight line connecting a bottom vertex to its
    # matching top vertex, evaluated at this height)
    for vertex_2d in cross_section:
        distance_sq = (point_2d[0] - vertex_2d[0]) ** 2 + (point_2d[1] - vertex_2d[1]) ** 2
        if safe_zero_test_sq(distance_sq, eps):
            return True

    # On a side face at this height
    for i in range(len(cross_section)):
        p1 = cross_section[i]
        p2 = cross_section[(i + 1) % len(cross_section)]
        edge = p2 - p1
        to_point = point_2d - p1

        edge_length_sq = edge[0] ** 2 + edge[1] ** 2
        # Degeneracy is a property of the polygon, not of how close the
        # caller clicked, so this takes no query tolerance.
        if safe_zero_test_sq(edge_length_sq):
            continue

        u = (to_point[0] * edge[0] + to_point[1] * edge[1]) / edge_length_sq
        u_in_range = safe_compare(u, 0, Comparison.GE, eps=eps) and safe_compare(u - scalar(1), 0, Comparison.LE, eps=eps)

        if u_in_range:
            closest_point = p1 + edge * u
            distance_sq = (point_2d[0] - closest_point[0]) ** 2 + (point_2d[1] - closest_point[1]) ** 2
            if safe_zero_test_sq(distance_sq, eps):
                return True

    return False

get_outward_normal

get_outward_normal(point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]

Get the outward normal vector at a boundary point.

For the top/bottom caps this is the (constant) local ±Z axis. For a side face, the face is in general a ruled (non-planar) surface, so the normal is computed from the face's parametric partial derivatives at this point rather than being constant across the face.

Parameters:

Name Type Description Default
point V3

A point on the boundary

required

Returns:

Type Description
Optional[Direction3D]

The outward normal vector at the point, or None if cannot be determined

Source code in kumiki/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    """
    Get the outward normal vector at a boundary point.

    For the top/bottom caps this is the (constant) local ±Z axis. For a side
    face, the face is in general a ruled (non-planar) surface, so the normal
    is computed from the face's parametric partial derivatives at this point
    rather than being constant across the face.

    Args:
        point: A point on the boundary

    Returns:
        The outward normal vector at the point, or None if cannot be determined
    """
    x_coord, y_coord, z_coord = self._local_coords(point)

    if safe_zero_test(z_coord - self.end_distance, eps=eps):
        local_normal = Matrix([scalar(0), scalar(0), scalar(1)])
        return safe_transform_vector(self.transform.orientation.matrix, local_normal)

    if safe_zero_test(z_coord - self.start_distance, eps=eps):
        local_normal = Matrix([scalar(0), scalar(0), scalar(-1)])
        return safe_transform_vector(self.transform.orientation.matrix, local_normal)

    t_height = self._height_fraction(z_coord)
    cross_section = self._cross_section_at(t_height)
    point_2d = Matrix([x_coord, y_coord])
    n = len(cross_section)

    for i in range(n):
        p1 = cross_section[i]
        p2 = cross_section[(i + 1) % n]
        edge = p2 - p1
        to_point = point_2d - p1

        edge_length_sq = edge[0] ** 2 + edge[1] ** 2
        # Degeneracy is a property of the polygon, not of how close the
        # caller clicked, so this takes no query tolerance.
        if safe_zero_test_sq(edge_length_sq):
            continue

        u = (to_point[0] * edge[0] + to_point[1] * edge[1]) / edge_length_sq
        if not (safe_compare(u, 0, Comparison.GE, eps=eps) and safe_compare(u, 1, Comparison.LE, eps=eps)):
            continue

        closest_point = p1 + edge * u
        distance_sq = (point_2d[0] - closest_point[0]) ** 2 + (point_2d[1] - closest_point[1]) ** 2
        if not safe_zero_test_sq(distance_sq, eps):
            continue

        # Point is on the side face spanning edge i. Parametrize the face by
        # (u, t): P(u, t) = lerp(bottom_i + u*(bottom_{i+1}-bottom_i),
        #                        top_i + u*(top_{i+1}-top_i), t)
        # and take dP/du x dP/dt as the (unnormalized, not-yet-oriented) normal.
        bottom_i, bottom_i1 = self.bottom_points[i], self.bottom_points[(i + 1) % n]
        top_i, top_i1 = self.top_points[i], self.top_points[(i + 1) % n]
        length = self.end_distance - self.start_distance

        d_edge = (scalar(1) - t_height) * (bottom_i1 - bottom_i) + t_height * (top_i1 - top_i)
        d_height_xy = (top_i - bottom_i) + u * ((top_i1 - top_i) - (bottom_i1 - bottom_i))

        d_edge_3d = Matrix([d_edge[0], d_edge[1], scalar(0)])
        d_height_3d = Matrix([d_height_xy[0], d_height_xy[1], length])
        local_normal = cross_product(d_edge_3d, d_height_3d)

        # Orient outward: flip if it doesn't point away from this height's
        # cross-section centroid (mirrors ConvexPolygonExtrusion's approach).
        center_x = sum(p[0] for p in cross_section) / n
        center_y = sum(p[1] for p in cross_section) / n
        to_edge = closest_point - Matrix([center_x, center_y])
        outward_dot = local_normal[0] * to_edge[0] + local_normal[1] * to_edge[1]
        if safe_compare(outward_dot, 0, Comparison.LT, eps=eps):
            local_normal = -local_normal

        return safe_normalize_vector(safe_transform_vector(self.transform.orientation.matrix, local_normal))

    return None

get_aabb

get_aabb() -> BoundingBox
Source code in kumiki/cutcsg.py
def get_aabb(self) -> BoundingBox:
    corners_global = (
        [self.transform.local_to_global(Matrix([pt[0], pt[1], self.start_distance])) for pt in self.bottom_points] +
        [self.transform.local_to_global(Matrix([pt[0], pt[1], self.end_distance])) for pt in self.top_points]
    )

    xs = [p[0] for p in corners_global]
    ys = [p[1] for p in corners_global]
    zs = [p[2] for p in corners_global]
    return BoundingBox(
        _numeric_min(*xs), _numeric_min(*ys), _numeric_min(*zs),
        _numeric_max(*xs), _numeric_max(*ys), _numeric_max(*zs),
    )

ArrangementNames

Bases: Enum

identifies each of the timbers in the various arrangements, we just use one enum for convenience but we really only want to refer to timbers specific to a certain arrangement when using this class

timber1 class-attribute instance-attribute

timber1 = ('timber1',)

timber2 class-attribute instance-attribute

timber2 = ('timber2',)

receiving_timber class-attribute instance-attribute

receiving_timber = ('receiving_timber',)

butt_timber class-attribute instance-attribute

butt_timber = ('butt_timber',)

butt_timber_1 class-attribute instance-attribute

butt_timber_1 = ('butt_timber_1',)

butt_timber_2 class-attribute instance-attribute

butt_timber_2 = ('butt_timber_2',)

main_butt_timber_1 class-attribute instance-attribute

main_butt_timber_1 = ('main_butt_timber_1',)

main_butt_timber_2 class-attribute instance-attribute

main_butt_timber_2 = ('main_butt_timber_2',)

awk_timber class-attribute instance-attribute

awk_timber = ('awk_timber',)

awk_1 class-attribute instance-attribute

awk_1 = ('awk_1',)

awk_2 class-attribute instance-attribute

awk_2 = ('awk_2',)

post_timber class-attribute instance-attribute

post_timber = ('post_timber',)

cross_timber_1 class-attribute instance-attribute

cross_timber_1 = ('cross_timber_1',)

cross_timber_2 class-attribute instance-attribute

cross_timber_2 = ('cross_timber_2',)

brace_timber class-attribute instance-attribute

brace_timber = ('brace_timber',)

ButtJointTimberArrangement

butt_timber instance-attribute

butt_timber: TimberLike

receiving_timber instance-attribute

receiving_timber: TimberLike

butt_timber_end instance-attribute

butt_timber_end: TimberEnd

front_face_on_butt_timber class-attribute instance-attribute

front_face_on_butt_timber: Optional[TimberLongFace] = None

top_face_on_butt_timber class-attribute instance-attribute

top_face_on_butt_timber: Optional[TimberLongFace] = None

compute_normalized_timber_cross_product

compute_normalized_timber_cross_product() -> Direction3D

Compute the normalized cross product of the butt timber and receiving timber length directions.

Source code in kumiki/construction.py
def compute_normalized_timber_cross_product(self) -> Direction3D:
    """Compute the normalized cross product of the butt timber and receiving timber length directions."""
    key = "normalized_timber_cross_product"
    if self._memo.get(key) is not None:
        return self._memo[key]

    result = safe_normalize_vector(cross_product(self.butt_timber.get_face_direction_global(self.butt_timber_end), self.receiving_timber.get_length_direction_global()))
    self._memo[key] = result
    return result

compute_arrangement_acute_angle

compute_arrangement_acute_angle() -> Numeric

Compute the angle between the 2 timbers

Source code in kumiki/construction.py
def compute_arrangement_acute_angle(self) -> Numeric:
    """Compute the angle between the 2 timbers"""
    dot = safe_dot_product(self.butt_timber.get_face_direction_global(self.butt_timber_end), self.receiving_timber.get_length_direction_global())
    return acos(dot)

check_plane_aligned

check_plane_aligned() -> Optional[str]

Return None if timbers are plane-aligned and front/top face are in plane, else an error message.

Source code in kumiki/construction.py
def check_plane_aligned(self) -> Optional[str]:
    """Return None if timbers are plane-aligned and front/top face are in plane, else an error message."""
    if not are_timbers_plane_aligned(self.butt_timber, self.receiving_timber):
        return "Timbers must be plane-aligned"
    front_error = self._check_front_face_orientation()
    if front_error is not None:
        return front_error
    return self._check_top_face_orientation()

check_face_aligned_and_orthogonal

check_face_aligned_and_orthogonal() -> Optional[str]

Return None if timbers are face-aligned, else an error message.

Unlike check_plane_aligned, this does not validate front_face_on_butt_timber: callers that use this (e.g. drop-in dovetail/housed butt joints) already apply their own, looser front-face validation suited to non-coplanar orthogonal arrangements.

Source code in kumiki/construction.py
def check_face_aligned_and_orthogonal(self) -> Optional[str]:
    """Return None if timbers are face-aligned, else an error message.

    Unlike check_plane_aligned, this does not validate front_face_on_butt_timber:
    callers that use this (e.g. drop-in dovetail/housed butt joints) already
    apply their own, looser front-face validation suited to non-coplanar
    orthogonal arrangements.
    """
    if not are_timbers_face_aligned(self.butt_timber, self.receiving_timber):
        return "Timbers must be face-aligned"
    return self._check_top_face_orientation()

check_perfection

check_perfection() -> Optional[str]

Return None if both timbers are perfect, else an error message.

Source code in kumiki/construction.py
def check_perfection(self) -> Optional[str]:
    """Return None if both timbers are perfect, else an error message."""
    if not self.butt_timber.is_perfect_timber():
        return "butt_timber must be perfect"
    if not self.receiving_timber.is_perfect_timber():
        return "receiving_timber must be perfect"
    return None

CrossJointScribeReliefConfig dataclass

Configuration for cross joint relief "Scribe" here means one timber is scribed onto the other and completely cut away

timber_to_be_scribed instance-attribute

timber_to_be_scribed: ArrangementNames

cross_timber_1 staticmethod

cross_timber_1()
Source code in kumiki/joints/workshop/shavings/relief.py
@staticmethod
def cross_timber_1():
    return CrossJointScribeReliefConfig(
        timber_to_be_scribed=ArrangementNames.cross_timber_1,
    )

cross_timber_2 staticmethod

cross_timber_2()
Source code in kumiki/joints/workshop/shavings/relief.py
@staticmethod
def cross_timber_2():
    return CrossJointScribeReliefConfig(
        timber_to_be_scribed=ArrangementNames.cross_timber_2,
    )

ButtJointScribeReliefConfig dataclass

Configuration for butt joint relief "Scribe" here means one timber is scribed onto the other and completely cut away

timber_to_be_scribed instance-attribute

timber_to_be_scribed: ArrangementNames

butt_timber staticmethod

butt_timber()
Source code in kumiki/joints/workshop/shavings/relief.py
@staticmethod
def butt_timber():
    return ButtJointScribeReliefConfig(
        timber_to_be_scribed=ArrangementNames.butt_timber,
    )

receiving_timber staticmethod

receiving_timber()
Source code in kumiki/joints/workshop/shavings/relief.py
@staticmethod
def receiving_timber():
    return ButtJointScribeReliefConfig(
        timber_to_be_scribed=ArrangementNames.receiving_timber,
    )

NotchFrom

Bases: Enum

Which reference plane a ButtJointNotchReliefConfig notch is anchored to.

Face class-attribute instance-attribute

Face = 0

Shoulder class-attribute instance-attribute

Shoulder = 1

ButtJointNotchReliefConfig dataclass

Configuration for butt joint relief via chop_butt_joint_shoulder_notch_relief_4sided.

Unlike ButtJointScribeReliefConfig (which scribes one timber's whole imperfect body onto the other), this relieves only the material near the inset shoulder using the 4-sided frustum notch -- see chop_butt_joint_shoulder_notch_relief_4sided.

Attributes:

Name Type Description
notch_from NotchFrom

Which plane the notch is measured from. - Shoulder (default): the notch is anchored to the actual (possibly inset) shoulder plane. The only value cut_mortise_and_tenon_joint itself supports. - Face: only supported by cut_mortise_and_tenon_joint_on_plane_aligned_timbers / _on_face_aligned_timbers. The joint is still fit at the real (inset) shoulder (via the default scribe-based housing cut), but the notch relief itself is anchored to the mortise entry face -- as if mortise_shoulder_inset were 0 -- so it reads as starting at the timber's outer face regardless of how deep the shoulder is actually inset.

notch_from class-attribute instance-attribute

SpliceJointScribeReliefConfig dataclass

Configuration for splice joint relief "Scribe" here means one timber is scribed onto the other and completely cut away

timber_to_be_scribed instance-attribute

timber_to_be_scribed: ArrangementNames

timber1 staticmethod

timber1()
Source code in kumiki/joints/workshop/shavings/relief.py
@staticmethod
def timber1():
    return SpliceJointScribeReliefConfig(
        timber_to_be_scribed=ArrangementNames.timber1,
    )

timber2 staticmethod

timber2()
Source code in kumiki/joints/workshop/shavings/relief.py
@staticmethod
def timber2():
    return SpliceJointScribeReliefConfig(
        timber_to_be_scribed=ArrangementNames.timber2,
    )

CornerJointScribeReliefConfig dataclass

Configuration for corner joint relief "Scribe" here means one timber is scribed onto the other and completely cut away

timber_to_be_scribed instance-attribute

timber_to_be_scribed: ArrangementNames

timber1 staticmethod

timber1()
Source code in kumiki/joints/workshop/shavings/relief.py
@staticmethod
def timber1():
    return CornerJointScribeReliefConfig(
        timber_to_be_scribed=ArrangementNames.timber1,
    )

timber2 staticmethod

timber2()
Source code in kumiki/joints/workshop/shavings/relief.py
@staticmethod
def timber2():
    return CornerJointScribeReliefConfig(
        timber_to_be_scribed=ArrangementNames.timber2,
    )

DoubleButtJointScribeReliefConfig dataclass

Configuration for double butt joint relief.

first_timber_to_be_scribed is scribed first, then second_timber_to_be_scribed is scribed onto the remaining timber.

first_timber_to_be_scribed instance-attribute

first_timber_to_be_scribed: ArrangementNames

second_timber_to_be_scribed instance-attribute

second_timber_to_be_scribed: ArrangementNames

with_order staticmethod

with_order(first_timber_to_be_scribed: ArrangementNames, second_timber_to_be_scribed: ArrangementNames)

Create a DoubleButtJointScribeReliefConfig from the order of which timbers to be scribed

Source code in kumiki/joints/workshop/shavings/relief.py
@staticmethod
def with_order(
    first_timber_to_be_scribed: ArrangementNames,
    second_timber_to_be_scribed: ArrangementNames,
):
    """
    Create a DoubleButtJointScribeReliefConfig from the order of which timbers to be scribed
    """
    return DoubleButtJointScribeReliefConfig(
        first_timber_to_be_scribed=first_timber_to_be_scribed,
        second_timber_to_be_scribed=second_timber_to_be_scribed,
    )

TripleButtJointScribeReliefConfig dataclass

Configuration for triple butt joint relief.

first_timber_to_be_scribed is scribed first, then second_timber_to_be_scribed, then third_timber_to_be_scribed.

first_timber_to_be_scribed instance-attribute

first_timber_to_be_scribed: ArrangementNames

second_timber_to_be_scribed instance-attribute

second_timber_to_be_scribed: ArrangementNames

third_timber_to_be_scribed instance-attribute

third_timber_to_be_scribed: ArrangementNames

with_order staticmethod

with_order(first_timber_to_be_scribed: ArrangementNames, second_timber_to_be_scribed: ArrangementNames, third_timber_to_be_scribed: ArrangementNames)

Create a TripleButtJointScribeReliefConfig from the order of which timbers to be scribed

Source code in kumiki/joints/workshop/shavings/relief.py
@staticmethod
def with_order(
    first_timber_to_be_scribed: ArrangementNames,
    second_timber_to_be_scribed: ArrangementNames,
    third_timber_to_be_scribed: ArrangementNames,
):
    """
    Create a TripleButtJointScribeReliefConfig from the order of which timbers to be scribed
    """
    return TripleButtJointScribeReliefConfig(
        first_timber_to_be_scribed=first_timber_to_be_scribed,
        second_timber_to_be_scribed=second_timber_to_be_scribed,
        third_timber_to_be_scribed=third_timber_to_be_scribed,
    )

QuadrupleButtJointScribeReliefConfig dataclass

Configuration for quadruple butt joint relief.

first_timber_to_be_scribed is scribed first, then second_timber_to_be_scribed, third_timber_to_be_scribed, and fourth_timber_to_be_scribed.

first_timber_to_be_scribed instance-attribute

first_timber_to_be_scribed: ArrangementNames

second_timber_to_be_scribed instance-attribute

second_timber_to_be_scribed: ArrangementNames

third_timber_to_be_scribed instance-attribute

third_timber_to_be_scribed: ArrangementNames

fourth_timber_to_be_scribed instance-attribute

fourth_timber_to_be_scribed: ArrangementNames

with_order staticmethod

with_order(first_timber_to_be_scribed: ArrangementNames, second_timber_to_be_scribed: ArrangementNames, third_timber_to_be_scribed: ArrangementNames, fourth_timber_to_be_scribed: ArrangementNames)

Create a QuadrupleButtJointScribeReliefConfig from the order of which timbers to be scribed

Source code in kumiki/joints/workshop/shavings/relief.py
@staticmethod
def with_order(
    first_timber_to_be_scribed: ArrangementNames,
    second_timber_to_be_scribed: ArrangementNames,
    third_timber_to_be_scribed: ArrangementNames,
    fourth_timber_to_be_scribed: ArrangementNames,
):
    """
    Create a QuadrupleButtJointScribeReliefConfig from the order of which timbers to be scribed
    """
    return QuadrupleButtJointScribeReliefConfig(
        first_timber_to_be_scribed=first_timber_to_be_scribed,
        second_timber_to_be_scribed=second_timber_to_be_scribed,
        third_timber_to_be_scribed=third_timber_to_be_scribed,
        fourth_timber_to_be_scribed=fourth_timber_to_be_scribed,
    )

CrossCapJointScribeReliefConfig dataclass

Configuration for cross-cap joint relief.

first_timber_to_be_scribed is scribed first, then second_timber_to_be_scribed is scribed onto the remaining timber.

first_timber_to_be_scribed instance-attribute

first_timber_to_be_scribed: ArrangementNames

second_timber_to_be_scribed instance-attribute

second_timber_to_be_scribed: ArrangementNames

with_order staticmethod

with_order(first_timber_to_be_scribed: ArrangementNames, second_timber_to_be_scribed: ArrangementNames)

Create a CrossCapJointScribeReliefConfig from the order of which timbers to be scribed

Source code in kumiki/joints/workshop/shavings/relief.py
@staticmethod
def with_order(
    first_timber_to_be_scribed: ArrangementNames,
    second_timber_to_be_scribed: ArrangementNames,
):
    """
    Create a CrossCapJointScribeReliefConfig from the order of which timbers to be scribed
    """
    return CrossCapJointScribeReliefConfig(
        first_timber_to_be_scribed=first_timber_to_be_scribed,
        second_timber_to_be_scribed=second_timber_to_be_scribed,
    )

BraceJointScribeReliefConfig dataclass

Configuration for brace joint relief.

The 2 braced timbers are always scribed onto the brace timber.

first_timber_to_be_scribed instance-attribute

first_timber_to_be_scribed: ArrangementNames

second_timber_to_be_scribed instance-attribute

second_timber_to_be_scribed: ArrangementNames

with_order staticmethod

with_order(first_timber_to_be_scribed: ArrangementNames, second_timber_to_be_scribed: ArrangementNames)

Create a BraceJointScribeReliefConfig from the order of which timbers to be scribed

Source code in kumiki/joints/workshop/shavings/relief.py
@staticmethod
def with_order(
    first_timber_to_be_scribed: ArrangementNames,
    second_timber_to_be_scribed: ArrangementNames,
):
    """
    Create a BraceJointScribeReliefConfig from the order of which timbers to be scribed
    """
    return BraceJointScribeReliefConfig(
        first_timber_to_be_scribed=first_timber_to_be_scribed,
        second_timber_to_be_scribed=second_timber_to_be_scribed,
    )

DropinButtJointSweepScribeReliefConfig dataclass

Configuration for drop in butt joint relief

The butting (drop in) timber is always scribed onto the receiving timber

Actually the butting timber and its entire swept volume in the drop-in path are scribed onto the receiving timber

ShoulderReliefCSGGeometry dataclass

CSG geometry produced by the shoulder notch relief functions.

  • receiving_timber_notch_negative_CSG: cut applied to the receiving (mortise) timber, expressed in that timber's local frame.
  • butting_timber_relief_negative_CSG: cut applied to the butting (tenon) timber, expressed in that timber's local frame. None only when no relief geometry is necessary (currently always populated).

receiving_timber_notch_negative_CSG instance-attribute

receiving_timber_notch_negative_CSG: CutCSG

butting_timber_relief_negative_CSG instance-attribute

butting_timber_relief_negative_CSG: CutCSG | None

orientation_pointing_towards_face_sitting_on_face

orientation_pointing_towards_face_sitting_on_face(towards_face: TimberFace, sitting_face: TimberFace) -> Orientation

Returns a marking orientation with +z toward towards_face and +y pointing into the timber from sitting_face.

Marking transforms use a convention where, for transforms sitting on a timber face, +y points into the timber. This helper builds that orientation from two perpendicular faces.

Parameters:

Name Type Description Default
towards_face TimberFace

The face the orientation's +z axis should point toward.

required
sitting_face TimberFace

The face the orientation is sitting on; its outward normal becomes -y.

required

Returns:

Type Description
Orientation

Orientation with +z pointing toward towards_face and +y pointing into the timber.

Raises:

Type Description
AssertionError

If towards_face and sitting_face are not perpendicular.

Source code in kumiki/joints/workshop/shavings/shavings.py
def orientation_pointing_towards_face_sitting_on_face(towards_face : TimberFace, sitting_face : TimberFace) -> 'Orientation':
    """
    Returns a marking orientation with +z toward towards_face and +y pointing into the timber from sitting_face.

    Marking transforms use a convention where, for transforms sitting on a timber face,
    +y points into the timber. This helper builds that orientation from two perpendicular faces.

    Args:
        towards_face: The face the orientation's +z axis should point toward.
        sitting_face: The face the orientation is sitting on; its outward normal becomes -y.

    Returns:
        Orientation with +z pointing toward towards_face and +y pointing into the timber.

    Raises:
        AssertionError: If towards_face and sitting_face are not perpendicular.
    """
    assert are_vectors_perpendicular(towards_face.get_direction(), sitting_face.get_direction())
    return Orientation.from_z_and_y(towards_face.get_direction(), -sitting_face.get_direction())

scribe_face_plane_onto_centerline

scribe_face_plane_onto_centerline(face: TimberFace, face_timber: TimberLike) -> UnsignedPlane

Mark the face plane on a timber.

Returns the plane defined by the face on face_timber. This plane can then be measured onto another timber's centerline to find shoulder plane positions in various butt joints.

Parameters:

Name Type Description Default
face TimberFace

The face on face_timber to mark

required
face_timber TimberLike

The timber whose face defines the plane

required

Returns:

Type Description
UnsignedPlane

UnsignedPlane representing the face plane. This can be measured onto a centerline using

UnsignedPlane

mark_distance_from_end_along_centerline() to find intersection points.

Example

Mark the plane for timber_b's FRONT face

face_plane = scribe_face_plane_onto_centerline( ... face=TimberFace.FRONT, ... face_timber=timber_b ... )

Then measure onto timber_a's centerline

marking = mark_distance_from_end_along_centerline(face_plane, timber_a) shoulder_distance = measurement.distance

Source code in kumiki/joints/workshop/shavings/shavings.py
def scribe_face_plane_onto_centerline(face: TimberFace, face_timber: TimberLike) -> UnsignedPlane:
    """
    Mark the face plane on a timber.

    Returns the plane defined by the face on face_timber. This plane can then be measured 
    onto another timber's centerline to find shoulder plane positions in various butt joints.

    Args:
        face: The face on face_timber to mark
        face_timber: The timber whose face defines the plane

    Returns:
        UnsignedPlane representing the face plane. This can be measured onto a centerline using
        mark_distance_from_end_along_centerline() to find intersection points.

    Example:
        >>> # Mark the plane for timber_b's FRONT face
        >>> face_plane = scribe_face_plane_onto_centerline(
        ...     face=TimberFace.FRONT,
        ...     face_timber=timber_b
        ... )
        >>> # Then measure onto timber_a's centerline
        >>> marking = mark_distance_from_end_along_centerline(face_plane, timber_a)
        >>> shoulder_distance = measurement.distance
    """
    # Get the face plane (any point on the face works - we use locate_into_face for simplicity)
    return locate_into_face(0, face, face_timber)

locate_pat_shoulder_plane_from_centerline_to_reference_face

locate_pat_shoulder_plane_from_centerline_to_reference_face(shoulder_timber: TimberLike, reference_timber: TimberLike, reference_face: TimberFace) -> Plane

Compute a shoulder plane on shoulder_timber using a face plane on reference_timber.

This helper assumes a plane-aligned arrangement. It scribes the reference face plane onto the shoulder timber centerline, then returns the timber cross-section plane at that mark (normal = shoulder timber length direction).

Parameters:

Name Type Description Default
shoulder_timber TimberLike

Timber receiving the shoulder plane.

required
reference_timber TimberLike

Timber that owns the reference face.

required
reference_face TimberFace

Face on reference_timber that defines where the shoulder lands.

required

Returns:

Type Description
Plane

Plane perpendicular to shoulder_timber length axis at the marked shoulder.

Source code in kumiki/joints/workshop/shavings/shavings.py
def locate_pat_shoulder_plane_from_centerline_to_reference_face(
    shoulder_timber: TimberLike,
    reference_timber: TimberLike,
    reference_face: TimberFace,
) -> Plane:
    """
    Compute a shoulder plane on `shoulder_timber` using a face plane on `reference_timber`.

    This helper assumes a plane-aligned arrangement. It scribes the
    reference face plane onto the shoulder timber centerline, then returns the timber
    cross-section plane at that mark (normal = shoulder timber length direction).

    Args:
        shoulder_timber: Timber receiving the shoulder plane.
        reference_timber: Timber that owns the reference face.
        reference_face: Face on `reference_timber` that defines where the shoulder lands.

    Returns:
        Plane perpendicular to `shoulder_timber` length axis at the marked shoulder.
    """
    reference_plane = scribe_face_plane_onto_centerline(reference_face, reference_timber)
    shoulder_distance_from_bottom = mark_distance_from_end_along_centerline(
        reference_plane,
        shoulder_timber,
        TimberEnd.BOTTOM,
    ).distance

    shoulder_length_direction = shoulder_timber.get_length_direction_global()
    shoulder_point = (
        shoulder_timber.get_bottom_position_global()
        + shoulder_length_direction * shoulder_distance_from_bottom
    )

    return Plane(normal=shoulder_length_direction, point=shoulder_point)

scribe_centerline_onto_centerline

scribe_centerline_onto_centerline(timber: TimberLike) -> Line

Mark the centerline of a timber.

Returns the Line representing the timber's centerline. This line can then be measured onto another timber's centerline to find closest points between skew centerlines.

This is useful for positioning timbers relative to each other, especially in complex 3D joints where centerlines may be skew (non-intersecting, non-parallel).

Parameters:

Name Type Description Default
timber TimberLike

The timber whose centerline to mark

required

Returns:

Type Description
Line

Line representing the timber's centerline. This can be measured onto another

Line

timber's centerline using mark_distance_from_end_along_centerline() to find closest points.

Example

Mark the centerline of timber_b

centerline_b = scribe_centerline_onto_centerline(timber_b)

Then measure onto timber_a's centerline

measurement_a = mark_distance_from_end_along_centerline(centerline_b, timber_a) dist_a = measurement_a.distance

Source code in kumiki/joints/workshop/shavings/shavings.py
def scribe_centerline_onto_centerline(timber: TimberLike) -> Line:
    """
    Mark the centerline of a timber.

    Returns the Line representing the timber's centerline. This line can then be measured 
    onto another timber's centerline to find closest points between skew centerlines.

    This is useful for positioning timbers relative to each other, especially in
    complex 3D joints where centerlines may be skew (non-intersecting, non-parallel).

    Args:
        timber: The timber whose centerline to mark

    Returns:
        Line representing the timber's centerline. This can be measured onto another
        timber's centerline using mark_distance_from_end_along_centerline() to find closest points.

    Example:
        >>> # Mark the centerline of timber_b
        >>> centerline_b = scribe_centerline_onto_centerline(timber_b)
        >>> # Then measure onto timber_a's centerline
        >>> measurement_a = mark_distance_from_end_along_centerline(centerline_b, timber_a)
        >>> dist_a = measurement_a.distance
    """
    # Mark the centerline of the timber as a Line feature
    return locate_centerline(timber)

check_timber_overlap_for_splice_joint_is_sensible

check_timber_overlap_for_splice_joint_is_sensible(timberA: TimberLike, timberB: TimberLike, timberA_end: TimberEnd, timberB_end: TimberEnd) -> Optional[str]

Check if two timbers overlap in a sensible way for a splice joint.

A sensible splice joint configuration requires: 1. The joint ends are pointing in opposite directions (anti-parallel) 2. The joint end planes either touch each other or go past each other 3. The joint end planes have not gone so far past each other that they reach the opposite end of the other timber

ASCII diagram of a sensible splice joint: A |==================| <- timberA_end timberB_end -> |==================| B

Parameters:

Name Type Description Default
timberA TimberLike

First timber in the splice joint

required
timberB TimberLike

Second timber in the splice joint

required
timberA_end TimberEnd

Which end of timberA is being joined (TOP or BOTTOM)

required
timberB_end TimberEnd

Which end of timberB is being joined (TOP or BOTTOM)

required

Returns:

Type Description
Optional[str]

Optional[str]: None if the configuration is sensible, otherwise a string explaining why the configuration fails the sensibility check

Example

error = check_timber_overlap_for_splice_joint_is_sensible( ... gooseneck, receiving, TimberEnd.BOTTOM, TimberEnd.TOP ... ) if error: ... print(f"Joint configuration error: {error}")

Source code in kumiki/joints/workshop/shavings/shavings.py
def check_timber_overlap_for_splice_joint_is_sensible(
    timberA: TimberLike,
    timberB: TimberLike,
    timberA_end: TimberEnd,
    timberB_end: TimberEnd
) -> Optional[str]:
    """
    Check if two timbers overlap in a sensible way for a splice joint.

    A sensible splice joint configuration requires:
    1. The joint ends are pointing in opposite directions (anti-parallel)
    2. The joint end planes either touch each other or go past each other
    3. The joint end planes have not gone so far past each other that they reach 
       the opposite end of the other timber

    ASCII diagram of a sensible splice joint:
    A |==================| <- timberA_end
       timberB_end -> |==================| B

    Args:
        timberA: First timber in the splice joint
        timberB: Second timber in the splice joint
        timberA_end: Which end of timberA is being joined (TOP or BOTTOM)
        timberB_end: Which end of timberB is being joined (TOP or BOTTOM)

    Returns:
        Optional[str]: None if the configuration is sensible, otherwise a string
                      explaining why the configuration fails the sensibility check

    Example:
        >>> error = check_timber_overlap_for_splice_joint_is_sensible(
        ...     gooseneck, receiving, TimberEnd.BOTTOM, TimberEnd.TOP
        ... )
        >>> if error:
        ...     print(f"Joint configuration error: {error}")
    """
    assert isinstance(timberA_end, TimberEnd), f"expected TimberEnd, got {type(timberA_end).__name__}"
    assert isinstance(timberB_end, TimberEnd), f"expected TimberEnd, got {type(timberB_end).__name__}"
    # Get the length directions for both timbers
    timberA_length_direction = timberA.get_length_direction_global()
    timberB_length_direction = timberB.get_length_direction_global()

    # First, check that timbers are parallel (not perpendicular or skewed)
    dot_product = numeric_dot_product(timberA_length_direction, timberB_length_direction)

    if not are_vectors_parallel(timberA_length_direction, timberB_length_direction):
        return (
            f"Timbers are not parallel. TimberA length direction {timberA_length_direction.T} "
            f"and timberB length direction {timberB_length_direction.T} must be parallel "
            f"(dot product should be ±1, got {float(dot_product):.3f})"
        )

    # Get the end positions and directions in world coordinates
    # Note: end_direction points AWAY from the timber (outward from the end)
    if timberA_end == TimberEnd.TOP:
        timberA_end_pos = locate_top_center_position(timberA).position
        timberA_end_direction = timberA.get_length_direction_global()  # Points away from timber
        timberA_opposite_end_pos = timberA.get_bottom_position_global()
    else:  # BOTTOM
        timberA_end_pos = timberA.get_bottom_position_global()
        timberA_end_direction = -timberA.get_length_direction_global()  # Points away from timber
        timberA_opposite_end_pos = locate_top_center_position(timberA).position

    if timberB_end == TimberEnd.TOP:
        timberB_end_pos = locate_top_center_position(timberB).position
        timberB_end_direction = timberB.get_length_direction_global()  # Points away from timber
        timberB_opposite_end_pos = timberB.get_bottom_position_global()
    else:  # BOTTOM
        timberB_end_pos = timberB.get_bottom_position_global()
        timberB_end_direction = -timberB.get_length_direction_global()  # Points away from timber
        timberB_opposite_end_pos = locate_top_center_position(timberB).position

    # Check 1: The joint ends must be pointing in opposite directions (anti-parallel)
    # For a proper splice joint, the specified ends should point towards each other
    # (dot product of end directions should be close to -1)
    end_dot_product = numeric_dot_product(timberA_end_direction, timberB_end_direction)

    if safe_compare(end_dot_product, 0, Comparison.GT):
        return (
            f"Joint ends are pointing in the same direction (dot product = {float(end_dot_product):.3f}). "
            f"For a splice joint, the ends should point in opposite directions (dot product should be -1). "
            f"TimberA {timberA_end.name} end direction: {timberA_end_direction.T}, "
            f"TimberB {timberB_end.name} end direction: {timberB_end_direction.T}"
        )

    # Check 2: The joint ends should either touch or overlap (not be separated)
    # Vector from timberA end to timberB end
    end_to_end_vector = timberB_end_pos - timberA_end_pos

    # Project this vector onto timberA's end direction
    # If positive, timberB end is in the direction timberA end is pointing (they overlap or touch)
    # If negative, timberB end is behind timberA end (they're separated)
    projection_A = numeric_dot_product(end_to_end_vector, timberA_end_direction)

    # Also check from timberB's perspective
    projection_B = -numeric_dot_product(end_to_end_vector, timberB_end_direction)

    # For a valid splice, at least one timber should be extending towards or past the other
    # Both projections should be >= 0 (allowing for small numerical errors)
    gap_threshold = -EPSILON_GENERIC * 10  # Allow small numerical errors

    if projection_A < gap_threshold and projection_B < gap_threshold:
        return (
            f"Joint ends are separated by a gap. The ends should touch or overlap. "
            f"Distance from timberA end to timberB end along timberA direction: {float(projection_A):.6f}. "
            f"Distance from timberB end to timberA end along timberB direction: {float(projection_B):.6f}"
        )

    # Check 3: The joint ends should not have gone so far past each other that they 
    # reach the opposite end of the other timber

    # Check if timberA end has passed through timberB's opposite end
    # Vector from timberB's opposite end to timberA's end
    vector_to_timberA_end = timberA_end_pos - timberB_opposite_end_pos
    # Project onto timberB's end direction (pointing from joined end towards opposite end)
    penetration_into_B = vector_to_timberA_end.dot(-timberB_end_direction)

    # If positive and large, timberA has penetrated through timberB
    if penetration_into_B > timberB.length + EPSILON_GENERIC:
        return (
            f"TimberA end has penetrated too far through timberB. "
            f"TimberA end extends {float(penetration_into_B):.3f} past timberB's joined end, "
            f"but timberB is only {float(timberB.length):.3f} long. "
            f"The joint should not extend past the opposite end of the timber."
        )

    # Check if timberB end has passed through timberA's opposite end
    vector_to_timberB_end = timberB_end_pos - timberA_opposite_end_pos
    penetration_into_A = vector_to_timberB_end.dot(-timberA_end_direction)

    if penetration_into_A > timberA.length + EPSILON_GENERIC:
        return (
            f"TimberB end has penetrated too far through timberA. "
            f"TimberB end extends {float(penetration_into_A):.3f} past timberA's joined end, "
            f"but timberA is only {float(timberA.length):.3f} long. "
            f"The joint should not extend past the opposite end of the timber."
        )

    # All checks passed
    return None

chop_timber_end_with_prism

chop_timber_end_with_prism(timber: TimberLike, end: TimberEnd, distance_from_end_to_cut: Numeric, label: CutCSGLabel = CutCSGLabel('timber_end_prism_cut')) -> RectangularPrism

Create a RectangularPrism CSG for chopping off material from a timber end (in local coordinates).

Creates a CSG prism in the timber's local coordinate system that starts at distance_from_end_to_cut from the timber end and extends to infinity in the timber length direction. The prism has the same cross-section size as the timber.

This is useful when you need a volumetric cut that exactly matches the timber's cross-section (e.g., for CSGCut objects in compound cuts).

Parameters:

Name Type Description Default
timber TimberLike

The timber to create a chop prism for

required
end TimberEnd

Which end to chop from (TOP or BOTTOM)

required
distance_from_end_to_cut Numeric

Distance from the end where the cut begins

required

Returns:

Name Type Description
RectangularPrism RectangularPrism

A CSG prism in local coordinates representing the material beyond distance_from_end_to_cut from the end, extending to infinity

Example

Chop everything beyond 2 inches from the top of a timber

chop_prism = chop_timber_end_with_prism(my_timber, TimberEnd.TOP, scalar(2))

This creates a semi-infinite prism starting 2 inches from the top

Source code in kumiki/joints/workshop/shavings/shavings.py
def chop_timber_end_with_prism(
    timber: TimberLike,
    end: TimberEnd,
    distance_from_end_to_cut: Numeric,
    label: CutCSGLabel = CutCSGLabel("timber_end_prism_cut"),
) -> RectangularPrism:
    """
    Create a RectangularPrism CSG for chopping off material from a timber end (in local coordinates).

    Creates a CSG prism in the timber's local coordinate system that starts at 
    distance_from_end_to_cut from the timber end and extends to infinity in the timber 
    length direction. The prism has the same cross-section size as the timber.

    This is useful when you need a volumetric cut that exactly matches the timber's 
    cross-section (e.g., for CSGCut objects in compound cuts).

    Args:
        timber: The timber to create a chop prism for
        end: Which end to chop from (TOP or BOTTOM)
        distance_from_end_to_cut: Distance from the end where the cut begins

    Returns:
        RectangularPrism: A CSG prism in local coordinates representing the material beyond 
               distance_from_end_to_cut from the end, extending to infinity

    Example:
        >>> # Chop everything beyond 2 inches from the top of a timber
        >>> chop_prism = chop_timber_end_with_prism(my_timber, TimberEnd.TOP, scalar(2))
        >>> # This creates a semi-infinite prism starting 2 inches from the top
    """
    assert isinstance(end, TimberEnd), f"expected TimberEnd, got {type(end).__name__}"
    # In timber local coordinates:
    # - Bottom is at 0
    # - Top is at timber.length
    # - Z-axis points along the length direction (bottom to top)

    if end == TimberEnd.TOP:
        # For TOP end:
        # - Start at (timber.length - distance_from_end_to_cut)
        # - Extend to infinity in the +Z direction (beyond the top)
        start_distance_local = timber.length - distance_from_end_to_cut
        end_distance_local = None  # Infinite in +Z direction
    else:  # BOTTOM
        # For BOTTOM end:
        # - Start at infinity in the -Z direction (below the bottom)
        # - End at distance_from_end_to_cut from the bottom
        start_distance_local = None  # Infinite in -Z direction
        end_distance_local = distance_from_end_to_cut

    # Create the prism with identity transform (local coordinates)
    return RectangularPrism(
        size=timber.size,
        transform=Transform.identity(),
        start_distance=start_distance_local,
        end_distance=end_distance_local,
        label=label,
    )

chop_timber_end_with_half_plane

chop_timber_end_with_half_plane(timber: TimberLike, end: TimberEnd, distance_from_end_to_cut: Numeric, label: CutCSGLabel = NoLabel()) -> HalfSpace

Create a HalfSpace CSG for chopping off material from a timber end (in local coordinates).

Creates a half-plane cut in the timber's local coordinate system, perpendicular to the timber's length direction, positioned at distance_from_end_to_cut from the specified end. The half-plane removes everything beyond that distance.

This is simpler and more efficient than a prism-based cut when you just need a planar cut perpendicular to the timber's length (e.g., for simple butt joints or splice joints).

Parameters:

Name Type Description Default
timber TimberLike

The timber to create a chop half-plane for

required
end TimberEnd

Which end to chop from (TOP or BOTTOM)

required
distance_from_end_to_cut Numeric

Distance from the end where the cut plane is positioned

required
label CutCSGLabel

What the resulting plane is called in the CSG tree. Left to the caller because an end-chop means something different in every joint that makes one; the top_end_cut / bottom_end_cut names belong to a Cutting's own maybe-end-cuts, not to every plane of this shape.

NoLabel()

Returns:

Name Type Description
HalfSpace HalfSpace

A half-plane in local coordinates that removes material beyond distance_from_end_to_cut from the end

Example

Chop everything beyond 2 inches from the top of a timber

chop_plane = chop_timber_end_with_half_plane(my_timber, TimberEnd.TOP, scalar(2))

This creates a half-plane 2 inches from the top, removing everything beyond

Source code in kumiki/joints/workshop/shavings/shavings.py
def chop_timber_end_with_half_plane(
    timber: TimberLike,
    end: TimberEnd,
    distance_from_end_to_cut: Numeric,
    label: CutCSGLabel = CutCSGLabel.NoLabel(),
) -> HalfSpace:
    """
    Create a HalfSpace CSG for chopping off material from a timber end (in local coordinates).

    Creates a half-plane cut in the timber's local coordinate system, perpendicular to the 
    timber's length direction, positioned at distance_from_end_to_cut from the specified end.
    The half-plane removes everything beyond that distance.

    This is simpler and more efficient than a prism-based cut when you just need a planar
    cut perpendicular to the timber's length (e.g., for simple butt joints or splice joints).

    Args:
        timber: The timber to create a chop half-plane for
        end: Which end to chop from (TOP or BOTTOM)
        distance_from_end_to_cut: Distance from the end where the cut plane is positioned
        label: What the resulting plane is called in the CSG tree. Left to the
            caller because an end-chop means something different in every joint
            that makes one; the top_end_cut / bottom_end_cut names belong to a
            Cutting's own maybe-end-cuts, not to every plane of this shape.

    Returns:
        HalfSpace: A half-plane in local coordinates that removes material beyond 
                   distance_from_end_to_cut from the end

    Example:
        >>> # Chop everything beyond 2 inches from the top of a timber
        >>> chop_plane = chop_timber_end_with_half_plane(my_timber, TimberEnd.TOP, scalar(2))
        >>> # This creates a half-plane 2 inches from the top, removing everything beyond
    """
    assert isinstance(end, TimberEnd), f"expected TimberEnd, got {type(end).__name__}"
    # In timber local coordinates:
    # - Bottom is at 0
    # - Top is at timber.length
    # - Z-axis (local) points along the length direction (bottom to top)

    # The half-plane is perpendicular to the length direction (Z-axis in local coords)
    # Normal vector in local coordinates is always +Z or -Z

    if end == TimberEnd.TOP:
        # For TOP end:
        # - Cut plane is at (timber.length - distance_from_end_to_cut)
        # - Normal points in +Z direction (away from the timber body, toward the top)
        # - We want to remove everything beyond this point (in +Z direction)
        # - HalfSpace keeps points where normal·P >= offset
        # - So normal should point in +Z and offset should be the cut position
        normal = create_v3(0, 0, 1)
        # note offset is measured from the timber bottom position, not the timber top end position
        offset = timber.length - distance_from_end_to_cut
    else:  # BOTTOM
        # For BOTTOM end:
        # - Cut plane is at distance_from_end_to_cut from bottom
        # - Normal points in -Z direction (away from the timber body, toward the bottom)
        # - We want to remove everything beyond this point (in -Z direction)
        # - HalfSpace keeps points where normal·P >= offset
        # - So normal should point in -Z and offset should be negative of cut position
        normal = create_v3(0, 0, -1)
        offset = -distance_from_end_to_cut

    return HalfSpace(normal=normal, offset=offset, label=label)

chop_lap_on_timber_end

chop_lap_on_timber_end(lap_timber: TimberLike, lap_timber_end: TimberEnd, lap_timber_face: TimberFace, lap_length: Numeric, lap_shoulder_position_from_lap_timber_end: Numeric, lap_depth: Numeric, label: CutCSGLabel = CutCSGLabel('lap_cut')) -> Tuple[CutCSG, HalfSpace]

Create CSG cuts for a lap joint between two timber ends.

Creates material removal volumes for both timbers in a lap joint configuration where one timber (top lap) has material removed from one face, and the other timber (bottom lap) has material removed from the opposite face so they interlock.

lap_timber_face
v           |--------| lap_length

╔════════════════════════╗ - ║face_lap_timber ║ | lap_depth ║ ╔════════╝ - ║ ║ ║ ║ ╚═══════════════╝ ^ lap_shoulder_position_from_lap_timber_end

Parameters:

Name Type Description Default
lap_timber TimberLike

The timber that will have material removed from the specified face

required
lap_timber_end TimberEnd

Which end of the top lap timber is being joined

required
lap_timber_face TimberFace

Which face of the top lap timber to remove material from

required
lap_length Numeric

Length of the lap region along the timber length

required
lap_shoulder_position_from_lap_timber_end Numeric

Distance from the timber end to the shoulder (inward)

required
lap_depth Numeric

Depth of material to remove (measured from lap_timber_face)

required

Returns:

Type Description
CutCSG

Tuple of (lap_prism, end_cut_half_plane) representing material to remove from the timber

HalfSpace

Both CSGs are in local coordinates of the timber

Example

Create a half-lap joint

top_lap, top_end_cut = chop_lap_on_timber_end( ... timber_a, TimberEnd.TOP, ... TimberFace.BOTTOM, lap_length=4, lap_depth=2, shoulder_pos=1 ... )

Source code in kumiki/joints/workshop/shavings/shavings.py
def chop_lap_on_timber_end(
    lap_timber: TimberLike,
    lap_timber_end: TimberEnd,
    lap_timber_face: TimberFace,
    lap_length: Numeric,
    lap_shoulder_position_from_lap_timber_end: Numeric,
    lap_depth: Numeric,
    label: CutCSGLabel = CutCSGLabel("lap_cut"),
) -> Tuple[CutCSG, HalfSpace]:
    """
    Create CSG cuts for a lap joint between two timber ends.

    Creates material removal volumes for both timbers in a lap joint configuration where
    one timber (top lap) has material removed from one face, and the other timber (bottom lap)
    has material removed from the opposite face so they interlock.

        lap_timber_face
        v           |--------| lap_length
    ╔════════════════════════╗          -
    ║face_lap_timber         ║          | lap_depth
    ║               ╔════════╝          -
    ║               ║
    ║               ║
    ╚═══════════════╝
                    ^ lap_shoulder_position_from_lap_timber_end

    Args:
        lap_timber: The timber that will have material removed from the specified face
        lap_timber_end: Which end of the top lap timber is being joined
        lap_timber_face: Which face of the top lap timber to remove material from
        lap_length: Length of the lap region along the timber length
        lap_shoulder_position_from_lap_timber_end: Distance from the timber end to the shoulder (inward)
        lap_depth: Depth of material to remove (measured from lap_timber_face)

    Returns:
        Tuple of (lap_prism, end_cut_half_plane) representing material to remove from the timber
        Both CSGs are in local coordinates of the timber

    Example:
        >>> # Create a half-lap joint
        >>> top_lap, top_end_cut = chop_lap_on_timber_end(
        ...     timber_a, TimberEnd.TOP,
        ...     TimberFace.BOTTOM, lap_length=4, lap_depth=2, shoulder_pos=1
        ... )
    """
    assert isinstance(lap_timber_end, TimberEnd), f"expected TimberEnd, got {type(lap_timber_end).__name__}"

    # Step 1: Determine the end positions and shoulder position of the top lap timber
    if lap_timber_end == TimberEnd.TOP:
        lap_end_pos = locate_top_center_position(lap_timber).position
        lap_direction = lap_timber.get_length_direction_global() 
    else:  # BOTTOM
        lap_end_pos = locate_bottom_center_position(lap_timber).position
        lap_direction = -lap_timber.get_length_direction_global()

    # Calculate the shoulder position (where the lap starts)
    shoulder_pos_global = lap_end_pos - lap_direction * lap_shoulder_position_from_lap_timber_end

    # Calculate the end of the lap (shoulder + lap_length)
    lap_end_pos_global = shoulder_pos_global + lap_direction * lap_length

    # Step 3: Create half-plane cuts to remove the ends beyond the lap region
    # Top lap: remove everything beyond the shoulder position (towards the timber end)
    lap_end_distance_from_bottom = ((lap_end_pos_global - lap_timber.get_bottom_position_global()).T * lap_timber.get_length_direction_global())[0, 0]
    lap_shoulder_distance_from_bottom = ((shoulder_pos_global - lap_timber.get_bottom_position_global()).T * lap_timber.get_length_direction_global())[0, 0]

    lap_shoulder_distance_from_end = (lap_timber.length - lap_end_distance_from_bottom
                                         if lap_timber_end == TimberEnd.TOP 
                                         else lap_end_distance_from_bottom)

    lap_half_plane = chop_timber_end_with_half_plane(lap_timber, lap_timber_end, lap_shoulder_distance_from_end)


    # Step 4: Determine the orientation of the lap based on lap_timber_face

    # For the top lap timber: remove material on the specified face
    # The prism should extend from shoulder to lap_end in length direction
    # And remove lap_depth of material perpendicular to the face

    # Calculate the prism dimensions and position for top lap
    # Start and end distances in local coordinates
    # Ensure start <= end for RectangularPrism
    prism_start = min(lap_shoulder_distance_from_bottom, lap_end_distance_from_bottom)
    prism_end = max(lap_shoulder_distance_from_bottom, lap_end_distance_from_bottom)

    # Step 5: Find where the two laps meet based on lap_depth
    # The top lap removes material from lap_timber_face
    # The bottom lap removes material from the opposite side

    # For a face-based lap, we need to offset the prism perpendicular to the face
    # Get the face direction and offset
    if lap_timber_face == TimberFace.TOP or lap_timber_face == TimberFace.BOTTOM:
        raise ValueError("cannot cut lap on end faces")
    elif lap_timber_face == TimberFace.LEFT or lap_timber_face == TimberFace.RIGHT:
        # Lap is on a width face (X-axis in local coords)
        # Remove material from the OPPOSITE side of lap_timber_face
        # lap_depth is the thickness of material we KEEP on the lap_timber_face side
        if lap_timber_face == TimberFace.RIGHT:
            # Keep lap_depth on RIGHT side, remove from LEFT side
            # Remove from x = -size[0]/2 to x = +size[0]/2 - lap_depth
            removal_width = lap_timber.size[0] - lap_depth
            x_offset = -lap_timber.size[0] / scalar(2) + removal_width / scalar(2)
        else:  # LEFT
            # Keep lap_depth on LEFT side, remove from RIGHT side
            # Remove from x = -size[0]/2 + lap_depth to x = +size[0]/2
            removal_width = lap_timber.size[0] - lap_depth
            x_offset = lap_timber.size[0] / scalar(2) - removal_width / scalar(2)

        lap_prism = RectangularPrism(
            size=create_v2(removal_width, lap_timber.size[1]),
            transform=Transform(position=create_v3(x_offset, 0, 0), orientation=Orientation.identity()),
            start_distance=prism_start,
            end_distance=prism_end,
            label=label,
        )
    else:  # FRONT or BACK
        # Lap is on a height face (Y-axis in local coords)
        # Remove material from the OPPOSITE side of lap_timber_face
        # lap_depth is the thickness of material we KEEP on the lap_timber_face side
        if lap_timber_face == TimberFace.FRONT:
            # Keep lap_depth on FRONT side, remove from BACK side
            # Remove from y = -size[1]/2 to y = +size[1]/2 - lap_depth
            removal_height = lap_timber.size[1] - lap_depth
            y_offset = -lap_timber.size[1] / scalar(2) + removal_height / scalar(2)
        else:  # BACK
            # Keep lap_depth on BACK side, remove from FRONT side
            # Remove from y = -size[1]/2 + lap_depth to y = +size[1]/2
            removal_height = lap_timber.size[1] - lap_depth
            y_offset = lap_timber.size[1] / scalar(2) - removal_height / scalar(2)

        lap_prism = RectangularPrism(
            size=create_v2(lap_timber.size[0], removal_height),
            transform=Transform(position=create_v3(0, y_offset, 0), orientation=Orientation.identity()),
            start_distance=prism_start,
            end_distance=prism_end,
            label=label,
        )

    # Step 7: Return the lap prism and end cut separately
    return lap_prism, lap_half_plane

chop_lap_on_timber_ends

chop_lap_on_timber_ends(top_lap_timber: TimberLike, top_lap_timber_end: TimberEnd, bottom_lap_timber: TimberLike, bottom_lap_timber_end: TimberEnd, top_lap_timber_face: TimberLongFace, lap_length: Numeric, top_lap_shoulder_position_from_top_lap_shoulder_timber_end: Numeric, lap_depth: Numeric, label: CutCSGLabel = CutCSGLabel('lap_cut')) -> Tuple[Tuple[CutCSG, HalfSpace], Tuple[CutCSG, HalfSpace]]

Create CSG cuts for a lap joint between two timber ends.

Creates material removal volumes for both timbers in a lap joint configuration where one timber (top lap) has material removed from one face, and the other timber (bottom lap) has material removed from the opposite face so they interlock.

top_lap_timber_face
v           |--------| lap_length

╔════════════════════════╗╔══════╗ - ║face_lap_timber ║║ ║ | lap_depth ║ ╔════════╝║ ║ - ║ ║╔════════╝ ║ ║ ║║ timberB ║ ╚═══════════════╝╚═══════════════╝ ^ top_lap_shoulder_position_from_top_lap_shoulder_timber_end

Parameters:

Name Type Description Default
top_lap_timber TimberLike

The timber that will have material removed from the specified face

required
top_lap_timber_end TimberEnd

Which end of the top lap timber is being joined

required
bottom_lap_timber TimberLike

The timber that will have material removed from the opposite face

required
bottom_lap_timber_end TimberEnd

Which end of the bottom lap timber is being joined

required
top_lap_timber_face TimberLongFace

Which face of the top lap timber to remove material from

required
lap_length Numeric

Length of the lap region along the timber length

required
top_lap_shoulder_position_from_top_lap_shoulder_timber_end Numeric

Distance from the timber end to the shoulder (inward)

required
lap_depth Numeric

Depth of material to remove (measured from top_lap_timber_face)

required

Returns:

Type Description
Tuple[CutCSG, HalfSpace]

Tuple of ((top_lap_prism, top_end_cut), (bottom_lap_prism, bottom_end_cut))

Tuple[CutCSG, HalfSpace]

Each tuple contains the lap CSG and end cut HalfSpace for that timber

Tuple[Tuple[CutCSG, HalfSpace], Tuple[CutCSG, HalfSpace]]

All CSGs are in local coordinates of their respective timbers

Example

Create a half-lap joint

(top_lap, top_end), (bottom_lap, bottom_end) = chop_lap_on_timber_ends( ... timber_a, TimberEnd.TOP, ... timber_b, TimberEnd.BOTTOM, ... TimberFace.BOTTOM, lap_length=4, lap_depth=2, shoulder_pos=1 ... )

Source code in kumiki/joints/workshop/shavings/shavings.py
def chop_lap_on_timber_ends(
    top_lap_timber: TimberLike,
    top_lap_timber_end: TimberEnd,
    bottom_lap_timber: TimberLike,
    bottom_lap_timber_end: TimberEnd,
    top_lap_timber_face: TimberLongFace,
    lap_length: Numeric,
    top_lap_shoulder_position_from_top_lap_shoulder_timber_end: Numeric,
    lap_depth: Numeric,
    label: CutCSGLabel = CutCSGLabel("lap_cut"),
) -> Tuple[Tuple[CutCSG, HalfSpace], Tuple[CutCSG, HalfSpace]]:
    """
    Create CSG cuts for a lap joint between two timber ends.

    Creates material removal volumes for both timbers in a lap joint configuration where
    one timber (top lap) has material removed from one face, and the other timber (bottom lap)
    has material removed from the opposite face so they interlock.

        top_lap_timber_face
        v           |--------| lap_length
    ╔════════════════════════╗╔══════╗  -
    ║face_lap_timber         ║║      ║  | lap_depth
    ║               ╔════════╝║      ║  -
    ║               ║╔════════╝      ║ 
    ║               ║║      timberB  ║ 
    ╚═══════════════╝╚═══════════════╝
                    ^ top_lap_shoulder_position_from_top_lap_shoulder_timber_end

    Args:
        top_lap_timber: The timber that will have material removed from the specified face
        top_lap_timber_end: Which end of the top lap timber is being joined
        bottom_lap_timber: The timber that will have material removed from the opposite face
        bottom_lap_timber_end: Which end of the bottom lap timber is being joined
        top_lap_timber_face: Which face of the top lap timber to remove material from
        lap_length: Length of the lap region along the timber length
        top_lap_shoulder_position_from_top_lap_shoulder_timber_end: Distance from the timber end to the shoulder (inward)
        lap_depth: Depth of material to remove (measured from top_lap_timber_face)

    Returns:
        Tuple of ((top_lap_prism, top_end_cut), (bottom_lap_prism, bottom_end_cut))
        Each tuple contains the lap CSG and end cut HalfSpace for that timber
        All CSGs are in local coordinates of their respective timbers

    Example:
        >>> # Create a half-lap joint
        >>> (top_lap, top_end), (bottom_lap, bottom_end) = chop_lap_on_timber_ends(
        ...     timber_a, TimberEnd.TOP,
        ...     timber_b, TimberEnd.BOTTOM,
        ...     TimberFace.BOTTOM, lap_length=4, lap_depth=2, shoulder_pos=1
        ... )
    """

    # assert the face types are correct
    assert isinstance(top_lap_timber_end, TimberEnd), \
        f"expected TimberEnd, got {type(top_lap_timber_end).__name__}"
    assert isinstance(bottom_lap_timber_end, TimberEnd), \
        f"expected TimberEnd, got {type(bottom_lap_timber_end).__name__}"
    assert isinstance(top_lap_timber_face, TimberLongFace), \
        f"expected TimberLongFace, got {type(top_lap_timber_face).__name__}"

    # Assert that the 2 timbers are face aligned
    assert are_timbers_face_aligned(top_lap_timber, bottom_lap_timber), \
        f"Timbers must be face-aligned for a splice lap joint. " \
        f"{top_lap_timber.ticket.path} and {bottom_lap_timber.ticket.path} orientations are not related by 90-degree rotations."

    # Assert the 2 timbers are parallel (either same direction or opposite)
    assert are_vectors_parallel(top_lap_timber.get_length_direction_global(), bottom_lap_timber.get_length_direction_global()), \
        f"Timbers must be parallel for a splice lap joint. " \
        f"{top_lap_timber.ticket.path} length_direction {top_lap_timber.get_length_direction_global().T} and " \
        f"{bottom_lap_timber.ticket.path} length_direction {bottom_lap_timber.get_length_direction_global().T} are not parallel."

    # Assert the 2 timber cross sections overlap at least a little
    assert do_xy_cross_section_on_parallel_timbers_overlap(top_lap_timber, bottom_lap_timber), \
        f"Timber cross sections should overlap for a splice lap joint or there is nothing to cut! " \
        f"{top_lap_timber.ticket.path} and {bottom_lap_timber.ticket.path} cross sections do not overlap."


    top_lap_prism, top_end_cut = chop_lap_on_timber_end(top_lap_timber, top_lap_timber_end, top_lap_timber_face.to.face(), lap_length, top_lap_shoulder_position_from_top_lap_shoulder_timber_end, lap_depth, label=label)
    top_lap_csg = (top_lap_prism, top_end_cut)

    # Step 2: Find the corresponding face on the bottom lap timber
    # Get top_lap_timber_face direction in global space
    top_lap_face_direction_global = top_lap_timber.get_face_direction_global(top_lap_timber_face)

    # Negate it to get the direction for the bottom timber face
    bottom_lap_face_direction_global = -top_lap_face_direction_global

    # Find which face of the bottom timber aligns with this direction
    bottom_lap_timber_face = bottom_lap_timber.get_closest_oriented_face_from_global_direction(bottom_lap_face_direction_global)

    # Step 3: Calculate the depth for the bottom lap
    # The bottom lap depth is measured from the bottom timber's face to the top timber's cutting plane
    # This accounts for any rotation or offset between the timbers
    # Create a plane at lap_depth from the top timber's face
    top_cutting_plane = locate_into_face(lap_depth, top_lap_timber_face, top_lap_timber)
    # Find the opposing face on the bottom timber
    top_face_direction = top_lap_timber.get_face_direction_global(top_lap_timber_face)
    bottom_face_direction = -top_face_direction
    bottom_face = bottom_lap_timber.get_closest_oriented_face_from_global_direction(bottom_face_direction)
    # Measure from the bottom face to the cutting plane
    marking = mark_distance_from_face_in_normal_direction(top_cutting_plane, bottom_lap_timber, bottom_face)
    bottom_lap_depth = Abs(marking.distance)

    # Step 4: Calculate the shoulder position for the bottom lap timber
    # Starting from scratch to avoid confusion between timber END and lap END
    #
    # For interlocking splice lap joint:
    # - Top timber SHOULDER → Bottom timber LAP END
    # - Top timber LAP END → Bottom timber SHOULDER

    # Calculate top timber's shoulder and lap end positions in global space
    if top_lap_timber_end == TimberEnd.TOP:
        top_timber_end_pos = locate_top_center_position(top_lap_timber).position
        top_lap_direction = top_lap_timber.get_length_direction_global() 
    else:  # BOTTOM
        top_timber_end_pos = locate_bottom_center_position(top_lap_timber).position
        top_lap_direction = -top_lap_timber.get_length_direction_global() 

    # Top timber shoulder: move inward from timber end by shoulder distance
    top_shoulder_global = top_timber_end_pos - top_lap_direction * top_lap_shoulder_position_from_top_lap_shoulder_timber_end

    # Top timber lap end: move outward from shoulder by lap_length
    top_lap_end_global = top_shoulder_global + top_lap_direction * lap_length

    bottom_shoulder_global = top_lap_end_global

    # Project bottom shoulder position onto bottom timber's length axis
    bottom_shoulder_from_bottom_timber_bottom = safe_dot_product((bottom_shoulder_global - bottom_lap_timber.get_bottom_position_global()), bottom_lap_timber.get_length_direction_global())

    # Calculate shoulder distance from bottom timber's reference end
    if bottom_lap_timber_end == TimberEnd.TOP:
        # Measuring from top end
        bottom_lap_shoulder_position_from_bottom_timber_end = bottom_lap_timber.length - bottom_shoulder_from_bottom_timber_bottom
    else:  # BOTTOM
        # Measuring from bottom end
        bottom_lap_shoulder_position_from_bottom_timber_end = bottom_shoulder_from_bottom_timber_bottom

    bottom_lap_prism, bottom_end_cut = chop_lap_on_timber_end(bottom_lap_timber, bottom_lap_timber_end, bottom_lap_timber_face, lap_length, bottom_lap_shoulder_position_from_bottom_timber_end, bottom_lap_depth, label=label)
    bottom_lap_csg = (bottom_lap_prism, bottom_end_cut)
    return (top_lap_csg, bottom_lap_csg)

chop_profile_on_timber_face

chop_profile_on_timber_face(timber: TimberLike, end: TimberEnd, face: TimberFace, profile: Union[List[V2], List[List[V2]]], depth: Numeric, profile_y_offset_from_end: Numeric = scalar(0), label: CutCSGLabel = CutCSGLabel('profile_cut')) -> Union[SolidUnion, ConvexPolygonExtrusion]

Create a CSG extrusion of a profile (or multiple profiles) on a timber face. See the diagram below for understanding how to interpret the profile in the timber's local space based on the end and face arguments.

                    end

timber v ^ ╔════════════════════════╗ -x ║face ║< (0,profile_y_offset_from_end) of the profile +y -> ╚════════════════════════╝ +x v

Parameters:

Name Type Description Default
timber TimberLike

The timber to create a profile for

required
end TimberEnd

Which end to create the profile on (determines the origin and rotation of the profile)

required
face TimberFace

Which face to create the profile on (determines the origin, rotation, and extrusion direction of the profile)

required
profile Union[List[V2], List[List[V2]]]

Either a single profile (List[V2]) or multiple profiles (List[List[V2]]). Multiple profiles are provided as a convenience for creating non-convex shapes by unioning multiple convex polygon extrusions.

required
depth Numeric

Depth to extrude the profile through the timber's face

required
profile_y_offset_from_end Numeric

Offset in the Y direction (along timber length from end). The profile will be translated by -profile_y_offset_from_end, so the origin (0,0) in profile coordinates corresponds to (0, profile_y_offset_from_end) in the timber's end-face coordinate system.

scalar(0)

Returns:

Type Description
Union[SolidUnion, ConvexPolygonExtrusion]

CutCSG representing the extruded profile(s) in the timber's local coordinates.

Union[SolidUnion, ConvexPolygonExtrusion]

If multiple profiles are provided, returns a SolidUnion of all extruded profiles.

Notes
  • The profile is positioned at the intersection of the specified end and face
  • Profile coordinates: X-axis points into timber from end, Y-axis across face, origin at (0,0) on face
  • The extrusion extends inward from the face by the specified depth
  • For non-convex shapes, provide multiple profiles (List[List[V2]]) which will be individually extruded and unioned together
  • Each individual profile uses ConvexPolygonExtrusion, so complex non-convex shapes should be decomposed into multiple convex profiles
Source code in kumiki/joints/workshop/shavings/shavings.py
def chop_profile_on_timber_face(
    timber: TimberLike,
    end: TimberEnd,
    face: TimberFace,
    profile: Union[List[V2], List[List[V2]]],
    depth: Numeric,
    profile_y_offset_from_end: Numeric = scalar(0),
    label: CutCSGLabel = CutCSGLabel("profile_cut"),
) -> Union[SolidUnion, ConvexPolygonExtrusion]:
    """
    Create a CSG extrusion of a profile (or multiple profiles) on a timber face.
    See the diagram below for understanding how to interpret the profile in the timber's local space based on the end and face arguments.


                            end
    timber                   v                                                  ^
    ╔════════════════════════╗                                                  -x
    ║face                    ║< (0,profile_y_offset_from_end) of the profile    +y ->
    ╚════════════════════════╝                                                  +x
                                                                                v


    Args:
        timber: The timber to create a profile for
        end: Which end to create the profile on (determines the origin and rotation of the profile)
        face: Which face to create the profile on (determines the origin, rotation, and extrusion direction of the profile)
        profile: Either a single profile (List[V2]) or multiple profiles (List[List[V2]]).
                 Multiple profiles are provided as a convenience for creating non-convex shapes
                 by unioning multiple convex polygon extrusions.
        depth: Depth to extrude the profile through the timber's face
        profile_y_offset_from_end: Offset in the Y direction (along timber length from end).
                                   The profile will be translated by -profile_y_offset_from_end,
                                   so the origin (0,0) in profile coordinates corresponds to
                                   (0, profile_y_offset_from_end) in the timber's end-face coordinate system.

    Returns:
        CutCSG representing the extruded profile(s) in the timber's local coordinates.
        If multiple profiles are provided, returns a SolidUnion of all extruded profiles.

    Notes:
        - The profile is positioned at the intersection of the specified end and face
        - Profile coordinates: X-axis points into timber from end, Y-axis across face, origin at (0,0) on face
        - The extrusion extends inward from the face by the specified depth
        - For non-convex shapes, provide multiple profiles (List[List[V2]]) which will be 
          individually extruded and unioned together
        - Each individual profile uses ConvexPolygonExtrusion, so complex non-convex shapes
          should be decomposed into multiple convex profiles
    """
    assert isinstance(end, TimberEnd), f"expected TimberEnd, got {type(end).__name__}"

    # Check if we have a single profile or multiple profiles
    # If the first element is a list, we have multiple profiles
    is_multiple_profiles = isinstance(profile, list) and len(profile) > 0 and isinstance(profile[0], list)

    if is_multiple_profiles:
        # Recursively call this function for each profile and union the results
        extrusions = []
        for single_profile in profile:
            extrusion = chop_profile_on_timber_face(
                timber, end, face, cast(List[V2], single_profile), depth,
                profile_y_offset_from_end, label=CutCSGLabel.NoLabel(),
            )
            extrusions.append(extrusion)
        return SolidUnion(extrusions, label=label)

    # Single profile case - continue with original logic

    # Translate the profile by -profile_y_offset_from_end in the Y direction
    # This allows the user to specify profiles with arbitrary Y origins and position them correctly
    translated_profile = [point + create_v2(0, -profile_y_offset_from_end) for point in profile]

    # ========================================================================
    # Step 1: Determine the origin position in timber local coordinates
    # ========================================================================
    # The origin is at the intersection of the end and the face
    # In timber local coordinates:
    # - Local X-axis = width_direction
    # - Local Y-axis = height_direction
    # - Local Z-axis = length_direction (bottom to top)
    # - Origin is at bottom_position (center of bottom face)

    # Get Z coordinate based on end
    if end == TimberEnd.TOP:
        origin_z = timber.length
    else:  # BOTTOM
        origin_z = scalar(0)

    # Get X and Y offset based on face
    # The face determines where on the cross-section the origin is
    half_width = timber.size[0] / scalar(2)
    half_height = timber.size[1] / scalar(2)

    if face == TimberFace.TOP or face == TimberFace.BOTTOM:
        # For end faces, we can't really position a profile "on" them in the way described
        # This shouldn't happen based on the function's design
        raise ValueError(f"Face cannot be an end face (TOP or BOTTOM), got {face}")
    elif face == TimberFace.RIGHT:
        origin_x = half_width
        origin_y = scalar(0)
    elif face == TimberFace.LEFT:
        origin_x = -half_width
        origin_y = scalar(0)
    elif face == TimberFace.FRONT:
        origin_x = scalar(0)
        origin_y = half_height
    else:  # BACK
        origin_x = scalar(0)
        origin_y = -half_height

    origin_local = create_v3(origin_x, origin_y, origin_z)

    # ========================================================================
    # Step 2: Determine the profile coordinate system orientation
    # ========================================================================
    # The profile's coordinate system needs:
    # - X-axis: points along timber length (into timber from end)
    # - Y-axis: points across the face (perpendicular to length and face normal)
    # - Z-axis: points inward from face (extrusion direction)

    # Get face normal direction in timber local coordinates
    face_normal_local = face.get_direction()  # This gives the outward normal

    # Profile Y-axis: points towards the reference end
    if end == TimberEnd.TOP:
        profile_y_axis = create_v3(0, 0, 1)
    else:  # BOTTOM
        profile_y_axis = create_v3(0, 0, -1)

    # Profile Z-axis (extrusion): points outward from face (negative of face normal)
    profile_z_axis = face_normal_local

    # Profile Y-axis: perpendicular to X and Z, using right-hand rule
    # X = Y × Z (so that X, Y, Z form a right-handed system)
    profile_x_axis = cross_product(profile_y_axis, profile_z_axis)
    profile_x_axis = safe_normalize_vector(profile_x_axis)

    # Create the orientation matrix for the profile
    # Columns are: X-axis, Y-axis, Z-axis
    profile_orientation_matrix = Matrix([
        [profile_x_axis[0], profile_y_axis[0], profile_z_axis[0]],
        [profile_x_axis[1], profile_y_axis[1], profile_z_axis[1]],
        [profile_x_axis[2], profile_y_axis[2], profile_z_axis[2]]
    ])

    profile_orientation = Orientation(profile_orientation_matrix)
    profile_transform = Transform(position=origin_local, orientation=profile_orientation)

    # ========================================================================
    # Step 3: Create the ConvexPolygonExtrusion
    # ========================================================================
    # The extrusion starts at the origin (start_distance=0) and extends 
    # inward by depth along the profile's Z-axis

    extrusion = ConvexPolygonExtrusion(
        points=translated_profile,
        transform=profile_transform,
        start_distance=-depth,
        end_distance=scalar(0),
        label=label,
    )

    return extrusion

draw_gooseneck_polygon_NONCONVEX

draw_gooseneck_polygon_NONCONVEX(length: Numeric, small_width: Numeric, large_width: Numeric, head_length: Numeric) -> List[V2]

Returns the non-convex gooseneck profile as a single polygon (for reference/visualization).

The gooseneck shape has a narrow neck that widens into a trapezoidal head. This polygon is non-convex and cannot be used directly with chop_profile_on_timber_face. Use draw_gooseneck_polygon_CONVEX (aliased as draw_gooseneck_polygon) for actual cutting.

Parameters:

Name Type Description Default
length Numeric

Total length of the gooseneck shape along the profile Y-axis.

required
small_width Numeric

Width of the neck (the narrow portion).

required
large_width Numeric

Width of the head (the wide trapezoid base; must be > small_width).

required
head_length Numeric

Length of the trapezoidal head portion.

required

Returns:

Type Description
List[V2]

List of 2D points forming the non-convex gooseneck polygon (counter-clockwise).

Source code in kumiki/joints/workshop/shavings/shavings.py
def draw_gooseneck_polygon_NONCONVEX(length: Numeric, small_width: Numeric, large_width: Numeric, head_length: Numeric) -> List[V2]:
    """
    Returns the non-convex gooseneck profile as a single polygon (for reference/visualization).

    The gooseneck shape has a narrow neck that widens into a trapezoidal head. This polygon
    is non-convex and cannot be used directly with chop_profile_on_timber_face. Use
    draw_gooseneck_polygon_CONVEX (aliased as draw_gooseneck_polygon) for actual cutting.

    Args:
        length: Total length of the gooseneck shape along the profile Y-axis.
        small_width: Width of the neck (the narrow portion).
        large_width: Width of the head (the wide trapezoid base; must be > small_width).
        head_length: Length of the trapezoidal head portion.

    Returns:
        List of 2D points forming the non-convex gooseneck polygon (counter-clockwise).
    """
    from kumiki.rule import Matrix as _Matrix
    return [
            _Matrix([small_width/2, 0]),
            _Matrix([small_width/2, length-head_length]),
            _Matrix([large_width/2, length-head_length]),
            _Matrix([small_width/2, length]),
            _Matrix([-small_width/2, length]),
            _Matrix([-large_width/2, length-head_length]),
            _Matrix([-small_width/2, length-head_length]),
            _Matrix([-small_width/2, 0]),
        ]

draw_gooseneck_polygon_CONVEX

draw_gooseneck_polygon_CONVEX(length: Numeric, small_width: Numeric, large_width: Numeric, head_length: Numeric) -> List[List[V2]]

Returns the gooseneck profile decomposed into convex polygons for use with chop_profile_on_timber_face.

The non-convex gooseneck shape is split into two convex polygons — a neck rectangle and a head trapezoid — whose union gives the full gooseneck. This is the format required by chop_profile_on_timber_face (List[List[V2]]).

Parameters:

Name Type Description Default
length Numeric

Total length of the gooseneck shape along the profile Y-axis.

required
small_width Numeric

Width of the neck (the narrow portion).

required
large_width Numeric

Width of the head (the wide trapezoid base; must be > small_width).

required
head_length Numeric

Length of the trapezoidal head portion.

required

Returns:

Type Description
List[List[V2]]

List of two convex polygon point lists: [neck_rectangle, head_trapezoid].

Source code in kumiki/joints/workshop/shavings/shavings.py
def draw_gooseneck_polygon_CONVEX(length: Numeric, small_width: Numeric, large_width: Numeric, head_length: Numeric) -> List[List[V2]]:
    """
    Returns the gooseneck profile decomposed into convex polygons for use with chop_profile_on_timber_face.

    The non-convex gooseneck shape is split into two convex polygons — a neck rectangle and
    a head trapezoid — whose union gives the full gooseneck. This is the format required by
    chop_profile_on_timber_face (List[List[V2]]).

    Args:
        length: Total length of the gooseneck shape along the profile Y-axis.
        small_width: Width of the neck (the narrow portion).
        large_width: Width of the head (the wide trapezoid base; must be > small_width).
        head_length: Length of the trapezoidal head portion.

    Returns:
        List of two convex polygon point lists: [neck_rectangle, head_trapezoid].
    """
    from kumiki.rule import Matrix as _Matrix
    # Decompose the gooseneck into 2 convex polygons
    # Center rectangle and head trapezoid

    # Center neck rectangle
    center_rect = [
        _Matrix([small_width/2, 0]),
        _Matrix([small_width/2, length-head_length]),
        _Matrix([-small_width/2, length-head_length]),
        _Matrix([-small_width/2, 0]),
    ]

    # Head trapezoid (single shape)
    head_trap = [
        _Matrix([-large_width/2, length-head_length]),
        _Matrix([large_width/2, length-head_length]),
        _Matrix([small_width/2, length]),
        _Matrix([-small_width/2, length]),
    ]

    return [center_rect, head_trap]

solve_assembly

solve_assembly(members: Sequence[AssemblyMember], joints: Sequence[AssemblyJoint], clearout_clearance: float = _DEFAULT_CLEAROUT_CLEARANCE, should_cancel: Optional[Callable[[], bool]] = None) -> Optional[AssemblySolution]

Solve the disassembly sequence for an abstract assembly graph.

Returns None when no member has any translational freedom. On an unsolvable ordering the already-solved steps (including the failing ordering's earlier substeps) are returned with an AssemblyFailure — it never raises for unsolvability.

Raises NotImplementedError for rotational freedoms and ValueError for joints referencing unknown member keys.

Source code in kumiki/assembly.py
def solve_assembly(
    members: Sequence[AssemblyMember],
    joints: Sequence[AssemblyJoint],
    clearout_clearance: float = _DEFAULT_CLEAROUT_CLEARANCE,
    should_cancel: Optional[Callable[[], bool]] = None,
) -> Optional[AssemblySolution]:
    """Solve the disassembly sequence for an abstract assembly graph.

    Returns None when no member has any translational freedom. On an
    unsolvable ordering the already-solved steps (including the failing
    ordering's earlier substeps) are returned with an AssemblyFailure — it
    never raises for unsolvability.

    Raises NotImplementedError for rotational freedoms and ValueError for
    joints referencing unknown member keys.
    """
    member_by_key: Dict[int, AssemblyMember] = {member.key: member for member in members}
    for joint in joints:
        for key, spec in joint.members.items():
            if key not in member_by_key:
                raise ValueError(
                    f"Joint '{joint.name}' references unknown assembly member key {key}"
                )
            if spec.freedom is not None and spec.freedom.rotations:
                raise NotImplementedError(
                    f"Joint '{joint.name}': rotational assembly freedoms are not supported yet"
                )

    step_orderings = sorted({
        spec.ordering
        for joint in joints
        for spec in joint.members.values()
        if _spec_has_translations(spec)
    })
    if not step_orderings:
        return None

    member_orderings: Dict[int, Set[Ordering]] = {}
    for joint in joints:
        for key, spec in joint.members.items():
            if _spec_has_translations(spec):
                member_orderings.setdefault(key, set()).add(spec.ordering)
    member_min_ordering = {key: min(values) for key, values in member_orderings.items()}

    positions: Dict[int, _Float3] = {member.key: _float3(member.position) for member in members}
    pairs, pairs_by_member = _build_pairs(joints)

    engaged_count: Dict[int, int] = {member.key: 0 for member in members}
    for pair in pairs:
        engaged_count[pair.m] += 1
        engaged_count[pair.p] += 1
    removed: Set[int] = {key for key, count in engaged_count.items() if count == 0}

    warnings: List[str] = []
    warned: Set[Tuple[int, Ordering]] = set()
    steps: List[AssemblyStep] = []
    removed_before_step: List[Set[int]] = []
    failure: Optional[AssemblyFailure] = None
    sequence = 0  # micro-step counter; pairs record the seq they separated at

    def note_separation(pair: _Pair, ordering: Ordering) -> None:
        engaged_count[pair.m] -= 1
        engaged_count[pair.p] -= 1
        for key in (pair.m, pair.p):
            if engaged_count[key] == 0:
                removed.add(key)
        if pair.scheduled_orderings and all(o > ordering for o in pair.scheduled_orderings):
            warnings.append(
                f"Joint '{pair.joint_name}' separated incidentally during step {ordering.label()}"
            )

    def warn_dragged(group: Set[int], primaries: Set[int], ordering: Ordering) -> None:
        for key in sorted(group):
            if key in primaries:
                continue
            own = member_min_ordering.get(key)
            if own is not None and own > ordering and (key, ordering) not in warned:
                warned.add((key, ordering))
                warnings.append(
                    f"Disassembling step {ordering.label()} dragged member "
                    f"'{member_by_key[key].name}' whose own ordering is {own.label()}"
                )

    for ordering in step_orderings:
        removed_at_ordering_start = set(removed)
        active_members = {key for key in member_by_key if key not in removed_at_ordering_start}
        ms_members = {key for key in active_members
                      if ordering in member_orderings.get(key, ())}
        cs_members = active_members - ms_members
        plane_normal = _dominant_plane_normal(
            [positions[key] for key in sorted(ms_members)]
        ) if ms_members else None

        # Substep accumulator (Phase 3): list of (movement map, primaries,
        # start sequence). A micro-step merges into the open substep when its
        # group is disjoint AND every pair between them was separated before
        # the substep began.
        ordering_substeps: List[Tuple[Dict[int, _Float3], Set[int], int]] = []

        def emit_micro(movements: Dict[int, _Float3], primaries: Set[int],
                       mergeable: bool) -> None:
            if mergeable and ordering_substeps:
                current_map, current_primaries, start_seq = ordering_substeps[-1]
                if not (set(movements) & set(current_map)):
                    blocked = False
                    for key in movements:
                        for pair in pairs_by_member.get(key, []):
                            if pair.other(key) not in current_map:
                                continue
                            if not pair.separated or pair.separated_at_seq >= start_seq:
                                blocked = True
                                break
                        if blocked:
                            break
                    if not blocked:
                        current_map.update(movements)
                        current_primaries.update(primaries)
                        return
            ordering_substeps.append((dict(movements), set(primaries), sequence))

        while failure is None:
            if should_cancel is not None and should_cancel():
                return None
            scheduled_pairs = [
                pair for pair in pairs
                if not pair.separated and ordering in pair.scheduled_orderings
            ]
            if not scheduled_pairs:
                break

            # A ray only makes its AUTHOR a primary target, at the author's
            # own ordering: at a peg's suborder step the peg pops — the timber
            # it locks must not be extracted early via the same interface.
            candidate_inputs: Dict[Tuple[int, Tuple[float, float, float]], Tuple[int, _Float3]] = {}
            for pair in scheduled_pairs:
                for ray in pair.rays:
                    for owner_key, owner_ordering in ray.owners:
                        if owner_ordering != ordering:
                            continue
                        direction = ray.axis if owner_key == pair.m else _neg3(ray.axis)
                        candidate_inputs.setdefault((owner_key, _axis_key(direction)), (owner_key, direction))

            best: Optional[_Candidate] = None
            for target, direction in sorted(
                candidate_inputs.values(),
                key=lambda item: (member_by_key[item[0]].name, item[0], _axis_key(item[1])),
            ):
                if should_cancel is not None and should_cancel():
                    return None
                candidate = _evaluate_candidate(
                    target, direction, ordering, pairs_by_member, member_by_key,
                    member_min_ordering, positions, active_members, plane_normal,
                )
                if candidate is None:
                    continue
                if best is None or candidate.score < best.score:
                    best = candidate

            if best is None:
                ring = _attempt_simultaneous_step(ordering, pairs, should_cancel=should_cancel)
                if ring is not None:
                    sequence += 1
                    primaries: Set[int] = set()
                    for pair in scheduled_pairs:
                        delta = _sub3(ring.get(pair.m, (0.0, 0.0, 0.0)),
                                      ring.get(pair.p, (0.0, 0.0, 0.0)))
                        if _norm3(delta) > _ZERO_EPSILON:
                            primaries.add(pair.m)
                            primaries.add(pair.p)
                    for pair in pairs:
                        delta = _sub3(ring.get(pair.m, (0.0, 0.0, 0.0)),
                                      ring.get(pair.p, (0.0, 0.0, 0.0)))
                        if _norm3(delta) < _ZERO_EPSILON:
                            continue
                        was_separated = pair.separated
                        pair.apply_relative_delta(delta, sequence)
                        if not was_separated and pair.separated:
                            note_separation(pair, ordering)
                    warn_dragged(set(ring), primaries, ordering)
                    emit_micro(ring, primaries & set(ring), mergeable=False)
                    continue

                # ring is None here either because no valid extraction exists,
                # or because should_cancel fired partway through the search
                # (which _attempt_simultaneous_step reports the same way, as
                # None). Only the former is a genuine failure -- a cancelled
                # search must propagate as an abort, not a fabricated
                # AssemblyFailure with an incomplete diagnostic.
                if should_cancel is not None and should_cancel():
                    return None

                diagnostics: List[str] = []
                for pair in scheduled_pairs[:8]:
                    for target in (pair.m, pair.p):
                        partner = pair.other(target)
                        for ray in pair.rays:
                            direction = ray.axis if target == pair.m else _neg3(ray.axis)
                            group, parent = _closure(target, direction, pairs_by_member)
                            if partner in group:
                                diagnostics.append(
                                    f"moving '{member_by_key[target].name}' along "
                                    f"({direction[0]:.3f}, {direction[1]:.3f}, {direction[2]:.3f}) "
                                    f"absorbs its partner: "
                                    f"{_chain_text(target, partner, parent, member_by_key)}"
                                )
                first = scheduled_pairs[0]
                failure = AssemblyFailure(
                    ordering=ordering,
                    message=(
                        f"Cannot disassemble step {ordering.label()}: no valid extraction "
                        f"found for {len(scheduled_pairs)} remaining joint pair(s), e.g. "
                        f"'{member_by_key[first.m].name}' / '{member_by_key[first.p].name}' "
                        f"in joint '{first.joint_name}'"
                    ),
                    diagnostics=tuple(diagnostics[:12]),
                )
                break

            # Execute the winning micro-step: the group translates rigidly, so
            # only crossing pairs accumulate relative motion (engaged crossing
            # pairs were validated by closure; separated crossing pairs track
            # their keep-out margin).
            sequence += 1
            for pair, ray, relative in best.crossing:
                was_separated = pair.separated
                pair.apply_relative_delta(_scale3(relative, best.distance), sequence)
                if not was_separated and pair.separated:
                    note_separation(pair, ordering)
            updated = {pair.index for pair, _, _ in best.crossing}
            for member in best.group:
                for pair in pairs_by_member.get(member, []):
                    if pair.index in updated:
                        continue
                    if (pair.m in best.group) == (pair.p in best.group):
                        continue
                    updated.add(pair.index)
                    relative = best.direction if pair.m in best.group else _neg3(best.direction)
                    pair.relative_displacement = _add3(
                        pair.relative_displacement,
                        _scale3(relative, best.distance),
                    )
            warn_dragged(best.group, {best.target}, ordering)
            emit_micro(
                {member: _scale3(best.direction, best.distance) for member in best.group},
                {best.target},
                mergeable=True,
            )

        # Phase 2 (anchored centering) + emission of this ordering's substeps.
        for substep_index, (movement_map, primaries, _) in enumerate(ordering_substeps):
            final_map = dict(movement_map)
            if ms_members and not cs_members:
                total = (0.0, 0.0, 0.0)
                for key in ms_members:
                    total = _add3(total, final_map.get(key, (0.0, 0.0, 0.0)))
                average = _scale3(total, 1.0 / len(ms_members))
                if _norm3(average) > _ZERO_EPSILON:
                    for key in ms_members:
                        final_map[key] = _sub3(final_map.get(key, (0.0, 0.0, 0.0)), average)
            movements: List[MemberMovement] = []
            for key in sorted(final_map, key=lambda k: (member_by_key[k].name, k)):
                vector = final_map[key]
                magnitude = _norm3(vector)
                if magnitude < _ZERO_EPSILON:
                    continue
                unit = _scale3(vector, 1.0 / magnitude)
                movements.append(MemberMovement(
                    member_key=key,
                    direction=create_v3(unit[0], unit[1], unit[2]),
                    distance=magnitude,
                    dragged=key not in primaries,
                ))
            if movements:
                steps.append(AssemblyStep(ordering=ordering, movements=tuple(movements),
                                          substep=substep_index + 1))
                removed_before_step.append(removed_at_ordering_start)

        if failure is not None:
            break

    _clear_out(steps, removed_before_step, member_by_key, clearout_clearance)

    return AssemblySolution(steps=tuple(steps), warnings=tuple(warnings), failure=failure)

compute_timber_orientation

compute_timber_orientation(length_direction: Direction3D, width_direction: Direction3D) -> Orientation

Compute the orientation matrix from length and width directions

Parameters:

Name Type Description Default
length_direction Direction3D

Direction vector for the length axis as 3D vector, the +length direction is the +Z direction

required
width_direction Direction3D

Direction vector for the width axis as 3D vector, the +width direction is the +X direction

required

Returns:

Type Description
Orientation

Orientation object representing the timber's orientation in 3D space

Source code in kumiki/timber.py
def compute_timber_orientation(length_direction: Direction3D, width_direction: Direction3D) -> Orientation:
    """Compute the orientation matrix from length and width directions

    Args:
        length_direction: Direction vector for the length axis as 3D vector, the +length direction is the +Z direction
        width_direction: Direction vector for the width axis as 3D vector, the +width direction is the +X direction

    Returns:
        Orientation object representing the timber's orientation in 3D space
    """
    # Normalize the length direction first (this will be our primary axis)
    length_norm = safe_normalize_vector(length_direction)

    # Orthogonalize face direction relative to length direction using Gram-Schmidt
    face_input = safe_normalize_vector(width_direction)

    # Project face_input onto length_norm and subtract to get orthogonal component
    projection = length_norm * (face_input.dot(length_norm))
    face_orthogonal = face_input - projection

    # Check if face_orthogonal is too small (vectors were nearly parallel)
    if safe_zero_test(safe_norm(face_orthogonal)):
        # Choose an arbitrary orthogonal direction
        # Find a vector that's not parallel to length_norm
        if Abs(length_norm[0]) < scalar(9, 10):  # Threshold comparison
            temp_vector = create_v3(scalar(1), scalar(0), scalar(0))
        else:
            temp_vector = create_v3(scalar(0), scalar(1), scalar(0))

        # Project and orthogonalize
        projection = length_norm * (temp_vector.dot(length_norm))
        face_orthogonal = temp_vector - projection

    # Normalize the orthogonalized face direction
    face_norm = safe_normalize_vector(face_orthogonal)

    # Cross product to get the third axis (guaranteed to be orthogonal)
    cross_result = cross_product(length_norm, face_norm)
    height_norm = safe_normalize_vector(cross_result)

    # Create rotation matrix [face_norm, height_norm, length_norm]
    rotation_matrix = Matrix([
        [face_norm[0], height_norm[0], length_norm[0]],
        [face_norm[1], height_norm[1], length_norm[1]],
        [face_norm[2], height_norm[2], length_norm[2]]
    ])

    # Convert to Orientation
    return Orientation(rotation_matrix)

create_timber

create_timber(length: Numeric, size: V2, bottom_position: V3, length_direction: Direction3D, width_direction: Direction3D, ticket: Optional[Union[TimberTicket, str]] = None) -> Timber

Factory function to create a Timber with computed orientation from direction vectors

This is the main way to construct Timber instances. It takes direction vectors and computes the proper orientation matrix automatically.

AGENT NOTE: AVOID this function if possible, prefer methods like join_timber, attach_timber, create_*_timber_on_footprint, or even create_axis_aligned_timber, which are more robust and easier to use.

Parameters:

Name Type Description Default
length Numeric

Length of the timber

required
size V2

Cross-sectional size (width, height) as 2D vector, width is the X dimension (left to right), height is the Y dimension (front to back)

required
bottom_position V3

Position of the bottom point (center of cross-section) as 3D vector

required
length_direction Direction3D

Direction vector for the length axis as 3D vector, the +length direction is the +Z direction

required
width_direction Direction3D

Direction vector for the width axis as 3D vector, the +width direction is the +X direction

required
ticket Optional[Union[TimberTicket, str]]

Optional ticket for this timber (can be TimberTicket object or string name, used for rendering/debugging)

None

Returns:

Type Description
Timber

Timber instance with computed orientation

Source code in kumiki/timber.py
def create_timber(length: Numeric, size: V2, bottom_position: V3,
                          length_direction: Direction3D, width_direction: Direction3D,
                          ticket: Optional[Union[TimberTicket, str]] = None) -> 'Timber':
    """Factory function to create a Timber with computed orientation from direction vectors

    This is the main way to construct Timber instances. It takes direction vectors
    and computes the proper orientation matrix automatically.

    AGENT NOTE: AVOID this function if possible, prefer methods like join_timber, attach_timber, create_*_timber_on_footprint, or even create_axis_aligned_timber, which are more robust and easier to use.

    Args:
        length: Length of the timber
        size: Cross-sectional size (width, height) as 2D vector, width is the X dimension (left to right), height is the Y dimension (front to back)
        bottom_position: Position of the bottom point (center of cross-section) as 3D vector
        length_direction: Direction vector for the length axis as 3D vector, the +length direction is the +Z direction
        width_direction: Direction vector for the width axis as 3D vector, the +width direction is the +X direction
        ticket: Optional ticket for this timber (can be TimberTicket object or string name, used for rendering/debugging)

    Returns:
        Timber instance with computed orientation
    """
    orientation = compute_timber_orientation(length_direction, width_direction)
    transform = Transform(position=bottom_position, orientation=orientation)
    return Timber(length=length, size=size, transform=transform, ticket=_ensure_ticket(ticket))

did_end_cuts_extend_timber

did_end_cuts_extend_timber(timber: PerfectTimberWithin, cuts: List[Cutting]) -> bool

Check if any end cuts extend beyond the timber's original bounds.

An end cut extends beyond if: - Top cut: The cutting plane is at z > timber.length (cuts beyond the top) - Bottom cut: The cutting plane is at z < 0 (cuts beyond the bottom)

In local coordinates, HalfSpace end cuts are defined with: - Top cuts: normal pointing up (+Z), offset at the cut location - Bottom cuts: normal pointing down (-Z), offset at the cut location (negative value)

Parameters:

Name Type Description Default
timber PerfectTimberWithin

The timber being cut

required
cuts List[Cutting]

List of cuts on the timber

required

Returns:

Type Description
bool

True if any end cut extends beyond the timber's original length

Source code in kumiki/timber.py
def did_end_cuts_extend_timber(timber: PerfectTimberWithin, cuts: List['Cutting']) -> bool:
    """
    Check if any end cuts extend beyond the timber's original bounds.

    An end cut extends beyond if:
    - Top cut: The cutting plane is at z > timber.length (cuts beyond the top)
    - Bottom cut: The cutting plane is at z < 0 (cuts beyond the bottom)

    In local coordinates, HalfSpace end cuts are defined with:
    - Top cuts: normal pointing up (+Z), offset at the cut location
    - Bottom cuts: normal pointing down (-Z), offset at the cut location (negative value)

    Args:
        timber: The timber being cut
        cuts: List of cuts on the timber

    Returns:
        True if any end cut extends beyond the timber's original length
    """

    for cut in cuts:
        top_end_cut = cut.get_maybe_top_end_cut()
        bottom_end_cut = cut.get_maybe_bottom_end_cut()

        # Check top end cut
        if top_end_cut is not None:
            # For top cuts, normal is (0,0,1) and offset is the z-position of the cut
            # If offset > timber.length, the cut extends beyond the top
            if safe_compare(top_end_cut.offset - timber.length, 0, Comparison.GT):
                return True

        # Check bottom end cut
        if bottom_end_cut is not None:
            # For bottom cuts, normal is (0,0,-1) and offset is negative
            # If offset > 0, the cut extends beyond the bottom (into negative z)
            if safe_compare(bottom_end_cut.offset, 0, Comparison.GT):
                return True

    return False

make_compound_joint

make_compound_joint(joints: List[Joint], ticket: JointTicket) -> Joint

Create a compound joint that combines multiple joints together.

The cuttings and accessories from all joints are merged into a single Joint object. Numeric suffixes are added to accessory and cutting keys if there are conflicts. The tickets of the input joints are ignored.

Parameters:

Name Type Description Default
joints List[Joint]

List of Joint objects to combine

required
ticket JointTicket

JointTicket for the compound joint

required
Source code in kumiki/timber.py
def make_compound_joint(joints: List[Joint], ticket: JointTicket) -> Joint:
    """
    Create a compound joint that combines multiple joints together.

    The cuttings and accessories from all joints are merged into a single Joint object.
    Numeric suffixes are added to accessory and cutting keys if there are conflicts.
    The tickets of the input joints are ignored.

    Args:
        joints: List of Joint objects to combine
        ticket: JointTicket for the compound joint
    """
    def _add_with_unique_key(target: dict, key: str, value) -> None:
        if key not in target:
            target[key] = value
            return
        suffix = 2
        while f"{key}_{suffix}" in target:
            suffix += 1
        target[f"{key}_{suffix}"] = value

    merged_cuttings: Dict[str, Cutting] = {}
    merged_accessories: Dict[str, Accessory] = {}
    for joint in joints:
        for key, cutting in joint.cuttings.items():
            _add_with_unique_key(merged_cuttings, key, cutting)
        for key, accessory in joint.jointAccessories.items():
            _add_with_unique_key(merged_accessories, key, accessory)

    return Joint(cuttings=merged_cuttings, ticket=ticket, jointAccessories=merged_accessories)

require_check

require_check(err: Optional[str])
Source code in kumiki/timber.py
def require_check(err: Optional[str]):
    if err is not None:
        raise KumikiArrangementError(err)

add_milestone

add_milestone(name: str)

Emit a milestone marker for the viewer loading screen.

Writes a JSON protocol message to the real stdout pipe so the viewer extension can display progress during script execution. No-ops when not running inside the Kigumi extension (checks KIGUMI_VIEWER_MILESTONES environment variable).

Source code in kumiki/timber.py
def add_milestone(name: str):
    """Emit a milestone marker for the viewer loading screen.

    Writes a JSON protocol message to the real stdout pipe so the viewer
    extension can display progress during script execution.  No-ops when
    not running inside the Kigumi extension (checks KIGUMI_VIEWER_MILESTONES
    environment variable).
    """
    import os, sys, json as _json  # noqa: E401 — lazy imports to avoid burdening the core module
    if not os.environ.get("KIGUMI_VIEWER_MILESTONES"):
        return
    stdout = sys.__stdout__
    assert stdout is not None
    _json.dump({"type": "milestone", "name": name}, stdout)
    stdout.write("\n")
    stdout.flush()

solve_frame_assembly

solve_frame_assembly(frame: Frame, should_cancel: Optional[Callable[[], bool]] = None) -> Optional[AssemblySolution]

Solve the disassembly sequence for a frame's source joints.

Adapts the frame into the abstract assembly graph of kumiki/assembly.py — one AssemblyMember per distinct timber/accessory (keyed by ticket kumiki_id, positioned at the timber centroid) and one AssemblyJoint per source joint — then delegates to solve_assembly.

Returns None when no member of any source joint has an assembly freedom.

Source code in kumiki/timber.py
def solve_frame_assembly(
    frame: Frame,
    should_cancel: Optional[Callable[[], bool]] = None,
) -> Optional[AssemblySolution]:
    """Solve the disassembly sequence for a frame's source joints.

    Adapts the frame into the abstract assembly graph of kumiki/assembly.py —
    one AssemblyMember per distinct timber/accessory (keyed by ticket
    kumiki_id, positioned at the timber centroid) and one AssemblyJoint per
    source joint — then delegates to solve_assembly.

    Returns None when no member of any source joint has an assembly freedom.
    """
    source_joints = list(frame.source_joints or [])
    has_any_freedom = any(
        cutting.assembly_freedom is not None
        for joint in source_joints
        for cutting in joint.cuttings.values()
    ) or any(
        accessory.assembly_freedom is not None
        for joint in source_joints
        for accessory in joint.jointAccessories.values()
    )
    if not has_any_freedom:
        return None

    members: Dict[int, AssemblyMember] = {}

    def register_timber(timber: PerfectTimberWithin) -> int:
        key = timber.ticket.kumiki_id
        if key not in members:
            centroid = (
                timber.get_bottom_position_global()
                + timber.get_length_direction_global() * timber.length / 2
            )
            corners = [
                [float(giraffe_evalf(corner_position[axis, 0])) for axis in range(3)]
                for corner_position in (
                    timber.get_corner_position_global(corner) for corner in TimberCorner
                )
            ]
            bbox = AssemblyBoundingBox(
                min_x=min(c[0] for c in corners), max_x=max(c[0] for c in corners),
                min_y=min(c[1] for c in corners), max_y=max(c[1] for c in corners),
                min_z=min(c[2] for c in corners), max_z=max(c[2] for c in corners),
            )
            members[key] = AssemblyMember(key=key, name=timber.ticket.path, position=centroid, bbox=bbox)
        return key

    def register_accessory(accessory: Accessory) -> int:
        key = accessory.ticket.kumiki_id
        if key not in members:
            transform = getattr(accessory, "transform", None)
            position = transform.position if transform is not None else create_v3(0, 0, 0)
            # Accessory extents are not modeled yet; a small box at the
            # transform position lets the clear-out pass shove parked pegs.
            px, py, pz = (float(giraffe_evalf(position[axis, 0])) for axis in range(3))
            radius = 0.02
            bbox = AssemblyBoundingBox(
                min_x=px - radius, max_x=px + radius,
                min_y=py - radius, max_y=py + radius,
                min_z=pz - radius, max_z=pz + radius,
            )
            members[key] = AssemblyMember(key=key, name=accessory.ticket.path, position=position, bbox=bbox)
        return key

    def add_spec(specs: Dict[int, JointMemberSpec], key: int,
                 freedom: Optional[AssemblyFreedom], ordering: Ordering) -> None:
        existing = specs.get(key)
        if existing is None:
            specs[key] = JointMemberSpec(freedom=freedom, ordering=ordering)
            return
        # The same member can appear under several cutting keys of one
        # (compound) joint; its escape DOFs are the union of all of them and
        # the earliest ordering wins.
        if existing.freedom is not None and freedom is not None:
            combined = AssemblyFreedom.combine(existing.freedom, freedom)
        else:
            combined = existing.freedom if freedom is None else freedom
        specs[key] = JointMemberSpec(freedom=combined, ordering=min(existing.ordering, ordering))

    assembly_joints: List[AssemblyJoint] = []
    for joint in source_joints:
        specs: Dict[int, JointMemberSpec] = {}
        for cutting in joint.cuttings.values():
            add_spec(specs, register_timber(cutting.timber), cutting.assembly_freedom, cutting.assembly_ordering)
        for accessory in joint.jointAccessories.values():
            add_spec(specs, register_accessory(accessory), accessory.assembly_freedom, accessory.assembly_ordering)
        joint_name = joint.ticket.get_name()
        if joint_name == "[no-name]":
            joint_name = joint.ticket.joint_type or "joint"
        assembly_joints.append(AssemblyJoint(name=joint_name, members=specs))

    return solve_assembly(list(members.values()), assembly_joints, should_cancel=should_cancel)

intersect_planes

intersect_planes(a: Optional[Plane], b: Optional[Plane]) -> Optional[Line]

The infinite line where two planes meet, or None if they never do.

None covers three cases that all mean "no line here": either plane missing (a caller passing through a locate() that declined), the planes parallel, and the planes coincident. Coincident planes are geometrically a whole shared plane rather than a line, so they are not an intersection this can describe -- that relation is worth capturing separately, since two coincident faces is exactly the rough-matches-perfect test, but it is not an edge.

The returned direction is normalised; the returned point is the point on the line closest to the origin.

Source code in kumiki/geometry.py
def intersect_planes(a: Optional[Plane], b: Optional[Plane]) -> Optional[Line]:
    """The infinite line where two planes meet, or None if they never do.

    None covers three cases that all mean "no line here": either plane missing
    (a caller passing through a locate() that declined), the planes parallel,
    and the planes coincident. Coincident planes are geometrically a whole
    shared plane rather than a line, so they are not an intersection this can
    describe -- that relation is worth capturing separately, since two
    coincident faces is exactly the rough-matches-perfect test, but it is not
    an edge.

    The returned direction is normalised; the returned point is the point on
    the line closest to the origin.
    """
    if a is None or b is None:
        return None

    direction = cross_product(a.normal, b.normal)
    # |n1 x n2| is |n1||n2|sin(theta), so this is zero exactly when the normals
    # are parallel. It is a SQUARED magnitude, hence safe_zero_test_sq.
    magnitude_squared = safe_dot_product(direction, direction)
    if safe_zero_test_sq(magnitude_squared):
        return None

    # Each plane is dot(normal, x) == offset; solve the pair for a point on both.
    offset_a = safe_dot_product(a.normal, a.point)
    offset_b = safe_dot_product(b.normal, b.point)
    point = (
        cross_product(b.normal, direction) * offset_a
        + cross_product(direction, a.normal) * offset_b
    ) / magnitude_squared
    return Line(direction=safe_normalize_vector(direction), point=point)

planes_are_parallel

planes_are_parallel(a: Optional[Plane], b: Optional[Plane]) -> bool

Whether two planes never meet in a line (parallel, or the same plane).

Source code in kumiki/geometry.py
def planes_are_parallel(a: Optional[Plane], b: Optional[Plane]) -> bool:
    """Whether two planes never meet in a line (parallel, or the same plane)."""
    if a is None or b is None:
        return False
    return are_vectors_parallel(a.normal, b.normal)

arris_against_cap

arris_against_cap(side: int, sides: int, end: bool) -> FeatureKey

The arris where a side meets one of the caps.

One of the two places that know how the ARRIS run is laid out; see FeatureCategory. Reading "arris.5" back needs the side count, which nothing in the code has to do -- a key is matched and named, never decoded -- so that cost falls on a person rather than on a caller.

Source code in kumiki/cutcsg.py
def arris_against_cap(side: int, sides: int, end: bool) -> FeatureKey:
    """The arris where a side meets one of the caps.

    One of the two places that know how the ARRIS run is laid out; see
    FeatureCategory. Reading "arris.5" back needs the side count, which nothing
    in the code has to do -- a key is matched and named, never decoded -- so
    that cost falls on a person rather than on a caller.
    """
    return (FeatureCategory.ARRIS, sides * (2 if end else 1) + side)

corner_on_cap

corner_on_cap(vertex: int, vertices: int, end: bool) -> FeatureKey

Vertex n of the start or end profile, in the one CORNER run.

Source code in kumiki/cutcsg.py
def corner_on_cap(vertex: int, vertices: int, end: bool) -> FeatureKey:
    """Vertex n of the start or end profile, in the one CORNER run."""
    return (FeatureCategory.CORNER, (vertices if end else 0) + vertex)

default_feature_name

default_feature_name(key: FeatureKey) -> str

What a default feature is called when nobody has named it.

Deterministic, so a default is referenceable -- from a drawing, a measurement, an override -- without anyone having authored a name for it. Lower case and dotted to sit alongside the authored names already in use, which look like "ptw.front" and "rough.back_right".

Source code in kumiki/cutcsg.py
def default_feature_name(key: FeatureKey) -> str:
    """What a default feature is called when nobody has named it.

    Deterministic, so a default is referenceable -- from a drawing, a
    measurement, an override -- without anyone having authored a name for it.
    Lower case and dotted to sit alongside the authored names already in use,
    which look like "ptw.front" and "rough.back_right".
    """
    category, index = key
    return f"{category.name.lower()}.{index}"

feature_groups_intersect

feature_groups_intersect(a: FeatureGroup, b: FeatureGroup) -> bool

Whether features in groups a and b may form an edge together.

Source code in kumiki/cutcsg.py
def feature_groups_intersect(a: FeatureGroup, b: FeatureGroup) -> bool:
    """Whether features in groups *a* and *b* may form an edge together."""
    return b in FEATURE_GROUP_PAIRS[a]

derive_edge_hits

derive_edge_hits(owner: CutCSG, face_hits: List[OwnedFeatureHit]) -> List[OwnedFeatureHit]

Every edge formed by a pair of face_hits, owned by owner.

The pairs come from a scan run at the edge tolerance, so if two faces both turned up there, the conjunction that defines their edge holds at that tolerance by construction -- no further point testing needed. That is what makes this O(k^2) over the few faces near the point rather than over everything the subtree declares.

Not deduplicated: derivation runs once, at whichever node the caller queried, so nothing arrives here twice. Names are not unique enough to dedupe by anyway -- two tenons on one timber legitimately declare the same face names, which makes their edges share a name while being genuinely different edges.

Source code in kumiki/cutcsg.py
def derive_edge_hits(
    owner: 'CutCSG',
    face_hits: List['OwnedFeatureHit'],
) -> List['OwnedFeatureHit']:
    """Every edge formed by a pair of *face_hits*, owned by *owner*.

    The pairs come from a scan run at the edge tolerance, so if two faces both
    turned up there, the conjunction that defines their edge holds at that
    tolerance by construction -- no further point testing needed. That is what
    makes this O(k^2) over the few faces near the point rather than over
    everything the subtree declares.

    Not deduplicated: derivation runs once, at whichever node the caller
    queried, so nothing arrives here twice. Names are not unique enough to
    dedupe by anyway -- two tenons on one timber legitimately declare the same
    face names, which makes their edges share a name while being genuinely
    different edges.
    """
    hits: List['OwnedFeatureHit'] = []
    for i in range(len(face_hits)):
        for j in range(i + 1, len(face_hits)):
            edge = DerivedEdgeFeature.derive(face_hits[i], face_hits[j])
            if edge is not None:
                hits.append(OwnedFeatureHit(feature=edge, owner=owner))
    return hits

csg_children

csg_children(csg: CutCSG) -> List[CutCSG]

The nodes directly beneath csg; empty for a primitive.

Source code in kumiki/cutcsg.py
def csg_children(csg: CutCSG) -> List[CutCSG]:
    """The nodes directly beneath *csg*; empty for a primitive."""
    if isinstance(csg, SolidUnion):
        return list(csg.children)
    if isinstance(csg, Intersection):
        return [csg.left, csg.right]
    if isinstance(csg, Difference):
        return [csg.base, *csg.subtract]
    return []

csg_children_with_parity

csg_children_with_parity(csg: CutCSG, parity: CSGParity = ADDITIVE) -> List[Tuple[CutCSG, CSGParity]]

The nodes directly beneath csg, each with its own parity.

The one statement of the rule: a Difference's subtract children invert, and nothing else does. A union's children are each monotone-increasing in the union, an intersection's operands in the intersection, and a Difference's base in the difference -- so those all inherit.

Children come back in csg_children order.

Source code in kumiki/cutcsg.py
def csg_children_with_parity(
    csg: CutCSG,
    parity: CSGParity = CSGParity.ADDITIVE,
) -> List[Tuple[CutCSG, CSGParity]]:
    """The nodes directly beneath *csg*, each with its own parity.

    The one statement of the rule: a Difference's subtract children invert,
    and nothing else does. A union's children are each monotone-increasing in
    the union, an intersection's operands in the intersection, and a
    Difference's base in the difference -- so those all inherit.

    Children come back in csg_children order.
    """
    if isinstance(csg, Difference):
        flipped = parity.flipped()
        return [(csg.base, parity), *((sub, flipped) for sub in csg.subtract)]
    return [(child, parity) for child in csg_children(csg)]

walk_csg_with_parity

walk_csg_with_parity(root: CutCSG, parity: CSGParity = ADDITIVE) -> Iterator[Tuple[CutCSG, CSGParity]]

Every node beneath root, including root, with its parity.

Parity belongs to a node's POSITION, not to the node: a node has no parent pointer and cannot answer on its own, and the same subtree placed twice in one tree can have a different answer each time. So this yields one entry per occurrence and always starts from a root -- there is no way to ask a node about itself.

Two subtract edges cancel: in A - (B - C) the C is ADDITIVE, and indeed C restores material that B removed.

Source code in kumiki/cutcsg.py
def walk_csg_with_parity(
    root: CutCSG,
    parity: CSGParity = CSGParity.ADDITIVE,
) -> Iterator[Tuple[CutCSG, CSGParity]]:
    """Every node beneath *root*, including *root*, with its parity.

    Parity belongs to a node's POSITION, not to the node: a node has no parent
    pointer and cannot answer on its own, and the same subtree placed twice in
    one tree can have a different answer each time. So this yields one entry
    per occurrence and always starts from a root -- there is no way to ask a
    node about itself.

    Two subtract edges cancel: in ``A - (B - C)`` the C is ADDITIVE, and
    indeed C restores material that B removed.
    """
    yield root, parity
    for child, child_parity in csg_children_with_parity(root, parity):
        yield from walk_csg_with_parity(child, child_parity)

translate_profile

translate_profile(profile: Profile, translation: V2) -> Profile

Translate a profile by a given translation vector.

Source code in kumiki/cutcsg.py
def translate_profile(profile: Profile, translation: V2) -> Profile:
    """
    Translate a profile by a given translation vector.
    """
    return [point + translation for point in profile]

translate_profiles

translate_profiles(profiles: Profiles, translation: V2) -> Profiles

Translate a list of profiles by a given translation vector.

Source code in kumiki/cutcsg.py
def translate_profiles(profiles: Profiles, translation: V2) -> Profiles:
    """
    Translate a list of profiles by a given translation vector.
    """
    return [translate_profile(profile, translation) for profile in profiles]

decompose_simple_polygon_into_convex_pieces

decompose_simple_polygon_into_convex_pieces(points: Profile) -> List[Profile]

Decompose a simple (non-self-intersecting) polygon, given as an ordered list of (u, v) points, into convex quads/triangles whose union equals the polygon — via horizontal (constant-v) trapezoidal decomposition.

See pathcsg.decompose_path_into_convex_pieces for the same algorithm generalized to a Path (lines + arcs): it sweeps directly over a Path's segments instead of a pre-tessellated point list, so the expensive exact-arithmetic part runs over the (small) segment count rather than however many points arc tessellation would otherwise produce. Not wired together with this function (would need pathcsg -> cutcsg -> pathcsg, which is circular) — kept as two independent implementations of the same sweep for now.

Splits the polygon at every vertex's v-coordinate, and within each resulting v-band, finds every edge active there, sorts their u-crossings left to right, and pairs them up with the standard even-odd polygon-fill rule (1st-2nd pair is interior, 3rd-4th pair is interior, and so on). This handles overlapping v-ranges between edges correctly (unlike naively treating each edge as its own independent band), and degenerate edges that double back along another edge (contributing paired, zero-width crossings) simply cancel out.

Parameters:

Name Type Description Default
points Profile

Ordered polygon vertices (u, v), last connects back to first. v need not be monotonic along the boundary.

required

Returns:

Type Description
List[Profile]

List of convex pieces, each a Profile (quad or triangle) suitable for

List[Profile]

ConvexPolygonExtrusion.

Source code in kumiki/cutcsg.py
def decompose_simple_polygon_into_convex_pieces(points: Profile) -> List[Profile]:
    """
    Decompose a simple (non-self-intersecting) polygon, given as an ordered
    list of (u, v) points, into convex quads/triangles whose union equals the
    polygon — via horizontal (constant-v) trapezoidal decomposition.

    See pathcsg.decompose_path_into_convex_pieces for the same algorithm
    generalized to a Path (lines + arcs): it sweeps directly over a Path's
    segments instead of a pre-tessellated point list, so the expensive
    exact-arithmetic part runs over the (small) segment count rather than
    however many points arc tessellation would otherwise produce. Not wired
    together with this function (would need pathcsg -> cutcsg -> pathcsg,
    which is circular) — kept as two independent implementations of the same
    sweep for now.

    Splits the polygon at every vertex's v-coordinate, and within each
    resulting v-band, finds every edge active there, sorts their u-crossings
    left to right, and pairs them up with the standard even-odd polygon-fill
    rule (1st-2nd pair is interior, 3rd-4th pair is interior, and so on).
    This handles overlapping v-ranges between edges correctly (unlike naively
    treating each edge as its own independent band), and degenerate edges
    that double back along another edge (contributing paired, zero-width
    crossings) simply cancel out.

    Args:
        points: Ordered polygon vertices (u, v), last connects back to first.
            v need not be monotonic along the boundary.

    Returns:
        List of convex pieces, each a Profile (quad or triangle) suitable for
        ConvexPolygonExtrusion.
    """
    n = len(points)
    edges: List[Tuple[Numeric, Numeric, Numeric, Numeric]] = []  # (v_lo, v_hi, u_at_v_lo, u_at_v_hi)
    for i in range(n):
        a = points[i]
        b = points[(i + 1) % n]
        if safe_zero_test(a[1] - b[1]):
            continue  # horizontal edge: no v-crossings, doesn't bound any band
        if safe_compare(a[1], b[1], Comparison.LT):
            edges.append((a[1], b[1], a[0], b[0]))
        else:
            edges.append((b[1], a[1], b[0], a[0]))

    breakpoints: List[Numeric] = sorted((p[1] for p in points), key=giraffe_evalf)
    deduped_breakpoints: List[Numeric] = []
    for v in breakpoints:
        if not deduped_breakpoints or not safe_zero_test(v - deduped_breakpoints[-1]):
            deduped_breakpoints.append(v)

    pieces: List[Profile] = []
    for i in range(len(deduped_breakpoints) - 1):
        v_lo, v_hi = deduped_breakpoints[i], deduped_breakpoints[i + 1]
        v_mid = (v_lo + v_hi) / scalar(2)

        crossings = []  # (u_at_v_mid, u_at_v_lo, u_at_v_hi)
        for (e_v_lo, e_v_hi, e_u_lo, e_u_hi) in edges:
            if safe_compare(e_v_lo, v_mid, Comparison.LE) and safe_compare(v_mid, e_v_hi, Comparison.LE):
                t_lo = (v_lo - e_v_lo) / (e_v_hi - e_v_lo)
                t_hi = (v_hi - e_v_lo) / (e_v_hi - e_v_lo)
                t_mid = (v_mid - e_v_lo) / (e_v_hi - e_v_lo)
                u_lo = e_u_lo + t_lo * (e_u_hi - e_u_lo)
                u_hi = e_u_lo + t_hi * (e_u_hi - e_u_lo)
                u_mid = e_u_lo + t_mid * (e_u_hi - e_u_lo)
                crossings.append((u_mid, u_lo, u_hi))
        crossings.sort(key=lambda c: giraffe_evalf(c[0]))

        if len(crossings) % 2 != 0:
            raise ValueError("profile polygon is not simple: odd number of boundary crossings in a v-band")

        for j in range(0, len(crossings) - 1, 2):
            _, u_left_lo, u_left_hi = crossings[j]
            _, u_right_lo, u_right_hi = crossings[j + 1]
            # A degenerate (zero-area, e.g. two edges retracing the same line)
            # pair — both corners coincide at both v_lo and v_hi — contributes
            # nothing and isn't a valid convex polygon; skip it.
            if safe_zero_test(u_right_lo - u_left_lo) and safe_zero_test(u_right_hi - u_left_hi):
                continue
            pieces.append([
                create_v2(u_left_lo, v_lo), create_v2(u_right_lo, v_lo),
                create_v2(u_right_hi, v_hi), create_v2(u_left_hi, v_hi),
            ])

    return pieces

translate_csg

translate_csg(csg: CutCSG, translation: V3) -> CutCSG

Return a copy of the CSG object translated by the given vector.

Parameters:

Name Type Description Default
csg CutCSG

The CSG object to translate

required
translation V3

3D translation vector (3x1 Matrix)

required

Returns:

Type Description
CutCSG

A new CSG object with the same structure but translated by translation

Source code in kumiki/cutcsg.py
def translate_csg(csg: CutCSG, translation: V3) -> CutCSG:
    """
    Return a copy of the CSG object translated by the given vector.

    Args:
        csg: The CSG object to translate
        translation: 3D translation vector (3x1 Matrix)

    Returns:
        A new CSG object with the same structure but translated by translation
    """
    if isinstance(csg, SolidUnion):
        return SolidUnion(children=[translate_csg(c, translation) for c in csg.children], label=csg.label)
    if isinstance(csg, Difference):
        return Difference(
            base=translate_csg(csg.base, translation),
            subtract=[translate_csg(s, translation) for s in csg.subtract],
            label=csg.label,
        )
    if isinstance(csg, Intersection):
        return Intersection(
            left=translate_csg(csg.left, translation),
            right=translate_csg(csg.right, translation),
            label=csg.label,
        )
    if isinstance(csg, HalfSpace):
        # HalfSpace: normal·P >= offset. After translating by T: normal·(P - T) >= offset => normal·P >= offset + normal·T
        new_offset = csg.offset + safe_dot_product(csg.normal, translation)
        return replace(csg, offset=new_offset)
    if isinstance(csg, RectangularPrism):
        new_position = csg.transform.position + translation
        new_transform = replace(csg.transform, position=new_position)
        return replace(csg, transform=new_transform)
    if isinstance(csg, ConvexPolygonExtrusion):
        new_position = csg.transform.position + translation
        new_transform = replace(csg.transform, position=new_position)
        return replace(csg, transform=new_transform)
    if isinstance(csg, ConvexPolygonSimpleLoft):
        new_position = csg.transform.position + translation
        new_transform = replace(csg.transform, position=new_position)
        return replace(csg, transform=new_transform)
    if isinstance(csg, Cylinder):
        return replace(csg, position=csg.position + translation)
    # Unknown CSG type: return as-is
    return csg

create_axis_aligned_timber

create_axis_aligned_timber(bottom_position: V3, length: Numeric, size: V2, length_direction: TimberFace, width_direction: Optional[TimberFace] = None, ticket: Optional[Union[TimberTicket, str]] = None) -> Timber

Creates an axis-aligned timber using TimberFace to reference directions in the world coordinate system.

AGENT NOTE: Prefer methods like join_timber, attach_face/plane_aligned_timber, create_*_timber_on_footprint, which are more robust and easier to use.

Parameters:

Name Type Description Default
bottom_position V3

Position of the bottom point of the timber

required
length Numeric

Length of the timber

required
size V2

Cross-sectional size (width, height)

required
length_direction TimberFace

Direction for the timber's length axis

required
width_direction Optional[TimberFace]

Optional direction for the timber's width axis. If not provided, defaults to RIGHT (+X) unless length_direction is RIGHT, in which case TOP (+Z) is used.

None
ticket Optional[Union[TimberTicket, str]]

Optional ticket for this timber (can be Ticket object or string name, used for rendering/debugging)

None

Returns:

Type Description
Timber

New timber with the specified axis-aligned orientation

Source code in kumiki/construction.py
def create_axis_aligned_timber(bottom_position: V3, length: Numeric, size: V2,
                              length_direction: TimberFace, width_direction: Optional[TimberFace] = None, 
                              ticket: Optional[Union[TimberTicket, str]] = None) -> Timber:
    """
    Creates an axis-aligned timber using TimberFace to reference directions
    in the world coordinate system.

    AGENT NOTE: Prefer methods like join_timber, attach_face/plane_aligned_timber, create_*_timber_on_footprint, which are more robust and easier to use.

    Args:
        bottom_position: Position of the bottom point of the timber
        length: Length of the timber
        size: Cross-sectional size (width, height)
        length_direction: Direction for the timber's length axis
        width_direction: Optional direction for the timber's width axis.
                        If not provided, defaults to RIGHT (+X) unless length_direction
                        is RIGHT, in which case TOP (+Z) is used.
        ticket: Optional ticket for this timber (can be Ticket object or string name, used for rendering/debugging)

    Returns:
        New timber with the specified axis-aligned orientation
    """
    # Convert TimberFace to direction vectors
    length_vec = length_direction.get_direction()

    # Determine width direction if not provided
    if width_direction is None:
        # Default to RIGHT (+X) unless length is in +X direction
        if length_direction == TimberFace.RIGHT:
            width_direction = TimberFace.TOP
        else:
            width_direction = TimberFace.RIGHT

    if length_direction == TimberFace.BOTTOM:
        # print a warning, this is usually not what you want
        warnings.warn("Creating an axis-aligned timber with length_direction == BOTTOM. This is usually not what you want. Consider using length_direction == TOP instead.")

    width_vec = width_direction.get_direction()

    return create_timber(length=length, size=size, bottom_position=bottom_position, length_direction=length_vec, width_direction=width_vec, ticket=ticket)

create_vertical_timber_on_footprint_corner

create_vertical_timber_on_footprint_corner(footprint: Footprint, corner_index: int, length: Numeric, location_type: FootprintLocation, size: V2, ticket: Optional[Union[TimberTicket, str]] = None) -> Timber

Creates a vertical timber (post) on a footprint boundary corner.

The post is positioned on an orthogonal boundary corner (where two boundary sides are perpendicular) according to the location type:

Location types: - INSIDE: Post has one vertex of bottom face on the boundary corner, with 2 edges aligned with the 2 boundary sides, post extends inside the boundary - OUTSIDE: Post positioned with opposite vertex on the boundary corner, extends outside - CENTER: Post center is on the boundary corner, with 2 edges parallel to boundary sides

Parameters:

Name Type Description Default
footprint Footprint

The footprint to place the timber on

required
corner_index int

Index of the boundary corner

required
length Numeric

Length of the vertical timber (height)

required
location_type FootprintLocation

Where to position the timber relative to the boundary corner

required
size V2

Timber size (width, depth) as a 2D vector

required
ticket Optional[Union[TimberTicket, str]]

Optional ticket for this timber (can be Ticket object or string name, used for rendering/debugging)

None

Returns:

Type Description
Timber

Timber positioned vertically on the footprint boundary corner

Source code in kumiki/construction.py
def create_vertical_timber_on_footprint_corner(footprint: Footprint, corner_index: int, 
                                               length: Numeric, location_type: FootprintLocation,
                                               size: V2, ticket: Optional[Union[TimberTicket, str]] = None) -> Timber:
    """
    Creates a vertical timber (post) on a footprint boundary corner.

    The post is positioned on an orthogonal boundary corner (where two boundary sides 
    are perpendicular) according to the location type:

    Location types:
    - INSIDE: Post has one vertex of bottom face on the boundary corner, with 2 edges 
              aligned with the 2 boundary sides, post extends inside the boundary
    - OUTSIDE: Post positioned with opposite vertex on the boundary corner, extends outside
    - CENTER: Post center is on the boundary corner, with 2 edges parallel to boundary sides

    Args:
        footprint: The footprint to place the timber on
        corner_index: Index of the boundary corner
        length: Length of the vertical timber (height)
        location_type: Where to position the timber relative to the boundary corner
        size: Timber size (width, depth) as a 2D vector
        ticket: Optional ticket for this timber (can be Ticket object or string name, used for rendering/debugging)

    Returns:
        Timber positioned vertically on the footprint boundary corner
    """
    # Get the boundary corner point
    corner = footprint.corners[corner_index]

    # Get the two boundary sides meeting at this corner
    # Previous side: from corner_index-1 to corner_index
    # Next side: from corner_index to corner_index+1
    n_corners = len(footprint.corners)
    prev_corner = footprint.corners[(corner_index - 1) % n_corners]
    next_corner = footprint.corners[(corner_index + 1) % n_corners]

    # Calculate direction vectors for the two sides
    # Keep as exact values - don't convert to float
    outgoing_dir = Matrix([next_corner[0] - corner[0], 
                          next_corner[1] - corner[1]])

    # Normalize the direction vector
    outgoing_len_sq = outgoing_dir[0]**2 + outgoing_dir[1]**2
    outgoing_len = sqrt(outgoing_len_sq)
    outgoing_dir_normalized = outgoing_dir / outgoing_len

    # Timber dimensions - keep as exact values from size parameter
    timber_width = size[0]   # Face direction (X-axis of timber)
    timber_depth = size[1]   # Height direction (Y-axis of timber)

    # Vertical direction (length)
    length_direction = create_v3(scalar(0), scalar(0), scalar(1))

    # Align timber face direction with outgoing boundary side
    # Face direction is in the XY plane along the outgoing side
    width_direction = create_v3(outgoing_dir_normalized[0], outgoing_dir_normalized[1], scalar(0))

    # Calculate bottom position based on location type
    # Keep corner coordinates exact
    corner_x = corner[0]
    corner_y = corner[1]

    # For orthogonal corners, the two in-boundary axes are:
    # 1) outgoing side direction (corner -> next_corner)
    # 2) previous side direction from corner (corner -> prev_corner)
    prev_dir = Matrix([prev_corner[0] - corner[0], prev_corner[1] - corner[1]])
    prev_len_sq = prev_dir[0]**2 + prev_dir[1]**2
    prev_len = sqrt(prev_len_sq)
    prev_dir_normalized = prev_dir / prev_len

    if location_type == FootprintLocation.INSIDE:
        # Center-origin timber: move center inward by half size in both axes.
        offset_x = timber_width / scalar(2) * outgoing_dir_normalized[0] + timber_depth / scalar(2) * prev_dir_normalized[0]
        offset_y = timber_width / scalar(2) * outgoing_dir_normalized[1] + timber_depth / scalar(2) * prev_dir_normalized[1]
        bottom_position = create_v3(corner_x + offset_x, corner_y + offset_y, scalar(0))

    elif location_type == FootprintLocation.OUTSIDE:
        # Center-origin timber: move center outward by half size in both axes.
        offset_x = -timber_width / scalar(2) * outgoing_dir_normalized[0] - timber_depth / scalar(2) * prev_dir_normalized[0]
        offset_y = -timber_width / scalar(2) * outgoing_dir_normalized[1] - timber_depth / scalar(2) * prev_dir_normalized[1]
        bottom_position = create_v3(corner_x + offset_x, corner_y + offset_y, scalar(0))

    else:  # CENTER
        # Center of bottom face lies on the boundary corner.
        bottom_position = create_v3(corner_x, corner_y, scalar(0))

    return create_timber(length=length, size=size, bottom_position=bottom_position, length_direction=length_direction, width_direction=width_direction, ticket=ticket)

create_vertical_timber_on_footprint_side

create_vertical_timber_on_footprint_side(footprint: Footprint, side_index: int, distance_along_side: Numeric, length: Numeric, location_type: FootprintLocation, size: V2, ticket: Optional[Union[TimberTicket, str]] = None) -> Timber

Creates a vertical timber (post) positioned at a point along a footprint boundary side.

The post is placed at a specified distance along the boundary side from the starting corner.

Location types: - INSIDE: One edge of bottom face lies on boundary side, center of edge at the point, post extends inside - OUTSIDE: One edge of bottom face lies on boundary side, center of edge at the point, post extends outside - CENTER: Center of bottom face is on the point, 2 edges of bottom face parallel to boundary side

Parameters:

Name Type Description Default
footprint Footprint

The footprint to place the timber on

required
side_index int

Index of the boundary side (from corner[side_index] to corner[side_index+1])

required
distance_along_side Numeric

Distance from the starting corner along the side (0 = at start corner)

required
length Numeric

Length of the vertical timber (height)

required
location_type FootprintLocation

Where to position the timber relative to the boundary side

required
size V2

Timber size (width, depth) as a 2D vector

required
ticket Optional[Union[TimberTicket, str]]

Optional ticket for this timber (can be Ticket object or string name, used for rendering/debugging)

None

Returns:

Type Description
Timber

Timber positioned vertically at the specified point on the footprint boundary side

Source code in kumiki/construction.py
def create_vertical_timber_on_footprint_side(footprint: Footprint, side_index: int, 
                                            distance_along_side: Numeric,
                                            length: Numeric, location_type: FootprintLocation, 
                                            size: V2, ticket: Optional[Union[TimberTicket, str]] = None) -> Timber:
    """
    Creates a vertical timber (post) positioned at a point along a footprint boundary side.

    The post is placed at a specified distance along the boundary side from the starting corner.

    Location types:
    - INSIDE: One edge of bottom face lies on boundary side, center of edge at the point, post extends inside
    - OUTSIDE: One edge of bottom face lies on boundary side, center of edge at the point, post extends outside
    - CENTER: Center of bottom face is on the point, 2 edges of bottom face parallel to boundary side

    Args:
        footprint: The footprint to place the timber on
        side_index: Index of the boundary side (from corner[side_index] to corner[side_index+1])
        distance_along_side: Distance from the starting corner along the side (0 = at start corner)
        length: Length of the vertical timber (height)
        location_type: Where to position the timber relative to the boundary side
        size: Timber size (width, depth) as a 2D vector
        ticket: Optional ticket for this timber (can be Ticket object or string name, used for rendering/debugging)

    Returns:
        Timber positioned vertically at the specified point on the footprint boundary side
    """
    # Get the boundary side endpoints
    start_corner = footprint.corners[side_index]
    end_corner = footprint.corners[(side_index + 1) % len(footprint.corners)]

    # Calculate direction along the boundary side - keep exact
    side_dir = Matrix([end_corner[0] - start_corner[0], 
                       end_corner[1] - start_corner[1]])

    # Normalize the direction vector
    side_len_sq = side_dir[0]**2 + side_dir[1]**2
    side_len = sqrt(side_len_sq)
    side_dir_normalized = side_dir / side_len

    # Calculate the point along the side
    point_x = start_corner[0] + side_dir_normalized[0] * distance_along_side
    point_y = start_corner[1] + side_dir_normalized[1] * distance_along_side

    # Calculate inward normal (perpendicular to side, pointing inward)
    # For a 2D vector (dx, dy), the perpendicular is (-dy, dx) or (dy, -dx)
    # We need to determine which one points inward
    perp_x = -side_dir_normalized[1]  # Left perpendicular
    perp_y = side_dir_normalized[0]

    # Test if this perpendicular points inward
    test_point = Matrix([point_x + perp_x * OFFSET_TEST_POINT,
                        point_y + perp_y * OFFSET_TEST_POINT])

    if footprint.contains_point(test_point):
        # Left perpendicular points inward
        inward_x = perp_x
        inward_y = perp_y
    else:
        # Right perpendicular points inward
        inward_x = side_dir_normalized[1]
        inward_y = -side_dir_normalized[0]

    # Timber dimensions - keep as exact values from size parameter
    timber_width = size[0]   # Width in face direction (parallel to boundary side)
    timber_depth = size[1]   # Depth perpendicular to boundary side

    # Vertical direction (length)
    length_direction = create_v3(scalar(0), scalar(0), scalar(1))

    # Face direction is parallel to the boundary side
    width_direction = create_v3(side_dir_normalized[0], side_dir_normalized[1], scalar(0))

    # Calculate bottom position based on location type
    if location_type == FootprintLocation.CENTER:
        # Center of bottom face is on the point
        # No offset needed since timber local origin is at center of bottom face
        bottom_position = create_v3(point_x, point_y, scalar(0))

    elif location_type == FootprintLocation.INSIDE:
        # One edge of bottom face lies on boundary side
        # Center of that edge is at the point
        # Post extends inside (in direction of inward normal)
        # Offset the center by half depth in the inward direction
        bottom_position = create_v3(point_x + inward_x * timber_depth / scalar(2), 
                                         point_y + inward_y * timber_depth / scalar(2), 
                                         scalar(0))

    else:  # OUTSIDE
        # One edge of bottom face lies on boundary side
        # Center of that edge is at the point
        # Post extends outside (opposite of inward normal)
        # Offset the center by half depth in the outward direction
        bottom_position = create_v3(point_x - inward_x * timber_depth / scalar(2), 
                                         point_y - inward_y * timber_depth / scalar(2), 
                                         scalar(0))

    return create_timber(length=length, size=size, bottom_position=bottom_position, length_direction=length_direction, width_direction=width_direction, ticket=ticket)

create_horizontal_timber_on_footprint

create_horizontal_timber_on_footprint(footprint: Footprint, corner_index: int, location_type: FootprintLocation, size: V2, length: Optional[Numeric] = None, ticket: Optional[Union[TimberTicket, str]] = None) -> Timber

Creates a horizontal timber (mudsill) on the footprint boundary side.

The mudsill runs from corner_index to corner_index + 1 along the boundary side. With the face ends of the mudsill timber starting/ending on the footprint corners.

Location types: - INSIDE: One edge of the timber lies on the boundary side, timber is on the inside - OUTSIDE: One edge of the timber lies on the boundary side, timber is on the outside - CENTER: The centerline of the timber lies on the boundary side

Parameters:

Name Type Description Default
footprint Footprint

The footprint to place the timber on

required
corner_index int

Index of the starting boundary corner

required
location_type FootprintLocation

Where to position the timber relative to the boundary side

required
size V2

Timber size (width, height) as a 2D vector

required
length Optional[Numeric]

Length of the timber (optional; if not provided, uses boundary side length)

None
ticket Optional[Union[TimberTicket, str]]

Optional ticket for this timber (can be Ticket object or string name, used for rendering/debugging)

None

Returns:

Type Description
Timber

Timber positioned on the footprint boundary side

Source code in kumiki/construction.py
def create_horizontal_timber_on_footprint(footprint: Footprint, corner_index: int,
                                        location_type: FootprintLocation, 
                                        size: V2,
                                        length: Optional[Numeric] = None, ticket: Optional[Union[TimberTicket, str]] = None) -> Timber:
    """
    Creates a horizontal timber (mudsill) on the footprint boundary side.

    The mudsill runs from corner_index to corner_index + 1 along the boundary side.
    With the face ends of the mudsill timber starting/ending on the footprint corners.

    Location types:
    - INSIDE: One edge of the timber lies on the boundary side, timber is on the inside
    - OUTSIDE: One edge of the timber lies on the boundary side, timber is on the outside
    - CENTER: The centerline of the timber lies on the boundary side

    Args:
        footprint: The footprint to place the timber on
        corner_index: Index of the starting boundary corner
        location_type: Where to position the timber relative to the boundary side
        size: Timber size (width, height) as a 2D vector
        length: Length of the timber (optional; if not provided, uses boundary side length)
        ticket: Optional ticket for this timber (can be Ticket object or string name, used for rendering/debugging)

    Returns:
        Timber positioned on the footprint boundary side
    """
    # Get the footprint points
    start_point = footprint.corners[corner_index]
    end_point = footprint.corners[(corner_index + 1) % len(footprint.corners)]

    length_direction = safe_normalize_vector(Matrix([end_point[0] - start_point[0], end_point[1] - start_point[1], 0]))

    # Calculate length from boundary side if not provided
    if length is None:
        dx = end_point[0] - start_point[0]
        dy = end_point[1] - start_point[1]
        length = sqrt(dx**2 + dy**2)

    # Get the inward normal from the footprint
    inward_normal = footprint.get_inward_normal(corner_index)

    # Face direction is up (Z+)
    width_direction = create_v3(scalar(0), scalar(0), scalar(1))

    # The timber's orientation will be:
    #   X-axis (width/size[0]) = width_direction = (0, 0, 1) = vertical (up)
    #   Y-axis (height/size[1]) = length × face = perpendicular to boundary in XY plane
    #   Z-axis (length) = length_direction = along boundary side
    # Therefore, size[1] is the dimension perpendicular to the boundary, and
    # size[0] is the vertical dimension.
    timber_height = size[1]
    timber_vertical_size = size[0]

    # Calculate bottom position based on location type
    # Start at the start_point on the boundary side. The cross-section is
    # centered on bottom_position, so raise it by half the vertical size
    # to put the timber's -Z face (rather than its midline) on the footprint
    # plane — the mudsill should sit above the footprint, not straddle it.
    bottom_position = create_v3(start_point[0], start_point[1], timber_vertical_size / scalar(2))

    # Apply offset based on location type
    if location_type == FootprintLocation.INSIDE:
        # Position so one edge lies on the boundary side, timber extends inward
        # Move the centerline inward by half the timber height (perpendicular dimension)
        bottom_position = bottom_position + inward_normal * (timber_height / scalar(2))
    elif location_type == FootprintLocation.OUTSIDE:
        # Position so one edge lies on the boundary side, timber extends outward
        # Move the centerline outward by half the timber height (perpendicular dimension)
        bottom_position = bottom_position - inward_normal * (timber_height / scalar(2))
    # For CENTER, no offset needed - centerline is already on the boundary side

    return create_timber(length=length, size=size, bottom_position=bottom_position, length_direction=length_direction, width_direction=width_direction, ticket=ticket)

stretch_timber

stretch_timber(timber: Timber, end: TimberEnd, overlap_length: Numeric, extend_length: Numeric) -> Timber

Creates a new timber extending the original timber by a given length.

The original timber is conceptually discarded and replaced with a new timber that is the original timber plus the extension.

Parameters:

Name Type Description Default
end TimberEnd

The end of the timber to extend

required
overlap_length Numeric

Length of timber to overlap with existing timber

required
extend_length Numeric

Length of timber to extend beyond the end of the original timber (does not include the overlap length)

required
Source code in kumiki/construction.py
def stretch_timber(timber: Timber, end: TimberEnd, overlap_length: Numeric, 
                  extend_length: Numeric) -> Timber:
    """
    Creates a new timber extending the original timber by a given length.

    The original timber is conceptually discarded and replaced with a new timber that is the original timber plus the extension.

    Args:
        end: The end of the timber to extend
        overlap_length: Length of timber to overlap with existing timber
        extend_length: Length of timber to extend beyond the end of the original timber (does not include the overlap length)
    """
    assert isinstance(end, TimberEnd), f"expected TimberEnd, got {type(end).__name__}"
    # Calculate new position based on end
    if end == TimberEnd.TOP:
        # Extend from top
        extension_vector = timber.get_length_direction_global() * (timber.length - overlap_length)
        new_bottom_position = timber.get_bottom_position_global() + extension_vector
    else:  # BOTTOM
        # Extend from bottom
        extension_vector = timber.get_length_direction_global() * extend_length
        new_bottom_position = timber.get_bottom_position_global() - extension_vector

    # Create new timber with extended length
    new_length = timber.length + extend_length + overlap_length

    return create_timber(new_length, timber.size, new_bottom_position, 
                                   timber.get_length_direction_global(), timber.get_width_direction_global())

split_timber

split_timber(timber: Timber, distance_from_bottom: Numeric, ticket1: Optional[Union[TimberTicket, str]] = None, ticket2: Optional[Union[TimberTicket, str]] = None) -> Tuple[Timber, Timber]

Split a timber into two timbers at the specified distance from the bottom.

The original timber is conceptually discarded and replaced with two new timbers: - The first timber extends from the original bottom to the split point - The second timber extends from the split point to the original top

Both timbers maintain the same cross-sectional size and orientation as the original. You will often follow this with a splice joint to join the two timbers together.

Parameters:

Name Type Description Default
timber Timber

The timber to split

required
distance_from_bottom Numeric

Distance along the timber's length where to split (0 < distance < timber.length)

required
ticket1 Optional[Union[TimberTicket, str]]

Optional ticket for the bottom timber (defaults to "{original_name}_bottom")

None
ticket2 Optional[Union[TimberTicket, str]]

Optional ticket for the top timber (defaults to "{original_name}_top")

None

Returns:

Type Description
Timber

Tuple of (bottom_timber, top_timber) where:

Timber
  • bottom_timber starts at the same position as the original
Tuple[Timber, Timber]
  • top_timber starts at the top end of bottom_timber
Example

If a timber has length 10 and is split at distance 3: - bottom_timber has length 3, same origin as original - top_timber has length 7, origin at distance 3 from original origin

Source code in kumiki/construction.py
def split_timber(
    timber: Timber, 
    distance_from_bottom: Numeric,
    ticket1: Optional[Union[TimberTicket, str]] = None,
    ticket2: Optional[Union[TimberTicket, str]] = None
) -> Tuple[Timber, Timber]:
    """
    Split a timber into two timbers at the specified distance from the bottom.

    The original timber is conceptually discarded and replaced with two new timbers:
    - The first timber extends from the original bottom to the split point
    - The second timber extends from the split point to the original top

    Both timbers maintain the same cross-sectional size and orientation as the original.
    You will often follow this with a splice joint to join the two timbers together.

    Args:
        timber: The timber to split
        distance_from_bottom: Distance along the timber's length where to split (0 < distance < timber.length)
        ticket1: Optional ticket for the bottom timber (defaults to "{original_name}_bottom")
        ticket2: Optional ticket for the top timber (defaults to "{original_name}_top")

    Returns:
        Tuple of (bottom_timber, top_timber) where:
        - bottom_timber starts at the same position as the original
        - top_timber starts at the top end of bottom_timber

    Example:
        If a timber has length 10 and is split at distance 3:
        - bottom_timber has length 3, same origin as original
        - top_timber has length 7, origin at distance 3 from original origin
    """
    # Validate input
    assert 0 < distance_from_bottom < timber.length, \
        f"Split distance {distance_from_bottom} must be between 0 and {timber.length}"

    # Determine tickets for the split timbers
    bottom_ticket = ticket1 if ticket1 is not None else f"{timber.ticket.path}/bottom"
    top_ticket = ticket2 if ticket2 is not None else f"{timber.ticket.path}/top"

    # Create first timber (bottom part)
    bottom_timber = create_timber(
        length=distance_from_bottom,
        size=create_v2(timber.size[0], timber.size[1]),
        bottom_position=timber.get_bottom_position_global(),
        length_direction=timber.get_length_direction_global(),
        width_direction=timber.get_width_direction_global(),
        ticket=bottom_ticket
    )

    # Calculate the bottom position of the second timber
    # It's at the top of the first timber
    top_of_first = timber.get_bottom_position_global() + distance_from_bottom * timber.get_length_direction_global()

    # Create second timber (top part)
    top_timber = create_timber(
        length=timber.length - distance_from_bottom,
        size=create_v2(timber.size[0], timber.size[1]),
        bottom_position=top_of_first,
        length_direction=timber.get_length_direction_global(),
        width_direction=timber.get_width_direction_global(),
        ticket=top_ticket
    )

    return (bottom_timber, top_timber)

attach_timber

attach_timber(original_timber: TimberLike, size: V2, attached_timber_direction: Direction3D, attached_timber_length: Numeric, attached_timber_opposite_length: Numeric = scalar(0), attached_timber_width_direction: Optional[Direction3D] = None, attached_timber_end_that_points_towards_original_timber: TimberEnd = BOTTOM, original_timber_end_to_measure_from_for_length_position: TimberEnd = BOTTOM, length_position_measurement: Numeric = scalar(0), lateral_offset: Numeric = scalar(0), ticket: Optional[Union[TimberTicket, str]] = None)

NOTE this function is perhaps not so useful in practice, it's mainly here for completeness. Perhaps there are some cases where it's a better alternative to create_timber

Creates a timber that is attached to original_timber.

The original timber is referred to as "original_timber" and the new timber as "attached_timber".

Positioning

The attached timber's attached_timber_end_that_points_towards_original_timber end position is length_position_measurement away from original_timber_end_to_measure_from_for_length_position and then lateral_offset away from the centerline of the original timber, measured in the direction length axis of the original timber CROSS length axis of the created attached timber

Orientation

The attached timber's orientation is such that its length axis is in the attached_timber_direction direction and its right face best aligns with attached_timber_right_direction. if attached_timber_right_direction is None then the direction of the TOP face of the original timber is used instead.

Returns:

Type Description

The new attached timber, face-aligned with and positioned relative to the original timber.

Source code in kumiki/construction.py
def attach_timber(
    original_timber: TimberLike,
    size: V2, 
    attached_timber_direction: Direction3D,
    attached_timber_length: Numeric,
    attached_timber_opposite_length: Numeric = scalar(0),
    attached_timber_width_direction: Optional[Direction3D] = None,
    attached_timber_end_that_points_towards_original_timber: TimberEnd = TimberEnd.BOTTOM,
    original_timber_end_to_measure_from_for_length_position: TimberEnd = TimberEnd.BOTTOM,
    length_position_measurement: Numeric = scalar(0),
    lateral_offset: Numeric = scalar(0),
    ticket: Optional[Union[TimberTicket, str]] = None,
):
    """
    NOTE this function is perhaps not so useful in practice, it's mainly here for completeness. Perhaps there are some cases where it's a better alternative to create_timber

    Creates a timber that is attached to ``original_timber``.

    The original timber is referred to as "original_timber" and the new timber as "attached_timber". 

    ## Positioning

    The attached timber's ``attached_timber_end_that_points_towards_original_timber`` end position is ``length_position_measurement`` away from 
    ``original_timber_end_to_measure_from_for_length_position`` and then ``lateral_offset`` away from the centerline of the original timber, 
    measured in the direction 
    length axis of the original timber CROSS length axis of the created attached timber

    ## Orientation

    The attached timber's orientation is such that its length axis is in the attached_timber_direction direction
    and its right face best aligns with ``attached_timber_right_direction``.
    if ``attached_timber_right_direction`` is None then the direction of the TOP face of the original timber is used instead.

    Returns:
        The new attached timber, face-aligned with and positioned relative to the original timber.
    """
    # ---- type checks ----
    assert isinstance(original_timber, PerfectTimberWithin), \
        f"original_timber must be a timber (PerfectTimberWithin), got {type(original_timber).__name__}"
    assert isinstance(attached_timber_end_that_points_towards_original_timber, TimberEnd), \
        f"attached_timber_end_that_points_towards_original_timber must be TimberEnd, got {type(attached_timber_end_that_points_towards_original_timber).__name__}"
    assert isinstance(original_timber_end_to_measure_from_for_length_position, TimberEnd), \
        f"original_timber_end_to_measure_from_for_length_position must be TimberEnd, got {type(original_timber_end_to_measure_from_for_length_position).__name__}"

    # point_dir is the direction the attached timber points; length_dir is its +length (bottom->top),
    # which flips when the TOP end is the one sitting against the original timber.
    point_dir = safe_normalize_vector(attached_timber_direction)
    if attached_timber_end_that_points_towards_original_timber == TimberEnd.BOTTOM:
        length_dir = point_dir
    else:
        length_dir = -point_dir

    # ---- reference point: a position on the original timber's centerline, then offset laterally ----
    if original_timber_end_to_measure_from_for_length_position == TimberEnd.BOTTOM:
        reference = locate_position_on_centerline_from_bottom(original_timber, length_position_measurement).position
    else:  # TOP
        reference = locate_position_on_centerline_from_top(original_timber, length_position_measurement).position

    if lateral_offset != scalar(0):
        # lateral direction = original length axis CROSS the attached timber's length axis
        original_length_dir = original_timber.get_length_direction_global()
        assert not are_vectors_parallel(length_dir, original_length_dir), \
            "lateral_offset requires the attached timber to not be parallel to the original timber's length"
        lateral_dir = safe_normalize_vector(cross_product(original_length_dir, length_dir))
        reference = reference + lateral_dir * lateral_offset

    # ---- extend along the pointing direction and build the timber ----
    attached_total_length = attached_timber_length + attached_timber_opposite_length
    assert safe_compare(attached_total_length, scalar(0), Comparison.GT), \
        "attached timber total length (attached_timber_length + attached_timber_opposite_length) must be positive"

    center = reference + point_dir * (attached_timber_length - attached_timber_opposite_length) / scalar(2)
    bottom_position = center - length_dir * (attached_total_length / scalar(2))

    # default the width direction to the original timber's length (its TOP face direction)
    width_direction = attached_timber_width_direction if attached_timber_width_direction is not None \
        else original_timber.get_length_direction_global()

    return create_timber(
        bottom_position=bottom_position,
        length=attached_total_length,
        size=size,
        length_direction=length_dir,
        width_direction=width_direction,
        ticket=ticket,
    )

attach_plane_aligned_timber

attach_plane_aligned_timber(original_timber: TimberLike, size: V2, original_timber_long_face_that_attached_timber_points_to: TimberLongFace, attached_timber_angle: Numeric, attached_timber_length_or_target: Union[Numeric, TimberLike], attached_timber_stickout: Stickout = nostickout(), attached_timber_end_that_points_towards_original_timber: TimberEnd = BOTTOM, original_timber_end_to_measure_from_for_length_position: TimberEnd = BOTTOM, attached_timber_long_face_to_measure_to_for_length_position: Union[TimberLongFace, TimberCenterline] = CENTERLINE, length_position_measurement: Numeric = scalar(0), original_timber_face_to_measure_from_for_lateral_position: Union[TimberFace, TimberCenterline] = CENTERLINE, attached_timber_long_face_to_measure_to_for_lateral_position: Union[TimberLongFace, TimberCenterline] = CENTERLINE, lateral_position_measurement: Numeric = scalar(0), ticket: Optional[Union[TimberTicket, str]] = None) -> Timber

Creates a timber that is plane-aligned with and attached to original_timber at an angle.

Generalizes :func:attach_face_aligned_timber: the attached timber's length axis lies in the plane spanned by the original timber's length axis and the normal of original_timber_long_face_that_attached_timber_points_to (the face it points out of), making an angle of attached_timber_angle with the original timber's length axis. The attached timber stays plane-aligned with the original (two of its long faces remain parallel to the original's lateral faces). attach_face_aligned_timber is the attached_timber_angle == pi/2 (perpendicular) case.

attached_timber_end_that_points_towards_original_timber chooses which end of the attached timber sits on the original-timber side; note that this flips the realized angle to pi - attached_timber_angle.

Extents

attached_timber_length_or_target places the target end (the end pointing away from the original timber): - a numeric length extends the timber along its (tilted) length axis, measured from the original timber's centerline. - a timber extends the attached timber until its centerline just touches the target timber's reference feature selected by attached_timber_stickout.stickoutReference2, taken on the target's silhouette projected onto the plane spanned by the original timber's length axis and the attach direction: its CENTER_LINE, or the near (INSIDE) / far (OUTSIDE) boundary of the silhouette (for a target plane-aligned with that plane these are its long faces; for a rotated target, its projected corner edges). If the target's centerline is parallel to the lateral axis it projects to a single point, which is dropped perpendicularly onto the attached timber's length axis. stickout2 then extends the target end beyond that feature. stickout2 is ignored (with a warning if set) when a numeric length is given instead.

attached_timber_stickout places the start end (the end that attaches to the original timber): the start end is where the attached timber's centerline just touches the original timber's feature selected by stickoutReference1 — its CENTER_LINE (default), the INSIDE face (the face the attached timber points out of), or the OUTSIDE face (the opposite face) — extended by stickout1 beyond it.

Everything else follows attach_face_aligned_timber: - the length-position is measured along the original timber's length axis from original_timber_end_to_measure_from_for_length_position to attached_timber_long_face_to_measure_to_for_length_position (or orthogonally to its centerline). - the lateral-position is measured along the lateral axis from original_timber_face_to_measure_from_for_lateral_position to attached_timber_long_face_to_measure_to_for_lateral_position (or orthogonally to its centerline).

All measurements are taken from the perfect timber within of the original and attached timber.

original_timber_long_face_that_attached_timber_points_to chosen on the wrong side (e.g. the target actually lies opposite the face's outward normal) would otherwise solve for a non-positive attached timber length. Rather than failing in that case, this automatically retries with the opposite long face (RIGHT<->LEFT or FRONT<->BACK) and, if that succeeds, emits a warning and uses it instead. Only fails if BOTH the requested face and its opposite produce a non-positive length.

Returns:

Type Description
Timber

The new attached timber, plane-aligned with and positioned relative to the original timber.

Source code in kumiki/construction.py
def attach_plane_aligned_timber(
    original_timber: TimberLike,
    size: V2, # always width, height in the local coordinates of the created attached timber
    original_timber_long_face_that_attached_timber_points_to: TimberLongFace,
    attached_timber_angle: Numeric, # angle between the length axis of the original timber and attached timber, note that this flips depending on attached_timber_end_that_points_towards_original_timber
    attached_timber_length_or_target: Union[Numeric, TimberLike],
    attached_timber_stickout: Stickout = Stickout.nostickout(),
    attached_timber_end_that_points_towards_original_timber: TimberEnd = TimberEnd.BOTTOM,
    original_timber_end_to_measure_from_for_length_position: TimberEnd = TimberEnd.BOTTOM,
    attached_timber_long_face_to_measure_to_for_length_position: Union[TimberLongFace, TimberCenterline] = TimberCenterline.CENTERLINE,
    length_position_measurement: Numeric = scalar(0),
    original_timber_face_to_measure_from_for_lateral_position: Union[TimberFace, TimberCenterline] = TimberCenterline.CENTERLINE,
    attached_timber_long_face_to_measure_to_for_lateral_position: Union[TimberLongFace, TimberCenterline] = TimberCenterline.CENTERLINE,
    lateral_position_measurement: Numeric = scalar(0),
    ticket: Optional[Union[TimberTicket, str]] = None,
) -> Timber:
    """
    Creates a timber that is plane-aligned with and attached to ``original_timber`` at an angle.

    Generalizes :func:`attach_face_aligned_timber`: the attached timber's length axis lies in the
    plane spanned by the original timber's length axis and the normal of
    ``original_timber_long_face_that_attached_timber_points_to`` (the face it points out of), making
    an angle of ``attached_timber_angle`` with the original timber's length axis. The attached
    timber stays plane-aligned with the original (two of its long faces remain parallel to the
    original's lateral faces). ``attach_face_aligned_timber`` is the ``attached_timber_angle == pi/2``
    (perpendicular) case.

    ``attached_timber_end_that_points_towards_original_timber`` chooses which end of the attached
    timber sits on the original-timber side; note that this flips the realized angle to
    ``pi - attached_timber_angle``.

    ## Extents

    ``attached_timber_length_or_target`` places the target end (the end pointing away from the
    original timber):
    - a numeric length extends the timber along its (tilted) length axis, measured from the
      original timber's centerline.
    - a timber extends the attached timber until its centerline just touches the target timber's
      reference feature selected by ``attached_timber_stickout.stickoutReference2``, taken on the
      target's silhouette projected onto the plane spanned by the original timber's length axis
      and the attach direction: its CENTER_LINE, or the near (INSIDE) / far (OUTSIDE) boundary of
      the silhouette (for a target plane-aligned with that plane these are its long faces; for a
      rotated target, its projected corner edges). If the target's centerline is parallel to the
      lateral axis it projects to a single point, which is dropped perpendicularly onto the
      attached timber's length axis. ``stickout2`` then extends the target end beyond that
      feature. ``stickout2`` is ignored (with a warning if set) when a numeric length is given
      instead.

    ``attached_timber_stickout`` places the start end (the end that attaches to the original
    timber): the start end is where the attached timber's centerline just touches the original
    timber's feature selected by ``stickoutReference1`` — its CENTER_LINE (default), the INSIDE
    face (the face the attached timber points out of), or the OUTSIDE face (the opposite face) —
    extended by ``stickout1`` beyond it.

    Everything else follows attach_face_aligned_timber:
    - the length-position is measured along the original timber's length axis from
      ``original_timber_end_to_measure_from_for_length_position`` to
      ``attached_timber_long_face_to_measure_to_for_length_position`` (or orthogonally to its centerline).
    - the lateral-position is measured along the lateral axis from
      ``original_timber_face_to_measure_from_for_lateral_position`` to
      ``attached_timber_long_face_to_measure_to_for_lateral_position`` (or orthogonally to its centerline).

    All measurements are taken from the perfect timber within of the original and attached timber.

    ``original_timber_long_face_that_attached_timber_points_to`` chosen on the wrong side (e.g. the
    target actually lies opposite the face's outward normal) would otherwise solve for a
    non-positive attached timber length. Rather than failing in that case, this automatically
    retries with the opposite long face (RIGHT<->LEFT or FRONT<->BACK) and, if that succeeds,
    emits a warning and uses it instead. Only fails if BOTH the requested face and its opposite
    produce a non-positive length.

    Returns:
        The new attached timber, plane-aligned with and positioned relative to the original timber.
    """
    # ---- type checks ----
    assert isinstance(original_timber, PerfectTimberWithin), \
        f"original_timber must be a timber (PerfectTimberWithin), got {type(original_timber).__name__}"
    assert isinstance(original_timber_long_face_that_attached_timber_points_to, TimberLongFace), \
        f"original_timber_long_face_that_attached_timber_points_to must be TimberLongFace, got {type(original_timber_long_face_that_attached_timber_points_to).__name__}"
    assert isinstance(attached_timber_length_or_target, (PerfectTimberWithin, float, int)), \
        f"attached_timber_length_or_target must be a numeric length or a timber (PerfectTimberWithin), got {type(attached_timber_length_or_target).__name__}"
    assert isinstance(attached_timber_stickout, Stickout), \
        f"attached_timber_stickout must be Stickout, got {type(attached_timber_stickout).__name__}"
    assert isinstance(attached_timber_end_that_points_towards_original_timber, TimberEnd), \
        f"attached_timber_end_that_points_towards_original_timber must be TimberEnd, got {type(attached_timber_end_that_points_towards_original_timber).__name__}"
    assert isinstance(original_timber_end_to_measure_from_for_length_position, TimberEnd), \
        f"original_timber_end_to_measure_from_for_length_position must be TimberEnd, got {type(original_timber_end_to_measure_from_for_length_position).__name__}"
    assert isinstance(attached_timber_long_face_to_measure_to_for_length_position, (TimberLongFace, TimberCenterline)), \
        f"attached_timber_long_face_to_measure_to_for_length_position must be TimberLongFace or TimberCenterline, got {type(attached_timber_long_face_to_measure_to_for_length_position).__name__}"
    assert isinstance(original_timber_face_to_measure_from_for_lateral_position, (TimberFace, TimberCenterline)), \
        f"original_timber_face_to_measure_from_for_lateral_position must be TimberFace or TimberCenterline, got {type(original_timber_face_to_measure_from_for_lateral_position).__name__}"
    assert isinstance(attached_timber_long_face_to_measure_to_for_lateral_position, (TimberLongFace, TimberCenterline)), \
        f"attached_timber_long_face_to_measure_to_for_lateral_position must be TimberLongFace or TimberCenterline, got {type(attached_timber_long_face_to_measure_to_for_lateral_position).__name__}"

    def _build(points_to_face: TimberLongFace) -> Optional[Timber]:
        """Full geometry solve for one choice of points_to_face; returns None (instead of
        asserting) when it would produce a non-positive attached timber length, so the
        caller can retry with the opposite face -- see the retry loop below."""
        # ---- orthonormal basis from the original timber's perfect-timber-within ----
        # a = attach direction (out of the chosen long face), l = original length axis, t = lateral axis.
        # The attached timber lives in the a-l plane; t is the shared (plane-aligned) lateral normal.
        a = original_timber.get_face_direction_global(points_to_face)
        l = original_timber.get_length_direction_global()
        t = cross_product(a, l)

        # ---- attached timber length axis (tilted within the a-l plane) ----
        # point_dir points out of the chosen face at attached_timber_angle from the original length; it
        # is the direction the attached timber extends, independent of which end faces the original.
        point_dir = cos(attached_timber_angle) * l + sin(attached_timber_angle) * a
        if attached_timber_end_that_points_towards_original_timber == TimberEnd.BOTTOM:
            length_dir = point_dir
        else:  # the TOP end sits on the original-timber side, so +length points back toward it
            length_dir = -point_dir
        # in-plane cross-section axis: perpendicular to length_dir within the a-l plane.
        # (reduces to +l when attached_timber_angle == pi/2, matching attach_face_aligned_timber)
        p = sin(attached_timber_angle) * l - cos(attached_timber_angle) * a

        # ---- derive the cross-section orientation from the named measure-to faces ----
        # The attached timber's two cross-section axes are p (in the a-l plane) and t (lateral):
        #   - the length-position face has its normal along p (its position is read along the length l)
        #   - the lateral-position face has its normal along t (parallel to the original's lateral faces)
        # A long face on RIGHT/LEFT lies on the width (X) axis; FRONT/BACK lies on the height (Y) axis,
        # so the only decision is whether the width axis runs along p or along t.
        def _is_width_axis(face: TimberLongFace) -> bool:
            return face in (TimberLongFace.RIGHT, TimberLongFace.LEFT)

        width_axis_along_p: Optional[bool] = None
        if isinstance(attached_timber_long_face_to_measure_to_for_length_position, TimberLongFace):
            width_axis_along_p = _is_width_axis(attached_timber_long_face_to_measure_to_for_length_position)
        if isinstance(attached_timber_long_face_to_measure_to_for_lateral_position, TimberLongFace):
            lateral_wants_width_along_p = not _is_width_axis(attached_timber_long_face_to_measure_to_for_lateral_position)
            if width_axis_along_p is None:
                width_axis_along_p = lateral_wants_width_along_p
            else:
                assert width_axis_along_p == lateral_wants_width_along_p, (
                    "attached_timber_long_face_to_measure_to_for_length_position and "
                    "attached_timber_long_face_to_measure_to_for_lateral_position imply conflicting cross-section "
                    "orientations (they must reference perpendicular faces of the attached timber)"
                )
        if width_axis_along_p is None:
            # neither face named (both CENTERLINE): default the width axis to the in-plane (p) axis
            width_axis_along_p = True

        width_dir = p if width_axis_along_p else t
        height_dir = safe_normalize_vector(cross_product(length_dir, width_dir))

        def _attached_long_face_normal_and_half(face: TimberLongFace) -> Tuple[Direction3D, Numeric]:
            """Outward global normal and center-to-face half size for a long face of the attached timber."""
            if face == TimberLongFace.RIGHT:
                return width_dir, size[0] / scalar(2)
            elif face == TimberLongFace.LEFT:
                return -width_dir, size[0] / scalar(2)
            elif face == TimberLongFace.FRONT:
                return height_dir, size[1] / scalar(2)
            else:  # BACK
                return -height_dir, size[1] / scalar(2)

        O = original_timber.get_bottom_position_global()

        # ---- length-position-axis coordinate (along l) ----
        # Measure from the chosen end of the original timber, going into the timber.
        end_face = original_timber_end_to_measure_from_for_length_position
        end_l = get_center_point_on_face_global(end_face, original_timber).dot(l)
        if end_face == TimberEnd.TOP:
            target_l = end_l - length_position_measurement  # into the timber from the top is -l
        else:  # BOTTOM
            target_l = end_l + length_position_measurement  # into the timber from the bottom is +l
        if isinstance(attached_timber_long_face_to_measure_to_for_length_position, TimberLongFace):
            len_normal, len_half = _attached_long_face_normal_and_half(attached_timber_long_face_to_measure_to_for_length_position)
            center_l = target_l - len_normal.dot(l) * len_half
        else:  # CENTERLINE
            center_l = target_l

        # ---- lateral-position-axis coordinate (along t) ----
        if isinstance(original_timber_face_to_measure_from_for_lateral_position, TimberFace):
            orig_lat_normal = safe_normalize_vector(original_timber.get_face_direction_global(original_timber_face_to_measure_from_for_lateral_position))
            assert are_vectors_parallel(orig_lat_normal, t), (
                "original_timber_face_to_measure_from_for_lateral_position must be a lateral face of the original timber "
                "(perpendicular to original_timber_long_face_that_attached_timber_points_to and to the length)"
            )
            from_t = get_center_point_on_face_global(original_timber_face_to_measure_from_for_lateral_position, original_timber).dot(t)
            into_sign_t = -orig_lat_normal.dot(t)  # positive measurement goes into the original timber
        else:  # CENTERLINE
            from_t = O.dot(t)
            into_sign_t = scalar(1)
        target_t = from_t + lateral_position_measurement * into_sign_t
        if isinstance(attached_timber_long_face_to_measure_to_for_lateral_position, TimberLongFace):
            lat_normal, lat_half = _attached_long_face_normal_and_half(attached_timber_long_face_to_measure_to_for_lateral_position)
            assert are_vectors_parallel(lat_normal, t), (
                "attached_timber_long_face_to_measure_to_for_lateral_position must be a lateral face (perpendicular to "
                "original_timber_long_face_that_attached_timber_points_to)"
            )
            center_t = target_t - lat_normal.dot(t) * lat_half
        else:  # CENTERLINE
            center_t = target_t

        # ---- resolve the timber's extent along its centerline ----
        # Parametrize the attached centerline by s (in units of point_dir), with s = 0 where it
        # crosses the plane through the original timber's centerline with normal a. The start end
        # (the end that attaches to the original timber) sits at s = -opposite_length and the target
        # end at s = attached_timber_length.

        # start end: where the centerline touches the stickoutReference1 feature of the original
        # timber, extended by stickout1 beyond it (in -point_dir).
        if attached_timber_stickout.stickoutReference1 == StickoutReference.CENTER_LINE:
            start_reference_s = scalar(0)
        else:
            sin_attach = point_dir.dot(a)  # sin(attached_timber_angle)
            assert safe_compare(sin_attach, scalar(0), Comparison.GT), \
                "INSIDE/OUTSIDE stickoutReference1 requires attached_timber_angle strictly between 0 and pi (the attached timber must point out of the original timber's face)"
            # center-to-face depth of the original timber along the attach direction
            half_depth_along_a = get_center_point_on_face_global(
                points_to_face, original_timber).dot(a) - O.dot(a)
            if attached_timber_stickout.stickoutReference1 == StickoutReference.INSIDE:
                start_reference_s = half_depth_along_a / sin_attach
            else:  # OUTSIDE: the face opposite the one the attached timber points out of
                start_reference_s = -half_depth_along_a / sin_attach
        opposite_length = attached_timber_stickout.stickout1 - start_reference_s


        if isinstance(attached_timber_length_or_target, PerfectTimberWithin):
            # target end: where the centerline touches the stickoutReference2 feature of the target
            # timber, extended by stickout2 beyond it (in +point_dir). The feature is the target's
            # centerline -- or the near (INSIDE) / far (OUTSIDE) boundary of its silhouette --
            # projected onto the a-l plane. Projected along t, each feature is a plane {x . m = d}
            # with m . t == 0, so intersecting the attached centerline with the plane equals
            # intersecting it with the projected feature.
            target_timber = attached_timber_length_or_target
            target_length_dir = target_timber.get_length_direction_global()
            if are_vectors_parallel(target_length_dir, t):
                # the target's centerline is parallel to the lateral axis, so it projects to a single
                # *point* on the a-l plane: drop that point perpendicularly onto the attached timber's
                # length axis, i.e. touch the plane through the target's centerline with normal
                # point_dir (the perpendicular foot is where the centerline crosses that plane)
                m = point_dir
            else:
                m = safe_normalize_vector(cross_product(target_length_dir, t))
            crossing_rate = point_dir.dot(m)
            d = target_timber.get_bottom_position_global().dot(m)
            if attached_timber_stickout.stickoutReference2 != StickoutReference.CENTER_LINE:
                assert safe_compare(crossing_rate, scalar(0), Comparison.NE), \
                    "attached timber runs parallel to the target timber's projection, so INSIDE/OUTSIDE stickoutReference2 cannot be resolved"
                # near/far silhouette boundary: the projected centerline offset by the largest
                # cross-section corner offset along m. For a target plane-aligned with the a-l plane
                # this reduces to its long face planes; for a rotated target it is the projected
                # corner-edge boundary.
                silhouette_half_extent = (
                    target_timber.size[0] / scalar(2) * Abs(target_timber.get_width_direction_global().dot(m))
                    + target_timber.size[1] / scalar(2) * Abs(target_timber.get_height_direction_global().dot(m))
                )
                # INSIDE is the boundary the attached timber reaches first travelling along +point_dir
                approaching_along_m = safe_compare(crossing_rate, scalar(0), Comparison.GT)
                if (attached_timber_stickout.stickoutReference2 == StickoutReference.INSIDE) == approaching_along_m:
                    d = d - silhouette_half_extent
                else:
                    d = d + silhouette_half_extent

            # The length-position measurement pins the attached timber's *center* at (center_l,
            # center_t), and the center sits at s = (length - opposite_length)/2, so for non-
            # perpendicular angles the centerline's position itself depends on the length being
            # solved for. Substituting the s = 0 point
            #   P0 = (O.a, center_l - (length - opposite_length)/2 * cos_attach, center_t)
            # into the touch condition (d - P0.m) / (point_dir.m) + stickout2 = length and solving
            # the (linear) equation for length gives the closed form below.
            cos_attach = point_dir.dot(l)
            m_l = m.dot(l)
            # P0.m evaluated as if length == opposite_length; the length dependence is folded into
            # the denominator.
            centerline_anchor_dot_m = O.dot(a) * m.dot(a) + center_l * m_l + center_t * m.dot(t)
            denominator = crossing_rate - cos_attach * m_l / scalar(2)
            assert safe_compare(denominator, scalar(0), Comparison.NE), \
                "attached timber's centerline (as positioned by the length-position measurement) never crosses the target feature"
            attached_timber_length = (
                attached_timber_stickout.stickout2 * crossing_rate
                + (d - centerline_anchor_dot_m)
                - opposite_length / scalar(2) * cos_attach * m_l
            ) / denominator
        else:
            attached_timber_length = attached_timber_length_or_target
            if attached_timber_stickout.stickout2 != scalar(0) or attached_timber_stickout.stickoutReference2 != StickoutReference.CENTER_LINE:
                warnings.warn("attached_timber_stickout.stickout2 is ignored when attached_timber_length_or_target is a numeric length; pass a target timber to use it")

        attached_total_length = attached_timber_length + opposite_length
        if not safe_compare(attached_total_length, scalar(0), Comparison.GT):
            return None

        # ---- attach-axis coordinate of the attached timber's center ----
        # The timber extends from s = -opposite_length to s = attached_timber_length along point_dir,
        # so the center's a-coordinate shifts by (length - opposite)/2 times the a-component of point_dir.
        center_a = O.dot(a) + (attached_timber_length - opposite_length) / scalar(2) * point_dir.dot(a)

        # ---- reconstruct the center in global coordinates and build the timber ----
        # (a, l, t) is an orthonormal basis, so a global point equals the sum of its coords times the axes.
        center = center_a * a + center_l * l + center_t * t
        bottom_position = center - length_dir * (attached_total_length / scalar(2))

        return create_timber(
            bottom_position=bottom_position,
            length=attached_total_length,
            size=size,
            length_direction=length_dir,
            width_direction=width_dir,
            ticket=ticket,
        )

    # ---- try the requested face; if it would produce a non-positive length (the target lies
    # on the opposite side of the original timber from the requested face), silently retry with
    # the opposite long face instead of failing -- see _build's docstring. ----
    result = _build(original_timber_long_face_that_attached_timber_points_to)
    if result is None:
        opposite_face = original_timber_long_face_that_attached_timber_points_to.to.face().get_opposite_face().to.long_face()
        result = _build(opposite_face)
        if result is not None:
            warnings.warn(
                "attach_plane_aligned_timber: original_timber_long_face_that_attached_timber_points_to="
                f"{original_timber_long_face_that_attached_timber_points_to.name} would have produced a "
                "non-positive attached timber length (the target lies on the opposite side of the original "
                f"timber); used {opposite_face.name} instead.",
                stacklevel=2,
            )
    assert result is not None, (
        "attached timber total length (attached_timber_length + opposite length from stickout1) must be "
        "positive for original_timber_long_face_that_attached_timber_points_to or its opposite face"
    )
    return result

attach_face_aligned_timber

attach_face_aligned_timber(original_timber: TimberLike, size: V2, original_timber_long_face_that_attached_timber_points_to: TimberLongFace, attached_timber_length_or_target: Union[Numeric, TimberLike], attached_timber_stickout: Stickout = nostickout(), attached_timber_end_that_points_towards_original_timber: TimberEnd = BOTTOM, original_timber_end_to_measure_from_for_length_position: TimberEnd = BOTTOM, attached_timber_long_face_to_measure_to_for_length_position: Union[TimberLongFace, TimberCenterline] = CENTERLINE, length_position_measurement: Numeric = scalar(0), original_timber_face_to_measure_from_for_lateral_position: Union[TimberFace, TimberCenterline] = CENTERLINE, attached_timber_long_face_to_measure_to_for_lateral_position: Union[TimberLongFace, TimberCenterline] = CENTERLINE, lateral_position_measurement: Numeric = scalar(0), ticket: Optional[Union[TimberTicket, str]] = None) -> Timber

Creates a timber that is face-aligned with and attached to original_timber.

The original timber is referred to as "original_timber" and the new timber as "attached_timber". The attached timber runs perpendicular to the chosen long face of the original timber and is fully face-aligned with it.

All measurements are taken from the perfect timber within of the original and attached timber

Orientation

The attached timber's length axis runs along the normal of original_timber_long_face_that_attached_timber_points_to (the face it "points to" / sticks out of). attached_timber_end_that_points_towards_original_timber chooses which end (TOP/BOTTOM) of the attached timber sits on the original-timber side. If the chosen face turns out to be on the wrong side of the target, this automatically retries with its opposite long face instead of failing -- see :func:attach_plane_aligned_timber.

The attached timber's height and width axis orientation are determined by: - attached_timber_long_face_to_measure_to_for_lateral_position - attached_timber_long_face_to_measure_to_for_length_position so that these faces are parallel to the features on original_timber that they are measured from.

Extents

attached_timber_length_or_target places the far end of the attached timber: either a numeric length measured from the original timber's centerline, or a timber to extend to (up to the feature selected by attached_timber_stickout.stickoutReference2, plus stickout2 beyond it). attached_timber_stickout places the near end relative to the original timber's feature selected by stickoutReference1 (CENTER_LINE by default), extended by stickout1 beyond it. See :func:attach_plane_aligned_timber for details.

Positioning

The attached timber's position is such that the distance between original_timber_end_to_measure_from_for_length_position and attached_timber_long_face_to_measure_to_for_length_position is length_position_measurement,

and the distance between original_timber_face_to_measure_from_for_lateral_position and attached_timber_long_face_to_measure_to_for_lateral_position is lateral_position_measurement.

Returns:

Type Description
Timber

The new attached timber, face-aligned with and positioned relative to the original timber.

Source code in kumiki/construction.py
def attach_face_aligned_timber(
    original_timber: TimberLike,
    size: V2, # always width, height in the local coordinates of the created attached timber
    original_timber_long_face_that_attached_timber_points_to: TimberLongFace,
    attached_timber_length_or_target: Union[Numeric, TimberLike],
    attached_timber_stickout: Stickout = Stickout.nostickout(),
    attached_timber_end_that_points_towards_original_timber: TimberEnd = TimberEnd.BOTTOM,
    original_timber_end_to_measure_from_for_length_position: TimberEnd = TimberEnd.BOTTOM,
    attached_timber_long_face_to_measure_to_for_length_position: Union[TimberLongFace, TimberCenterline] = TimberCenterline.CENTERLINE,
    length_position_measurement: Numeric = scalar(0),
    original_timber_face_to_measure_from_for_lateral_position: Union[TimberFace, TimberCenterline] = TimberCenterline.CENTERLINE,
    attached_timber_long_face_to_measure_to_for_lateral_position: Union[TimberLongFace, TimberCenterline] = TimberCenterline.CENTERLINE,
    lateral_position_measurement: Numeric = scalar(0),
    ticket: Optional[Union[TimberTicket, str]] = None,
) -> Timber:
    """
    Creates a timber that is face-aligned with and attached to ``original_timber``.

    The original timber is referred to as "original_timber" and the new timber as
    "attached_timber". The attached timber runs perpendicular to the chosen long face of the
    original timber and is fully face-aligned with it.

    All measurements are taken from the perfect timber within of the original and attached timber

    ## Orientation

    The attached timber's length axis runs along the normal of
    ``original_timber_long_face_that_attached_timber_points_to`` (the face it "points to" / sticks
    out of). ``attached_timber_end_that_points_towards_original_timber`` chooses which end
    (TOP/BOTTOM) of the attached timber sits on the original-timber side. If the chosen face turns
    out to be on the wrong side of the target, this automatically retries with its opposite long
    face instead of failing -- see :func:`attach_plane_aligned_timber`.

    The attached timber's height and width axis orientation are determined by:
    - attached_timber_long_face_to_measure_to_for_lateral_position
    - attached_timber_long_face_to_measure_to_for_length_position
    so that these faces are parallel to the features on original_timber that they are measured from.

    ## Extents

    ``attached_timber_length_or_target`` places the far end of the attached timber: either a
    numeric length measured from the original timber's centerline, or a timber to extend to (up
    to the feature selected by ``attached_timber_stickout.stickoutReference2``, plus
    ``stickout2`` beyond it). ``attached_timber_stickout`` places the near end relative to the
    original timber's feature selected by ``stickoutReference1`` (CENTER_LINE by default),
    extended by ``stickout1`` beyond it. See :func:`attach_plane_aligned_timber` for details.

    ## Positioning

    The attached timber's position is such that the distance between 
    ``original_timber_end_to_measure_from_for_length_position`` and ``attached_timber_long_face_to_measure_to_for_length_position`` 
    is ``length_position_measurement``, 

    and the distance between 
    ``original_timber_face_to_measure_from_for_lateral_position`` and ``attached_timber_long_face_to_measure_to_for_lateral_position`` 
    is ``lateral_position_measurement``.


    Returns:
        The new attached timber, face-aligned with and positioned relative to the original timber.
    """
    # Face-aligned is the perpendicular (pi/2) special case of attach_plane_aligned_timber.
    return attach_plane_aligned_timber(
        original_timber=original_timber,
        size=size,
        original_timber_long_face_that_attached_timber_points_to=original_timber_long_face_that_attached_timber_points_to,
        attached_timber_angle= radians(pi / 2),
        attached_timber_length_or_target=attached_timber_length_or_target,
        attached_timber_stickout=attached_timber_stickout,
        attached_timber_end_that_points_towards_original_timber=attached_timber_end_that_points_towards_original_timber,
        original_timber_end_to_measure_from_for_length_position=original_timber_end_to_measure_from_for_length_position,
        attached_timber_long_face_to_measure_to_for_length_position=attached_timber_long_face_to_measure_to_for_length_position,
        length_position_measurement=length_position_measurement,
        original_timber_face_to_measure_from_for_lateral_position=original_timber_face_to_measure_from_for_lateral_position,
        attached_timber_long_face_to_measure_to_for_lateral_position=attached_timber_long_face_to_measure_to_for_lateral_position,
        lateral_position_measurement=lateral_position_measurement,
        ticket=ticket,
    )

join_timbers

join_timbers(timber1: PerfectTimberWithin, timber2: PerfectTimberWithin, location_on_timber1: Numeric, location_on_timber2: Optional[Numeric] = None, lateral_offset: Numeric = scalar(0), stickout: Stickout = nostickout(), size: Optional[V2] = None, orientation_width_vector: Optional[Direction3D] = None, ticket: Optional[Union[TimberTicket, str]] = None) -> Timber

Joins two timbers by creating a connecting timber from centerline to centerline.

This function creates a timber that connects the centerline of timber1 to the centerline of timber2. The joining timber's length direction goes from timber1 to timber2, and its position can be laterally offset from this centerline-to-centerline path.

Parameters:

Name Type Description Default
timber1 PerfectTimberWithin

First timber to join (start point)

required
timber2 PerfectTimberWithin

Second timber to join (end point)

required
location_on_timber1 Numeric

Position along timber1's length where the joining timber starts

required
location_on_timber2 Optional[Numeric]

Optional position along timber2's length where the joining timber ends. If not provided, uses the same Z-height as location_on_timber1.

None
lateral_offset Numeric

Lateral offset of the joining timber perpendicular to the direct centerline-to-centerline path. The offset direction is determined by the cross product of timber1's length direction and the joining direction. Defaults to scalar(0) (no offset).

scalar(0)
stickout Stickout

How much the joining timber extends beyond each connection point (both sides). Always measured from centerlines in this function. Defaults to Stickout.nostickout() if not provided.

nostickout()
size Optional[V2]

Optional size (width, height) of the joining timber. If not provided, determined from timber1's size based on orientation.

None
orientation_width_vector Optional[Direction3D]

Optional width direction hint for the created timber in global space. Will be automatically projected onto the normal plane of the length axis of the created timber. This is useful for specifying orientation like "face up" for rafters. If not provided, uses timber1's length direction projected onto the perpendicular plane. If the provided vector is parallel to the joining direction, falls back to timber1's width direction.

None
ticket Optional[Union[TimberTicket, str]]

Optional ticket for this timber (can be Ticket object or string name, used for rendering/debugging)

None

Returns:

Type Description
Timber

New timber connecting timber1 and timber2 along their centerlines

Source code in kumiki/construction.py
def join_timbers(timber1: PerfectTimberWithin, timber2: PerfectTimberWithin, 
                location_on_timber1: Numeric,
                location_on_timber2: Optional[Numeric] = None,
                lateral_offset: Numeric = scalar(0),
                stickout: Stickout = Stickout.nostickout(),
                size: Optional[V2] = None,
                orientation_width_vector: Optional[Direction3D] = None, 
                ticket: Optional[Union[TimberTicket, str]] = None) -> Timber:
    """
    Joins two timbers by creating a connecting timber from centerline to centerline.

    This function creates a timber that connects the centerline of timber1 to the centerline
    of timber2. The joining timber's length direction goes from timber1 to timber2, and its
    position can be laterally offset from this centerline-to-centerline path.

    Args:
        timber1: First timber to join (start point)
        timber2: Second timber to join (end point)
        location_on_timber1: Position along timber1's length where the joining timber starts
        location_on_timber2: Optional position along timber2's length where the joining timber ends.
                            If not provided, uses the same Z-height as location_on_timber1.
        lateral_offset: Lateral offset of the joining timber perpendicular to the direct 
                       centerline-to-centerline path. The offset direction is determined by the
                       cross product of timber1's length direction and the joining direction.
                       Defaults to scalar(0) (no offset).
        stickout: How much the joining timber extends beyond each connection point (both sides).
                  Always measured from centerlines in this function.
                  Defaults to Stickout.nostickout() if not provided.
        size: Optional size (width, height) of the joining timber. If not provided,
              determined from timber1's size based on orientation.
        orientation_width_vector: Optional width direction hint for the created timber in global space.
                                 Will be automatically projected onto the normal plane of the length axis of the created timber.
                                This is useful for 
                                 specifying orientation like "face up" for rafters.
                                 If not provided, uses timber1's length direction projected onto
                                 the perpendicular plane.
                                 If the provided vector is parallel to the joining direction, falls back
                                 to timber1's width direction.
        ticket: Optional ticket for this timber (can be Ticket object or string name, used for rendering/debugging)

    Returns:
        New timber connecting timber1 and timber2 along their centerlines
    """
    # Calculate position on timber1
    pos1 = locate_position_on_centerline_from_bottom(timber1, location_on_timber1).position

    # Calculate position on timber2
    if location_on_timber2 is not None:
        pos2 = locate_position_on_centerline_from_bottom(timber2, location_on_timber2).position
    else:
        # Find the point on timber2's centerline at the same z-height as pos1
        timber2_bottom = timber2.get_bottom_position_global()
        pos2 = Matrix([timber2_bottom[0], timber2_bottom[1], timber2_bottom[2] + location_on_timber1])

    # Calculate length direction (from timber1 to timber2)
    length_direction = pos2 - pos1
    length_direction = safe_normalize_vector(length_direction)

    # Calculate face direction (width direction for the created timber)
    if orientation_width_vector is not None:
        reference_direction = orientation_width_vector
    else:
        # Default: use timber1's length direction
        reference_direction = timber1.get_length_direction_global()

    # Check if reference direction is parallel to the joining direction
    if _are_directions_parallel(reference_direction, length_direction):
        # If parallel, cannot project - use a perpendicular fallback
        if orientation_width_vector is not None:
            warnings.warn(f"orientation_width_vector {orientation_width_vector} is parallel to the joining direction {length_direction}. Using timber1's width direction instead.")
            reference_direction = timber1.get_width_direction_global()
        else:
            warnings.warn("timber1's length direction is parallel to the joining direction. Using timber1's width direction instead.")
            reference_direction = timber1.get_width_direction_global()

    # Project reference direction onto the plane perpendicular to the joining direction
    # Formula: v_perp = v - (v·n)n
    dot_product = reference_direction.dot(length_direction)
    width_direction = reference_direction - dot_product * length_direction
    width_direction = safe_normalize_vector(width_direction)

    # TODO TEST THIS IT'S PROBABLY WRONG
    # Determine size if not provided
    if size is None:
        # Check the orientation of the created timber relative to timber1
        # Dot product of the created timber's face direction with timber1's length direction
        dot_product = Abs(width_direction.dot(timber1.get_length_direction_global()))

        if dot_product < scalar(1, 2):  # < 0.5, meaning more perpendicular than parallel
            # The created timber is joining perpendicular to timber1
            # Its X dimension (width, along width_direction) should match the dimension 
            # of the face it's joining to on timber1, which is timber1's width (size[0])
            size = create_v2(timber1.size[0], timber1.size[1])
        else:
            # For other orientations, use timber1's size as-is
            size = create_v2(timber1.size[0], timber1.size[1])

    # Assert that join_timbers only uses CENTER_LINE stickout reference
    assert stickout.stickoutReference1 == StickoutReference.CENTER_LINE, \
        "join_timbers only supports CENTER_LINE stickout reference. Use join_face_aligned_on_face_aligned_timbers for INSIDE/OUTSIDE references."
    assert stickout.stickoutReference2 == StickoutReference.CENTER_LINE, \
        "join_timbers only supports CENTER_LINE stickout reference. Use join_face_aligned_on_face_aligned_timbers for INSIDE/OUTSIDE references."

    # Calculate timber length with stickout (always from centerline in join_timbers)
    centerline_distance = safe_magnitude(pos2 - pos1)
    timber_length = centerline_distance + stickout.stickout1 + stickout.stickout2

    # Apply lateral offset
    if lateral_offset != scalar(0):
        # Calculate offset direction (cross product of length vectors)
        offset_dir = safe_normalize_vector(cross_product(timber1.get_length_direction_global(), length_direction))

    # Calculate the bottom position (start of timber)
    # Start from pos1 and move backward by stickout1 (always centerline)
    bottom_pos = pos1 - length_direction * stickout.stickout1

    # Apply offset to bottom position as well (if any offset was applied to center)
    if lateral_offset != scalar(0):
        bottom_pos += offset_dir * lateral_offset

    return create_timber(length=timber_length, size=size, bottom_position=bottom_pos, length_direction=length_direction, width_direction=width_direction, ticket=ticket)

join_plane_aligned_on_plane_aligned_timbers

join_plane_aligned_on_plane_aligned_timbers(timber1: PerfectTimberWithin, timber2: PerfectTimberWithin, location_on_timber1: Numeric, location_on_timber2: Numeric, stickout: Stickout, size: V2, lateral_offset_from_timber1: Numeric = scalar(0), feature_to_mark_on_joining_timber: Optional[TimberFeature] = None, orientation_long_face_on_timber1: Optional[TimberLongFace] = None, orientation_long_face_on_timber2: Optional[TimberLongFace] = RIGHT, ticket: Optional[Union[TimberTicket, str]] = None) -> Timber

Joins two plane-aligned timbers with a connecting timber that lies in their shared plane.

Deprecated: This function's own signature is the main reason for the deprecation -- the lateral-offset/feature-marking/orientation-long-face parameters are hard to reason about together. attach_plane_aligned_timber covers the same cases with a signature that, while still not simple, is a bit easier to follow. Prefer it for new code.

Parameters:

Name Type Description Default
timber1 PerfectTimberWithin

First timber to join

required
timber2 PerfectTimberWithin

Second timber to join (plane-aligned with timber1)

required
location_on_timber1 Numeric

Position along timber1's length where the joining timber attaches

required
location_on_timber2 Numeric

Position along timber2's length where the joining timber attaches

required
stickout Stickout

How much the joining timber extends beyond each connection point

required
size V2

Cross-sectional size (width, height) of the joining timber

required
lateral_offset_from_timber1 Numeric

Lateral offset, in the axis perpendicular to the shared plane, from feature_to_mark_on_joining_timber. Defaults to scalar(0).

scalar(0)
feature_to_mark_on_joining_timber Optional[TimberFeature]

Optional feature on the created timber to use as the reference for the lateral offset. It is intended for you to use the locate_face or locate_long_edge functions to create a plane or line on a timber. If not provided, uses the centerline. If a plane is provided, the "origin" of the plane is used for longitudinal positioning (i.e. location_on_timber1). In the case of locate_face, the origin aligns with the center of the created timber.

None
orientation_long_face_on_timber1 Optional[TimberLongFace]

Optional long face of timber1, on the shared plane, to orient against. If None, an arbitrary face of timber1 on the shared plane is used.

None
orientation_long_face_on_timber2 Optional[TimberLongFace]

The long face on the created timber that will align with orientation_long_face_on_timber1. Defaults to TimberLongFace.RIGHT.

RIGHT
ticket Optional[Union[TimberTicket, str]]

Optional ticket for this timber (can be Ticket object or string name, used for rendering/debugging)

None

Returns:

Type Description
Timber

New timber that joins timber1 and timber2, lying in their shared plane

Source code in kumiki/construction.py
@deprecated("attach_plane_aligned_timber is easier to understand")
def join_plane_aligned_on_plane_aligned_timbers(timber1: PerfectTimberWithin, timber2: PerfectTimberWithin,
                                                location_on_timber1: Numeric, location_on_timber2: Numeric,
                                                stickout: Stickout,
                                                size: V2,
                                                # lateral offset (in the axis perpendicular to the face parallel plane) from feature_to_mark_on_joining_timber
                                                lateral_offset_from_timber1: Numeric = scalar(0),
                                                feature_to_mark_on_joining_timber: Optional[TimberFeature] = None,
                                                # if None, set to some arbitrary face of timber1 on the parallel face plane
                                                orientation_long_face_on_timber1: Optional[TimberLongFace] = None,
                                                # this face on the created timber will align with orientation_long_face_on_timber1
                                                orientation_long_face_on_timber2: Optional[TimberLongFace] = TimberLongFace.RIGHT,
                                                ticket: Optional[Union[TimberTicket, str]] = None) -> Timber:
    """
    Joins two plane-aligned timbers with a connecting timber that lies in their shared plane.

    Deprecated: This function's own signature is the main reason for the deprecation --
    the lateral-offset/feature-marking/orientation-long-face parameters are hard to reason
    about together. `attach_plane_aligned_timber` covers the same cases with a signature
    that, while still not simple, is a bit easier to follow. Prefer it for new code.

    Args:
        timber1: First timber to join
        timber2: Second timber to join (plane-aligned with timber1)
        location_on_timber1: Position along timber1's length where the joining timber attaches
        location_on_timber2: Position along timber2's length where the joining timber attaches
        stickout: How much the joining timber extends beyond each connection point
        size: Cross-sectional size (width, height) of the joining timber
        lateral_offset_from_timber1: Lateral offset, in the axis perpendicular to the shared
                        plane, from feature_to_mark_on_joining_timber. Defaults to scalar(0).
        feature_to_mark_on_joining_timber: Optional feature on the created timber to use as the reference for the lateral offset.
                                           It is intended for you to use the locate_face or locate_long_edge functions to create a plane or line on a timber.
                                           If not provided, uses the centerline. If a plane is provided, the "origin" of the plane is used for longitudinal positioning (i.e. location_on_timber1). In the case of locate_face, the origin aligns with the center of the created timber.
        orientation_long_face_on_timber1: Optional long face of timber1, on the shared plane, to orient against.
                                          If None, an arbitrary face of timber1 on the shared plane is used.
        orientation_long_face_on_timber2: The long face on the created timber that will align with
                                          orientation_long_face_on_timber1. Defaults to TimberLongFace.RIGHT.
        ticket: Optional ticket for this timber (can be Ticket object or string name, used for rendering/debugging)

    Returns:
        New timber that joins timber1 and timber2, lying in their shared plane
    """
    require_check(None if are_timbers_plane_aligned(timber1, timber2) else "Timbers must be plane-aligned")
    plane_normal = safe_normalize_vector(cross_product(timber1.get_length_direction_global(), timber2.get_length_direction_global()))

    if orientation_long_face_on_timber1 is None:
        orientation_long_face_on_timber1 = timber1.get_closest_oriented_long_face_from_global_direction(plane_normal)
    else:
        require_check(
            None
            if are_vectors_parallel(timber1.get_face_direction_global(orientation_long_face_on_timber1), plane_normal)
            else "orientation_long_face_on_timber1 must point in the aligned plane normal"
        )

    if orientation_long_face_on_timber2 is None:
        orientation_long_face_on_timber2 = TimberLongFace.RIGHT

    aligned_face_direction = timber1.get_face_direction_global(orientation_long_face_on_timber1)

    point1 = locate_position_on_centerline_from_bottom(timber1, location_on_timber1).position
    point2 = locate_position_on_centerline_from_bottom(timber2, location_on_timber2).position
    joining_direction = safe_normalize_vector(point2 - point1)
    lateral_offset_direction = safe_normalize_vector(cross_product(timber1.get_length_direction_global(), joining_direction))
    lateral_offset = lateral_offset_from_timber1
    if safe_compare(lateral_offset_direction.dot(plane_normal), 0, Comparison.LT):
        lateral_offset = -lateral_offset

    if orientation_long_face_on_timber2 == TimberLongFace.RIGHT:
        orientation_width_vector = aligned_face_direction
    elif orientation_long_face_on_timber2 == TimberLongFace.LEFT:
        orientation_width_vector = -aligned_face_direction
    elif orientation_long_face_on_timber2 == TimberLongFace.FRONT:
        orientation_width_vector = cross_product(aligned_face_direction, joining_direction)
    else:
        orientation_width_vector = -cross_product(aligned_face_direction, joining_direction)

    return join_timbers(
        timber1=timber1,
        timber2=timber2,
        location_on_timber1=location_on_timber1,
        location_on_timber2=location_on_timber2,
        lateral_offset=lateral_offset,
        stickout=stickout,
        size=size,
        orientation_width_vector=safe_normalize_vector(orientation_width_vector),
        ticket=ticket,
    )

join_face_aligned_on_face_aligned_timbers

join_face_aligned_on_face_aligned_timbers(timber1: PerfectTimberWithin, timber2: PerfectTimberWithin, location_on_timber1: Numeric, stickout: Stickout, size: V2, lateral_offset_from_timber1: Numeric = scalar(0), feature_to_mark_on_joining_timber: Optional[TimberFeature] = None, orientation_face_on_timber1: Optional[TimberFace] = None, ticket: Optional[Union[TimberTicket, str]] = None) -> Timber

Joins two face-aligned timbers with a perpendicular timber.

Deprecated: This function's own signature is the main reason for the deprecation -- the lateral-offset/feature-marking/orientation-face parameters are hard to reason about together. attach_face_aligned_timber covers the same cases with a signature that, while still not simple, is a bit easier to follow. Prefer it for new code.

Parameters:

Name Type Description Default
timber1 PerfectTimberWithin

First timber to join

required
timber2 PerfectTimberWithin

Second timber to join (face-aligned with timber1)

required
location_on_timber1 Numeric

Position along timber1's length where the joining timber attaches

required
stickout Stickout

How much the joining timber extends beyond each connection point

required
size V2

Cross-sectional size (width, height) of the joining timber

required
lateral_offset_from_timber1 Numeric

Lateral offset from timber1's centerline reference. Defaults to scalar(0).

scalar(0)
feature_to_mark_on_joining_timber Optional[TimberFeature]

Optional feature on the create timber to use as the reference for the lateral offset. It is intended for you to use the locate_face or locate_long_edge functions to create a plane or line on a timber. If not provided, uses the centerline. If a plane is provided, the "origin" of the plane is used for longitudinal positioning (i.e. location_on_timber1). In the case of locate_face, the origin aligns with the center of the created timber.

None
orientation_face_on_timber1 Optional[TimberFace]

Optional face of timber1 to orient against. If provided, the width direction of the created timber will align with this face on timber1. If not provided, uses timber1's length direction projected onto the perpendicular plane.

None
ticket Optional[Union[TimberTicket, str]]

Optional ticket for this timber (can be Ticket object or string name, used for rendering/debugging)

None

Returns:

Type Description
Timber

New timber that joins timber1 and timber2

Source code in kumiki/construction.py
@deprecated("attach_face_aligned_timber is easier to understand")
def join_face_aligned_on_face_aligned_timbers(timber1: PerfectTimberWithin, timber2: PerfectTimberWithin,
                                                location_on_timber1: Numeric,
                                                stickout: Stickout,
                                                size: V2,
                                                lateral_offset_from_timber1: Numeric = scalar(0),
                                                feature_to_mark_on_joining_timber: Optional[TimberFeature] = None,
                                                orientation_face_on_timber1: Optional[TimberFace] = None, 
                                                ticket: Optional[Union[TimberTicket, str]] = None) -> Timber:
    """
    Joins two face-aligned timbers with a perpendicular timber.

    Deprecated: This function's own signature is the main reason for the deprecation --
    the lateral-offset/feature-marking/orientation-face parameters are hard to reason
    about together. `attach_face_aligned_timber` covers the same cases with a signature
    that, while still not simple, is a bit easier to follow. Prefer it for new code.

    Args:
        timber1: First timber to join
        timber2: Second timber to join (face-aligned with timber1)
        location_on_timber1: Position along timber1's length where the joining timber attaches
        stickout: How much the joining timber extends beyond each connection point
        size: Cross-sectional size (width, height) of the joining timber
        lateral_offset_from_timber1: Lateral offset from timber1's centerline reference.
                        Defaults to scalar(0).
        feature_to_mark_on_joining_timber: Optional feature on the create timber to use as the reference for the lateral offset.
                                           It is intended for you to use the locate_face or locate_long_edge functions to create a plane or line on a timber.
                                           If not provided, uses the centerline. If a plane is provided, the "origin" of the plane is used for longitudinal positioning (i.e. location_on_timber1). In the case of locate_face, the origin aligns with the center of the created timber.
        orientation_face_on_timber1: Optional face of timber1 to orient against. If provided,
                                     the width direction of the created timber will align with this face on timber1.
                                     If not provided, uses timber1's length direction projected onto
                                     the perpendicular plane.
        ticket: Optional ticket for this timber (can be Ticket object or string name, used for rendering/debugging)

    Returns:
        New timber that joins timber1 and timber2
    """
    # Verify that the two timbers are face-aligned
    assert are_timbers_face_aligned(timber1, timber2), \
        "timber1 and timber2 must be face-aligned (share at least one parallel direction)"

    # Auto-determine size if not provided
    if size is None:
        # Use timber1's size as the default
        size = timber1.size

    # Calculate position on timber1
    pos1 = locate_position_on_centerline_from_bottom(timber1, location_on_timber1).position

    # Project pos1 onto timber2's centerline to find location_on_timber2
    # Vector from timber2's bottom to pos1
    to_pos1 = pos1 - timber2.get_bottom_position_global()

    # Project this onto timber2's length direction to find the parameter t
    location_on_timber2 = to_pos1.dot(timber2.get_length_direction_global()) / timber2.get_length_direction_global().dot(timber2.get_length_direction_global())

    # Intentionally do not clamp the projected location. Callers may rely on
    # measurements beyond timber2's nominal extents.
    #location_on_timber2 = max(scalar(0), min(timber2.length, location_on_timber2))

    # Calculate position on timber2 to determine joining direction
    pos2 = locate_position_on_centerline_from_bottom(timber2, location_on_timber2).position
    joining_direction = safe_normalize_vector(pos2 - pos1)

    # Convert TimberFace to a direction vector for orientation (if provided)
    orientation_width_vector = orientation_face_on_timber1.get_direction() if orientation_face_on_timber1 is not None else None

    # Calculate the width_direction for the joining timber (needed for feature offset calculation)
    if orientation_width_vector is not None:
        reference_direction = orientation_width_vector
    else:
        # Default: use timber1's length direction
        reference_direction = timber1.get_length_direction_global()

    # Check if reference direction is parallel to the joining direction
    if _are_directions_parallel(reference_direction, joining_direction):
        # If parallel, use a perpendicular fallback
        reference_direction = timber1.get_width_direction_global()

    # Project reference direction onto the plane perpendicular to the joining direction
    dot_product = reference_direction.dot(joining_direction)
    width_direction = reference_direction - dot_product * joining_direction
    width_direction = safe_normalize_vector(width_direction)

    # Calculate height direction (perpendicular to both length and width)
    height_direction = safe_normalize_vector(cross_product(joining_direction, width_direction))

    # Now convert feature-relative measurements to centerline-relative measurements
    longitudinal_offset = scalar(0)
    lateral_offset_adjustment = scalar(0)

    # Convert TimberFeature enum to geometric object if provided
    feature_geometry = None
    if feature_to_mark_on_joining_timber is not None:
        # Create a temporary timber to measure the feature on
        # This timber has the same cross-section and orientation as the final joining timber
        temp_timber_center = pos1  # Arbitrary position for temp timber
        temp_timber = create_timber(
            bottom_position=temp_timber_center,
            length=scalar(1),  # Arbitrary length
            size=size,
            length_direction=joining_direction,
            width_direction=width_direction
        )

        # Convert TimberFeature enum to the corresponding geometric object
        if feature_to_mark_on_joining_timber == TimberFeature.CENTERLINE:
            # No offset needed for centerline
            feature_geometry = None
        elif feature_to_mark_on_joining_timber == TimberFeature.TOP_FACE:
            feature_geometry = locate_face(temp_timber, TimberFace.TOP)
        elif feature_to_mark_on_joining_timber == TimberFeature.BOTTOM_FACE:
            feature_geometry = locate_face(temp_timber, TimberFace.BOTTOM)
        elif feature_to_mark_on_joining_timber == TimberFeature.RIGHT_FACE:
            feature_geometry = locate_face(temp_timber, TimberFace.RIGHT)
        elif feature_to_mark_on_joining_timber == TimberFeature.LEFT_FACE:
            feature_geometry = locate_face(temp_timber, TimberFace.LEFT)
        elif feature_to_mark_on_joining_timber == TimberFeature.FRONT_FACE:
            feature_geometry = locate_face(temp_timber, TimberFace.FRONT)
        elif feature_to_mark_on_joining_timber == TimberFeature.BACK_FACE:
            feature_geometry = locate_face(temp_timber, TimberFace.BACK)
        elif feature_to_mark_on_joining_timber == TimberFeature.RIGHT_FRONT_EDGE:
            feature_geometry = locate_long_edge(temp_timber, TimberLongEdge.RIGHT_FRONT)
        elif feature_to_mark_on_joining_timber == TimberFeature.FRONT_LEFT_EDGE:
            feature_geometry = locate_long_edge(temp_timber, TimberLongEdge.FRONT_LEFT)
        elif feature_to_mark_on_joining_timber == TimberFeature.LEFT_BACK_EDGE:
            feature_geometry = locate_long_edge(temp_timber, TimberLongEdge.LEFT_BACK)
        elif feature_to_mark_on_joining_timber == TimberFeature.BACK_RIGHT_EDGE:
            feature_geometry = locate_long_edge(temp_timber, TimberLongEdge.BACK_RIGHT)
        else:
            raise ValueError(f"Unsupported TimberFeature: {feature_to_mark_on_joining_timber}")

    if isinstance(feature_geometry, Plane):
        # Feature is a face plane - need to calculate both longitudinal and lateral offsets
        # Determine which face this plane represents by comparing normals using dot product

        # Normalize the plane normal for comparison
        plane_normal = safe_normalize_vector(feature_geometry.normal)

        # Check dot products to determine which direction the normal points
        # dot product ≈ +1 means same direction, ≈ -1 means opposite direction
        width_dot = safe_dot_product(plane_normal, width_direction)
        height_dot = safe_dot_product(plane_normal, height_direction)

        # Determine which axis has the strongest alignment (should be close to ±1)
        if Abs(width_dot) > Abs(height_dot):
            # Normal is aligned with width_direction (RIGHT/LEFT faces)
            if safe_compare(width_dot, 0, Comparison.GT):
                # RIGHT face (normal = +width_direction)
                longitudinal_offset = size[0] / 2
                lateral_offset_adjustment = scalar(0)
            else:
                # LEFT face (normal = -width_direction)
                longitudinal_offset = -size[0] / 2
                lateral_offset_adjustment = scalar(0)
        else:
            # Normal is aligned with height_direction (FRONT/BACK faces)
            if safe_compare(height_dot, 0, Comparison.GT):
                # FRONT face (normal = +height_direction)
                longitudinal_offset = scalar(0)
                lateral_offset_adjustment = size[1] / 2
            else:
                # BACK face (normal = -height_direction)
                longitudinal_offset = scalar(0)
                lateral_offset_adjustment = -size[1] / 2

    elif isinstance(feature_geometry, Line):
        # Feature is an edge line - need to calculate lateral offset only
        # The edge position is determined by offsets in both width and height directions

        # Create an imaginary centerline at pos1 for comparison
        centerline_point = pos1

        # Calculate the offset of the line's point from the centerline
        offset_vector = feature_geometry.point - centerline_point

        # Project onto width and height directions to get the edge position
        width_offset = offset_vector.dot(width_direction)
        height_offset = offset_vector.dot(height_direction)

        # The lateral offset is the distance in the lateral direction (perpendicular to joining direction)
        # For a joining timber, the lateral direction is typically the cross product of
        # timber1's length direction and the joining direction
        lateral_direction = safe_normalize_vector(cross_product(timber1.get_length_direction_global(), joining_direction))

        # Calculate total lateral offset from the edge position
        # The edge has offsets in both width and height directions
        lateral_offset_adjustment = width_offset * width_direction.dot(lateral_direction) + \
                                   height_offset * height_direction.dot(lateral_direction)

        # No longitudinal offset for edge lines (they run along the length)
        longitudinal_offset = scalar(0)

    # Adjust location_on_timber1 for longitudinal offset (along joining_direction)
    # The longitudinal offset affects where along timber1's length we measure from
    adjusted_location_on_timber1 = location_on_timber1 + longitudinal_offset

    # Adjust lateral offset
    adjusted_lateral_offset = lateral_offset_from_timber1 + lateral_offset_adjustment

    # Recalculate pos1 with the adjusted location
    pos1 = locate_position_on_centerline_from_bottom(timber1, adjusted_location_on_timber1).position

    # Recalculate location_on_timber2 and pos2 based on adjusted pos1
    to_pos1 = pos1 - timber2.get_bottom_position_global()
    location_on_timber2 = to_pos1.dot(timber2.get_length_direction_global()) / timber2.get_length_direction_global().dot(timber2.get_length_direction_global())
    # Intentionally do not clamp the projected location. Keep this consistent
    # with the initial projection above.
    pos2 = locate_position_on_centerline_from_bottom(timber2, location_on_timber2).position
    joining_direction = safe_normalize_vector(pos2 - pos1)

    # Determine which dimension of the created timber is perpendicular to the joining direction
    # The created timber will have:
    # - length_direction = joining_direction
    # - width_direction = orientation_width_vector
    # - height_direction = cross(length_direction, width_direction)

    # To determine which dimension (width=size[0] or height=size[1]) affects the stickout,
    # we need to see which one is aligned with the joining direction's perpendicular plane
    # For simplicity, we'll use the dot product to determine which axis is more aligned

    # Determine perpendicular size for stickout conversion
    # Only needed if stickout references are not CENTER_LINE
    if stickout.stickoutReference1 != StickoutReference.CENTER_LINE or stickout.stickoutReference2 != StickoutReference.CENTER_LINE:
        # Need to determine which dimension is perpendicular
        if orientation_width_vector is not None:
            # The width (size[0]) is along the width_direction
            # The height (size[1]) is along the height_direction (perpendicular to both)
            height_direction = safe_normalize_vector(cross_product(joining_direction, orientation_width_vector))

            # Check which dimension is more perpendicular to timber1's length direction
            # This determines which face is "inside" (facing timber1)
            face_dot = Abs(orientation_width_vector.dot(timber1.get_length_direction_global()))
            height_dot = Abs(height_direction.dot(timber1.get_length_direction_global()))

            # Use the dimension that's more perpendicular to timber1's length
            if face_dot < height_dot:
                # Face direction is more perpendicular, so width (size[0]) affects inside/outside
                perpendicular_size = size[0]
            else:
                # Height direction is more perpendicular, so height (size[1]) affects inside/outside
                perpendicular_size = size[1]
        else:
            # Without orientation specified, default to using width
            perpendicular_size = size[0]
    else:
        # Not needed for CENTER_LINE stickout
        perpendicular_size = 0

    # Convert stickout references to centerline offsets
    centerline_stickout1 = stickout.stickout1
    centerline_stickout2 = stickout.stickout2

    if stickout.stickoutReference1 == StickoutReference.INSIDE:
        # INSIDE: Extends from the face closest to timber2
        # Add half the perpendicular size
        centerline_stickout1 = stickout.stickout1 + perpendicular_size / scalar(2)
    elif stickout.stickoutReference1 == StickoutReference.OUTSIDE:
        # OUTSIDE: Extends from the face away from timber2
        # Subtract half the perpendicular size
        centerline_stickout1 = stickout.stickout1 - perpendicular_size / scalar(2)

    if stickout.stickoutReference2 == StickoutReference.INSIDE:
        # INSIDE: Extends from the face closest to timber1
        centerline_stickout2 = stickout.stickout2 + perpendicular_size / scalar(2)
    elif stickout.stickoutReference2 == StickoutReference.OUTSIDE:
        # OUTSIDE: Extends from the face away from timber1
        centerline_stickout2 = stickout.stickout2 - perpendicular_size / scalar(2)

    # Create a new Stickout with CENTER_LINE reference
    centerline_stickout = Stickout(
        centerline_stickout1,
        centerline_stickout2,
        StickoutReference.CENTER_LINE,
        StickoutReference.CENTER_LINE
    )

    # Call join_timbers to do the actual work with adjusted centerline-based parameters
    return join_timbers(
        timber1=timber1,
        timber2=timber2,
        location_on_timber1=adjusted_location_on_timber1,
        stickout=centerline_stickout,
        location_on_timber2=location_on_timber2,
        lateral_offset=adjusted_lateral_offset,
        orientation_width_vector=orientation_width_vector,
        size=size,
        ticket=ticket
    )

get_point_on_feature

get_point_on_feature(feature: Union[UnsignedPlane, Plane, Line, Point, LineOnPlane], timber: PerfectTimberWithin) -> V3

Get a point on a feature.

Source code in kumiki/measuring.py
def get_point_on_feature(feature: Union[UnsignedPlane, Plane, Line, Point, LineOnPlane], timber: PerfectTimberWithin) -> V3:
    """
    Get a point on a feature.
    """

    if isinstance(feature, LineOnPlane):
        return feature.point_on_line
    elif isinstance(feature, UnsignedPlane):
        return feature.point
    elif isinstance(feature, Plane):
        return feature.point
    elif isinstance(feature, Line):
        return feature.point
    elif isinstance(feature, Point):
        return feature.position

    raise ValueError(f"Unsupported feature type: {type(feature)}")

locate_face

locate_face(timber: PerfectTimberWithin, face: SomeTimberFace) -> Plane

Measure a face on a timber, returning a Plane centered on the face pointing outward.

The plane's normal points OUT of the timber (away from the timber's interior), and the plane's point is positioned at the center of the face surface.

Parameters:

Name Type Description Default
timber PerfectTimberWithin

The timber to measure

required
face SomeTimberFace

The face to measure

required

Returns:

Type Description
Plane

Plane with normal pointing outward from the face and point at the face center

Example

plane = locate_face(timber, TimberFace.RIGHT)

plane.normal points in +X direction (outward from RIGHT face)

plane.point is at the center of the RIGHT face surface

Source code in kumiki/measuring.py
def locate_face(timber: PerfectTimberWithin, face: SomeTimberFace) -> Plane:
    """
    Measure a face on a timber, returning a Plane centered on the face pointing outward.

    The plane's normal points OUT of the timber (away from the timber's interior),
    and the plane's point is positioned at the center of the face surface.

    Args:
        timber: The timber to measure
        face: The face to measure

    Returns:
        Plane with normal pointing outward from the face and point at the face center

    Example:
        >>> plane = locate_face(timber, TimberFace.RIGHT)
        >>> # plane.normal points in +X direction (outward from RIGHT face)
        >>> # plane.point is at the center of the RIGHT face surface
    """
    # Get the face normal (pointing OUT of the timber)
    face_normal = timber.get_face_direction_global(face)

    # Get a point on the face surface (at the center)
    face_point = get_center_point_on_face_global(face, timber)

    return Plane(face_normal, face_point)

locate_edge

locate_edge(timber: PerfectTimberWithin, edge: EdgeOrCenterline) -> Line

Measure any edge or centerline on a timber, returning a Line along it.

For TimberCenterline.CENTERLINE: direction = timber length direction, point at mid-length center. For TimberEdge values: uses canonical_line_from_corner to get the starting corner and direction face, then computes the global position and direction.

Parameters:

Name Type Description Default
timber PerfectTimberWithin

The timber to measure

required
edge EdgeOrCenterline

Which edge or centerline to measure

required

Returns:

Type Description
Line

Line representing the edge in global coordinates

Source code in kumiki/measuring.py
def locate_edge(timber: PerfectTimberWithin, edge: EdgeOrCenterline) -> Line:
    """
    Measure any edge or centerline on a timber, returning a Line along it.

    For TimberCenterline.CENTERLINE: direction = timber length direction, point at mid-length center.
    For TimberEdge values: uses canonical_line_from_corner to get the starting
    corner and direction face, then computes the global position and direction.

    Args:
        timber: The timber to measure
        edge: Which edge or centerline to measure

    Returns:
        Line representing the edge in global coordinates
    """
    if isinstance(edge, TimberCenterline):
        length_direction = timber.get_length_direction_global()
        center_position = timber.get_bottom_position_global() + length_direction * timber.length / 2
        return Line(length_direction, center_position)

    corner, direction_face = edge.canonical_line_from_corner()
    corner_position = timber.get_corner_position_global(corner)
    direction = timber.get_face_direction_global(direction_face)
    return Line(direction, corner_position)

locate_long_edge

locate_long_edge(timber: PerfectTimberWithin, edge: TimberLongEdge) -> Line

Measure a long edge on a timber. Thin wrapper around locate_edge.

Source code in kumiki/measuring.py
def locate_long_edge(timber: PerfectTimberWithin, edge: TimberLongEdge) -> Line:
    """Measure a long edge on a timber. Thin wrapper around locate_edge."""
    return locate_edge(timber, TimberEdge(edge.value))

locate_short_edge

locate_short_edge(timber: PerfectTimberWithin, edge: TimberShortEdge) -> Line

Measure a short edge on a timber. Thin wrapper around locate_edge.

Source code in kumiki/measuring.py
def locate_short_edge(timber: PerfectTimberWithin, edge: TimberShortEdge) -> Line:
    """Measure a short edge on a timber. Thin wrapper around locate_edge."""
    return locate_edge(timber, TimberEdge(edge.value))

locate_edge_on_face

locate_edge_on_face(timber: PerfectTimberWithin, edge: TimberLongEdge, face: TimberFace) -> LineOnPlane
Source code in kumiki/measuring.py
def locate_edge_on_face(timber: PerfectTimberWithin, edge: TimberLongEdge, face: TimberFace) -> LineOnPlane:
    # TODO: Implement this function
    raise NotImplementedError("locate_edge_on_face is not yet implemented")

locate_position_on_centerline_from_bottom

locate_position_on_centerline_from_bottom(timber: PerfectTimberWithin, distance: Numeric) -> Point

Measure a position at a specific point along the timber's centerline, measured from the bottom.

Parameters:

Name Type Description Default
timber PerfectTimberWithin

The timber to measure on

required
distance Numeric

Distance along the timber's length direction from the bottom position

required

Returns:

Type Description
Point

Point on the timber's centerline at the specified distance from bottom

Source code in kumiki/measuring.py
def locate_position_on_centerline_from_bottom(timber: PerfectTimberWithin, distance: Numeric) -> Point:
    """
    Measure a position at a specific point along the timber's centerline, measured from the bottom.

    Args:
        timber: The timber to measure on
        distance: Distance along the timber's length direction from the bottom position

    Returns:
        Point on the timber's centerline at the specified distance from bottom
    """
    position = timber.get_bottom_position_global() + timber.get_length_direction_global() * distance
    return Point(position)

locate_position_on_centerline_from_top

locate_position_on_centerline_from_top(timber: PerfectTimberWithin, distance: Numeric) -> Point

Measure a position at a specific point along the timber's centerline, measured from the top.

Parameters:

Name Type Description Default
timber PerfectTimberWithin

The timber to measure on

required
distance Numeric

Distance along the timber's length direction from the top position

required

Returns:

Type Description
Point

Point on the timber's centerline at the specified distance from top

Source code in kumiki/measuring.py
def locate_position_on_centerline_from_top(timber: PerfectTimberWithin, distance: Numeric) -> Point:
    """
    Measure a position at a specific point along the timber's centerline, measured from the top.

    Args:
        timber: The timber to measure on
        distance: Distance along the timber's length direction from the top position

    Returns:
        Point on the timber's centerline at the specified distance from top
    """
    position = timber.get_bottom_position_global() + timber.get_length_direction_global() * (timber.length - distance)
    return Point(position)

locate_bottom_center_position

locate_bottom_center_position(timber: PerfectTimberWithin) -> Point

Measure the position of the center of the bottom cross-section of the timber.

Parameters:

Name Type Description Default
timber PerfectTimberWithin

The timber to measure on

required

Returns:

Type Description
Point

Point at the center of the bottom cross-section

Source code in kumiki/measuring.py
def locate_bottom_center_position(timber: PerfectTimberWithin) -> Point:
    """
    Measure the position of the center of the bottom cross-section of the timber.

    Args:
        timber: The timber to measure on

    Returns:
        Point at the center of the bottom cross-section
    """
    return Point(timber.get_bottom_position_global())

locate_top_center_position

locate_top_center_position(timber: PerfectTimberWithin) -> Point

Measure the position of the center of the top cross-section of the timber.

Parameters:

Name Type Description Default
timber PerfectTimberWithin

The timber to measure on

required

Returns:

Type Description
Point

Point at the center of the top cross-section

Source code in kumiki/measuring.py
def locate_top_center_position(timber: PerfectTimberWithin) -> Point:
    """
    Measure the position of the center of the top cross-section of the timber.

    Args:
        timber: The timber to measure on

    Returns:
        Point at the center of the top cross-section
    """
    position = timber.get_bottom_position_global() + timber.get_length_direction_global() * timber.length
    return Point(position)

locate_into_face

locate_into_face(distance: Numeric, face: SomeTimberFace, timber: PerfectTimberWithin) -> UnsignedPlane

Measure a distance from a face on a timber.

Parameters:

Name Type Description Default
distance Numeric

How far into the timber, along the face's inward normal, to place the plane

required
face SomeTimberFace

The face to measure from (can be TimberFace, TimberEnd, or TimberLongFace)

required
timber PerfectTimberWithin

The timber to measure on

required

Returns:

Type Description
UnsignedPlane

UnsignedPlane parallel to the face, offset distance into the timber along the face's

UnsignedPlane

normal. This is the inverse of mark_distance_from_face_in_normal_direction: if

UnsignedPlane

feature = locate_into_face(d, face, timber), then

UnsignedPlane

mark_distance_from_face_in_normal_direction(feature, timber, face).distance == d

Source code in kumiki/measuring.py
def locate_into_face(distance: Numeric, face: SomeTimberFace, timber: PerfectTimberWithin) -> UnsignedPlane:
    """
    Measure a distance from a face on a timber.

    Args:
        distance: How far into the timber, along the face's inward normal, to place the plane
        face: The face to measure from (can be TimberFace, TimberEnd, or TimberLongFace)
        timber: The timber to measure on

    Returns:
        UnsignedPlane parallel to the face, offset `distance` into the timber along the face's
        normal. This is the inverse of mark_distance_from_face_in_normal_direction: if
        feature = locate_into_face(d, face, timber), then
        mark_distance_from_face_in_normal_direction(feature, timber, face).distance == d
    """

    # First pick any point on the face
    point_on_face = get_center_point_on_face_global(face, timber)

    # Measure INTO the face
    point_on_plane = point_on_face - timber.get_face_direction_global(face) * distance

    return UnsignedPlane(timber.get_face_direction_global(face), point_on_plane)

locate_plane_from_centerline_in_direction

locate_plane_from_centerline_in_direction(timber: PerfectTimberWithin, direction: Direction3D) -> Plane
Source code in kumiki/measuring.py
def locate_plane_from_centerline_in_direction(timber: PerfectTimberWithin, direction: Direction3D) -> Plane:
    return locate_plane_from_edge_in_direction(timber, TimberCenterline.CENTERLINE, direction)

mark_distance_from_face_in_normal_direction

mark_distance_from_face_in_normal_direction(feature: Union[UnsignedPlane, Plane, Line, Point, LineOnPlane], timber: PerfectTimberWithin, face: SomeTimberFace) -> DistanceFromFace

Mark a feature onto a face on a timber.

Returns a DistanceFromFace measurement representing the distance from the face to the feature, measured INTO the timber. Positive means the feature is inside the timber (deeper than the face surface). Negative means the feature is outside the timber (shallower than the face surface).

This is the inverse of locate_into_face: If feature = locate_into_face(d, face, timber), then mark_distance_from_face_in_normal_direction(feature, timber, face).distance = d

Source code in kumiki/measuring.py
def mark_distance_from_face_in_normal_direction(feature: Union[UnsignedPlane, Plane, Line, Point, LineOnPlane], timber: PerfectTimberWithin, face: SomeTimberFace) -> DistanceFromFace:
    """
    Mark a feature onto a face on a timber.

    Returns a DistanceFromFace measurement representing the distance from the face to the feature, measured INTO the timber.
    Positive means the feature is inside the timber (deeper than the face surface).
    Negative means the feature is outside the timber (shallower than the face surface).

    This is the inverse of locate_into_face:
    If feature = locate_into_face(d, face, timber), then mark_distance_from_face_in_normal_direction(feature, timber, face).distance = d
    """

    if isinstance(feature, UnsignedPlane) or isinstance(feature, Plane) or isinstance(feature, LineOnPlane):
        assert are_vectors_parallel(feature.normal, timber.get_face_direction_global(face)), \
            f"Feature must be parallel to the face. Feature {feature} is not parallel to face {face} on timber {timber}"
    elif isinstance(feature, Line):
        assert are_vectors_perpendicular(feature.direction, timber.get_face_direction_global(face)), \
            f"Feature must be parallel to the face. Feature {feature} is not parallel to face {face} on timber {timber}"

    # Pick a point on the feature
    feature_point = get_point_on_feature(feature, timber)

    # Project the feature point onto the face to get the signed distance
    # Get a reference point on the face surface
    face_point_global = get_center_point_on_face_global(face, timber)

    # Get the face normal (pointing OUT of the timber)
    face_direction_global = timber.get_face_direction_global(face)

    # Calculate signed distance: how far from the face is the point?
    # Positive if point is in the direction opposite to face_direction (inside timber)
    # Negative if point is in the direction of face_direction (outside timber)
    distance = safe_dot_product(face_direction_global, (face_point_global - feature_point))

    return DistanceFromFace(distance=distance, timber=timber, face=face)

mark_distance_from_corner_along_edge_by_intersecting_plane

mark_distance_from_corner_along_edge_by_intersecting_plane(plane: Union[UnsignedPlane, Plane], timber: PerfectTimberWithin, edge: Union[TimberLongEdge, TimberShortEdge, EdgeOrCenterline], end: TimberEnd) -> DistanceFromCornerAlongEdge

Mark onto an edge by intersecting a plane, returning a DistanceFromCornerAlongEdge.

Parameters:

Name Type Description Default
plane Union[UnsignedPlane, Plane]

the plane to intersect with

required
timber PerfectTimberWithin

the timber whose edge we're intersecting

required
edge Union[TimberLongEdge, TimberShortEdge, EdgeOrCenterline]

the edge to intersect with (TimberLongEdge, TimberShortEdge, TimberEdge, or TimberCenterline)

required
end TimberEnd

the end of the timber to mark from

required

Returns:

Type Description
DistanceFromCornerAlongEdge

DistanceFromCornerAlongEdge with the signed distance from the end to the

DistanceFromCornerAlongEdge

intersection. Positive means into the timber from the end.

Source code in kumiki/measuring.py
def mark_distance_from_corner_along_edge_by_intersecting_plane(plane: Union[UnsignedPlane, Plane], timber: PerfectTimberWithin, edge: Union[TimberLongEdge, TimberShortEdge, EdgeOrCenterline], end: TimberEnd) -> DistanceFromCornerAlongEdge:
    """
    Mark onto an edge by intersecting a plane, returning a DistanceFromCornerAlongEdge.

    Args:
        plane: the plane to intersect with
        timber: the timber whose edge we're intersecting
        edge: the edge to intersect with (TimberLongEdge, TimberShortEdge, TimberEdge, or TimberCenterline)
        end: the end of the timber to mark from

    Returns:
        DistanceFromCornerAlongEdge with the signed distance from the end to the
        intersection. Positive means into the timber from the end.
    """
    assert isinstance(end, TimberEnd), f"expected TimberEnd, got {type(end).__name__}"
    if isinstance(edge, (TimberLongEdge, TimberShortEdge)):
        edge_line = locate_edge(timber, TimberEdge(edge.value))
    else:
        edge_line = locate_edge(timber, edge)

    if end == TimberEnd.TOP:
        end_position = edge_line.point + edge_line.direction * (timber.length / scalar(2))
        into_timber_direction = -timber.get_length_direction_global()
    else:  # BOTTOM
        end_position = edge_line.point - edge_line.direction * (timber.length / scalar(2))
        into_timber_direction = timber.get_length_direction_global()

    numerator = safe_dot_product(plane.normal, (plane.point - end_position))
    denominator = safe_dot_product(plane.normal, into_timber_direction)

    if safe_zero_test(denominator):
        raise ValueError(f"Edge is parallel to plane - no intersection exists")

    resolved_edge: EdgeOrCenterline = TimberEdge(edge.value) if isinstance(edge, (TimberLongEdge, TimberShortEdge)) else edge
    return DistanceFromCornerAlongEdge(
        distance=numerator / denominator,
        timber=timber,
        edge=resolved_edge,
        end=end,
    )

mark_distance_from_corner_along_edge_by_finding_closest_point_on_line

mark_distance_from_corner_along_edge_by_finding_closest_point_on_line(line: Line, timber: PerfectTimberWithin, edge: Union[TimberLongEdge, TimberShortEdge, EdgeOrCenterline], end: TimberEnd) -> DistanceFromCornerAlongEdge

Mark onto an edge by finding the closest point to a line, returning a DistanceFromCornerAlongEdge.

Parameters:

Name Type Description Default
line Line

The line feature to mark from

required
timber PerfectTimberWithin

The timber whose edge we're marking to

required
edge Union[TimberLongEdge, TimberShortEdge, EdgeOrCenterline]

The edge to mark to (TimberLongEdge, TimberShortEdge, TimberEdge, or TimberCenterline)

required
end TimberEnd

Which end of the timber to mark from

required

Returns:

Type Description
DistanceFromCornerAlongEdge

DistanceFromCornerAlongEdge with the signed distance from the end to the closest point.

Source code in kumiki/measuring.py
def mark_distance_from_corner_along_edge_by_finding_closest_point_on_line(line: Line, timber: PerfectTimberWithin, edge: Union[TimberLongEdge, TimberShortEdge, EdgeOrCenterline], end: TimberEnd) -> DistanceFromCornerAlongEdge:
    """
    Mark onto an edge by finding the closest point to a line, returning a DistanceFromCornerAlongEdge.

    Args:
        line: The line feature to mark from
        timber: The timber whose edge we're marking to
        edge: The edge to mark to (TimberLongEdge, TimberShortEdge, TimberEdge, or TimberCenterline)
        end: Which end of the timber to mark from

    Returns:
        DistanceFromCornerAlongEdge with the signed distance from the end to the closest point.
    """
    assert isinstance(end, TimberEnd), f"expected TimberEnd, got {type(end).__name__}"
    if isinstance(edge, (TimberLongEdge, TimberShortEdge)):
        edge_line = locate_edge(timber, TimberEdge(edge.value))
    else:
        edge_line = locate_edge(timber, edge)

    if are_vectors_parallel(line.direction, edge_line.direction):
        raise ValueError(f"Lines are parallel - no intersection exists")

    if end == TimberEnd.TOP:
        edge_end_position = edge_line.point + edge_line.direction * (timber.length / scalar(2))
    else:  # BOTTOM
        edge_end_position = edge_line.point - edge_line.direction * (timber.length / scalar(2))

    # Solve for closest points on two 3D lines using the standard formula
    # Line 1 (given line): line.point + s * line.direction
    # Line 2 (edge): edge_end_position + t * edge_line.direction
    # We need to find s and t such that the connecting vector is perpendicular to both directions

    w = line.point - edge_end_position  # Vector between starting points

    a = safe_dot_product(line.direction, line.direction)  # Should be 1 for normalized directions
    b = safe_dot_product(line.direction, edge_line.direction)
    c = safe_dot_product(edge_line.direction, edge_line.direction)  # Should be 1 for normalized directions
    d = safe_dot_product(w, line.direction)
    e = safe_dot_product(w, edge_line.direction)

    denominator = a * c - b * b

    if safe_zero_test(denominator):
        t = scalar(0)
    else:
        t = (a * e - b * d) / denominator

    resolved_edge: EdgeOrCenterline = TimberEdge(edge.value) if isinstance(edge, (TimberLongEdge, TimberShortEdge)) else edge
    return DistanceFromCornerAlongEdge(
        distance=t,
        timber=timber,
        edge=resolved_edge,
        end=end,
    )

mark_distance_from_end_along_centerline

mark_distance_from_end_along_centerline(feature: Union[UnsignedPlane, Plane, Line, Point, LineOnPlane], timber: PerfectTimberWithin, end: TimberEnd = BOTTOM) -> DistanceFromPointIntoFace

Mark a feature onto the centerline of a timber.

Returns a DistanceFromPointIntoFace measurement representing the distance from the specified end of the timber to the intersection/closest point on the centerline.

Parameters:

Name Type Description Default
feature Union[UnsignedPlane, Plane, Line, Point, LineOnPlane]

The feature to mark (Plane, Line, Point, etc.)

required
timber PerfectTimberWithin

The timber whose centerline we're marking to

required
end TimberEnd

Which end of the timber to mark from (defaults to BOTTOM)

BOTTOM

Returns:

Type Description
DistanceFromPointIntoFace

DistanceFromPointIntoFace with distance from the specified end to where the feature intersects/is closest

DistanceFromPointIntoFace

to the centerline. Positive means into the timber from the end. The point is set to the end's

DistanceFromPointIntoFace

centerline position.

Source code in kumiki/measuring.py
def mark_distance_from_end_along_centerline(feature: Union[UnsignedPlane, Plane, Line, Point, LineOnPlane], timber: PerfectTimberWithin, end: TimberEnd = TimberEnd.BOTTOM) -> DistanceFromPointIntoFace:
    """
    Mark a feature onto the centerline of a timber.

    Returns a DistanceFromPointIntoFace measurement representing the distance from the specified end of the timber
    to the intersection/closest point on the centerline.

    Args:
        feature: The feature to mark (Plane, Line, Point, etc.)
        timber: The timber whose centerline we're marking to
        end: Which end of the timber to mark from (defaults to BOTTOM)

    Returns:
        DistanceFromPointIntoFace with distance from the specified end to where the feature intersects/is closest
        to the centerline. Positive means into the timber from the end. The point is set to the end's
        centerline position.
    """
    assert isinstance(end, TimberEnd), f"expected TimberEnd, got {type(end).__name__}"
    if isinstance(feature, UnsignedPlane) or isinstance(feature, Plane):
        distance = mark_distance_from_corner_along_edge_by_intersecting_plane(feature, timber, TimberCenterline.CENTERLINE, end).distance
    elif isinstance(feature, Line):
        distance = mark_distance_from_corner_along_edge_by_finding_closest_point_on_line(feature, timber, TimberCenterline.CENTERLINE, end).distance
    else:
        assert False, f"Not implemented for feature type {type(feature)}"

    # Get the reference end's centerline position as the reference point
    centerline = locate_centerline(timber)
    if end == TimberEnd.BOTTOM:
        end_centerline_position = timber.get_bottom_position_global()
        reference_face = TimberFace.BOTTOM
    else:  # TOP
        end_centerline_position = timber.get_bottom_position_global() + timber.get_length_direction_global() * timber.length
        reference_face = TimberFace.TOP

    return DistanceFromPointIntoFace(
        distance=distance,
        timber=timber,
        face=reference_face,
        point=end_centerline_position
    )

mark_plane_from_edge_in_direction

mark_plane_from_edge_in_direction(plane: Union[UnsignedPlane, Plane, LineOnPlane], timber: PerfectTimberWithin, edge: EdgeOrCenterline) -> PlaneFromEdgeInDirection

Mark a plane onto a timber edge, returning the direction and signed distance from the edge to the plane.

This is the inverse of locate_plane_from_edge_in_direction: if p = locate_plane_from_edge_in_direction(timber, edge, dir, dist), then mark_plane_from_edge_in_direction(p, timber, edge) returns (dir, dist).

Parameters:

Name Type Description Default
plane Union[UnsignedPlane, Plane, LineOnPlane]

The plane to mark (its normal becomes the direction)

required
timber PerfectTimberWithin

The timber whose edge we're measuring from

required
edge EdgeOrCenterline

Which edge to measure from

required
Source code in kumiki/measuring.py
def mark_plane_from_edge_in_direction(plane: Union[UnsignedPlane, Plane, LineOnPlane], timber: PerfectTimberWithin, edge: EdgeOrCenterline) -> PlaneFromEdgeInDirection:
    """
    Mark a plane onto a timber edge, returning the direction and signed distance
    from the edge to the plane.

    This is the inverse of locate_plane_from_edge_in_direction:
    if p = locate_plane_from_edge_in_direction(timber, edge, dir, dist),
    then mark_plane_from_edge_in_direction(p, timber, edge) returns (dir, dist).

    Args:
        plane: The plane to mark (its normal becomes the direction)
        timber: The timber whose edge we're measuring from
        edge: Which edge to measure from
    """
    edge_line = locate_edge(timber, edge)
    direction = plane.normal
    plane_point = plane.point_on_line if isinstance(plane, LineOnPlane) else plane.point
    distance = safe_dot_product(direction, plane_point - edge_line.point)
    return PlaneFromEdgeInDirection(
        timber=timber,
        edge=edge,
        direction=direction,
        distance=distance,
    )

get_rough_support_distance_from_centerline

get_rough_support_distance_from_centerline(timber: PerfectTimberWithin, direction: V2) -> Numeric

distance from cross-section centerline to support plane of the actual timber dimensions in direction

Source code in kumiki/timber_shavings.py
def get_rough_support_distance_from_centerline(timber: PerfectTimberWithin, direction: V2) -> Numeric:
    """distance from cross-section centerline to support plane of the actual timber dimensions in direction"""
    width_halves, height_halves = timber.get_rough_half_sizes()
    return _support_distance_local(
        position_local=create_v3(scalar(0), scalar(0), scalar(0)),
        direction_local=create_v3(direction[0], direction[1], scalar(0)),
        x_pos=width_halves[0],
        x_neg=width_halves[1],
        y_pos=height_halves[0],
        y_neg=height_halves[1],
        z_min=scalar(0),
        z_max=scalar(0),
    )

get_rough_support_distance

get_rough_support_distance(timber: PerfectTimberWithin, position_from_bottom: V3, direction: V3) -> Numeric

distance from a 3D local position to support plane of the actual timber dimensions in direction

Source code in kumiki/timber_shavings.py
def get_rough_support_distance(timber: PerfectTimberWithin, position_from_bottom: V3, direction: V3) -> Numeric:
    """distance from a 3D local position to support plane of the actual timber dimensions in direction"""
    width_halves, height_halves = timber.get_rough_half_sizes()
    return _support_distance_local(
        position_local=position_from_bottom,
        direction_local=direction,
        x_pos=width_halves[0],
        x_neg=width_halves[1],
        y_pos=height_halves[0],
        y_neg=height_halves[1],
        z_min=scalar(0),
        z_max=timber.length,
    )

get_perfect_support_distance

get_perfect_support_distance(timber: PerfectTimberWithin, position_from_bottom: V3, direction: V3) -> Numeric

distance from a 3D local position to support plane of the perfect timber dimensions in direction

Source code in kumiki/timber_shavings.py
def get_perfect_support_distance(timber: PerfectTimberWithin, position_from_bottom: V3, direction: V3) -> Numeric:
    """distance from a 3D local position to support plane of the perfect timber dimensions in direction"""
    w_half = timber.size[0] / scalar(2)
    h_half = timber.size[1] / scalar(2)
    return _support_distance_local(
        position_local=position_from_bottom,
        direction_local=direction,
        x_pos=w_half,
        x_neg=w_half,
        y_pos=h_half,
        y_neg=h_half,
        z_min=scalar(0),
        z_max=timber.length,
    )

find_opposing_face_on_another_timber

find_opposing_face_on_another_timber(reference_timber: PerfectTimberWithin, reference_face: TimberLongFace, target_timber: PerfectTimberWithin) -> TimberFace

Find the opposing face on another timber. Assumes that the target_timber has a face parallel to the reference face on the reference_timber.

Source code in kumiki/timber_shavings.py
def find_opposing_face_on_another_timber(reference_timber: PerfectTimberWithin, reference_face: TimberLongFace, target_timber: PerfectTimberWithin) -> TimberFace:
    """
    Find the opposing face on another timber. Assumes that the target_timber has a face parallel to the reference face on the reference_timber.
    """
    assert isinstance(reference_face, TimberLongFace), f"expected TimberLongFace, got {type(reference_face).__name__}"
    target_face = target_timber.get_closest_oriented_face_from_global_direction(-reference_timber.get_face_direction_global(reference_face))

    # assert that the target_face is parallel to the reference_face
    assert are_vectors_parallel(reference_timber.get_face_direction_global(reference_face), target_timber.get_face_direction_global(target_face)), \
        f"Target face {target_face} is not parallel to reference face {reference_face} on timber {reference_timber.ticket.path}"

    return target_face

create_peg_going_into_face

create_peg_going_into_face(timber: Timber, face: TimberLongFace, distance_from_bottom: Numeric, distance_from_centerline: Numeric, peg_size: Numeric, peg_shape: PegShape, forward_length: Numeric, stickout_length: Numeric) -> Peg

Create a peg that goes into a specified long face of a timber.

The peg is created in the local space of the timber, with the insertion end at the timber's surface and pointing inward perpendicular to the face.

Parameters:

Name Type Description Default
timber Timber

The timber to insert the peg into

required
face TimberLongFace

Which long face the peg enters from (RIGHT, LEFT, FRONT, or BACK)

required
distance_from_bottom Numeric

Distance along the timber's length from the bottom end

required
distance_from_centerline Numeric

Distance from the timber's centerline along the face

required
peg_size Numeric

Size/diameter of the peg (for square pegs, this is the side length)

required
peg_shape PegShape

Shape of the peg (SQUARE or ROUND)

required
forward_length Numeric

How far the peg reaches in the forward direction

required
stickout_length Numeric

How far the peg sticks out in the back direction

required

Returns:

Type Description
Peg

Peg object positioned and oriented appropriately in timber's local space

Source code in kumiki/timber_shavings.py
def create_peg_going_into_face(
    timber: Timber,
    face: TimberLongFace,
    distance_from_bottom: Numeric,
    distance_from_centerline: Numeric,
    peg_size: Numeric,
    peg_shape: PegShape,
    forward_length: Numeric,
    stickout_length: Numeric
) -> Peg:
    """
    Create a peg that goes into a specified long face of a timber.

    The peg is created in the local space of the timber, with the insertion end
    at the timber's surface and pointing inward perpendicular to the face.

    Args:
        timber: The timber to insert the peg into
        face: Which long face the peg enters from (RIGHT, LEFT, FRONT, or BACK)
        distance_from_bottom: Distance along the timber's length from the bottom end
        distance_from_centerline: Distance from the timber's centerline along the face
        peg_size: Size/diameter of the peg (for square pegs, this is the side length)
        peg_shape: Shape of the peg (SQUARE or ROUND)
        forward_length: How far the peg reaches in the forward direction
        stickout_length: How far the peg sticks out in the back direction

    Returns:
        Peg object positioned and oriented appropriately in timber's local space
    """
    assert isinstance(face, TimberLongFace), f"expected TimberLongFace, got {type(face).__name__}"
    # Get the face direction in local space (timber coordinate system)
    # In local coords: X = width, Y = height, Z = length
    face_normal_local = face.to.face().get_direction()

    # Position the peg on the timber's surface
    # Start at centerline, then move along length and offset from centerline
    position_local = create_v3(0, 0, distance_from_bottom)

    # Offset from centerline depends on which face we're on
    if face == TimberLongFace.RIGHT:
        # RIGHT face: offset in +X (width) direction, surface at +width/2
        position_local = create_v3(
            timber.size[0] / scalar(2),  # At right surface
            distance_from_centerline,  # Offset in height direction
            distance_from_bottom
        )
        # Peg points inward (-X direction in local space)
        length_dir = create_v3(-1, 0, 0)
        width_dir = create_v3(0, 1, 0)

    elif face == TimberLongFace.LEFT:
        # LEFT face: offset in -X (width) direction
        position_local = create_v3(
            -timber.size[0] / scalar(2),  # At left surface
            distance_from_centerline,  # Offset in height direction
            distance_from_bottom
        )
        # Peg points inward (+X direction in local space)
        length_dir = create_v3(1, 0, 0)
        width_dir = create_v3(0, 1, 0)

    elif face == TimberLongFace.FRONT:
        # FRONT face: offset in +Y (height) direction
        position_local = create_v3(
            distance_from_centerline,  # Offset in width direction
            timber.size[1] / scalar(2),  # At forward surface
            distance_from_bottom
        )
        # Peg points inward (-Y direction in local space)
        length_dir = create_v3(0, -1, 0)
        width_dir = create_v3(1, 0, 0)

    else:  # BACK
        # BACK face: offset in -Y (height) direction
        position_local = create_v3(
            distance_from_centerline,  # Offset in width direction
            -timber.size[1] / scalar(2),  # At back surface
            distance_from_bottom
        )
        # Peg points inward (+Y direction in local space)
        length_dir = create_v3(0, 1, 0)
        width_dir = create_v3(1, 0, 0)

    # Compute peg orientation (peg's Z-axis points into the timber)
    peg_orientation = compute_timber_orientation(length_dir, width_dir)
    peg_transform = Transform(position=position_local, orientation=peg_orientation)

    return Peg(
        transform=peg_transform,
        size=peg_size,
        shape=peg_shape,
        forward_length=forward_length,
        stickout_length=stickout_length
    )

create_wedge_in_timber_end

create_wedge_in_timber_end(timber: Timber, end: TimberEnd, position: V3, shape: WedgeShape) -> Wedge

Create a wedge at the end of a timber.

The wedge is created in the local space of the timber. In identity orientation, the point of the wedge goes in the length direction (Z-axis in local space).

Parameters:

Name Type Description Default
timber Timber

The timber to insert the wedge into

required
end TimberEnd

Which end of the timber (TOP or BOTTOM)

required
position V3

Position in the timber's cross-section (X, Y in local space, Z ignored)

required
shape WedgeShape

Specification of wedge dimensions

required

Returns:

Type Description
Wedge

Wedge object positioned and oriented appropriately in timber's local space

Source code in kumiki/timber_shavings.py
def create_wedge_in_timber_end(
    timber: Timber,
    end: TimberEnd,
    position: V3,
    shape: WedgeShape
) -> Wedge:
    """
    Create a wedge at the end of a timber.

    The wedge is created in the local space of the timber. In identity orientation,
    the point of the wedge goes in the length direction (Z-axis in local space).

    Args:
        timber: The timber to insert the wedge into
        end: Which end of the timber (TOP or BOTTOM)
        position: Position in the timber's cross-section (X, Y in local space, Z ignored)
        shape: Specification of wedge dimensions

    Returns:
        Wedge object positioned and oriented appropriately in timber's local space
    """
    # Determine wedge position and orientation based on which end
    if end == TimberEnd.TOP:
        # At top end, wedge points downward into timber (-Z in local space)
        # Position at the top of the timber
        wedge_position = create_v3(
            position[0],  # X position (cross-section)
            position[1],  # Y position (cross-section)
            timber.length  # At the top end
        )
        # Wedge points downward
        length_dir = create_v3(0, 0, -1)
        width_dir = create_v3(1, 0, 0)

    else:  # BOTTOM
        # At bottom end, wedge points upward into timber (+Z in local space)
        # Position at the bottom of the timber
        wedge_position = create_v3(
            position[0],  # X position (cross-section)
            position[1],  # Y position (cross-section)
            0  # At the bottom end
        )
        # Wedge points upward
        length_dir = create_v3(0, 0, 1)
        width_dir = create_v3(1, 0, 0)

    # Compute wedge orientation
    wedge_orientation = compute_timber_orientation(length_dir, width_dir)
    wedge_transform = Transform(position=wedge_position, orientation=wedge_orientation)

    return Wedge(
        transform=wedge_transform,
        base_width=shape.base_width,
        tip_width=shape.tip_width,
        height=shape.height,
        length=shape.length
    )

are_timbers_parallel

are_timbers_parallel(timber1: PerfectTimberWithin, timber2: PerfectTimberWithin, tolerance: Optional[Numeric] = None) -> bool

Check if two timbers have parallel length directions.

Parameters:

Name Type Description Default
timber1 PerfectTimberWithin

First timber

required
timber2 PerfectTimberWithin

Second timber

required
tolerance Optional[Numeric]

Optional tolerance for approximate comparison. If None, attempts exact comparison and uses default epsilon if not possible.

None

Returns:

Type Description
bool

True if timbers have parallel length directions, False otherwise

Source code in kumiki/timber_shavings.py
def are_timbers_parallel(timber1: PerfectTimberWithin, timber2: PerfectTimberWithin, tolerance: Optional[Numeric] = None) -> bool:
    """
    Check if two timbers have parallel length directions.

    Args:
        timber1: First timber
        timber2: Second timber
        tolerance: Optional tolerance for approximate comparison. If None, attempts exact comparison and uses default epsilon if not possible.

    Returns:
        True if timbers have parallel length directions, False otherwise
    """
    dot_product = Abs(numeric_dot_product(timber1.get_length_direction_global(), timber2.get_length_direction_global()))

    if tolerance is None:
        return safe_equality_test(dot_product, 1)
    else:
        return Abs(dot_product - 1) < tolerance

are_timbers_orthogonal

are_timbers_orthogonal(timber1: PerfectTimberWithin, timber2: PerfectTimberWithin, tolerance: Optional[Numeric] = None) -> bool

Check if two timbers have orthogonal (perpendicular) length directions.

Parameters:

Name Type Description Default
timber1 PerfectTimberWithin

First timber

required
timber2 PerfectTimberWithin

Second timber

required
tolerance Optional[Numeric]

Optional tolerance for approximate comparison. If None, automatically uses exact comparison for rational values or fuzzy comparison for floats.

None

Returns:

Type Description
bool

True if timbers have orthogonal length directions, False otherwise

Source code in kumiki/timber_shavings.py
def are_timbers_orthogonal(timber1: PerfectTimberWithin, timber2: PerfectTimberWithin, tolerance: Optional[Numeric] = None) -> bool:
    """
    Check if two timbers have orthogonal (perpendicular) length directions.

    Args:
        timber1: First timber
        timber2: Second timber
        tolerance: Optional tolerance for approximate comparison. If None, automatically
                   uses exact comparison for rational values or fuzzy comparison for floats.

    Returns:
        True if timbers have orthogonal length directions, False otherwise
    """
    dot_product = numeric_dot_product(timber1.get_length_direction_global(), timber2.get_length_direction_global())

    if tolerance is None:
        return safe_zero_test(dot_product)
    else:
        return Abs(dot_product) < tolerance

are_timbers_face_aligned

are_timbers_face_aligned(timber1: PerfectTimberWithin, timber2: PerfectTimberWithin, tolerance: Optional[Numeric] = None) -> bool

Check if two timbers are face-aligned.

Two timbers are face-aligned if any face of one timber is parallel to any face of the other timber. This occurs when their orientations are related by 90-degree rotations around any axis (i.e., they share the same coordinate grid alignment).

Mathematically, timbers are face-aligned if any of their orthogonal direction vectors (length_direction, width_direction, height_direction) are parallel to each other.

Parameters:

Name Type Description Default
timber1 PerfectTimberWithin

First timber

required
timber2 PerfectTimberWithin

Second timber

required
tolerance Optional[Numeric]

Optional numerical tolerance for parallel check. If None, uses exact equality. If provided, uses approximate floating-point comparison.

None

Returns:

Type Description
bool

True if timbers are face-aligned, False otherwise

Source code in kumiki/timber_shavings.py
def are_timbers_face_aligned(timber1: PerfectTimberWithin, timber2: PerfectTimberWithin, tolerance: Optional[Numeric] = None) -> bool:
    """
    Check if two timbers are face-aligned.

    Two timbers are face-aligned if any face of one timber is parallel to any face 
    of the other timber. This occurs when their orientations are related by 90-degree 
    rotations around any axis (i.e., they share the same coordinate grid alignment).

    Mathematically, timbers are face-aligned if any of their orthogonal direction 
    vectors (length_direction, width_direction, height_direction) are parallel to each other.

    Args:
        timber1: First timber
        timber2: Second timber  
        tolerance: Optional numerical tolerance for parallel check. If None, uses exact
                   equality. If provided, uses approximate floating-point comparison.

    Returns:
        True if timbers are face-aligned, False otherwise
    """
    # Get the three orthogonal direction vectors for each timber
    dirs1 = [timber1.get_length_direction_global(), timber1.get_width_direction_global(), timber1.get_height_direction_global()]
    dirs2 = [timber2.get_length_direction_global(), timber2.get_width_direction_global(), timber2.get_height_direction_global()]

    # Check all pairs of directions
    for dir1 in dirs1:
        for dir2 in dirs2:
            dot_product = Abs(numeric_dot_product(dir1, dir2))

            if tolerance is None:
                if safe_equality_test(dot_product, 1):
                    return True
            else:
                if Abs(dot_product - 1) < tolerance:
                    return True

    return False

do_xy_cross_section_on_parallel_timbers_overlap

do_xy_cross_section_on_parallel_timbers_overlap(timberA: PerfectTimberWithin, timberB: PerfectTimberWithin) -> bool

Check if the cross-section of two parallel timbers overlap.

Converts timberB into timberA's local space and checks if the XY cross-sections (defined by bottom_position and size) overlap.

Parameters:

Name Type Description Default
timberA PerfectTimberWithin

First timber

required
timberB PerfectTimberWithin

Second timber

required

Returns:

Type Description
bool

True if the cross-sections overlap, False otherwise

Source code in kumiki/timber_shavings.py
def do_xy_cross_section_on_parallel_timbers_overlap(timberA: PerfectTimberWithin, timberB: PerfectTimberWithin) -> bool:
    """
    Check if the cross-section of two parallel timbers overlap.

    Converts timberB into timberA's local space and checks if the XY cross-sections
    (defined by bottom_position and size) overlap.

    Args:
        timberA: First timber
        timberB: Second timber

    Returns:
        True if the cross-sections overlap, False otherwise
    """
    assert are_vectors_parallel(timberA.get_length_direction_global(), timberB.get_length_direction_global()), "Timbers must be parallel"

    # Convert timberB's bottom position into timberA's local space
    timberB_bottom_local = timberA.transform.global_to_local(timberB.get_bottom_position_global())

    # In timberA's local space:
    # - timberA's cross section is centered at (0, 0) in XY plane
    # - timberA spans from (-width/2, -height/2) to (width/2, height/2)
    timberA_x_min = -timberA.size[0] / scalar(2)
    timberA_x_max = timberA.size[0] / scalar(2)
    timberA_y_min = -timberA.size[1] / scalar(2)
    timberA_y_max = timberA.size[1] / scalar(2)

    # timberB's cross section is centered at (timberB_bottom_local.x, timberB_bottom_local.y)
    # We need to transform timberB's width and height directions into timberA's local space
    # to determine the extents of timberB's cross section

    # Get timberB's width and height directions in global space
    timberB_width_dir_global = timberB.get_width_direction_global()
    timberB_height_dir_global = timberB.get_height_direction_global()

    # Convert to timberA's local space (just rotate, don't translate)
    timberB_width_dir_local = safe_transform_vector(timberA.orientation.matrix.T, timberB_width_dir_global)
    timberB_height_dir_local = safe_transform_vector(timberA.orientation.matrix.T, timberB_height_dir_global)

    # Get the four corners of timberB's cross section in timberA's local space
    # Start from timberB's center in local space
    timberB_center_local_xy = create_v2(timberB_bottom_local[0], timberB_bottom_local[1])

    # Offset vectors for the corners (in timberA's local XY plane)
    half_width = timberB.size[0] / scalar(2)
    half_height = timberB.size[1] / scalar(2)

    # Corner offsets in timberA's local space
    offset_width_local = create_v2(timberB_width_dir_local[0], timberB_width_dir_local[1]) * half_width
    offset_height_local = create_v2(timberB_height_dir_local[0], timberB_height_dir_local[1]) * half_height

    # Four corners of timberB in timberA's local XY coordinates
    corner1 = timberB_center_local_xy + offset_width_local + offset_height_local
    corner2 = timberB_center_local_xy + offset_width_local - offset_height_local
    corner3 = timberB_center_local_xy - offset_width_local + offset_height_local
    corner4 = timberB_center_local_xy - offset_width_local - offset_height_local

    # Find axis-aligned bounding box of timberB in timberA's local space
    timberB_x_min = Min(corner1[0], corner2[0], corner3[0], corner4[0])
    timberB_x_max = Max(corner1[0], corner2[0], corner3[0], corner4[0])
    timberB_y_min = Min(corner1[1], corner2[1], corner3[1], corner4[1])
    timberB_y_max = Max(corner1[1], corner2[1], corner3[1], corner4[1])

    # Check if the axis-aligned bounding boxes overlap
    # Two rectangles overlap if they overlap in both X and Y dimensions
    x_overlap = timberA_x_max >= timberB_x_min and timberB_x_max >= timberA_x_min
    y_overlap = timberA_y_max >= timberB_y_min and timberB_y_max >= timberA_y_min

    return x_overlap and y_overlap

locate_mortise_timber_shoulder_plane_from_centerline_towards_tenon_timber

locate_mortise_timber_shoulder_plane_from_centerline_towards_tenon_timber(arrangement: ButtJointTimberArrangement, distance_from_centerline: Numeric) -> Plane

Computes the shoulder plane of the mortise timber, offset from its centerline toward the tenon.

The shoulder plane is parallel to the mortise timber's length axis and offset from the mortise centerline in the mortise cross-section toward the tenon. Its reference point is chosen using the tenon centerline relation.

Parameters:

Name Type Description Default
arrangement ButtJointTimberArrangement

Butt joint arrangement (receiving_timber = mortise, butt_timber = tenon).

required
distance_from_centerline Numeric

Signed offset from the mortise centerline toward the tenon. 0 = plane through the mortise centerline. Positive = toward tenon.

required

Returns:

Type Description
Plane

Plane parallel to the mortise length axis, offset by distance_from_centerline

Plane

from the mortise centerline toward the tenon.

Source code in kumiki/joints/workshop/shavings/build_a_butt.py
def locate_mortise_timber_shoulder_plane_from_centerline_towards_tenon_timber(
    arrangement: ButtJointTimberArrangement,
    distance_from_centerline: Numeric,
) -> Plane:
    """
    Computes the shoulder plane of the mortise timber, offset from its centerline toward the tenon.

    The shoulder plane is parallel to the mortise timber's length axis and offset from
    the mortise centerline in the mortise cross-section toward the tenon. Its reference
    point is chosen using the tenon centerline relation.

    Args:
        arrangement: Butt joint arrangement (receiving_timber = mortise, butt_timber = tenon).
        distance_from_centerline: Signed offset from the mortise centerline toward the tenon.
            0 = plane through the mortise centerline. Positive = toward tenon.

    Returns:
        Plane parallel to the mortise length axis, offset by distance_from_centerline
        from the mortise centerline toward the tenon.
    """
    return _compute_plane_parallel_to_receiving_length_axis_partially_perpendicular_to_butt(
        arrangement, distance_from_centerline
    )

resolve_parallel_shoulder_face

resolve_parallel_shoulder_face(arrangement: ButtJointTimberArrangement, set_mortise_shoulder_parallel_to_face: Union[TimberLongFace, bool]) -> TimberLongFace

Resolves the parallel shoulder face. If set_mortise_shoulder_parallel_to_face is True, it auto-detects the long face most perpendicular to the tenon axis. Otherwise, returns it directly.

Source code in kumiki/joints/workshop/shavings/build_a_butt.py
def resolve_parallel_shoulder_face(
    arrangement: ButtJointTimberArrangement,
    set_mortise_shoulder_parallel_to_face: Union[TimberLongFace, bool],
) -> TimberLongFace:
    """
    Resolves the parallel shoulder face. If set_mortise_shoulder_parallel_to_face is True,
    it auto-detects the long face most perpendicular to the tenon axis. Otherwise, returns it directly.
    """
    if set_mortise_shoulder_parallel_to_face is True:
        mortise_timber = arrangement.receiving_timber
        tenon_timber = arrangement.butt_timber
        tenon_end = arrangement.butt_timber_end
        tenon_end_direction = tenon_timber.get_face_direction_global(tenon_end)
        tenon_dir = -tenon_end_direction

        x_axis = mortise_timber.get_width_direction_global()
        y_axis = mortise_timber.get_height_direction_global()
        dot_x = abs(safe_dot_product(tenon_dir, x_axis))
        dot_y = abs(safe_dot_product(tenon_dir, y_axis))

        if dot_x < dot_y:
            if safe_dot_product(x_axis, tenon_dir) > 0:
                return TimberLongFace.RIGHT
            else:
                return TimberLongFace.LEFT
        else:
            if safe_dot_product(y_axis, tenon_dir) > 0:
                return TimberLongFace.FRONT
            else:
                return TimberLongFace.BACK
    else:
        assert isinstance(set_mortise_shoulder_parallel_to_face, TimberLongFace), "Must be a TimberLongFace"
        return set_mortise_shoulder_parallel_to_face

locate_mortise_timber_shoulder_plane_from_centerplane_towards_long_face

locate_mortise_timber_shoulder_plane_from_centerplane_towards_long_face(arrangement: ButtJointTimberArrangement, distance_from_centerplane: Numeric, face: TimberLongFace) -> Plane

Computes a shoulder plane that is forced to be parallel to a specific face of the mortise timber.

Source code in kumiki/joints/workshop/shavings/build_a_butt.py
def locate_mortise_timber_shoulder_plane_from_centerplane_towards_long_face(
    arrangement: ButtJointTimberArrangement,
    distance_from_centerplane: Numeric,
    face: TimberLongFace,
) -> Plane:
    """
    Computes a shoulder plane that is forced to be parallel to a specific face of the mortise timber.
    """
    mortise_timber = arrangement.receiving_timber
    ref_plane = _compute_plane_parallel_to_receiving_length_axis_partially_perpendicular_to_butt(
        arrangement, scalar(0)
    )
    direction_in_plane = ref_plane.normal

    chosen_normal = mortise_timber.get_face_direction_global(face)

    if safe_dot_product(chosen_normal, direction_in_plane) < 0:
        chosen_normal = -chosen_normal

    return locate_plane_from_edge_in_direction(
        mortise_timber, TimberCenterline.CENTERLINE, chosen_normal, distance_from_centerplane
    )

compute_butt_joint_shoulder

compute_butt_joint_shoulder(arrangement: ButtJointTimberArrangement, distance_from_centerline_or_centerplane: Numeric, up_direction: Direction3D, set_mortise_shoulder_parallel_to_face: Union[TimberLongFace, bool] = False) -> ButtJointShoulderResult

Compute the shoulder plane and an oriented marking space for a butt joint.

The marking space is positioned where the tenon (butt) timber's centerline intersects the shoulder plane, oriented with: +X = shoulder_plane.normal (from mortise centerline toward tenon) +Y = up_direction (orthogonalized against +X) +Z = right-hand rule cross product

Parameters:

Name Type Description Default
arrangement ButtJointTimberArrangement

Butt joint arrangement (receiving_timber = mortise, butt_timber = tenon).

required
distance_from_centerline_or_centerplane Numeric

Signed offset from the mortise centerline toward the tenon. 0 = plane through the mortise centerline. Positive = toward tenon.

required
up_direction Direction3D

Direction for +Y axis of the marking space. Will be orthogonalized against the shoulder plane normal.

required
set_mortise_shoulder_parallel_to_face Union[TimberLongFace, bool]

Force shoulder plane parallel to a face.

False

Returns:

Type Description
ButtJointShoulderResult

ButtJointShoulderResult with the shoulder plane, intersection point, and marking space.

Source code in kumiki/joints/workshop/shavings/build_a_butt.py
def compute_butt_joint_shoulder(
    arrangement: ButtJointTimberArrangement,
    distance_from_centerline_or_centerplane: Numeric,
    up_direction: Direction3D,
    set_mortise_shoulder_parallel_to_face: Union[TimberLongFace, bool] = False,
) -> ButtJointShoulderResult:
    """
    Compute the shoulder plane and an oriented marking space for a butt joint.

    The marking space is positioned where the tenon (butt) timber's centerline
    intersects the shoulder plane, oriented with:
        +X = shoulder_plane.normal (from mortise centerline toward tenon)
        +Y = up_direction (orthogonalized against +X)
        +Z = right-hand rule cross product

    Args:
        arrangement: Butt joint arrangement (receiving_timber = mortise, butt_timber = tenon).
        distance_from_centerline_or_centerplane: Signed offset from the mortise centerline toward the tenon.
            0 = plane through the mortise centerline. Positive = toward tenon.
        up_direction: Direction for +Y axis of the marking space. Will be orthogonalized
            against the shoulder plane normal.
        set_mortise_shoulder_parallel_to_face: Force shoulder plane parallel to a face.

    Returns:
        ButtJointShoulderResult with the shoulder plane, intersection point, and marking space.
    """
    tenon_timber = arrangement.butt_timber
    tenon_end = arrangement.butt_timber_end

    if set_mortise_shoulder_parallel_to_face:
        resolved_face = resolve_parallel_shoulder_face(arrangement, set_mortise_shoulder_parallel_to_face)
        shoulder_plane = locate_mortise_timber_shoulder_plane_from_centerplane_towards_long_face(
            arrangement, distance_from_centerline_or_centerplane, resolved_face
        )
    else:
        shoulder_plane = locate_mortise_timber_shoulder_plane_from_centerline_towards_tenon_timber(
            arrangement, distance_from_centerline_or_centerplane
        )

    shoulder_from_tenon_end_mark = mark_distance_from_end_along_centerline(
        shoulder_plane, tenon_timber, tenon_end
    )
    shoulder_point_global = shoulder_from_tenon_end_mark.locate().position

    orientation = Orientation.from_x_and_y(
        x_direction=shoulder_plane.normal,
        y_direction=up_direction,
    )
    marking_space = Space(
        transform=Transform(position=shoulder_point_global, orientation=orientation)
    )

    butt_direction = tenon_timber.get_face_direction_global(tenon_end)

    return ButtJointShoulderResult(
        shoulder_plane=shoulder_plane,
        butt_direction=butt_direction,
        marking_space=marking_space,
    )

build_dovetail_shoulder_geometery

build_dovetail_shoulder_geometery(arrangement: ButtJointTimberArrangement, shoulder_result: ButtJointShoulderResult, dovetail_depth: Numeric) -> CutCSG

Creates the shoulder geometry for a dovetail shoulder. The height of the dovetail is determined by the dimensions of the receiving timber. The depth of the dovetail is determined by the dovetail_depth parameter.

  |

__| v | \ | \ | _\ | ^ | ^ | | | dovetail_depth | dovetail_pointy_face_on_butt_timber

The resulting CutCSG object is in global space. It includes part of the butt timber itself, not just the dovetail shape. The resulting CutCSG object includes part of the butt timber itself, not just the dovetail shape. This is useful for cutting notches into the receiving timber for non perfect receiving timbers.

Source code in kumiki/joints/workshop/shavings/build_a_butt.py
def build_dovetail_shoulder_geometery(
    arrangement: ButtJointTimberArrangement,
    shoulder_result: ButtJointShoulderResult,

    # TODO pass in dovetail side on butt timberargument
    #dovetail_pointy_face_on_butt_timber : TimberLongFace,

    dovetail_depth: Numeric
    ) -> CutCSG:
    """

    Creates the shoulder geometry for a dovetail shoulder. The height of the dovetail is determined by the dimensions of the receiving timber.
    The depth of the dovetail is determined by the dovetail_depth parameter.


          |   
    ______|  v    |
           \      |
            \     |
    _________\    |
      ^   | ^     |
      |   | dovetail_depth
      | 
      dovetail_pointy_face_on_butt_timber


    The resulting CutCSG object is in global space. It includes part of the butt timber itself, not just the dovetail shape.
    The resulting CutCSG object includes part of the butt timber itself, not just the dovetail shape. This is useful for cutting notches into the receiving timber for non perfect receiving timbers.
    """
    if safe_compare(dovetail_depth, scalar(0), Comparison.LE):
        raise ValueError(f"dovetail_depth must be positive, got {dovetail_depth}")

    receiving_timber = arrangement.receiving_timber
    butt_timber = arrangement.butt_timber

    shoulder_transform = shoulder_result.marking_space.transform
    orientation_matrix = shoulder_transform.orientation.matrix

    x_axis_global = create_v3(orientation_matrix[0, 0], orientation_matrix[1, 0], orientation_matrix[2, 0])
    y_axis_global = create_v3(orientation_matrix[0, 1], orientation_matrix[1, 1], orientation_matrix[2, 1])
    z_axis_global = create_v3(orientation_matrix[0, 2], orientation_matrix[1, 2], orientation_matrix[2, 2])

    shoulder_height = receiving_timber.get_size_in_direction_3d(y_axis_global)
    half_height = shoulder_height / scalar(2)

    shoulder_span = butt_timber.get_size_in_direction_3d(z_axis_global)
    half_span = shoulder_span / scalar(2)

    # Keep a rectangular butt-side section so this geometry includes part of the
    # butt timber itself before transitioning along the dovetail ramp.
    butt_side_thickness = butt_timber.get_size_in_direction_3d(x_axis_global) / scalar(2)

    profile_points = [
        create_v2(-butt_side_thickness, -half_height),
        create_v2(-butt_side_thickness, half_height),
        create_v2(scalar(0), half_height),
        create_v2(dovetail_depth, -half_height),
    ]

    return ConvexPolygonExtrusion(
        points=profile_points,
        transform=shoulder_transform,
        start_distance=-half_span,
        end_distance=half_span,
    )

dovetail_tenon_geometry

dovetail_tenon_geometry(arrangement: ButtJointTimberArrangement, shoulder_result: ButtJointShoulderResult, dovetail_top_side_on_butt_timber: TimberLongFace, tenon_size: V2, tenon_depth: Numeric, dovetail_depth: Numeric, wedge_accessory_parameters: DovetailTenonWedgeAccessoryParameters, tenon_lateral_offset: Numeric = 0, receiving_timber_mortise_extra_depth: Numeric = 0, tenon_waste_label: CutCSGLabel = CutCSGLabel('tenon_waste'), mortise_label: CutCSGLabel = CutCSGLabel('mortise')) -> DovetailTenonGeometeryResult

Build the tenon geometry for a dovetail shoulder. The "top" of dovetail tenon is always flush with dovetail_top_side_on_butt_timber face of the butt timber, however x/y sizing still aligns with the usual width/height axis of the butt timber. tenon_lateral_offset is always in the perpendicular axis of the joint on the tenon timber. When 0, the tenon is laterally centered on the the butt timber.

dovetail_top_side_on_butt_timber
   v

     |
     |
  |\ |  < dovetail_depth
  | \|  <

_| ^^^ tenondepth

Source code in kumiki/joints/workshop/shavings/build_a_butt.py
def dovetail_tenon_geometry(
    arrangement: ButtJointTimberArrangement,
    shoulder_result: ButtJointShoulderResult,
    dovetail_top_side_on_butt_timber: TimberLongFace,
    tenon_size: V2,
    tenon_depth: Numeric,
    dovetail_depth: Numeric,
    wedge_accessory_parameters: DovetailTenonWedgeAccessoryParameters,
    tenon_lateral_offset: Numeric = 0,
    # the extra depth for the mortise hole in the receiving timber
    receiving_timber_mortise_extra_depth: Numeric = 0,
    tenon_waste_label: CutCSGLabel = CutCSGLabel("tenon_waste"),
    mortise_label: CutCSGLabel = CutCSGLabel("mortise"),
) -> DovetailTenonGeometeryResult:
    """
    Build the tenon geometry for a dovetail shoulder. The "top" of dovetail tenon is always flush with dovetail_top_side_on_butt_timber face of the butt timber, however x/y sizing still aligns with the usual width/height axis of the butt timber.
    tenon_lateral_offset is always in the perpendicular axis of the joint on the tenon timber. When 0, the tenon is laterally centered on the the butt timber.



        dovetail_top_side_on_butt_timber
           v 
    _________
             |
             |
          |\ |  < dovetail_depth
          | \|  <
    ______|
           ^^^ tenon_depth
    """ 

    if safe_compare(tenon_depth, scalar(0), Comparison.LE):
        raise ValueError(f"tenon_depth must be positive, got {tenon_depth}")
    if safe_compare(dovetail_depth, scalar(0), Comparison.LT):
        raise ValueError(f"dovetail_depth must be non-negative, got {dovetail_depth}")
    if safe_compare(receiving_timber_mortise_extra_depth, scalar(0), Comparison.LT):
        raise ValueError(
            "receiving_timber_mortise_extra_depth must be non-negative, "
            f"got {receiving_timber_mortise_extra_depth}"
        )
    if safe_compare(tenon_size[0], scalar(0), Comparison.LE) or safe_compare(tenon_size[1], scalar(0), Comparison.LE):
        raise ValueError(f"tenon_size values must be positive, got {tenon_size}")


    # Assert arrangement is face-aligned and orthogonal (for ButtJointTimberArrangement)
    err = arrangement.check_face_aligned_and_orthogonal()
    if err is not None:
        raise AssertionError(f"Arrangement not face-aligned/orthogonal: {err}")

    # Wedge fit constraints
    if wedge_accessory_parameters is not None:
        receiving_timber = arrangement.receiving_timber
        # The axis along which the wedge enters is the normal to the dovetail_top_side_on_butt_timber face
        receiving_axis_dir = receiving_timber.get_face_direction_global(dovetail_top_side_on_butt_timber.to.face())
        receiving_axis_width = receiving_timber.get_size_in_face_normal_axis(dovetail_top_side_on_butt_timber.rotate_left().to.face())
        total_depth = tenon_depth + receiving_timber_mortise_extra_depth
        # If the mortise is shallower than the timber's width in the receiving axis, wedge fit is constrained
        if safe_compare(total_depth, receiving_axis_width, Comparison.LT):
            wedge_tip_stickout = getattr(wedge_accessory_parameters, "wedge_tip_stickout", None)

            if wedge_tip_stickout is not None and safe_compare(wedge_tip_stickout, receiving_timber_mortise_extra_depth, Comparison.GT):
                raise AssertionError(
                    f"wedge_tip_stickout ({wedge_tip_stickout}) must be <= receiving_timber_mortise_extra_depth ({receiving_timber_mortise_extra_depth}) for wedge to fit!"
                )
            if getattr(wedge_accessory_parameters, "wedge_from_receiving_timber_side", False):
                raise AssertionError(
                    "wedge_from_receiving_timber_side cannot be True when mortise is shallower than receiving timber width in the receiving axis!"
                )


    tenon_timber = arrangement.butt_timber

    # Direction the tenon points from the shoulder into the receiving (mortise) timber.
    into_mortise_dir = shoulder_result.butt_direction

    # Outward normal of the face the dovetail top is flush with.
    top_face_dir = tenon_timber.get_face_direction_global(
        dovetail_top_side_on_butt_timber.to.face()
    )

    # The dovetail's "top" (the flat side) must lie along the receiving timber's length
    # axis: that's the only orientation where the dovetail's pull-out resistance is along
    # the joint's load axis. The opposite (sloped) side then naturally wraps around the
    # receiving timber's cross-section.
    receiving_length_dir = arrangement.receiving_timber.get_length_direction_global()
    top_dot_receiving_length = safe_dot_product(top_face_dir, receiving_length_dir)
    if not (safe_zero_test(top_dot_receiving_length - scalar(1)) or safe_zero_test(top_dot_receiving_length + scalar(1))):
        raise AssertionError(
            f"dovetail_top_side_on_butt_timber ({dovetail_top_side_on_butt_timber}) must point "
            f"along the receiving timber's length axis (dot product was {top_dot_receiving_length}, "
            "expected +/-1)."
        )

    # Lateral direction (across the joint width), perpendicular to both length and top-bottom.
    lateral_dir = safe_normalize_vector(cross_product(into_mortise_dir, top_face_dir))

    # tenon_size[0] aligns with the butt timber's width axis (RIGHT direction);
    # tenon_size[1] aligns with the butt timber's height axis (TOP direction).
    # The "top-to-bottom" of the dovetail is along whichever butt axis the top side belongs to.
    if dovetail_top_side_on_butt_timber in (TimberLongFace.RIGHT, TimberLongFace.LEFT):
        tenon_top_to_bottom_dim = tenon_size[0]
        tenon_lateral_dim = tenon_size[1]
    else:
        tenon_top_to_bottom_dim = tenon_size[1]
        tenon_lateral_dim = tenon_size[0]

    # Start at the centerline / shoulder-plane intersection on the butt timber.
    shoulder_origin = shoulder_result.marking_space.transform.position

    # Move to the dovetail_top_side face at the shoulder (centered laterally on the butt timber).
    butt_half_in_top_dir = tenon_timber.get_size_in_direction_3d(top_face_dir) / scalar(2)
    top_face_center_at_shoulder = shoulder_origin + top_face_dir * butt_half_in_top_dir

    # Apply the lateral offset (perpendicular axis on the tenon timber).
    top_face_tenon_center_at_shoulder = (
        top_face_center_at_shoulder + lateral_dir * tenon_lateral_offset
    )

    # Extrusion local frame:
    #   profile X = into_mortise_dir  (depth along tenon length, starting at the shoulder)
    #   profile Y = top_face_dir      (Y=0 sits on the dovetail_top_side face; the tenon body lives at Y<0)
    #   extrude Z = X × Y = lateral_dir
    extrusion_orientation = Orientation.from_x_and_y(
        x_direction=into_mortise_dir,
        y_direction=top_face_dir,
    )
    extrusion_transform = Transform(
        position=top_face_tenon_center_at_shoulder,
        orientation=extrusion_orientation,
    )

    half_lateral = tenon_lateral_dim / scalar(2)

    # ---- Positive tenon prism (the dovetail-shaped solid the tenon should be) ----
    # Top edge (flush with dovetail_top_side) runs at Y = 0 from X = 0 to X = tenon_depth.
    # Bottom edge slopes from (0, -t) at the shoulder to (tenon_depth, -t - d) at the tip,
    # giving the dovetail its characteristic widening toward the tip.
    tenon_bottom_at_shoulder = -tenon_top_to_bottom_dim
    tenon_bottom_at_tip = -(tenon_top_to_bottom_dim + dovetail_depth)
    tenon_profile_points = [
        create_v2(scalar(0), tenon_bottom_at_shoulder),
        create_v2(scalar(0), scalar(0)),
        create_v2(tenon_depth, scalar(0)),
        create_v2(tenon_depth, tenon_bottom_at_tip),
    ]
    positive_tenon = ConvexPolygonExtrusion(
        points=tenon_profile_points,
        transform=extrusion_transform,
        start_distance=-half_lateral,
        end_distance=half_lateral,
        label=CutCSGLabel("tenon"),
    )

    # ---- HalfSpace covering everything beyond the shoulder plane (into the mortise) ----
    # Used as the "box" that is differenced with the tenon: removing this from the butt timber
    # strips away material past the shoulder, except where the positive tenon lives.
    shoulder_offset = safe_dot_product(shoulder_origin, into_mortise_dir)
    shoulder_halfspace = HalfSpace(
        normal=into_mortise_dir,
        offset=shoulder_offset,
        label=CutCSGLabel("shoulder"),
    )

    tenon_negative_csg = Difference(
        base=shoulder_halfspace,
        subtract=[positive_tenon],
        label=tenon_waste_label,
    )

    # ---- Wedge accessory (optional) ----
    # The wedge's flat side sits on top of dovetail_top_side_on_butt_timber. Its length is
    # wedge_back_extra_length + tenon_depth + wedge_tip_stickout. In the extrusion frame
    # (origin = shoulder, +X = into mortise, +Y = out of dovetail_top_side):
    #   - if wedge_from_receiving_timber_side is False, the wedge spec origin is at the shoulder
    #     (X=0); the base extends "back" by wedge_back_extra (X<0) and the tip extends "forward"
    #     by wedge_tip_stickout past tenon_depth. wedge_small_height is measured at the tenon tip
    #     (X = tenon_depth).
    #   - if wedge_from_receiving_timber_side is True, the wedge spec origin is on the receiving
    #     timber's far face; the wedge enters from there and points back toward the butt timber.
    #     wedge_small_height is measured at the shoulder (X = 0).
    from kumiki.rule import tan as _sym_tan

    wedge_accessory_csg = None
    wedge_slot_in_mortise_csg = None


    wedge_extra_height = getattr(wedge_accessory_parameters, "wedge_extra_height", 0)
    wedge_small_height_value = dovetail_depth + wedge_extra_height
    wedge_angle = wedge_accessory_parameters.wedge_angle
    wedge_back_extra = wedge_accessory_parameters.wedge_back_extra_length
    wedge_tip_stickout = wedge_accessory_parameters.wedge_tip_stickout

    tan_wedge_angle = _sym_tan(wedge_angle)

    # Thicknesses at the base (large) and tip (small) ends.
    # The small_height reference is wedge_tip_stickout away from the tip end (toward base),
    # so distance from base to small_height ref = wedge_back_extra + tenon_depth.
    h_base = wedge_small_height_value + (wedge_back_extra + tenon_depth) * tan_wedge_angle
    h_tip = wedge_small_height_value - wedge_tip_stickout * tan_wedge_angle

    if not wedge_accessory_parameters.wedge_from_receiving_timber_side:
        # (0,0) at shoulder; base back, tip forward.
        x_base = -wedge_back_extra
        x_tip = tenon_depth + wedge_tip_stickout

        # Only the mortise slot is extended: grow from the base side until
        # the perfect receiving-timber boundary in this axis.
        receiving_perfect_boundary = -arrangement.receiving_timber.get_size_in_direction_3d(
            into_mortise_dir
        )
        x_base_slot = min(x_base, receiving_perfect_boundary)
    else:
        # (0,0) at the receiving timber's far face. The wedge points back toward the butt
        # timber. The earlier wedge-fit assertion guarantees this case is only used when the
        # mortise is at least as deep as the receiving timber, so the wedge clears.
        receiving_axis_width = arrangement.receiving_timber.get_size_in_face_normal_axis(
            dovetail_top_side_on_butt_timber.to.face()
        )
        x_base = receiving_axis_width + wedge_back_extra
        x_tip = -wedge_tip_stickout
        x_base_slot = max(x_base, receiving_axis_width)

    # Profile points (CW in math orientation) in the extrusion frame X-Y plane.
    wedge_profile_points = [
        create_v2(x_base, scalar(0)),
        create_v2(x_base, h_base),
        create_v2(x_tip, h_tip),
        create_v2(x_tip, scalar(0)),
    ]

    # Hold flat at h_base from x_base_slot to x_base (clearance room the wedge itself never
    # occupies), then taper from (x_base, h_base) to (x_tip, h_tip) -- the same two points
    # that define the wedge accessory's own edge, so the slot's taper angle matches the
    # wedge's taper angle exactly instead of being averaged over the longer x_base_slot..x_tip run.
    wedge_slot_profile_points = [
        create_v2(x_base_slot, scalar(0)),
        create_v2(x_base_slot, h_base),
        create_v2(x_base, h_base),
        create_v2(x_tip, h_tip),
        create_v2(x_tip, scalar(0)),
    ]

    # Accessory geometry is rendered in its own local frame; the CSGAccessory.transform
    # places it globally. We keep the wedge polygon in the extrusion frame's coordinates,
    # so the accessory transform IS the extrusion transform.
    wedge_positive_csg = ConvexPolygonExtrusion(
        points=wedge_profile_points,
        transform=Transform.identity(),
        start_distance=-half_lateral,
        end_distance=half_lateral,
    )
    # Assembly: the wedge backs out opposite its drive direction. It locks
    # the joint, so it pops at suborder 0 before the tenon slides.
    wedge_drive_direction = (
        -into_mortise_dir
        if wedge_accessory_parameters.wedge_from_receiving_timber_side
        else into_mortise_dir
    )
    wedge_length = wedge_back_extra + tenon_depth + wedge_tip_stickout
    wedge_accessory_csg = CSGAccessory(
        transform=extrusion_transform,
        positive_csg=wedge_positive_csg,
        assembly_freedom=AssemblyFreedom.translation(-wedge_drive_direction, freed_after=wedge_length),
        assembly_ordering=Ordering(0, -1),
    )

    # The mortise cavity must also include the wedge's slot (above Y=0), so the wedge can
    # actually sit in the receiving timber. Use the same profile in the extrusion frame.
    wedge_slot_in_mortise_csg = ConvexPolygonExtrusion(
        points=wedge_slot_profile_points,
        transform=extrusion_transform,
        start_distance=-half_lateral,
        end_distance=half_lateral,
        label=CutCSGLabel("wedge_slot"),
    )

    # ---- Mortise negative prism ----
    # Same dovetail plane (same bottom slope), but the prism is longer so the mortise cavity
    # extends past the tenon tip by receiving_timber_mortise_extra_depth.
    mortise_total_depth = tenon_depth + receiving_timber_mortise_extra_depth
    # Extend the bottom-edge slope to the deeper tip (slope = -dovetail_depth / tenon_depth).
    mortise_bottom_at_tip = -tenon_top_to_bottom_dim - (
        dovetail_depth * mortise_total_depth / tenon_depth
    )
    mortise_profile_points = [
        create_v2(scalar(0), tenon_bottom_at_shoulder),
        create_v2(scalar(0), scalar(0)),
        create_v2(mortise_total_depth, scalar(0)),
        create_v2(mortise_total_depth, mortise_bottom_at_tip),
    ]

    mortise_dovetail_prism = ConvexPolygonExtrusion(
        points=mortise_profile_points,
        transform=extrusion_transform,
        start_distance=-half_lateral,
        end_distance=half_lateral,
        label=CutCSGLabel("mortise_hole"),
    )

    if wedge_slot_in_mortise_csg is not None:
        mortise_negative_csg = SolidUnion(
            children=[mortise_dovetail_prism, wedge_slot_in_mortise_csg],
            label=mortise_label,
        )
    else:
        mortise_negative_csg = mortise_dovetail_prism

    return DovetailTenonGeometeryResult(
        tenon_negative_csg=tenon_negative_csg,
        mortise_negative_csg=mortise_negative_csg,
        wedge_accessory_csg=wedge_accessory_csg,
    )

tusk_tenon_geometry

tusk_tenon_geometry(arrangement: ButtJointTimberArrangement, opposite_shoulder_position_global: V3, tenon_length_direction: Direction3D, entry_face_designation: TimberLongFace, entry_axis_extent: Numeric, tusk_parameters: Any, rough_half_extent_past_opposite_shoulder: Numeric, tusk_hole_label: CutCSGLabel = CutCSGLabel('tusk_hole'), tusk_clearance_label: CutCSGLabel = CutCSGLabel('tusk_clearance')) -> TuskTenonGeometryResult

Build the crosswise locking key ("tusk") for a through mortise-and-tenon joint, the hole cut through the tenon for it, and (if needed) extra clearance cut into the receiving timber so the key can be slid crosswise into place.

Mirrors dovetail_tenon_geometry's wedge: the key is a tapered prism, driven crosswise (perpendicular to the tenon's own length, through one of the tenon's long faces -- entry_face_designation) through a hole in the through-tenon, positioned at the "opposite shoulder" reference. Like the wedge, it is asymmetric -- one side (Y=0) is flush with the opposite shoulder plane (the receiving timber's exit face), and the other side tapers (per tusk_angle) into the tenon's own body. The key's taper runs along the tenon's own length axis (the narrow, leading edge sits deepest, at the far side of the tenon's own cross-section; the taper continues into the back/tip stickout regions), so driving it in wedges the tenon's shoulder tight against the receiving timber's exit face. It is centered on the tenon's other cross axis (tusk_thickness).

entry_face_designation
   v
_ _
___
/ ___ <- tapers down to tusk_small_width at X=entry_axis_extent
/_____ <- flush at Y=0 (opposite shoulder plane / receiving timber exit face)
tusk_back_ tusk_tip_
stickout stickout
<-- entry_axis_extent -->

Parameters:

Name Type Description Default
arrangement ButtJointTimberArrangement

Butt joint arrangement (butt_timber = tenon, receiving_timber = mortise).

required
opposite_shoulder_position_global V3

Center of the tenon's cross-section at the "opposite shoulder" reference position (where the tusk hole is centered along the tenon's length axis).

required
tenon_length_direction Direction3D

Direction from the entry shoulder toward the tenon's tip.

required
entry_face_designation TimberLongFace

Which of the tenon's long faces the tusk is driven in from.

required
entry_axis_extent Numeric

The tenon's own cross-sectional size along the entry axis.

required
tusk_parameters Any

Tusk shape parameters.

required
rough_half_extent_past_opposite_shoulder Numeric

How far the receiving timber's rough stock extends past the "opposite shoulder" reference, measured along tenon_length_direction. <= 0 means no rough excess there (no clearance needed).

required

Returns:

Type Description
TuskTenonGeometryResult

TuskTenonGeometryResult with the tenon hole, optional mortise clearance, and the tusk

TuskTenonGeometryResult

accessory, all in global space.

Source code in kumiki/joints/workshop/shavings/build_a_butt.py
def tusk_tenon_geometry(
    arrangement: ButtJointTimberArrangement,
    opposite_shoulder_position_global: V3,
    tenon_length_direction: Direction3D,
    entry_face_designation: TimberLongFace,
    entry_axis_extent: Numeric,
    tusk_parameters: Any,
    rough_half_extent_past_opposite_shoulder: Numeric,
    tusk_hole_label: CutCSGLabel = CutCSGLabel("tusk_hole"),
    tusk_clearance_label: CutCSGLabel = CutCSGLabel("tusk_clearance"),
) -> TuskTenonGeometryResult:
    """
    Build the crosswise locking key ("tusk") for a through mortise-and-tenon joint, the hole
    cut through the tenon for it, and (if needed) extra clearance cut into the receiving
    timber so the key can be slid crosswise into place.

    Mirrors dovetail_tenon_geometry's wedge: the key is a tapered prism, driven crosswise
    (perpendicular to the tenon's own length, through one of the tenon's long faces --
    entry_face_designation) through a hole in the through-tenon, positioned at the "opposite
    shoulder" reference. Like the wedge, it is asymmetric -- one side (Y=0) is flush with the
    opposite shoulder plane (the receiving timber's exit face), and the other side tapers
    (per tusk_angle) into the tenon's own body. The key's taper runs along the tenon's own
    length axis (the narrow, leading edge sits deepest, at the far side of the tenon's own
    cross-section; the taper continues into the back/tip stickout regions), so driving it in
    wedges the tenon's shoulder tight against the receiving timber's exit face. It is centered
    on the tenon's other cross axis (tusk_thickness).

        entry_face_designation
           v
    _______|_______
           |
        ___|
       /   \\___          <- tapers down to tusk_small_width at X=entry_axis_extent
    __/________\\_______ <- flush at Y=0 (opposite shoulder plane / receiving timber exit face)
     tusk_back_       tusk_tip_
     stickout          stickout
           |<-- entry_axis_extent -->|

    Args:
        arrangement: Butt joint arrangement (butt_timber = tenon, receiving_timber = mortise).
        opposite_shoulder_position_global: Center of the tenon's cross-section at the
            "opposite shoulder" reference position (where the tusk hole is centered along the
            tenon's length axis).
        tenon_length_direction: Direction from the entry shoulder toward the tenon's tip.
        entry_face_designation: Which of the tenon's long faces the tusk is driven in from.
        entry_axis_extent: The tenon's own cross-sectional size along the entry axis.
        tusk_parameters: Tusk shape parameters.
        rough_half_extent_past_opposite_shoulder: How far the receiving timber's rough stock
            extends past the "opposite shoulder" reference, measured along
            tenon_length_direction. <= 0 means no rough excess there (no clearance needed).

    Returns:
        TuskTenonGeometryResult with the tenon hole, optional mortise clearance, and the tusk
        accessory, all in global space.
    """
    from kumiki.rule import tan as _sym_tan

    tenon_timber = arrangement.butt_timber

    tusk_tip_stickout = (
        entry_axis_extent * scalar(2/3)
        if tusk_parameters.tusk_tip_stickout is None
        else tusk_parameters.tusk_tip_stickout
    )
    tusk_back_stickout = (
        entry_axis_extent * scalar(2/3)
        if tusk_parameters.tusk_back_stickout is None
        else tusk_parameters.tusk_back_stickout
    )

    entry_normal = tenon_timber.get_face_direction_global(entry_face_designation.to.face())
    drive_direction = -entry_normal

    # Extrusion local frame: X = drive direction (crosswise, into the tenon from the entry
    # face), Y = tenon's own length axis (the taper direction -- this is what converts driving
    # the key in into a lengthwise tightening force against the shoulder), Z = the tenon's
    # other cross axis (constant, = tusk_thickness).
    extrusion_orientation = Orientation.from_x_and_y(
        x_direction=drive_direction,
        y_direction=tenon_length_direction,
    )
    tusk_origin_global = opposite_shoulder_position_global + entry_normal * (entry_axis_extent / scalar(2))
    extrusion_transform = Transform(position=tusk_origin_global, orientation=extrusion_orientation)

    half_thickness = tusk_parameters.tusk_thickness / scalar(2)
    tan_tusk_angle = _sym_tan(tusk_parameters.tusk_angle)

    # X=0 is the entry face; X=entry_axis_extent is the tenon's own far cross-face. The taper
    # is referenced (at its small value) at the far side, so the key's narrow, leading edge is
    # driven in deepest first, exactly like a simple wedge/shim. Y=0 is flush with the opposite
    # shoulder plane (mirroring the wedge's flush side against dovetail_top_side_on_butt_timber)
    # -- the key's solid is one-sided (Y from 0 to width(x)), not centered, so its flat face
    # bears directly against the receiving timber's exit face.
    x_near = -tusk_back_stickout
    x_far = entry_axis_extent + tusk_tip_stickout
    width_near = tusk_parameters.tusk_small_width + (entry_axis_extent - x_near) * tan_tusk_angle
    width_far = tusk_parameters.tusk_small_width + (entry_axis_extent - x_far) * tan_tusk_angle

    tusk_profile_points = [
        create_v2(x_near, scalar(0)),
        create_v2(x_near, width_near),
        create_v2(x_far, width_far),
        create_v2(x_far, scalar(0)),
    ]

    tenon_hole_negative_csg = ConvexPolygonExtrusion(
        points=tusk_profile_points,
        transform=extrusion_transform,
        start_distance=-half_thickness,
        end_distance=half_thickness,
        label=tusk_hole_label,
    )

    full_tusk_length = entry_axis_extent + tusk_tip_stickout + tusk_back_stickout
    tusk_positive_csg = ConvexPolygonExtrusion(
        points=tusk_profile_points,
        transform=Transform.identity(),
        start_distance=-half_thickness,
        end_distance=half_thickness,
    )
    # Assembly: the tusk backs out the way it was driven in. It locks the joint, so it pops
    # first, at suborder -1, before the timbers themselves slide apart.
    tusk_accessory_csg = CSGAccessory(
        transform=extrusion_transform,
        positive_csg=tusk_positive_csg,
        assembly_freedom=AssemblyFreedom.translation(entry_normal, freed_after=full_tusk_length),
        assembly_ordering=Ordering(0, -1),
    )

    mortise_clearance_negative_csg = None
    if safe_compare(rough_half_extent_past_opposite_shoulder, scalar(0), Comparison.GT):
        # A plain rectangular prism (not tapered like the key itself) that lets the tapered
        # key slide crosswise into place: same width as the key (Z, tusk_thickness), height
        # from the opposite shoulder out to the rough face (Y), and long enough (X) for the
        # key to be slid in from fully retracted -- one full tusk-length behind the tenon
        # face -- forward to its seated tip position.
        clearance_x_start = -full_tusk_length
        clearance_y_end = rough_half_extent_past_opposite_shoulder
        clearance_profile_points = [
            create_v2(clearance_x_start, scalar(0)),
            create_v2(clearance_x_start, clearance_y_end),
            create_v2(x_far, clearance_y_end),
            create_v2(x_far, scalar(0)),
        ]
        mortise_clearance_negative_csg = ConvexPolygonExtrusion(
            points=clearance_profile_points,
            transform=extrusion_transform,
            start_distance=-half_thickness,
            end_distance=half_thickness,
            label=tusk_clearance_label,
        )

    return TuskTenonGeometryResult(
        tenon_hole_negative_csg=tenon_hole_negative_csg,
        mortise_clearance_negative_csg=mortise_clearance_negative_csg,
        tusk_accessory_csg=tusk_accessory_csg,
    )

compute_peg_positions

compute_peg_positions(arrangement: ButtJointTimberArrangement, shoulder_plane: Plane, peg_parameters: SimplePegParameters, tenon_position: V2) -> List[PegPositionResult]

Compute peg positions in global space for a mortise and tenon joint.

Uses the arrangement's front_face_on_butt_timber as the peg face on the tenon. All computations are done in global space, using the measure/mark pattern where possible.

Parameters:

Name Type Description Default
arrangement ButtJointTimberArrangement

Butt joint arrangement (butt_timber = tenon, receiving_timber = mortise). Must have front_face_on_butt_timber set.

required
shoulder_plane Plane

The shoulder plane in global space (from _compute_plane_parallel_to_receiving_length_axis_partially_perpendicular_to_butt).

required
peg_parameters SimplePegParameters

Peg configuration (shape, positions, size, depth, offset).

required
tenon_position V2

Offset of tenon center from timber centerline in tenon local cross-section (X, Y).

required

Returns:

Type Description
List[PegPositionResult]

List of PegPositionResult, one per peg_position entry.

Source code in kumiki/joints/workshop/shavings/build_a_butt.py
def compute_peg_positions(
    arrangement: ButtJointTimberArrangement,
    shoulder_plane: Plane,
    peg_parameters: SimplePegParameters,
    tenon_position: V2,
) -> List[PegPositionResult]:
    """
    Compute peg positions in global space for a mortise and tenon joint.

    Uses the arrangement's front_face_on_butt_timber as the peg face on the tenon.
    All computations are done in global space, using the measure/mark pattern where possible.

    Args:
        arrangement: Butt joint arrangement (butt_timber = tenon, receiving_timber = mortise).
                     Must have front_face_on_butt_timber set.
        shoulder_plane: The shoulder plane in global space (from
                        _compute_plane_parallel_to_receiving_length_axis_partially_perpendicular_to_butt).
        peg_parameters: Peg configuration (shape, positions, size, depth, offset).
        tenon_position: Offset of tenon center from timber centerline in tenon local cross-section (X, Y).

    Returns:
        List of PegPositionResult, one per peg_position entry.
    """
    tenon_timber = arrangement.butt_timber
    mortise_timber = arrangement.receiving_timber
    tenon_end = arrangement.butt_timber_end

    assert arrangement.front_face_on_butt_timber is not None, (
        "arrangement.front_face_on_butt_timber must be set to determine the peg face"
    )
    tenon_face: TimberLongFace = arrangement.front_face_on_butt_timber
    peg_face: TimberFace = tenon_face.to.face()

    shoulder_mark = mark_distance_from_end_along_centerline(
        shoulder_plane,
        tenon_timber,
        tenon_end,
    )
    shoulder_point_global = shoulder_mark.locate().position

    tenon_right = tenon_timber.get_face_direction_global(TimberFace.RIGHT)
    tenon_front = tenon_timber.get_face_direction_global(TimberFace.FRONT)
    marking_origin_global = (
        shoulder_point_global
        + tenon_right * tenon_position[0]
        + tenon_front * tenon_position[1]
    )

    tenon_end_direction = tenon_timber.get_face_direction_global(tenon_end)

    tenon_face_plane = locate_face(tenon_timber, peg_face)
    peg_face_normal_global = tenon_face_plane.normal

    tenon_centerline = locate_centerline(tenon_timber)
    mortise_centerline = locate_centerline(mortise_timber)

    peg_drill_direction = -peg_face_normal_global
    peg_ray_direction = peg_face_normal_global

    orient_space, ccw_rotation_angle = peg_parameters.peg_orientation
    if orient_space == PegPositionSpace.TENON:
        peg_y_base = tenon_centerline.direction
    else:
        mortise_len_dir = mortise_centerline.direction
        if safe_dot_product(mortise_len_dir, tenon_end_direction) < 0:
            mortise_len_dir = -mortise_len_dir
        peg_y_base = mortise_len_dir

    if safe_zero_test(ccw_rotation_angle):
        peg_orientation_global = Orientation.from_z_and_y(
            z_direction=peg_drill_direction,
            y_direction=peg_y_base,
        )
    else:
        base_orientation = Orientation.from_z_and_y(
            z_direction=peg_drill_direction,
            y_direction=peg_y_base,
        )
        rotation_around_z = Orientation.from_angle_axis(
            ccw_rotation_angle,
            peg_drill_direction,
        )
        peg_orientation_global = Orientation(
            rotation_around_z.matrix * base_orientation.matrix
        )

    if tenon_face in [TimberLongFace.RIGHT, TimberLongFace.LEFT]:
        tenon_lateral_direction = tenon_front
    else:
        tenon_lateral_direction = tenon_right

    results: List[PegPositionResult] = []

    for distance_from_shoulder, distance_from_centerline in peg_parameters.peg_positions:
        if peg_parameters.peg_position_space[0] == PegPositionSpace.TENON:
            shoulder_axis = tenon_end_direction
        else:
            mortise_len_dir = mortise_centerline.direction
            if safe_dot_product(mortise_len_dir, tenon_end_direction) < 0:
                mortise_len_dir = -mortise_len_dir
            shoulder_axis = mortise_len_dir

        if peg_parameters.peg_position_space[1] == PegPositionSpace.TENON:
            lateral_axis = tenon_lateral_direction
        else:
            lateral_axis = mortise_centerline.direction

        peg_center_global = (
            marking_origin_global
            + shoulder_axis * distance_from_shoulder
            + lateral_axis * distance_from_centerline
        )

        dist_to_face = safe_dot_product(
            tenon_face_plane.normal,
            tenon_face_plane.point - peg_center_global,
        )
        peg_pos_on_tenon_face_global = (
            peg_center_global + tenon_face_plane.normal * dist_to_face
        )

        offset_direction = -tenon_end_direction
        peg_pos_on_tenon_face_with_offset_global = (
            peg_pos_on_tenon_face_global
            + offset_direction * peg_parameters.tenon_hole_offset
        )

        ray_origin_local = mortise_timber.transform.global_to_local(
            peg_pos_on_tenon_face_global
        )
        ray_dir_local = safe_transform_vector(
            mortise_timber.transform.orientation.matrix.T,
            peg_ray_direction,
        )

        box_mins = [
            -mortise_timber.size[0] / 2,
            -mortise_timber.size[1] / 2,
            scalar(0),
        ]
        box_maxs = [
            mortise_timber.size[0] / 2,
            mortise_timber.size[1] / 2,
            mortise_timber.length,
        ]

        t_enter_vals = []
        t_exit_vals = []
        for axis in range(3):
            d = ray_dir_local[axis]
            if safe_zero_test(d):
                assert box_mins[axis] <= ray_origin_local[axis] <= box_maxs[axis], (
                    f"Peg ray is parallel to mortise timber axis {axis} but peg position is "
                    f"outside the mortise timber bounds on that axis"
                )
            else:
                t1 = (box_mins[axis] - ray_origin_local[axis]) / d
                t2 = (box_maxs[axis] - ray_origin_local[axis]) / d
                t_enter_vals.append(min(t1, t2))
                t_exit_vals.append(max(t1, t2))

        assert t_enter_vals and t_exit_vals, (
            "Peg ray is parallel to all three mortise timber axes"
        )
        t_enter = max(t_enter_vals)
        t_exit = min(t_exit_vals)
        assert t_exit > t_enter, (
            "Peg ray does not intersect the mortise timber; "
            "check that the peg position and direction are correct"
        )

        peg_entry_t = t_exit if t_enter < 0 else t_enter
        peg_pos_on_mortise_face_global = (
            peg_pos_on_tenon_face_global + peg_ray_direction * peg_entry_t
        )

        if peg_parameters.depth is not None:
            peg_depth = peg_parameters.depth
        else:
            peg_depth = t_exit - t_enter
        if peg_parameters.stickout_length is not None:
            stickout_length = peg_parameters.stickout_length
        else:
            stickout_length = peg_depth * scalar(1, 2)

        results.append(PegPositionResult(
            tenon_face_position_global=peg_pos_on_tenon_face_global,
            tenon_face_position_with_offset_global=peg_pos_on_tenon_face_with_offset_global,
            mortise_entry_position_global=peg_pos_on_mortise_face_global,
            orientation_global=peg_orientation_global,
            peg_depth=peg_depth,
            stickout_length=stickout_length,
        ))

    return results

scalar

scalar(numerator, denominator=1) -> float

Create a float scalar value.

Parameters:

Name Type Description Default
numerator

The numerator (can be int, float, or str)

required
denominator

The denominator (default=1)

1

Returns:

Type Description
float

float value

Examples:

scalar(3) # 3.0 scalar(1, 2) # 0.5 scalar(2.5) # 2.5 scalar("1.5") # 1.5 from string scalar("1/32") # Parses fraction string

Source code in kumiki/rule.py
def scalar(numerator, denominator=1) -> float:
    """
    Create a float scalar value.

    Args:
        numerator: The numerator (can be int, float, or str)
        denominator: The denominator (default=1)

    Returns:
        float value

    Examples:
        scalar(3)             # 3.0
        scalar(1, 2)          # 0.5
        scalar(2.5)           # 2.5
        scalar("1.5")         # 1.5 from string
        scalar("1/32")        # Parses fraction string
    """
    if isinstance(numerator, str):
        text = numerator.strip()
        if "/" in text:
            num_str, den_str = text.split("/", 1)
            value = float(num_str) / float(den_str)
        else:
            value = float(text)
    else:
        value = float(numerator)
    return value / denominator if denominator != 1 else value

sin

sin(x)
Source code in kumiki/rule.py
def sin(x):
    return math.sin(x)

cos

cos(x)
Source code in kumiki/rule.py
def cos(x):
    return math.cos(x)

tan

tan(x)
Source code in kumiki/rule.py
def tan(x):
    return math.tan(x)

atan

atan(x)
Source code in kumiki/rule.py
def atan(x):
    return math.atan(x)

atan2

atan2(y, x)
Source code in kumiki/rule.py
def atan2(y, x):
    return math.atan2(y, x)

acos

acos(x)
Source code in kumiki/rule.py
def acos(x):
    return math.acos(x)

sqrt

sqrt(x)
Source code in kumiki/rule.py
def sqrt(x):
    # Tolerate tiny float noise around zero (e.g. two squares that should be
    # exactly equal, now computed with float rounding) without masking real
    # negative-argument bugs further away from zero.
    if -EPSILON_GENERIC < x < 0:
        return 0.0
    return math.sqrt(x)

simplify

simplify(expr)

No-op: floats need no symbolic simplification. Kept so old call sites (mostly simplify(a - b) == 0-style exactness checks) still parse; see safe_equality_test/safe_zero_test for the epsilon-based replacement.

Source code in kumiki/rule.py
def simplify(expr):
    """No-op: floats need no symbolic simplification. Kept so old call sites
    (mostly `simplify(a - b) == 0`-style exactness checks) still parse; see
    `safe_equality_test`/`safe_zero_test` for the epsilon-based replacement."""
    return expr

eye

eye(n: int) -> Matrix
Source code in kumiki/rule.py
def eye(n: int) -> Matrix:
    return Matrix.eye(n)

det

det(matrix: Matrix) -> float
Source code in kumiki/rule.py
def det(matrix: Matrix) -> float:
    return matrix.det()

prune

prune(value, collapse_mode=None)
Source code in kumiki/rule.py
def prune(value, collapse_mode=None):
    return value

giraffe_evalf

giraffe_evalf(expr) -> float
Source code in kumiki/rule.py
def giraffe_evalf(expr) -> float:
    return float(expr)

giraffe_norm

giraffe_norm(vec: Matrix, collapse_mode=None) -> float

Compute vector norm.

Source code in kumiki/rule.py
def giraffe_norm(vec: Matrix, collapse_mode=None) -> float:
    """Compute vector norm."""
    return vec.norm()

giraffe_det

giraffe_det(matrix: Matrix, collapse_mode=None) -> float

Compute matrix determinant.

Source code in kumiki/rule.py
def giraffe_det(matrix: Matrix, collapse_mode=None) -> float:
    """Compute matrix determinant."""
    return matrix.det()

giraffe_simplify

giraffe_simplify(expr, collapse_mode=None)

No-op (see module-level simplify).

Source code in kumiki/rule.py
def giraffe_simplify(expr, collapse_mode=None):
    """No-op (see module-level `simplify`)."""
    return expr

giraffe_compare

giraffe_compare(a, b, comparison: Comparison, collapse_mode=None, eps: Optional[float] = None) -> bool

Compare two values: evaluates a - b and applies comparison against zero.

eps overrides the default comparison tolerance for this one call.

Examples:

giraffe_compare(x, y, Comparison.GT) # x > y ? giraffe_compare(x, 0, Comparison.EQ) # x == 0 ?

Source code in kumiki/rule.py
def giraffe_compare(a, b, comparison: Comparison, collapse_mode=None, eps: Optional[float] = None) -> bool:
    """
    Compare two values: evaluates ``a - b`` and applies *comparison* against zero.

    *eps* overrides the default comparison tolerance for this one call.

    Examples:
        giraffe_compare(x, y, Comparison.GT)   # x > y ?
        giraffe_compare(x, 0, Comparison.EQ)   # x == 0 ?
    """
    try:
        val = float(a) - float(b)
    except Exception:
        return False
    return _apply_comparison(val, comparison, eps)

giraffe_dot_product

giraffe_dot_product(vec1: Matrix, vec2: Matrix, collapse_mode=None) -> float

Compute dot product.

Source code in kumiki/rule.py
def giraffe_dot_product(vec1: Matrix, vec2: Matrix, collapse_mode=None) -> float:
    """Compute dot product."""
    return vec1.dot(vec2)

giraffe_transform_vector

giraffe_transform_vector(matrix: Matrix, vector: Matrix, collapse_mode=None) -> Matrix

Compute matrix * vector (or matrix * matrix) transformation.

Source code in kumiki/rule.py
def giraffe_transform_vector(matrix: Matrix, vector: Matrix, collapse_mode=None) -> Matrix:
    """Compute matrix * vector (or matrix * matrix) transformation."""
    return matrix * vector

giraffe_normalize_vector

giraffe_normalize_vector(vec: Matrix, collapse_mode=None) -> Matrix

Normalize a vector.

Source code in kumiki/rule.py
def giraffe_normalize_vector(vec: Matrix, collapse_mode=None) -> Matrix:
    """Normalize a vector."""
    norm = giraffe_norm(vec)
    if norm < EPSILON_GENERIC:
        return vec
    return vec / norm

giraffe_magnitude

giraffe_magnitude(vec: Matrix, collapse_mode=None) -> float

Compute vector magnitude. Alias for giraffe_norm.

Source code in kumiki/rule.py
def giraffe_magnitude(vec: Matrix, collapse_mode=None) -> float:
    """Compute vector magnitude. Alias for giraffe_norm."""
    return giraffe_norm(vec)

create_v2

create_v2(x: Numeric, y: Numeric) -> V2

Create a 2D vector

Source code in kumiki/rule.py
def create_v2(x: Numeric, y: Numeric) -> V2:
    """Create a 2D vector"""
    return Matrix([x, y])

create_v3

create_v3(x: Numeric, y: Numeric, z: Numeric) -> V3

Create a 3D vector

Source code in kumiki/rule.py
def create_v3(x: Numeric, y: Numeric, z: Numeric) -> V3:
    """Create a 3D vector"""
    return Matrix([x, y, z])

cross_product

cross_product(v1: V3, v2: V3) -> V3

Calculate cross product of two 3D vectors

Source code in kumiki/rule.py
def cross_product(v1: V3, v2: V3) -> V3:
    """Calculate cross product of two 3D vectors"""
    return Matrix([
        v1[1]*v2[2] - v1[2]*v2[1],
        v1[2]*v2[0] - v1[0]*v2[2],
        v1[0]*v2[1] - v1[1]*v2[0]
    ])

radians

radians(angle: Numeric) -> Numeric

Identity function for angles already in radians. Use this to make it explicit that an angle is in radians.

Parameters:

Name Type Description Default
angle Numeric

Angle value in radians

required

Returns:

Type Description
Numeric

The same angle value (unchanged)

Examples:

radians(pi / 2) # 90 degrees in radians radians(pi / 4) # 45 degrees in radians

Source code in kumiki/rule.py
def radians(angle: Numeric) -> Numeric:
    """
    Identity function for angles already in radians.
    Use this to make it explicit that an angle is in radians.

    Args:
        angle: Angle value in radians

    Returns:
        The same angle value (unchanged)

    Examples:
        radians(pi / 2)      # 90 degrees in radians
        radians(pi / 4)       # 45 degrees in radians
    """
    return angle

degrees

degrees(angle: Numeric) -> Numeric

Convert an angle from degrees to radians.

Parameters:

Name Type Description Default
angle Numeric

Angle value in degrees

required

Returns:

Type Description
Numeric

Angle value in radians

Examples:

degrees(90) # 90 degrees = pi/2 radians degrees(45) # 45 degrees = pi/4 radians degrees(180) # 180 degrees = pi radians

Source code in kumiki/rule.py
def degrees(angle: Numeric) -> Numeric:
    """
    Convert an angle from degrees to radians.

    Args:
        angle: Angle value in degrees

    Returns:
        Angle value in radians

    Examples:
        degrees(90)           # 90 degrees = pi/2 radians
        degrees(45)           # 45 degrees = pi/4 radians
        degrees(180)          # 180 degrees = pi radians
    """
    return angle * pi / scalar(180)

inches

inches(numerator, denominator=1)

Create a measurement in meters from inches.

Parameters:

Name Type Description Default
numerator

The numerator (can be int, float, or str)

required
denominator

The denominator (default=1)

1

Returns:

Type Description

float value in meters

Examples:

inches(1, 32) # 1/32 inch inches(4) # 4 inches inches(3.5) # 3.5 inches inches("1.5") # 1.5 inches from string inches("1/32") # Parses fraction string

Source code in kumiki/rule.py
def inches(numerator, denominator=1):
    """
    Create a measurement in meters from inches.

    Args:
        numerator: The numerator (can be int, float, or str)
        denominator: The denominator (default=1)

    Returns:
        float value in meters

    Examples:
        inches(1, 32)        # 1/32 inch
        inches(4)            # 4 inches
        inches(3.5)          # 3.5 inches
        inches("1.5")        # 1.5 inches from string
        inches("1/32")       # Parses fraction string
    """
    return scalar(numerator, denominator) * INCH_TO_METER

feet

feet(numerator, denominator=1)

Create a measurement in meters from feet.

Parameters:

Name Type Description Default
numerator

The numerator (can be int, float, or str)

required
denominator

The denominator (default=1)

1

Returns:

Type Description

float value in meters

Examples:

feet(8) # 8 feet feet(1, 2) # 1/2 foot feet(6.5) # 6.5 feet

Source code in kumiki/rule.py
def feet(numerator, denominator=1):
    """
    Create a measurement in meters from feet.

    Args:
        numerator: The numerator (can be int, float, or str)
        denominator: The denominator (default=1)

    Returns:
        float value in meters

    Examples:
        feet(8)              # 8 feet
        feet(1, 2)           # 1/2 foot
        feet(6.5)            # 6.5 feet
    """
    return scalar(numerator, denominator) * FOOT_TO_METER

mm

mm(numerator, denominator=1)

Create a measurement in meters from millimeters.

Parameters:

Name Type Description Default
numerator

The numerator (can be int, float, or str)

required
denominator

The denominator (default=1)

1

Returns:

Type Description

float value in meters

Examples:

mm(90) # 90 millimeters mm(1, 2) # 1/2 millimeter mm(25.4) # 25.4 millimeters

Source code in kumiki/rule.py
def mm(numerator, denominator=1):
    """
    Create a measurement in meters from millimeters.

    Args:
        numerator: The numerator (can be int, float, or str)
        denominator: The denominator (default=1)

    Returns:
        float value in meters

    Examples:
        mm(90)               # 90 millimeters
        mm(1, 2)             # 1/2 millimeter
        mm(25.4)             # 25.4 millimeters
    """
    return scalar(numerator, denominator) / 1000

cm

cm(numerator, denominator=1)

Create a measurement in meters from centimeters.

Parameters:

Name Type Description Default
numerator

The numerator (can be int, float, or str)

required
denominator

The denominator (default=1)

1

Returns:

Type Description

float value in meters

Examples:

cm(9) # 9 centimeters cm(1, 2) # 1/2 centimeter cm(2.54) # 2.54 centimeters

Source code in kumiki/rule.py
def cm(numerator, denominator=1):
    """
    Create a measurement in meters from centimeters.

    Args:
        numerator: The numerator (can be int, float, or str)
        denominator: The denominator (default=1)

    Returns:
        float value in meters

    Examples:
        cm(9)                # 9 centimeters
        cm(1, 2)             # 1/2 centimeter
        cm(2.54)             # 2.54 centimeters
    """
    return scalar(numerator, denominator) / 100

m

m(numerator, denominator=1)

Create a measurement in meters.

Parameters:

Name Type Description Default
numerator

The numerator (can be int, float, or str)

required
denominator

The denominator (default=1)

1

Returns:

Type Description

float value in meters

Examples:

m(1) # 1 meter m(1, 2) # 1/2 meter m(2.5) # 2.5 meters

Source code in kumiki/rule.py
def m(numerator, denominator=1):
    """
    Create a measurement in meters.

    Args:
        numerator: The numerator (can be int, float, or str)
        denominator: The denominator (default=1)

    Returns:
        float value in meters

    Examples:
        m(1)                 # 1 meter
        m(1, 2)              # 1/2 meter
        m(2.5)               # 2.5 meters
    """
    return scalar(numerator, denominator)

shaku

shaku(numerator, denominator=1)

Create a measurement in meters from shaku (尺). Traditional Japanese carpentry unit.

1 shaku ≈ 303.03 mm (exactly 10/33 meters)

Parameters:

Name Type Description Default
numerator

The numerator (can be int, float, or str)

required
denominator

The denominator (default=1)

1

Returns:

Type Description

float value in meters

Examples:

shaku(1) # 1 shaku shaku(3, 2) # 3/2 shaku (1.5 shaku) shaku(2.5) # 2.5 shaku

Source code in kumiki/rule.py
def shaku(numerator, denominator=1):
    """
    Create a measurement in meters from shaku (尺).
    Traditional Japanese carpentry unit.

    1 shaku ≈ 303.03 mm (exactly 10/33 meters)

    Args:
        numerator: The numerator (can be int, float, or str)
        denominator: The denominator (default=1)

    Returns:
        float value in meters

    Examples:
        shaku(1)             # 1 shaku
        shaku(3, 2)          # 3/2 shaku (1.5 shaku)
        shaku(2.5)           # 2.5 shaku
    """
    return scalar(numerator, denominator) * SHAKU_TO_METER

sun

sun(numerator, denominator=1)

Create a measurement in meters from sun (寸). Traditional Japanese carpentry unit.

1 sun = 1/10 shaku ≈ 30.303 mm

Parameters:

Name Type Description Default
numerator

The numerator (can be int, float, or str)

required
denominator

The denominator (default=1)

1

Returns:

Type Description

float value in meters

Examples:

sun(1) # 1 sun sun(5) # 5 sun sun(1, 2) # 1/2 sun

Source code in kumiki/rule.py
def sun(numerator, denominator=1):
    """
    Create a measurement in meters from sun (寸).
    Traditional Japanese carpentry unit.

    1 sun = 1/10 shaku ≈ 30.303 mm

    Args:
        numerator: The numerator (can be int, float, or str)
        denominator: The denominator (default=1)

    Returns:
        float value in meters

    Examples:
        sun(1)               # 1 sun
        sun(5)               # 5 sun
        sun(1, 2)            # 1/2 sun
    """
    return scalar(numerator, denominator) * SHAKU_TO_METER / 10

bu

bu(numerator, denominator=1)

Create a measurement in meters from bu (分). Traditional Japanese carpentry unit.

1 bu = 1/10 sun = 1/100 shaku ≈ 3.0303 mm

Parameters:

Name Type Description Default
numerator

The numerator (can be int, float, or str)

required
denominator

The denominator (default=1)

1

Returns:

Type Description

float value in meters

Examples:

bu(1) # 1 bu bu(5) # 5 bu bu(1, 2) # 1/2 bu

Source code in kumiki/rule.py
def bu(numerator, denominator=1):
    """
    Create a measurement in meters from bu (分).
    Traditional Japanese carpentry unit.

    1 bu = 1/10 sun = 1/100 shaku ≈ 3.0303 mm

    Args:
        numerator: The numerator (can be int, float, or str)
        denominator: The denominator (default=1)

    Returns:
        float value in meters

    Examples:
        bu(1)                # 1 bu
        bu(5)                # 5 bu
        bu(1, 2)             # 1/2 bu
    """
    return scalar(numerator, denominator) * SHAKU_TO_METER / 100

safe_zero_test

safe_zero_test(value, eps: Optional[float] = None) -> bool

Test if a value is approximately zero, within eps (default EPSILON_GENERIC).

Source code in kumiki/rule.py
def safe_zero_test(value, eps: Optional[float] = None) -> bool:
    """Test if a value is approximately zero, within *eps* (default EPSILON_GENERIC)."""
    return safe_compare(value, 0, Comparison.EQ, eps=eps)

safe_equality_test

safe_equality_test(value, expected, eps: Optional[float] = None) -> bool

Test if two values are approximately equal, within eps (default EPSILON_GENERIC).

Source code in kumiki/rule.py
def safe_equality_test(value, expected, eps: Optional[float] = None) -> bool:
    """Test if two values are approximately equal, within *eps* (default EPSILON_GENERIC)."""
    return safe_compare(value, expected, Comparison.EQ, eps=eps)

safe_zero_test_sq

safe_zero_test_sq(value_squared, eps: Optional[float] = None) -> bool

Test whether a SQUARED quantity is approximately zero.

Takes a LINEAR tolerance and squares it internally, so eps means the same thing here as everywhere else in the library: a distance in model units, never a distance squared.

safe_zero_test_sq(dx * dx + dy * dy, eps)   # is the distance ~0?

Use this rather than safe_zero_test wherever the value under test is a square. Passing a squared value to safe_zero_test compares it against a linear tolerance, which sounds harmless and is not: at eps=5e-4 it treats any length below 22mm as zero. That has been the shape of two real bugs here already -- polygon edges declared degenerate, and pick tolerances meaning millimetres on one primitive and centimetres on another.

Source code in kumiki/rule.py
def safe_zero_test_sq(value_squared, eps: Optional[float] = None) -> bool:
    """Test whether a SQUARED quantity is approximately zero.

    Takes a LINEAR tolerance and squares it internally, so *eps* means the
    same thing here as everywhere else in the library: a distance in model
    units, never a distance squared.

        safe_zero_test_sq(dx * dx + dy * dy, eps)   # is the distance ~0?

    Use this rather than safe_zero_test wherever the value under test is a
    square. Passing a squared value to safe_zero_test compares it against a
    linear tolerance, which sounds harmless and is not: at eps=5e-4 it treats
    any length below 22mm as zero. That has been the shape of two real bugs
    here already -- polygon edges declared degenerate, and pick tolerances
    meaning millimetres on one primitive and centimetres on another.
    """
    tolerance = EPSILON_GENERIC if eps is None else eps
    return safe_compare(value_squared, 0, Comparison.EQ, eps=tolerance * tolerance)

are_vectors_parallel

are_vectors_parallel(vector1: Matrix, vector2: Matrix, eps: Optional[float] = None) -> bool

Check if two vectors are parallel.

For normalized vectors: dot product ≈ ±1 means parallel

Parameters:

Name Type Description Default
vector1 Matrix

First direction vector

required
vector2 Matrix

Second direction vector

required

Returns:

Type Description
bool

True if |abs(dot_product) - 1| is approximately zero (vectors are parallel)

Source code in kumiki/rule.py
def are_vectors_parallel(vector1: Matrix, vector2: Matrix, eps: Optional[float] = None) -> bool:
    """
    Check if two vectors are parallel.

    For normalized vectors: dot product ≈ ±1 means parallel

    Args:
        vector1: First direction vector
        vector2: Second direction vector

    Returns:
        True if |abs(dot_product) - 1| is approximately zero (vectors are parallel)
    """
    # Compute dot product
    dot_product = vector1.dot(vector2)

    # Check if |abs(dot_product) - 1| is approximately zero
    # This is equivalent to checking if abs(dot_product) is approximately 1
    deviation = Abs(Abs(dot_product) - 1)

    return safe_zero_test(deviation, eps)

are_vectors_perpendicular

are_vectors_perpendicular(vector1: Matrix, vector2: Matrix, eps: Optional[float] = None) -> bool

Check if two vectors are perpendicular.

For any vectors: dot product ≈ 0 means perpendicular

Parameters:

Name Type Description Default
vector1 Matrix

First direction vector

required
vector2 Matrix

Second direction vector

required

Returns:

Type Description
bool

True if dot_product is approximately zero (vectors are perpendicular)

Source code in kumiki/rule.py
def are_vectors_perpendicular(vector1: Matrix, vector2: Matrix, eps: Optional[float] = None) -> bool:
    """
    Check if two vectors are perpendicular.

    For any vectors: dot product ≈ 0 means perpendicular

    Args:
        vector1: First direction vector
        vector2: Second direction vector

    Returns:
        True if dot_product is approximately zero (vectors are perpendicular)
    """
    # Compute dot product
    dot_product = vector1.dot(vector2)

    # Check if dot product is approximately zero
    return safe_zero_test(dot_product, eps)

make_finite_rectangular_prism_from_half_space

make_finite_rectangular_prism_from_half_space(half_space: HalfSpace, size_of_space: Numeric, depth_of_space: Numeric) -> RectangularPrism

Build a finite RectangularPrism that approximates half_space near its boundary.

The returned prism: - has its "bottom" face (at start_distance = 0) lying on the half-space boundary plane, - extends depth_of_space into the half-space (in the +normal direction, i.e. the direction in which the half-space extends), - has a square cross-section of size_of_space × size_of_space centered on the point where the line through the origin along normal meets the boundary plane.

The cross-section orientation perpendicular to the normal is chosen arbitrarily.

Source code in kumiki/cutcsg.py
def make_finite_rectangular_prism_from_half_space(half_space: HalfSpace, size_of_space: Numeric, depth_of_space: Numeric) -> RectangularPrism:
    """
    Build a finite RectangularPrism that approximates ``half_space`` near its boundary.

    The returned prism:
    - has its "bottom" face (at start_distance = 0) lying on the half-space boundary plane,
    - extends ``depth_of_space`` into the half-space (in the +normal direction, i.e. the
      direction in which the half-space extends),
    - has a square cross-section of ``size_of_space`` × ``size_of_space`` centered on the
      point where the line through the origin along ``normal`` meets the boundary plane.

    The cross-section orientation perpendicular to the normal is chosen arbitrarily.
    """
    # Unit normal pointing into the half-space (HalfSpace contains points where P·normal >= offset)
    unit_normal = safe_normalize_vector(half_space.normal)

    # Pick a reference direction not parallel to the normal to build a perpendicular x-axis.
    world_x = Matrix([scalar(1), scalar(0), scalar(0)])
    world_y = Matrix([scalar(0), scalar(1), scalar(0)])
    if safe_compare(Abs(safe_dot_product(unit_normal, world_x)) - scalar(9, 10), 0, Comparison.LT):
        reference = world_x
    else:
        reference = world_y

    # Gram-Schmidt: project reference onto plane perpendicular to unit_normal, then normalize.
    x_direction = safe_normalize_vector(
        reference - unit_normal * safe_dot_product(reference, unit_normal)
    )

    orientation = Orientation.from_z_and_x(unit_normal, x_direction)

    # A point on the boundary plane: nearest point to origin on the plane P·normal = offset.
    # For normalized normal n, plane is P·n = offset/|normal|.
    normal_magnitude = safe_norm(half_space.normal)
    point_on_plane = unit_normal * (half_space.offset / normal_magnitude)

    return RectangularPrism(
        size=Matrix([size_of_space, size_of_space]),
        transform=Transform(position=point_on_plane, orientation=orientation),
        start_distance=scalar(0),
        end_distance=depth_of_space,
    )

adopt_csg

adopt_csg(orig_transform: Optional[Transform], adopting_transform: Optional[Transform], csg_in_orig_space: CutCSG) -> CutCSG

Transform a CSG object into another coordinate system.

If orig_transform is provided, the CSG is treated as being in that transform's local coordinates. If orig_transform is None, the CSG is treated as being in global coordinates. If adopting_transform is provided, the result is expressed in that transform's local coordinates. If adopting_transform is None, the result is expressed in global coordinates.

Parameters:

Name Type Description Default
orig_transform Optional[Transform]

The transform whose local space the CSG is in, or None for global

required
adopting_transform Optional[Transform]

The transform whose local space we want the CSG in, or None to return the CSG in global coordinates

required
csg_in_orig_space CutCSG

The CSG object (in orig_transform local, or global if orig_transform is None)

required

Returns:

Type Description
CutCSG

A new CSG object in adopting_transform's local coordinates, or in global

CutCSG

coordinates if adopting_transform is None

Example

cut_on_b = adopt_csg(timber_a.transform, timber_b.transform, cut_csg) csg_in_tenon_local = adopt_csg(None, tenon_timber.transform, csg_global) csg_in_global = adopt_csg(timber_a.transform, None, cut_csg)

Source code in kumiki/cutcsg.py
def adopt_csg(
    orig_transform: Optional[Transform],
    adopting_transform: Optional[Transform],
    csg_in_orig_space: CutCSG,
) -> CutCSG:
    """
    Transform a CSG object into another coordinate system.

    If orig_transform is provided, the CSG is treated as being in that transform's local
    coordinates. If orig_transform is None, the CSG is treated as being in global coordinates.
    If adopting_transform is provided, the result is expressed in that transform's local
    coordinates. If adopting_transform is None, the result is expressed in global coordinates.

    Args:
        orig_transform: The transform whose local space the CSG is in, or None for global
        adopting_transform: The transform whose local space we want the CSG in,
            or None to return the CSG in global coordinates
        csg_in_orig_space: The CSG object (in orig_transform local, or global if orig_transform is None)

    Returns:
        A new CSG object in adopting_transform's local coordinates, or in global
        coordinates if adopting_transform is None

    Example:
        >>> cut_on_b = adopt_csg(timber_a.transform, timber_b.transform, cut_csg)
        >>> csg_in_tenon_local = adopt_csg(None, tenon_timber.transform, csg_global)
        >>> csg_in_global = adopt_csg(timber_a.transform, None, cut_csg)
    """
    # Helper: Transform from orig (or global) to adopting local, or to global
    # coordinates when adopting_transform is None.
    def transform_transform(trans: Transform) -> Transform:
        if orig_transform is not None:
            global_position = orig_transform.numeric_local_to_global(trans.position)
            global_orientation = orig_transform.orientation * trans.orientation
        else:
            global_position = trans.position
            global_orientation = trans.orientation

        if adopting_transform is None:
            return Transform(position=global_position, orientation=global_orientation)

        local_position = adopting_transform.numeric_global_to_local(global_position)
        local_orientation = adopting_transform.orientation.invert() * global_orientation
        return Transform(position=local_position, orientation=local_orientation)

    # Helper: HalfSpace from orig (or global) to adopting local, or to global
    # coordinates when adopting_transform is None.
    def transform_halfspace(hp: HalfSpace) -> HalfSpace:
        if orig_transform is not None:
            global_normal = numeric_transform_vector(orig_transform.orientation.matrix, hp.normal)
        else:
            global_normal = hp.normal

        if adopting_transform is None:
            new_normal = global_normal
        else:
            new_normal = numeric_transform_vector(
                adopting_transform.orientation.matrix.T, global_normal
            )

        normal_length_sq = numeric_dot_product(hp.normal, hp.normal)
        if safe_zero_test_sq(normal_length_sq):
            return replace(hp, normal=new_normal, offset=hp.offset)

        point_on_plane_in_orig = hp.normal * (hp.offset / normal_length_sq)
        if orig_transform is not None:
            point_on_plane_global = orig_transform.numeric_local_to_global(point_on_plane_in_orig)
        else:
            point_on_plane_global = point_on_plane_in_orig

        if adopting_transform is None:
            new_offset = numeric_dot_product(new_normal, point_on_plane_global)
        else:
            point_on_plane_new_local = adopting_transform.numeric_global_to_local(point_on_plane_global)
            new_offset = numeric_dot_product(new_normal, point_on_plane_new_local)
        return replace(hp, normal=new_normal, offset=new_offset)

    # Recursively transform based on CSG type
    if isinstance(csg_in_orig_space, SolidUnion):
        transformed_children = [
            adopt_csg(orig_transform, adopting_transform, child)
            for child in csg_in_orig_space.children
        ]
        return SolidUnion(transformed_children, label=csg_in_orig_space.label)

    elif isinstance(csg_in_orig_space, Intersection):
        transformed_left = adopt_csg(orig_transform, adopting_transform, csg_in_orig_space.left)
        transformed_right = adopt_csg(orig_transform, adopting_transform, csg_in_orig_space.right)
        return Intersection(left=transformed_left, right=transformed_right, label=csg_in_orig_space.label)

    elif isinstance(csg_in_orig_space, Difference):
        transformed_base = adopt_csg(orig_transform, adopting_transform, csg_in_orig_space.base)
        transformed_subtract = [
            adopt_csg(orig_transform, adopting_transform, sub)
            for sub in csg_in_orig_space.subtract
        ]
        return Difference(base=transformed_base, subtract=transformed_subtract, label=csg_in_orig_space.label)

    elif isinstance(csg_in_orig_space, HalfSpace):
        return transform_halfspace(csg_in_orig_space)

    elif isinstance(csg_in_orig_space, Cylinder):
        cyl = csg_in_orig_space
        if orig_transform is not None:
            global_position = orig_transform.numeric_local_to_global(cyl.position)
            global_axis = numeric_transform_vector(orig_transform.orientation.matrix, cyl.axis_direction)
        else:
            global_position = cyl.position
            global_axis = cyl.axis_direction

        if adopting_transform is None:
            return replace(cyl, position=global_position, axis_direction=global_axis)

        new_local_position = adopting_transform.numeric_global_to_local(global_position)
        new_local_axis = numeric_transform_vector(
            adopting_transform.orientation.matrix.T, global_axis
        )
        return replace(cyl, position=new_local_position, axis_direction=new_local_axis)

    elif hasattr(csg_in_orig_space, "transform"):
        new_transform = transform_transform(cast(Transform, csg_in_orig_space.transform))
        return replace(csg_in_orig_space, transform=new_transform)

    else:
        return csg_in_orig_space

get_center_point_on_face_global

get_center_point_on_face_global(face: SomeTimberFace, timber: PerfectTimberWithin) -> V3

Get the center point of a timber face in global coordinates.

Parameters:

Name Type Description Default
face SomeTimberFace

The face to get the center of

required
timber PerfectTimberWithin

The timber

required

Returns:

Type Description
V3

Center point of the face surface in global coordinates

Source code in kumiki/measuring.py
def get_center_point_on_face_global(face: SomeTimberFace, timber: PerfectTimberWithin) -> V3:
    """
    Get the center point of a timber face in global coordinates.

    Args:
        face: The face to get the center of
        timber: The timber

    Returns:
        Center point of the face surface in global coordinates
    """
    timber_center = timber.get_bottom_position_global() + timber.get_length_direction_global() * timber.length / 2
    return timber_center + timber.get_face_direction_global(face) * timber.get_size_in_face_normal_axis(face) / 2

locate_centerline

locate_centerline(timber: PerfectTimberWithin) -> Line

Measure the centerline of a timber. Thin wrapper around locate_edge.

Source code in kumiki/measuring.py
def locate_centerline(timber: PerfectTimberWithin) -> Line:
    """Measure the centerline of a timber. Thin wrapper around locate_edge."""
    return locate_edge(timber, TimberCenterline.CENTERLINE)

locate_plane_from_edge_in_direction

locate_plane_from_edge_in_direction(timber: PerfectTimberWithin, edge: EdgeOrCenterline, direction: Direction3D, distance: Numeric = scalar(0)) -> Plane

Return a Plane that is parallel to the given edge, has direction as its normal, and sits distance away from the edge in that direction.

Parameters:

Name Type Description Default
timber PerfectTimberWithin

The timber whose edge to measure from

required
edge EdgeOrCenterline

Which edge or centerline

required
direction Direction3D

Normal direction of the resulting plane

required
distance Numeric

How far from the edge to place the plane (default 0 = through the edge)

scalar(0)
Source code in kumiki/measuring.py
def locate_plane_from_edge_in_direction(timber: PerfectTimberWithin, edge: EdgeOrCenterline, direction: Direction3D, distance: Numeric = scalar(0)) -> Plane:
    """
    Return a Plane that is parallel to the given edge, has `direction` as its
    normal, and sits `distance` away from the edge in that direction.

    Args:
        timber: The timber whose edge to measure from
        edge: Which edge or centerline
        direction: Normal direction of the resulting plane
        distance: How far from the edge to place the plane (default 0 = through the edge)
    """
    edge_line = locate_edge(timber, edge)
    return Plane(normal=direction, point=edge_line.point + direction * distance)

get_perfect_support_distance_from_centerline

get_perfect_support_distance_from_centerline(timber: PerfectTimberWithin, direction: V2) -> Numeric

distance from cross-section centerline to support plane of the perfect timber dimensions in direction

Source code in kumiki/timber_shavings.py
def get_perfect_support_distance_from_centerline(timber: PerfectTimberWithin, direction: V2) -> Numeric:
    """distance from cross-section centerline to support plane of the perfect timber dimensions in direction"""
    w_half = timber.size[0] / scalar(2)
    h_half = timber.size[1] / scalar(2)
    return _support_distance_local(
        position_local=create_v3(scalar(0), scalar(0), scalar(0)),
        direction_local=create_v3(direction[0], direction[1], scalar(0)),
        x_pos=w_half,
        x_neg=w_half,
        y_pos=h_half,
        y_neg=h_half,
        z_min=scalar(0),
        z_max=scalar(0),
    )

are_timbers_plane_aligned

are_timbers_plane_aligned(timber1: PerfectTimberWithin, timber2: PerfectTimberWithin, tolerance: Optional[Numeric] = None) -> bool

Check if two timbers are plane aligned

Parameters:

Name Type Description Default
timber1 PerfectTimberWithin

First timber

required
timber2 PerfectTimberWithin

Second timber

required
tolerance Optional[Numeric]

Optional numerical tolerance for parallel check. If None, uses exact equality. If provided, uses approximate floating-point comparison.

None

Returns:

Type Description
bool

True if timbers have at least one pair of parallel long faces, False otherwise

Source code in kumiki/timber_shavings.py
def are_timbers_plane_aligned(timber1: PerfectTimberWithin, timber2: PerfectTimberWithin, tolerance: Optional[Numeric] = None) -> bool:
    """
    Check if two timbers are plane aligned

    Args:
        timber1: First timber
        timber2: Second timber  
        tolerance: Optional numerical tolerance for parallel check. If None, uses exact
                   equality. If provided, uses approximate floating-point comparison.

    Returns:
        True if timbers have at least one pair of parallel long faces, False otherwise
    """
    # Long faces are determined by width_direction and height_direction
    # RIGHT/LEFT faces are perpendicular to width_direction
    # FRONT/BACK faces are perpendicular to height_direction
    long_face_normals1 = [timber1.get_width_direction_global(), timber1.get_height_direction_global()]
    long_face_normals2 = [timber2.get_width_direction_global(), timber2.get_height_direction_global()]

    # Check if any pair of long face normals are parallel
    for normal1 in long_face_normals1:
        for normal2 in long_face_normals2:
            dot_product = Abs(numeric_dot_product(normal1, normal2))

            if tolerance is None:
                if safe_equality_test(dot_product, 1):
                    return True
            else:
                if Abs(dot_product - 1) < tolerance:
                    return True

    return False

warn_if_arrangement_timbers_imperfect

warn_if_arrangement_timbers_imperfect(arrangement) -> None

Warn when a joint arrangement uses any timber that is not perfect.

Source code in kumiki/joints/workshop/shavings/relief.py
def warn_if_arrangement_timbers_imperfect(arrangement) -> None:
    """Warn when a joint arrangement uses any timber that is not perfect."""
    check_perfection = getattr(arrangement, "check_perfection", None)
    if not callable(check_perfection):
        return
    error = check_perfection()
    if error is not None:
        warnings.warn(IMPERFECT_TIMBER_WARNING, stacklevel=2)

does_shoulder_plane_need_notching

does_shoulder_plane_need_notching(arrangement: ButtJointTimberArrangement, mortise_shoulder_distance_from_centerline_or_centerplane: Numeric, check_against_rough_size: bool = True, set_mortise_shoulder_parallel_to_face: Union[TimberLongFace, bool] = False) -> bool

Determines whether a shoulder notch is needed on the mortise timber.

For plane-aligned timbers, checks whether the shoulder is inset from the mortise face surface. For non-plane-aligned timbers, always returns True.

Parameters:

Name Type Description Default
arrangement ButtJointTimberArrangement

Butt joint arrangement (receiving_timber = mortise, butt_timber = tenon).

required
mortise_shoulder_distance_from_centerline_or_centerplane Numeric

Distance from the mortise centerline to the shoulder plane, measured toward the tenon.

required
check_against_rough_size bool

If True (default), compare against the mortise timber's rough half-size on the entry face (using get_half_rough_size_in_face_normal_axis). If False, compare against the perfect-timber half-size (get_size_in_face_normal_axis / 2).

True
set_mortise_shoulder_parallel_to_face Union[TimberLongFace, bool]

If set to a face, then force the mortise shoulder to be parallel to that face.

False
Source code in kumiki/joints/workshop/shavings/relief.py
def does_shoulder_plane_need_notching(
    arrangement: ButtJointTimberArrangement,
    mortise_shoulder_distance_from_centerline_or_centerplane: Numeric,
    check_against_rough_size: bool = True,
    set_mortise_shoulder_parallel_to_face: Union[TimberLongFace, bool] = False,
) -> bool:
    """
    Determines whether a shoulder notch is needed on the mortise timber.

    For plane-aligned timbers, checks whether the shoulder is inset from the
    mortise face surface. For non-plane-aligned timbers, always returns True.

    Args:
        arrangement: Butt joint arrangement (receiving_timber = mortise, butt_timber = tenon).
        mortise_shoulder_distance_from_centerline_or_centerplane: Distance from the mortise centerline
            to the shoulder plane, measured toward the tenon.
        check_against_rough_size: If True (default), compare against the mortise timber's
            rough half-size on the entry face (using ``get_half_rough_size_in_face_normal_axis``).
            If False, compare against the perfect-timber half-size (``get_size_in_face_normal_axis / 2``).
        set_mortise_shoulder_parallel_to_face: If set to a face, then force the mortise shoulder to be parallel to that face.
    """
    mortise_timber = arrangement.receiving_timber
    tenon_timber = arrangement.butt_timber
    tenon_end = arrangement.butt_timber_end

    # we could check if the shoulder plane intersects the timber here, but then you'd have an unsupported tenon shoulder which is likely unintentional and certainly rare.
    # so just assume it does intersect and a notch is required
    if not are_timbers_plane_aligned(mortise_timber, tenon_timber):
        return True

    tenon_end_direction = tenon_timber.get_face_direction_global(
        TimberFace.TOP if tenon_end == TimberEnd.TOP else TimberFace.BOTTOM
    )
    if set_mortise_shoulder_parallel_to_face is not False:
        if set_mortise_shoulder_parallel_to_face is True:
            x_axis = mortise_timber.get_width_direction_global()
            y_axis = mortise_timber.get_height_direction_global()
            dot_x = abs(safe_dot_product(tenon_end_direction, x_axis))
            dot_y = abs(safe_dot_product(tenon_end_direction, y_axis))
            proj = tenon_end_direction - mortise_timber.get_length_direction_global() * safe_dot_product(tenon_end_direction, mortise_timber.get_length_direction_global())
            if dot_x < dot_y:
                if safe_dot_product(x_axis, proj) > 0:
                    mortise_face = TimberFace.RIGHT
                else:
                    mortise_face = TimberFace.LEFT
            else:
                if safe_dot_product(y_axis, proj) > 0:
                    mortise_face = TimberFace.FRONT
                else:
                    mortise_face = TimberFace.BACK
        else:
            mortise_face = set_mortise_shoulder_parallel_to_face.to.face()
    else:
        mortise_face = mortise_timber.get_closest_oriented_long_face_from_global_direction(
            -tenon_end_direction
        ).to.face()

    if check_against_rough_size:
        face_half_size = mortise_timber.get_half_rough_size_in_face_normal_axis(mortise_face)
    else:
        face_half_size = mortise_timber.get_size_in_face_normal_axis(mortise_face) / scalar(2)
    return (
        mortise_shoulder_distance_from_centerline_or_centerplane < face_half_size
        and not safe_zero_test(face_half_size - mortise_shoulder_distance_from_centerline_or_centerplane)
    )

chop_shoulder_notch_aligned_with_timber

chop_shoulder_notch_aligned_with_timber(notch_timber: TimberLike, butting_timber: TimberLike, butting_timber_end: TimberEnd, distance_from_centerline: Numeric, notch_wall_relief_cut_angle_radians: Numeric = scalar(0), set_mortise_shoulder_parallel_to_face: Union[TimberLongFace, bool] = False, label: CutCSGLabel = CutCSGLabel('shoulder_notch_relief')) -> Union[RectangularPrism, SolidUnion]

Create a shoulder notch on notch_timber at a given distance from its centerline, oriented by the butting_timber's approach direction.

Unlike chop_shoulder_notch_on_timber_face which is aligned to a specific face, this notch is aligned to the shoulder plane derived from the butting timber's approach direction (projected perpendicular to the notch timber's length axis if set_mortise_shoulder_parallel_to_face is not False).

The notch bottom (shoulder plane) is distance_from_centerline away from the notch_timber's centerline. The notch opens outward from the centerline. The notch width is along the notch_timber's length axis and hugs the butting timber's shoulder-plane slice exactly (its perfect cross-section; imperfect material beyond that is scribe relief's job, not the housing's). The span and depth clear the notch timber's entire rough cross-section via a worst-case corner-radius bound -- overshoot is free in both of those directions (the span channel exits the timber's sides, the depth exits its outer face) so neither needs to be exact.

notch_wall_relief_cut_angle_radians is a MINIMUM: the walls are always relieved by at least the butting timber's rake away from the shoulder-plane normal, since anything less would leave housing walls colliding with the raking butting timber above the shoulder plane.

Source code in kumiki/joints/workshop/shavings/relief.py
def chop_shoulder_notch_aligned_with_timber(
    notch_timber: TimberLike,
    butting_timber: TimberLike,
    butting_timber_end: TimberEnd,
    distance_from_centerline: Numeric,
    notch_wall_relief_cut_angle_radians: Numeric = scalar(0),
    set_mortise_shoulder_parallel_to_face: Union[TimberLongFace, bool] = False,
    label: CutCSGLabel = CutCSGLabel("shoulder_notch_relief"),
) -> Union[RectangularPrism, SolidUnion]:
    """
    Create a shoulder notch on notch_timber at a given distance from its centerline,
    oriented by the butting_timber's approach direction.

    Unlike chop_shoulder_notch_on_timber_face which is aligned to a specific face,
    this notch is aligned to the shoulder plane derived from the butting timber's
    approach direction (projected perpendicular to the notch timber's length axis if set_mortise_shoulder_parallel_to_face is not False).

    The notch bottom (shoulder plane) is distance_from_centerline away from the
    notch_timber's centerline. The notch opens outward from the centerline.
    The notch width is along the notch_timber's length axis and hugs the
    butting timber's shoulder-plane slice exactly (its perfect cross-section;
    imperfect material beyond that is scribe relief's job, not the housing's).
    The span and depth clear the notch timber's entire rough cross-section
    via a worst-case corner-radius bound -- overshoot is free in both of those
    directions (the span channel exits the timber's sides, the depth exits its
    outer face) so neither needs to be exact.

    notch_wall_relief_cut_angle_radians is a MINIMUM: the walls are always
    relieved by at least the butting timber's rake away from the shoulder-plane
    normal, since anything less would leave housing walls colliding with the
    raking butting timber above the shoulder plane.
    """

    notch_length_dir_global = notch_timber.get_length_direction_global()

    if butting_timber_end == TimberEnd.TOP:
        raw_approach = -butting_timber.get_length_direction_global()
    else:
        raw_approach = butting_timber.get_length_direction_global()

    projected = raw_approach - notch_length_dir_global * safe_dot_product(
        raw_approach, notch_length_dir_global
    )

    # the approach direction projected onto the plane perpendicular to the notch timber's length axis
    perpendicular_approach_direction_global = safe_normalize_vector(projected)

    arrangement = ButtJointTimberArrangement(
        butt_timber=butting_timber,
        receiving_timber=notch_timber,
        butt_timber_end=butting_timber_end,
    )

    if set_mortise_shoulder_parallel_to_face:
        from kumiki.joints.workshop.shavings.build_a_butt import (
            locate_mortise_timber_shoulder_plane_from_centerplane_towards_long_face,
            resolve_parallel_shoulder_face,
        )
        resolved_face = resolve_parallel_shoulder_face(arrangement, set_mortise_shoulder_parallel_to_face)
        shoulder_plane = locate_mortise_timber_shoulder_plane_from_centerplane_towards_long_face(
            arrangement,
            distance_from_centerline,
            resolved_face,
        )
    else:
        from kumiki.joints.workshop.shavings.build_a_butt import locate_mortise_timber_shoulder_plane_from_centerline_towards_tenon_timber
        shoulder_plane = locate_mortise_timber_shoulder_plane_from_centerline_towards_tenon_timber(
            arrangement,
            distance_from_centerline,
        )

    shoulder_plane_normal = shoulder_plane.normal
    butting_centerline = locate_centerline(butting_timber)
    denom = safe_dot_product(shoulder_plane.normal, butting_centerline.direction)
    assert not safe_zero_test(denom), "Butting timber centerline is parallel to the shoulder plane"
    t = safe_dot_product(
        shoulder_plane.normal,
        shoulder_plane.point - butting_centerline.point,
    ) / denom
    intersection_global = butting_centerline.point + butting_centerline.direction * t

    # ------------------------------------------------------------------
    # Notch prism dimensions. The three prism axes (all mutually
    # perpendicular): depth extrudes along the approach direction outward
    # from the shoulder plane, width runs along the notch timber's length
    # axis, span runs across the notch timber's cross-section.
    # ------------------------------------------------------------------

    # Span and depth must clear the notch timber's entire rough
    # (imperfect-bounding) cross-section regardless of how that cross-section
    # is rotated about the length axis, and overshoot in both directions is
    # free (span exits the timber's sides, depth exits its outer face), so
    # both are sized from the worst-case corner radius -- the farthest any
    # rough-corner can be from the centerline under any rotation -- rather
    # than computed exactly for the specific directions.
    notch_timber_width_halves, notch_timber_height_halves = notch_timber.get_rough_half_sizes()
    max_corner_radius = sqrt(
        Max(notch_timber_width_halves[0], notch_timber_width_halves[1]) ** 2
        + Max(notch_timber_height_halves[0], notch_timber_height_halves[1]) ** 2
    )
    # The prism is centered on intersection_global, which carries no offset
    # along the span direction as long as the two centerlines intersect: the
    # span direction is perpendicular to the plane spanned by both length
    # axes, and the intersection only ever moves within that plane.
    notch_span = scalar(2) * max_corner_radius
    # Depth is measured outward from the shoulder plane (start_distance=0 on
    # the prism below), so the material to clear is at most
    # max_corner_radius - distance_from_centerline; the Max keeps the prism
    # comfortably non-degenerate when the shoulder sits near the surface.
    notch_depth = Max(scalar(2) * max_corner_radius - distance_from_centerline, max_corner_radius)

    # Width must hug the butting timber exactly -- unlike span/depth,
    # overshoot here is NOT free: it would widen the housing and cut away
    # seat material. This is the true footprint of the butting timber's
    # (perfect) cross-section sliced by the shoulder plane.
    #
    # The notch width axis is the projection of the butting timber's length
    # axis onto the shoulder plane. For the centerline-derived shoulder plane
    # this coincides with notch_length_dir_global (no change). For
    # face-parallel shoulder planes (compound angles) the tenon may rake in
    # both the length and span directions of the receiving timber, so we use
    # the actual projected tenon direction as the notch width axis to keep
    # the notch tight against the tenon on both walls.
    b_global = butting_centerline.direction
    n_global = shoulder_plane.normal
    b_in_plane = b_global - n_global * safe_dot_product(b_global, n_global)
    b_in_plane_len_sq = safe_dot_product(b_in_plane, b_in_plane)
    if not safe_zero_test_sq(b_in_plane_len_sq):
        notch_width_axis_global = safe_normalize_vector(b_in_plane)
    else:
        notch_width_axis_global = notch_length_dir_global

    notch_width = _perfect_cross_section_slice_span_along_plane_direction(
        butting_timber,
        shoulder_plane.normal,
        notch_width_axis_global,
    )

    approach_direction_local = safe_transform_vector(
        notch_timber.orientation.matrix.T,
        shoulder_plane_normal,
    )
    notch_width_axis_local = safe_normalize_vector(
        safe_transform_vector(notch_timber.orientation.matrix.T, notch_width_axis_global)
    )

    prism_orientation = Orientation.from_z_and_x(approach_direction_local, notch_width_axis_local)
    prism_position_local = notch_timber.transform.global_to_local(intersection_global)

    # this prism is the "main" part of the notch
    notch_prism = RectangularPrism(
        size=create_v2(notch_width, notch_span),
        transform=Transform(position=prism_position_local, orientation=prism_orientation),
        label=CutCSGLabel("notch_relief"),
        start_distance=scalar(0),
        end_distance=notch_depth,
    )

    # The requested wall relief angle is a floor, not the final value: the
    # walls must be relieved by at least the butting timber's rake away from
    # the shoulder-plane normal, otherwise the housing walls would collide
    # with the raking butting timber above the shoulder plane.
    cos_butt_from_shoulder_normal = Abs(safe_dot_product(raw_approach, shoulder_plane.normal))
    butt_rake_from_shoulder_normal_radians = acos(Min(cos_butt_from_shoulder_normal, scalar(1)))
    wall_relief_angle_radians = Max(notch_wall_relief_cut_angle_radians, butt_rake_from_shoulder_normal_radians)

    if safe_zero_test(wall_relief_angle_radians):
        return notch_prism

    angle_rad = wall_relief_angle_radians
    span_direction_local = cross_product(approach_direction_local, notch_width_axis_local)
    span_direction_local = safe_normalize_vector(span_direction_local)

    corner_point_1 = prism_position_local + notch_width_axis_local * (notch_width / scalar(2))
    corner_point_2 = prism_position_local - notch_width_axis_local * (notch_width / scalar(2))

    axis_1 = Axis(position=corner_point_1, direction=span_direction_local)
    axis_2 = Axis(position=corner_point_2, direction=span_direction_local)

    extended_end_distance = notch_depth / cos(angle_rad)

    # these 2 prisms are the "relief" parts of the notch
    left_wall_prism = RectangularPrism(
        size=notch_prism.size,
        transform=notch_prism.transform.rotate_around_axis(axis_1, angle_rad),
        start_distance=notch_prism.start_distance,
        end_distance=extended_end_distance,
        label=CutCSGLabel("notch_wall_relief"),
    )
    right_wall_prism = RectangularPrism(
        size=notch_prism.size,
        transform=notch_prism.transform.rotate_around_axis(axis_2, -angle_rad),
        start_distance=notch_prism.start_distance,
        end_distance=extended_end_distance,
        label=CutCSGLabel("notch_wall_relief"),
    )

    return SolidUnion([notch_prism, left_wall_prism, right_wall_prism], label=label)

chop_shoulder_notch_on_timber_face

chop_shoulder_notch_on_timber_face(timber: TimberLike, notch_face: TimberFace, distance_along_timber: Numeric, notch_width: Numeric, notch_depth: Numeric, notch_wall_relief_cut_angle: Numeric = scalar(0), label: CutCSGLabel = CutCSGLabel('shoulder_notch_relief')) -> Union[RectangularPrism, SolidUnion]

Create a rectangular shoulder notch on a timber face with optional angled walls.

Source code in kumiki/joints/workshop/shavings/relief.py
def chop_shoulder_notch_on_timber_face(
    timber: TimberLike,
    # TODO TimberLongFace
    notch_face: TimberFace,
    distance_along_timber: Numeric,
    notch_width: Numeric,
    notch_depth: Numeric,
    notch_wall_relief_cut_angle: Numeric = scalar(0),
    label: CutCSGLabel = CutCSGLabel("shoulder_notch_relief"),
) -> Union[RectangularPrism, SolidUnion]:
    """
    Create a rectangular shoulder notch on a timber face with optional angled walls.
    """

    if notch_face == TimberFace.TOP or notch_face == TimberFace.BOTTOM:
        raise ValueError("Cannot cut shoulder notch on end faces (TOP or BOTTOM)")
    if notch_width <= 0:
        raise ValueError(f"notch_width must be positive, got {notch_width}")
    if notch_depth <= 0:
        raise ValueError(f"notch_depth must be positive, got {notch_depth}")
    if distance_along_timber < 0 or distance_along_timber > timber.length:
        raise ValueError(
            f"distance_along_timber must be between 0 and timber.length ({timber.length}), "
            f"got {distance_along_timber}"
        )
    if notch_wall_relief_cut_angle < 0 or notch_wall_relief_cut_angle >= 90:
        raise ValueError(
            f"notch_wall_relief_cut_angle must be between 0 and 90 degrees, got {notch_wall_relief_cut_angle}"
        )

    # Use rough half-sizes so asymmetric timbers (where the centerline isn't
    # at the geometric center of the rough bounding box) place the notch at
    # the correct face plane.
    half_face_offset = timber.get_half_rough_size_in_face_normal_axis(notch_face)

    if notch_face == TimberFace.FRONT:
        cross_span = timber.get_rough_size_in_face_normal_axis(TimberFace.RIGHT)
        position = create_v3(scalar(0), half_face_offset - notch_depth, distance_along_timber)
        orientation = Orientation.from_z_and_x(
            create_v3(scalar(0), scalar(1), scalar(0)),
            create_v3(scalar(0), scalar(0), scalar(1)),
        )
        prism_size = create_v2(notch_width, cross_span)
        corner_point_1 = create_v3(
            scalar(0),
            half_face_offset - notch_depth,
            distance_along_timber + notch_width / scalar(2),
        )
        corner_point_2 = create_v3(
            scalar(0),
            half_face_offset - notch_depth,
            distance_along_timber - notch_width / scalar(2),
        )
    elif notch_face == TimberFace.BACK:
        cross_span = timber.get_rough_size_in_face_normal_axis(TimberFace.RIGHT)
        position = create_v3(scalar(0), -half_face_offset + notch_depth, distance_along_timber)
        orientation = Orientation.from_z_and_x(
            create_v3(scalar(0), scalar(-1), scalar(0)),
            create_v3(scalar(0), scalar(0), scalar(1)),
        )
        prism_size = create_v2(notch_width, cross_span)
        corner_point_1 = create_v3(
            scalar(0),
            -half_face_offset + notch_depth,
            distance_along_timber + notch_width / scalar(2),
        )
        corner_point_2 = create_v3(
            scalar(0),
            -half_face_offset + notch_depth,
            distance_along_timber - notch_width / scalar(2),
        )
    elif notch_face == TimberFace.RIGHT:
        cross_span = timber.get_rough_size_in_face_normal_axis(TimberFace.FRONT)
        position = create_v3(half_face_offset - notch_depth, scalar(0), distance_along_timber)
        orientation = Orientation.from_z_and_x(
            create_v3(scalar(1), scalar(0), scalar(0)),
            create_v3(scalar(0), scalar(0), scalar(1)),
        )
        prism_size = create_v2(notch_width, cross_span)
        corner_point_1 = create_v3(
            half_face_offset - notch_depth,
            scalar(0),
            distance_along_timber + notch_width / scalar(2),
        )
        corner_point_2 = create_v3(
            half_face_offset - notch_depth,
            scalar(0),
            distance_along_timber - notch_width / scalar(2),
        )
    else:
        cross_span = timber.get_rough_size_in_face_normal_axis(TimberFace.FRONT)
        position = create_v3(-half_face_offset + notch_depth, scalar(0), distance_along_timber)
        orientation = Orientation.from_z_and_x(
            create_v3(scalar(-1), scalar(0), scalar(0)),
            create_v3(scalar(0), scalar(0), scalar(1)),
        )
        prism_size = create_v2(notch_width, cross_span)
        corner_point_1 = create_v3(
            -half_face_offset + notch_depth,
            scalar(0),
            distance_along_timber + notch_width / scalar(2),
        )
        corner_point_2 = create_v3(
            -half_face_offset + notch_depth,
            scalar(0),
            distance_along_timber - notch_width / scalar(2),
        )

    notch_additional_depth = timber.get_half_rough_size_in_face_normal_axis(notch_face)

    notch_prism = RectangularPrism(
        size=prism_size,
        transform=Transform(position=position, orientation=orientation),
        start_distance=scalar(0),
        end_distance=notch_depth + notch_additional_depth,
        label=label if notch_wall_relief_cut_angle == 0 else CutCSGLabel.NoLabel(),
    )

    if notch_wall_relief_cut_angle == 0:
        return notch_prism

    angle_rad = degrees(notch_wall_relief_cut_angle)

    if notch_face == TimberFace.FRONT or notch_face == TimberFace.BACK:
        axis_direction = create_v3(scalar(1), scalar(0), scalar(0))
    else:
        axis_direction = create_v3(scalar(0), scalar(1), scalar(0))

    axis_1 = Axis(position=corner_point_1, direction=axis_direction)
    axis_2 = Axis(position=corner_point_2, direction=axis_direction)

    extended_end_distance = (notch_depth + notch_additional_depth) / cos(angle_rad)

    left_wall_prism = RectangularPrism(
        size=notch_prism.size,
        transform=notch_prism.transform.rotate_around_axis(axis_1, radians(angle_rad)),
        start_distance=notch_prism.start_distance,
        end_distance=extended_end_distance,
        label=CutCSGLabel("notch_wall_relief"),
    )
    right_wall_prism = RectangularPrism(
        size=notch_prism.size,
        transform=notch_prism.transform.rotate_around_axis(axis_2, radians(-angle_rad)),
        start_distance=notch_prism.start_distance,
        end_distance=extended_end_distance,
        label=CutCSGLabel("notch_wall_relief"),
    )

    return SolidUnion([notch_prism, left_wall_prism, right_wall_prism], label=label)

chop_butt_joint_shoulder_notch_relief_on_plane_aligned_timbers_2sided

chop_butt_joint_shoulder_notch_relief_on_plane_aligned_timbers_2sided(arrangement: ButtJointTimberArrangement, mortise_shoulder_distance_from_centerline_or_centerplane: Numeric, notch_angle: Optional[Numeric] = None) -> ShoulderReliefCSGGeometry | None

Like chop_butt_joint_shoulder_notch_relief_4sided, but restricted to PLANE-ALIGNED arrangements, where it produces a simpler notch: only 2 of the 4 walls flare via the dihedral-bisector construction; the other 2 don't flare at all -- they're pushed straight out, for the notch's ENTIRE depth, to whichever is FURTHER along the "joint normal axis" -- the shared normal of the two timbers' aligned long faces (arrangement.compute_normalized_timber_cross_product()) -- between the RECEIVING timber's own ROUGH edge and the BUTT timber's own ROUGH edge. This guarantees a full TRANSVERSE relief spanning the receiving timber's entire width on this axis (never a pocket that stops partway across it), and is safe specifically because plane-alignment guarantees the butt timber's faces in that axis are exactly PARALLEL to the receiving timber's own faces there -- there's no dihedral angle to bisect, so a straight-walled channel is the natural choice, unlike the 4-sided version's fully general per-wall flare.

Geometry, in outline (P = joint normal axis, Q = the butt timber's other cross-sectional axis, both perpendicular to n_depth = the shoulder plane's normal): - Along P: both the shoulder-plane cross-section (quad-1) and the far cross-section (quad-2) span the SAME fixed extent -- per side, the FURTHER of the receiving timber's own ROUGH half-size and the butt timber's own ROUGH half-size on that axis (plane-alignment guarantees P is EXACTLY -- not just approximately -- one of EACH timber's own width/height axes, the same shared axis are_timbers_plane_aligned identifies, so there's no oblique stretching to account for). Flat, unflared walls in this axis, for the notch's whole depth. - Along Q: quad-1 uses the butt timber's PERFECT cross-section as it actually crosses the shoulder plane (same as the 4-sided version's quad-1 corners) -- NOT simply the tenon's raw PTW half-size, which understates the footprint whenever the tenon's length axis isn't perpendicular to the shoulder plane within the (Q, n_depth) plane (e.g. any raking brace-style joint); quad-2 flares outward via the SAME per-wall dihedral-bisector construction as the 4-sided version, using the SIGNED dihedral angle between each of the butt timber's two Q-normal faces and the shoulder plane independently -- negated normals generally give supplementary (not equal) signed angles, so the two flared walls generally reach out by different amounts, same as any two non-opposite walls in the 4-sided version would. - The two flared (Q-direction) walls reach a common depth exactly as in the 4-sided case (the deeper of their two natural depths); the two flat (P-direction) walls are, by construction, already at a fixed extent for that whole depth, so no rescaling is needed for them.

Parameters:

Name Type Description Default
arrangement ButtJointTimberArrangement

butt joint arrangement; must be plane-aligned (raises via arrangement.check_plane_aligned() otherwise).

required
mortise_shoulder_distance_from_centerline_or_centerplane Numeric

same as the 4-sided version -- signed distance from the receiving timber's centerline to the shoulder plane, toward the butt timber.

required
notch_angle Optional[Numeric]

optional MINIMUM wall-relief angle (radians) for the 2 flared (Q-axis) walls, floored independently against each wall's own natural dihedral-bisector angle -- same "floor, not override" convention as notch_wall_min_relief_cut_angle elsewhere in this file. None (default) uses each wall's natural bisector angle only (the tightest safe notch). Has no effect on the 2 flat (P-axis) walls, which never flare.

None

Returns None when no notch is required -- see does_shoulder_plane_need_notching.

Source code in kumiki/joints/workshop/shavings/relief.py
def chop_butt_joint_shoulder_notch_relief_on_plane_aligned_timbers_2sided(
    arrangement: ButtJointTimberArrangement,
    mortise_shoulder_distance_from_centerline_or_centerplane: Numeric,
    notch_angle: Optional[Numeric] = None,
) -> ShoulderReliefCSGGeometry | None:
    """
    Like ``chop_butt_joint_shoulder_notch_relief_4sided``, but restricted to PLANE-ALIGNED
    arrangements, where it produces a simpler notch: only 2 of the 4 walls flare via the
    dihedral-bisector construction; the other 2 don't flare at all -- they're pushed straight
    out, for the notch's ENTIRE depth, to whichever is FURTHER along the "joint normal axis"
    -- the shared normal of the two timbers' aligned long faces
    (``arrangement.compute_normalized_timber_cross_product()``) -- between the RECEIVING
    timber's own ROUGH edge and the BUTT timber's own ROUGH edge. This guarantees a full
    TRANSVERSE relief spanning the receiving timber's entire width on this axis (never a
    pocket that stops partway across it), and is safe specifically because plane-alignment
    guarantees the butt timber's faces in that axis are exactly PARALLEL to the receiving
    timber's own faces there -- there's no dihedral angle to bisect, so a straight-walled
    channel is the natural choice, unlike the 4-sided version's fully general per-wall flare.

    Geometry, in outline (P = joint normal axis, Q = the butt timber's other cross-sectional
    axis, both perpendicular to n_depth = the shoulder plane's normal):
    - Along P: both the shoulder-plane cross-section (quad-1) and the far cross-section
      (quad-2) span the SAME fixed extent -- per side, the FURTHER of the receiving timber's
      own ROUGH half-size and the butt timber's own ROUGH half-size on that axis
      (plane-alignment guarantees P is EXACTLY -- not just approximately -- one of EACH
      timber's own width/height axes, the same shared axis ``are_timbers_plane_aligned``
      identifies, so there's no oblique stretching to account for). Flat, unflared walls in
      this axis, for the notch's whole depth.
    - Along Q: quad-1 uses the butt timber's PERFECT cross-section as it actually crosses
      the shoulder plane (same as the 4-sided version's quad-1 corners) -- NOT simply the
      tenon's raw PTW half-size, which understates the footprint whenever the tenon's length
      axis isn't perpendicular to the shoulder plane within the (Q, n_depth) plane (e.g. any
      raking brace-style joint); quad-2 flares outward via the SAME per-wall dihedral-bisector
      construction as the 4-sided version, using the SIGNED dihedral angle between each of
      the butt timber's two Q-normal faces and the shoulder plane independently -- negated
      normals generally give supplementary (not equal) signed angles, so the two flared
      walls generally reach out by different amounts, same as any two non-opposite walls in
      the 4-sided version would.
    - The two flared (Q-direction) walls reach a common depth exactly as in the 4-sided
      case (the deeper of their two natural depths); the two flat (P-direction) walls are,
      by construction, already at a fixed extent for that whole depth, so no rescaling is
      needed for them.

    Args:
        arrangement: butt joint arrangement; must be plane-aligned (raises via
            ``arrangement.check_plane_aligned()`` otherwise).
        mortise_shoulder_distance_from_centerline_or_centerplane: same as the 4-sided
            version -- signed distance from the receiving timber's centerline to the
            shoulder plane, toward the butt timber.
        notch_angle: optional MINIMUM wall-relief angle (radians) for the 2 flared (Q-axis)
            walls, floored independently against each wall's own natural dihedral-bisector
            angle -- same "floor, not override" convention as
            ``notch_wall_min_relief_cut_angle`` elsewhere in this file. None (default) uses
            each wall's natural bisector angle only (the tightest safe notch). Has no effect
            on the 2 flat (P-axis) walls, which never flare.

    Returns ``None`` when no notch is required -- see ``does_shoulder_plane_need_notching``.
    """
    error = arrangement.check_plane_aligned()
    assert error is None, error

    if not does_shoulder_plane_need_notching(
        arrangement,
        mortise_shoulder_distance_from_centerline_or_centerplane,
    ):
        return None

    receiving_timber = arrangement.receiving_timber
    butt_timber = arrangement.butt_timber

    from kumiki.joints.workshop.shavings.build_a_butt import (
        locate_mortise_timber_shoulder_plane_from_centerline_towards_tenon_timber,
    )
    shoulder_plane_towards_tenon = locate_mortise_timber_shoulder_plane_from_centerline_towards_tenon_timber(
        arrangement, mortise_shoulder_distance_from_centerline_or_centerplane,
    )
    n_depth = safe_normalize_vector(shoulder_plane_towards_tenon.normal)
    shoulder_plane = Plane(normal=n_depth, point=shoulder_plane_towards_tenon.point)

    butt_length_dir = safe_normalize_vector(butt_timber.get_length_direction_global())
    joint_center_global = _intersect_line_with_plane(butt_timber.get_bottom_position_global(), butt_length_dir, shoulder_plane)

    # "Joint normal axis" P: the shared normal of the two timbers' aligned long faces (the
    # plane-alignment plane). By construction this is perpendicular to n_depth -- project
    # for numerical robustness rather than assuming exact orthogonality.
    joint_normal_axis_raw = safe_normalize_vector(arrangement.compute_normalized_timber_cross_product())
    joint_normal_axis = safe_normalize_vector(
        joint_normal_axis_raw - n_depth * safe_dot_product(joint_normal_axis_raw, n_depth)
    )

    # ------------------------------------------------------------------
    # "length": identical to chop_butt_joint_shoulder_notch_relief_4sided -- how far, in
    # the Q (flared) direction, the frustum needs to reach to clear the imperfect
    # (beyond-perfect) material of both timbers, given how obliquely the butt timber
    # approaches the shoulder plane.
    # ------------------------------------------------------------------
    sin_butt_angle = Min(Abs(safe_dot_product(butt_length_dir, n_depth)), scalar(1))
    assert not safe_zero_test(sin_butt_angle), "butt timber's length axis lies within the shoulder plane"
    cos_butt_angle = sqrt(scalar(1) - sin_butt_angle ** 2)

    shoulder_normal_in_receiving_local = safe_transform_vector(receiving_timber.orientation.matrix.T, n_depth)
    perfect_support_distance = get_perfect_support_distance_from_centerline(
        receiving_timber,
        create_v2(shoulder_normal_in_receiving_local[0], shoulder_normal_in_receiving_local[1]),
    )
    butt_rough_size = butt_timber.get_rough_size()
    rough_size_term = (
        scalar(0) if safe_zero_test(cos_butt_angle)
        else Max(butt_rough_size[0], butt_rough_size[1]) / cos_butt_angle
    )
    imperfect_clearance_length = Max(perfect_support_distance / sin_butt_angle, rough_size_term)

    # ------------------------------------------------------------------
    # Which of the butt timber's own cross-sectional axes (width or height) IS the joint
    # normal axis P (plane-alignment guarantees exactly one is). The OTHER axis is Q, the
    # one that flares.
    # ------------------------------------------------------------------
    butt_width_dir = butt_timber.get_width_direction_global()
    butt_height_dir = butt_timber.get_height_direction_global()
    width_is_joint_normal_axis = safe_compare(
        Abs(safe_dot_product(joint_normal_axis, butt_width_dir)), scalar(1, 2), Comparison.GT
    )

    if width_is_joint_normal_axis:
        q_half_size = butt_timber.size[1] / scalar(2)
        q_face_normal = butt_height_dir
    else:
        q_half_size = butt_timber.size[0] / scalar(2)
        q_face_normal = butt_width_dir

    # Q's two faces have negated normals (+q_face_normal / -q_face_normal), so -- per the
    # note in chop_butt_joint_shoulder_notch_relief_4sided's per-edge bisector -- their
    # SIGNED dihedral angles to the shoulder plane are generally supplementary, not equal:
    # unlike an earlier version of this function, the two flared walls are computed (and, in
    # general, reach out) independently, not from one shared value.
    def _tan_half(face_normal: V3) -> Numeric:
        cos_dihedral = Max(Min(safe_dot_product(face_normal, n_depth), scalar(1)), scalar(-1))
        assert not safe_zero_test(scalar(1) + cos_dihedral), (
            "butt timber's Q-axis face directly faces back through the shoulder plane"
        )
        sin_dihedral = sqrt(scalar(1) - cos_dihedral ** 2)
        tan_half = sin_dihedral / (scalar(1) + cos_dihedral)
        assert not safe_zero_test(tan_half), "butt timber's Q-axis face is parallel to the shoulder plane"
        if notch_angle is not None:
            tan_half = Max(tan_half, tan(notch_angle))
        return tan_half

    # Which physical side of the loft's own +Q/-Q axis q_face_normal actually points toward:
    # q_face_normal generally has some component along n_depth (that's what makes it flare),
    # so it isn't simply +-y_ref_q -- project out the n_depth component before comparing.
    y_ref_q = safe_normalize_vector(cross_product(n_depth, joint_normal_axis))
    q_face_normal_inplane = q_face_normal - n_depth * safe_dot_product(q_face_normal, n_depth)
    q_face_normal_is_pos_side = safe_compare(
        safe_dot_product(q_face_normal_inplane, y_ref_q), 0, Comparison.GT
    )

    tan_half_at_q_face_normal = _tan_half(q_face_normal)
    tan_half_at_negated_q_face_normal = _tan_half(-q_face_normal)
    tan_half_pos, tan_half_neg = (
        (tan_half_at_q_face_normal, tan_half_at_negated_q_face_normal)
        if q_face_normal_is_pos_side
        else (tan_half_at_negated_q_face_normal, tan_half_at_q_face_normal)
    )

    # reach = depth * tan_half along each wall's own bisector (see the 4-sided version's
    # per-edge bisector note); loft_depth is the deeper of the two natural depths (reach ==
    # imperfect_clearance_length), so each flared wall gets AT LEAST that much reach --
    # exactly that much for whichever wall defines loft_depth, more for the other.
    loft_depth = Max(imperfect_clearance_length / tan_half_pos, imperfect_clearance_length / tan_half_neg)
    q_reach_pos = loft_depth * tan_half_pos
    q_reach_neg = loft_depth * tan_half_neg

    # ------------------------------------------------------------------
    # Q-axis quad-1 half-extent: NOT simply q_half_size (the tenon's raw PTW half-size) --
    # like the 4-sided version's quad-1 corners, this must be the PTW's actual footprint
    # where its edge (running along the tenon's own length axis, offset by q_half_size in Q)
    # crosses the shoulder plane, which is stretched away from the raw half-size whenever the
    # tenon's length axis isn't perpendicular to the shoulder plane within the (Q, n_depth)
    # plane -- e.g. any raking (not just tilting-sideways) brace-style joint. Using the raw
    # half-size here draws quad-1 from where the tenon's CENTERLINE-ish cross-section sits,
    # not from where its PTW boundary actually meets the shoulder plane.
    # ------------------------------------------------------------------
    q_local_axis_index = 1 if width_is_joint_normal_axis else 0

    def _q_stretch_at_shoulder(q_local_value: Numeric) -> Numeric:
        coords = [scalar(0), scalar(0)]
        coords[q_local_axis_index] = q_local_value
        base_global = butt_timber.transform.local_to_global(create_v3(coords[0], coords[1], scalar(0)))
        shoulder_point_global = _intersect_line_with_plane(base_global, butt_length_dir, shoulder_plane)
        return Abs(safe_dot_product(shoulder_point_global - joint_center_global, y_ref_q))

    q_bottom_extent_at_q_face_normal = _q_stretch_at_shoulder(q_half_size)
    q_bottom_extent_at_negated_q_face_normal = _q_stretch_at_shoulder(-q_half_size)
    q_bottom_extent_pos, q_bottom_extent_neg = (
        (q_bottom_extent_at_q_face_normal, q_bottom_extent_at_negated_q_face_normal)
        if q_face_normal_is_pos_side
        else (q_bottom_extent_at_negated_q_face_normal, q_bottom_extent_at_q_face_normal)
    )

    # ------------------------------------------------------------------
    # P-direction boundaries: NOT a tight pocket sized to either timber's PERFECT extent --
    # the flat walls are pushed all the way out to whichever is FURTHER, per side, between
    # the RECEIVING timber's own ROUGH edge and the BUTT timber's own ROUGH edge (both
    # measured from joint_center_global, along P). This guarantees the notch is a full
    # TRANSVERSE relief spanning the receiving timber's entire width on this axis (and, if
    # the butt timber's rough stock happens to poke out even further, that too) -- never a
    # shorter pocket that stops partway across. Plane-alignment guarantees P is exactly (not
    # just approximately) one of EACH timber's own width/height axes (the same shared axis
    # identified by are_timbers_plane_aligned -- see the module-level proof in this
    # function's docstring), so there's no oblique stretching to account for on this axis.
    # ------------------------------------------------------------------

    def p_local(point: V3) -> Numeric:
        return safe_dot_product(joint_normal_axis, point - joint_center_global)

    def _rough_p_extent(timber: TimberLike, direction: Direction3D) -> Numeric:
        face = timber.get_closest_oriented_long_face_from_global_direction(direction).to.face()
        return timber.get_half_rough_size_in_face_normal_axis(face)

    receiving_centerline_p = p_local(receiving_timber.transform.position)
    butt_centerline_p = p_local(butt_timber.transform.position)

    p_boundary_pos = Max(
        receiving_centerline_p + _rough_p_extent(receiving_timber, joint_normal_axis),
        butt_centerline_p + _rough_p_extent(butt_timber, joint_normal_axis),
    )
    p_boundary_neg = Max(
        -receiving_centerline_p + _rough_p_extent(receiving_timber, -joint_normal_axis),
        -butt_centerline_p + _rough_p_extent(butt_timber, -joint_normal_axis),
    )

    # ------------------------------------------------------------------
    # Both cross-sections: flat (unflared) at the P boundaries for their entire depth;
    # flared in Q, from the tenon's PTW-at-the-shoulder extent (quad-1) out to
    # q_bottom_extent + q_reach (quad-2).
    # ------------------------------------------------------------------
    loft_transform = Transform(position=joint_center_global, orientation=Orientation.from_z_and_x(n_depth, joint_normal_axis))

    def make_quad(q_extent_pos: Numeric, q_extent_neg: Numeric) -> Profile:
        return [
            create_v2(p_boundary_pos, q_extent_pos),
            create_v2(-p_boundary_neg, q_extent_pos),
            create_v2(-p_boundary_neg, -q_extent_neg),
            create_v2(p_boundary_pos, -q_extent_neg),
        ]

    bottom_points = make_quad(q_bottom_extent_pos, q_bottom_extent_neg)
    top_points = make_quad(q_bottom_extent_pos + q_reach_pos, q_bottom_extent_neg + q_reach_neg)

    signed_area = sum(
        bottom_points[i][0] * bottom_points[(i + 1) % 4][1] - bottom_points[(i + 1) % 4][0] * bottom_points[i][1]
        for i in range(4)
    )
    if safe_compare(signed_area, 0, Comparison.LT):
        bottom_points = list(reversed(bottom_points))
        top_points = list(reversed(top_points))

    loft_global = ConvexPolygonSimpleLoft(
        bottom_points=bottom_points,
        top_points=top_points,
        start_distance=scalar(0),
        end_distance=loft_depth,
        transform=loft_transform,
        label=CutCSGLabel("shoulder_notch_relief"),
    )

    receiving_timber_notch_negative_csg_local = adopt_csg(
        None, receiving_timber.transform, loft_global
    )

    # ------------------------------------------------------------------
    # Butting timber relief: identical construction to
    # chop_butt_joint_shoulder_notch_relief_4sided -- see the KNOWN comment there.
    # ------------------------------------------------------------------
    near_plane_global = HalfSpace(
        normal=n_depth,
        offset=safe_dot_product(n_depth, shoulder_plane.point),
        label=CutCSGLabel("shoulder"),
    )
    far_plane_point_global = shoulder_plane.point + n_depth * loft_depth
    far_plane_global = HalfSpace(
        normal=n_depth,
        offset=safe_dot_product(n_depth, far_plane_point_global),
        label=CutCSGLabel("relief_far_bound"),
    )
    loft_in_butt_local = adopt_csg(None, butt_timber.transform, loft_global)
    butting_timber_relief_negative_csg_local = Difference(
        base=adopt_csg(None, butt_timber.transform, near_plane_global),
        subtract=[loft_in_butt_local, adopt_csg(None, butt_timber.transform, far_plane_global)],
        label=CutCSGLabel("shoulder_relief"),
    )

    return ShoulderReliefCSGGeometry(
        receiving_timber_notch_negative_CSG=receiving_timber_notch_negative_csg_local,
        butting_timber_relief_negative_CSG=butting_timber_relief_negative_csg_local,
    )

chop_butt_joint_shoulder_notch_relief_4sided

chop_butt_joint_shoulder_notch_relief_4sided(arrangement: ButtJointTimberArrangement, mortise_shoulder_distance_from_centerline_or_centerplane: Numeric) -> ShoulderReliefCSGGeometry | None

Compute the shoulder notch on the receiving timber AND the matching relief cut on the butting timber, for arrangements where the butt timber may approach the shoulder at a compound angle (not necessarily plane-aligned with the receiving timber).

The notch is a single 4-sided frustum (a ConvexPolygonSimpleLoft) rather than a union of a straight prism plus 2 tilted relief prisms, so all 4 walls can relieve independently based on how each of the butt timber's 4 long faces actually meets the shoulder plane.

Geometry, in outline: - quad-1 is the butt timber's PERFECT cross-section sliced by the shoulder plane (an oblique quadrilateral in general, since the butt timber's length axis need not be perpendicular to the shoulder plane). - Each of quad-1's 4 edges lies exactly on the line where one of the butt timber's long face planes crosses the shoulder plane (by construction: both of that edge's corners sit on that face). For each edge, the relieved wall direction bisects the dihedral angle between that face and the shoulder plane -- this stays on the safe (non-colliding) side of the face for any distance travelled along it, so a wall can safely be extended further than its own bisector's "natural" depth without becoming unsafe. - Each edge's own bisector reaches a different depth for the same in-plane reach (since the 4 dihedral angles generally differ), which would make quad-2 non-planar. Instead we take the deepest of the 4 (the "maximal loft distance") as a common depth, and rescale each edge's in-plane offset to match -- still safely on that edge's own bisector, just further out -- so quad-2 stays flat and parallel to quad-1 and every wall gets at least as much depth-clearance as it individually needs.

Returns None when no notch is required (shoulder sits at or past the receiving timber's rough entry face) -- see does_shoulder_plane_need_notching.

Source code in kumiki/joints/workshop/shavings/relief.py
def chop_butt_joint_shoulder_notch_relief_4sided(
    arrangement: ButtJointTimberArrangement,
    mortise_shoulder_distance_from_centerline_or_centerplane: Numeric,
) -> ShoulderReliefCSGGeometry | None:
    """
    Compute the shoulder notch on the receiving timber AND the matching relief cut on the
    butting timber, for arrangements where the butt timber may approach the shoulder at a
    compound angle (not necessarily plane-aligned with the receiving timber).

    The notch is a single 4-sided frustum
    (a ``ConvexPolygonSimpleLoft``) rather than a union of a straight prism plus 2 tilted
    relief prisms, so all 4 walls can relieve independently based on how each of the butt
    timber's 4 long faces actually meets the shoulder plane.

    Geometry, in outline:
    - quad-1 is the butt timber's PERFECT cross-section sliced by the shoulder plane (an
      oblique quadrilateral in general, since the butt timber's length axis need not be
      perpendicular to the shoulder plane).
    - Each of quad-1's 4 edges lies exactly on the line where one of the butt timber's long
      face planes crosses the shoulder plane (by construction: both of that edge's corners
      sit on that face). For each edge, the relieved wall direction bisects the dihedral
      angle between that face and the shoulder plane -- this stays on the safe (non-colliding)
      side of the face for any distance travelled along it, so a wall can safely be extended
      further than its own bisector's "natural" depth without becoming unsafe.
    - Each edge's own bisector reaches a different depth for the same in-plane reach (since
      the 4 dihedral angles generally differ), which would make quad-2 non-planar. Instead we
      take the deepest of the 4 (the "maximal loft distance") as a common depth, and rescale
      each edge's in-plane offset to match -- still safely on that edge's own bisector, just
      further out -- so quad-2 stays flat and parallel to quad-1 and every wall gets at least
      as much depth-clearance as it individually needs.

    Returns ``None`` when no notch is required (shoulder sits at or past the receiving
    timber's rough entry face) -- see ``does_shoulder_plane_need_notching``.
    """
    if not does_shoulder_plane_need_notching(
        arrangement,
        mortise_shoulder_distance_from_centerline_or_centerplane,
    ):
        return None

    receiving_timber = arrangement.receiving_timber
    butt_timber = arrangement.butt_timber

    # Shoulder plane, with normal pointing TOWARD the tenon (i.e. the direction the
    # notch/frustum extends into the receiving timber -- the material between the inset
    # shoulder and the receiving timber's own entry face). The located helper already
    # uses this convention directly.
    from kumiki.joints.workshop.shavings.build_a_butt import (
        locate_mortise_timber_shoulder_plane_from_centerline_towards_tenon_timber,
    )
    shoulder_plane_towards_tenon = locate_mortise_timber_shoulder_plane_from_centerline_towards_tenon_timber(
        arrangement, mortise_shoulder_distance_from_centerline_or_centerplane,
    )
    n_depth = safe_normalize_vector(shoulder_plane_towards_tenon.normal)
    shoulder_plane = Plane(normal=n_depth, point=shoulder_plane_towards_tenon.point)

    butt_length_dir = safe_normalize_vector(butt_timber.get_length_direction_global())

    # ------------------------------------------------------------------
    # "length": how far, in-plane, the frustum needs to reach to clear the imperfect
    # (beyond-perfect) material of both timbers, given how obliquely the butt timber
    # approaches the shoulder plane.
    # ------------------------------------------------------------------
    sin_butt_angle = Min(Abs(safe_dot_product(butt_length_dir, n_depth)), scalar(1))
    assert not safe_zero_test(sin_butt_angle), "butt timber's length axis lies within the shoulder plane"
    cos_butt_angle = sqrt(scalar(1) - sin_butt_angle ** 2)

    shoulder_normal_in_receiving_local = safe_transform_vector(receiving_timber.orientation.matrix.T, n_depth)
    perfect_support_distance = get_perfect_support_distance_from_centerline(
        receiving_timber,
        create_v2(shoulder_normal_in_receiving_local[0], shoulder_normal_in_receiving_local[1]),
    )
    butt_rough_size = butt_timber.get_rough_size()
    # cos_butt_angle is 0 for a straight (non-raking) approach -- the common case, not a
    # degenerate one -- in which case the rough-size term doesn't apply; only the receiving
    # timber's own perfect-support term governs.
    rough_size_term = (
        scalar(0) if safe_zero_test(cos_butt_angle)
        else Max(butt_rough_size[0], butt_rough_size[1]) / cos_butt_angle
    )
    imperfect_clearance_length = Max(perfect_support_distance / sin_butt_angle, rough_size_term)

    # ------------------------------------------------------------------
    # quad-1: the butt timber's perfect cross-section, sliced by the shoulder plane.
    # Each corner is tagged with its (sign_x, sign_y) in the butt timber's own local
    # frame so the adjacent long face for each edge can be recovered after quad-1 is
    # (possibly) re-wound below -- independent of corner order.
    # ------------------------------------------------------------------
    half_w = butt_timber.size[0] / scalar(2)
    half_h = butt_timber.size[1] / scalar(2)
    corner_signs = [(1, 1), (-1, 1), (-1, -1), (1, -1)]  # RIGHT_FRONT, FRONT_LEFT, LEFT_BACK, BACK_RIGHT

    butt_width_dir = butt_timber.get_width_direction_global()
    if safe_compare(Abs(safe_dot_product(n_depth, butt_width_dir)) - scalar(9, 10), 0, Comparison.LT):
        x_ref_reference = butt_width_dir
    else:
        x_ref_reference = butt_timber.get_height_direction_global()
    x_ref = safe_normalize_vector(x_ref_reference - n_depth * safe_dot_product(x_ref_reference, n_depth))

    joint_center_global = _intersect_line_with_plane(butt_timber.get_bottom_position_global(), butt_length_dir, shoulder_plane)
    loft_transform = Transform(position=joint_center_global, orientation=Orientation.from_z_and_x(n_depth, x_ref))

    corner_data = []  # (local_2d_point, sign_x, sign_y)
    for sign_x, sign_y in corner_signs:
        base_global = butt_timber.transform.local_to_global(create_v3(sign_x * half_w, sign_y * half_h, scalar(0)))
        corner_global = _intersect_line_with_plane(base_global, butt_length_dir, shoulder_plane)
        corner_local_3d = loft_transform.global_to_local(corner_global)
        corner_data.append((create_v2(corner_local_3d[0], corner_local_3d[1]), sign_x, sign_y))

    signed_area = sum(
        corner_data[i][0][0] * corner_data[(i + 1) % 4][0][1] - corner_data[(i + 1) % 4][0][0] * corner_data[i][0][1]
        for i in range(4)
    )
    if safe_compare(signed_area, 0, Comparison.LT):
        corner_data.reverse()

    bottom_points = [c[0] for c in corner_data]

    def adjacent_face(i: int) -> TimberFace:
        _, sign_x_a, sign_y_a = corner_data[i]
        _, sign_x_b, sign_y_b = corner_data[(i + 1) % 4]
        if sign_x_a == sign_x_b:
            return TimberFace.RIGHT if sign_x_a > 0 else TimberFace.LEFT
        return TimberFace.FRONT if sign_y_a > 0 else TimberFace.BACK

    # ------------------------------------------------------------------
    # Per-edge bisector: for edge i, the bisector between its adjacent long face's own
    # OUTWARD normal and n_depth is normalize(face_normal + n_depth). Decomposing a move
    # of arc-length T along that bisector into its n_depth component (depth) and its
    # in-plane component (reach) gives depth = T*cos(dihedral/2), reach = T*sin(dihedral/2)
    # -- so reach = depth * tan(dihedral/2), i.e. depth = reach / tan(dihedral/2). The
    # dihedral used here is the SIGNED angle between face_normal and n_depth (no Abs): the
    # bisector direction depends on which way face_normal actually points relative to
    # n_depth, not just how far off-parallel it is. Concretely, opposite faces of the
    # timber (e.g. RIGHT vs LEFT) have negated face_normal, so their SIGNED dihedral is
    # generally supplementary (e.g. 45 vs 135 degrees), not equal -- folding that through
    # Abs() bisects the wrong one of the two wedges the two planes form for whichever face
    # has a negative dot product, sending that wall's bisector back toward the timber's own
    # material instead of away from it.
    #
    # loft_depth is the deepest of the 4 natural depths (reach=imperfect_clearance_length
    # for each wall, taken along its own bisector); every edge's in-plane reach is then
    # rescaled to that common depth, staying on its own (safe) bisector ray.
    # ------------------------------------------------------------------
    tan_half_dihedrals = []
    for i in range(4):
        face_normal = butt_timber.get_face_direction_global(adjacent_face(i))
        cos_dihedral = Max(Min(safe_dot_product(face_normal, n_depth), scalar(1)), scalar(-1))
        assert not safe_zero_test(scalar(1) + cos_dihedral), (
            f"{adjacent_face(i)} face of butt timber directly faces back through the shoulder plane"
        )
        sin_dihedral = sqrt(scalar(1) - cos_dihedral ** 2)
        tan_half = sin_dihedral / (scalar(1) + cos_dihedral)
        assert not safe_zero_test(tan_half), f"{adjacent_face(i)} face of butt timber is parallel to the shoulder plane"
        tan_half_dihedrals.append(tan_half)

    loft_depth = Max(*[imperfect_clearance_length / t for t in tan_half_dihedrals])
    edge_reaches = [loft_depth * t for t in tan_half_dihedrals]

    centroid = sum(bottom_points, Matrix([scalar(0), scalar(0)])) / scalar(4)
    offset_lines = []
    for i in range(4):
        p, q = bottom_points[i], bottom_points[(i + 1) % 4]
        edge_dir = safe_normalize_vector(q - p)
        outward_normal = create_v2(edge_dir[1], -edge_dir[0])
        midpoint = (p + q) / scalar(2)
        if safe_compare(safe_dot_product(outward_normal, midpoint - centroid), 0, Comparison.LT):
            outward_normal = -outward_normal
        offset_point = p + outward_normal * edge_reaches[i]
        offset_lines.append((offset_point, edge_dir))

    top_points = [
        _intersect_2d_lines(*offset_lines[(i - 1) % 4], *offset_lines[i])
        for i in range(4)
    ]

    loft_global = ConvexPolygonSimpleLoft(
        bottom_points=bottom_points,
        top_points=top_points,
        start_distance=scalar(0),
        end_distance=loft_depth,
        transform=loft_transform,
        label=CutCSGLabel("shoulder_notch_relief"),
    )

    receiving_timber_notch_negative_csg_local = adopt_csg(
        None, receiving_timber.transform, loft_global
    )

    # ------------------------------------------------------------------
    # Butting timber: relieve rough (beyond-perfect) material that's WITHIN the frustum's
    # own depth range (from the shoulder to shoulder+loft_depth, in the +n_depth/
    # toward-entry-face direction -- the same span the loft itself occupies) AND outside
    # the frustum's own clearance envelope -- i.e. material that would collide with the
    # receiving timber's un-notched wood. Material outside that depth range must NOT be
    # touched: behind the shoulder is the tenon's own collar/tongue area (handled by the
    # dedicated shoulder cut elsewhere, not here), and beyond the frustum's far end is
    # just the timber's own untouched bulk running back to its far end.
    #
    # near/far are plain (unbounded, cross-section-agnostic) half-spaces, so this CSG is
    # unbounded in the directions perpendicular to n_depth -- meshing it standalone (e.g.
    # triangulate_cutcsg called directly on this piece) renders as a huge box, since there's
    # nothing here to bound its footprint. That's expected: like any negative_csg, it's only
    # meaningful once intersected with the butt timber's own actual solid body, which happens
    # implicitly wherever this gets applied as a Cutting.
    # ------------------------------------------------------------------
    near_plane_global = HalfSpace(
        normal=n_depth,
        offset=safe_dot_product(n_depth, shoulder_plane.point),
        label=CutCSGLabel("shoulder"),
    )
    far_plane_point_global = shoulder_plane.point + n_depth * loft_depth
    far_plane_global = HalfSpace(
        normal=n_depth,
        offset=safe_dot_product(n_depth, far_plane_point_global),
        label=CutCSGLabel("relief_far_bound"),
    )
    loft_in_butt_local = adopt_csg(None, butt_timber.transform, loft_global)
    butting_timber_relief_negative_csg_local = Difference(
        base=adopt_csg(None, butt_timber.transform, near_plane_global),
        subtract=[loft_in_butt_local, adopt_csg(None, butt_timber.transform, far_plane_global)],
        label=CutCSGLabel("shoulder_relief"),
    )

    return ShoulderReliefCSGGeometry(
        receiving_timber_notch_negative_CSG=receiving_timber_notch_negative_csg_local,
        butting_timber_relief_negative_CSG=butting_timber_relief_negative_csg_local,
    )

chop_scribe_relief

chop_scribe_relief(timber_to_be_scribed_cutting: Cutting, timber_to_be_cut_cutting: Cutting, scribe_relief_label: CutCSGLabel = CutCSGLabel('scribe_relief'), scribe_hollow_label: CutCSGLabel = CutCSGLabel('scribe_hollow_relief')) -> tuple[CutCSG, CutCSG]

scribes timber_to_be_scribed onto timber_to_be_cut such that the entirety of timber_to_be_scribed is cut out of timber_to_be_cut excluding the perfect timber within portion of timber_to_be_cut

Both timbers are given as their (already cut) Cutting, each of which carries its own timber via Cutting.timber -- callers never need to pass the bare timbers separately.

timber_to_be_scribed_cutting is the cutting already computed for timber_to_be_scribed elsewhere in the current joint (e.g. the tenon's shoulder cut, plus any end cut) -- material already removed there is excluded from what gets scribed onto timber_to_be_cut, otherwise the relief would be based on timber_to_be_scribed's full, uncut extent (its entire length) rather than just the part of it that actually survives near the joint.

returns a pair of CSG geometries, the first to be removed from timber_to_be_scribed and the second to be removed from timber_to_be_cut, both expressed in their respective local frames

Source code in kumiki/joints/workshop/shavings/relief.py
def chop_scribe_relief(
    timber_to_be_scribed_cutting: Cutting,
    timber_to_be_cut_cutting: Cutting,
    scribe_relief_label: CutCSGLabel = CutCSGLabel("scribe_relief"),
    scribe_hollow_label: CutCSGLabel = CutCSGLabel("scribe_hollow_relief"),
) -> tuple[CutCSG, CutCSG]:
    """
    scribes timber_to_be_scribed onto timber_to_be_cut such that the entirety of timber_to_be_scribed is cut out of timber_to_be_cut excluding the perfect timber within portion of timber_to_be_cut

    Both timbers are given as their (already cut) ``Cutting``, each of which carries
    its own timber via ``Cutting.timber`` -- callers never need to pass the bare
    timbers separately.

    timber_to_be_scribed_cutting is the cutting already computed for timber_to_be_scribed
    elsewhere in the current joint (e.g. the tenon's shoulder cut, plus any end cut) --
    material already removed there is excluded from what gets scribed onto timber_to_be_cut,
    otherwise the relief would be based on timber_to_be_scribed's full, uncut extent (its
    entire length) rather than just the part of it that actually survives near the joint.

    returns a pair of CSG geometries, the first to be removed from timber_to_be_scribed and the second to be removed from timber_to_be_cut, both expressed in their respective local frames
    """
    timber_to_be_scribed = timber_to_be_scribed_cutting.timber
    timber_to_be_cut = timber_to_be_cut_cutting.timber

    timber_to_be_scribed_actual_csg_global = adopt_csg(
        timber_to_be_scribed.transform,
        None,
        timber_to_be_scribed.get_extended_actual_csg_local(extend_bot=False, extend_top=False),
    )

    timber_to_be_scribed_imperfect_fringe_csg_global = adopt_csg(
        timber_to_be_scribed.transform,
        None,
        timber_to_be_scribed.get_imperfect_fringe_csg_local(),
    )

    scribed_own_cuts_local = timber_to_be_scribed_cutting.get_negative_csg_local()

    timber_to_be_cut_perfect_csg_global = adopt_csg(
        timber_to_be_cut.transform,
        None,
        timber_to_be_cut.get_perfect_timber_within_csg_local(),
    )

    # seems to create triangulation artifacts when used on circular timbers with trimesh right now :(
    scribed_relief_global = Intersection(
        left=timber_to_be_scribed_imperfect_fringe_csg_global,
        right=timber_to_be_cut_perfect_csg_global,
        label=scribe_relief_label,
    )

    # What actually remains of timber_to_be_scribed's full extent (perfect core
    # AND imperfect fringe alike) near the joint, after its own cuts (shoulder,
    # end cut, etc.) are accounted for -- per this function's contract, the
    # ENTIRETY of timber_to_be_scribed needs a matching hollow in
    # timber_to_be_cut (excluding timber_to_be_cut's own perfect-within, which
    # is handled by the subtraction below). Using only the imperfect fringe
    # here would leave timber_to_be_cut's imperfect material un-cut wherever it
    # overlaps timber_to_be_scribed's perfect-within region.
    # A cutting that removes nothing leaves the scribed timber as it is.
    timber_to_be_scribed_actual_after_own_cuts_global = (
        timber_to_be_scribed_actual_csg_global
        if scribed_own_cuts_local is None
        else Difference(
            base=timber_to_be_scribed_actual_csg_global,
            subtract=[adopt_csg(
                timber_to_be_scribed.transform,
                None,
                scribed_own_cuts_local,
            )],
            label=CutCSGLabel("scribed_remainder"),
        )
    )

    cut_relief_global = Difference(
        base=timber_to_be_scribed_actual_after_own_cuts_global,
        subtract=[timber_to_be_cut_perfect_csg_global],
        label=scribe_hollow_label,
    )


    scribed_relief_in_scribed_local = adopt_csg(
        None, timber_to_be_scribed.transform, scribed_relief_global
    )
    cut_relief_in_cut_local = adopt_csg(
        None, timber_to_be_cut.transform, cut_relief_global
    )

    return scribed_relief_in_scribed_local, cut_relief_in_cut_local

chop_scribe_relief_and_apply

chop_scribe_relief_and_apply(timber_to_be_scribed_cutting: Cutting, timber_to_be_cut_cutting: Cutting) -> tuple[Cutting, Cutting]

Apply scribe relief cuts from chop_scribe_relief to the given cuttings, unioning the new relief CSGs into each cutting's existing negative_csg.

Both timbers are given as their (already cut) Cutting, each of which carries its own timber via Cutting.timber -- callers never need to pass the bare timbers separately.

Returns (updated_cut_cutting, updated_scribed_cutting) (matching the order of the early-return path when both timbers are perfect).

Source code in kumiki/joints/workshop/shavings/relief.py
def chop_scribe_relief_and_apply(
    timber_to_be_scribed_cutting: Cutting,
    timber_to_be_cut_cutting: Cutting,
) -> tuple[Cutting, Cutting]:
    """
    Apply scribe relief cuts from ``chop_scribe_relief`` to the given cuttings, unioning the
    new relief CSGs into each cutting's existing ``negative_csg``.

    Both timbers are given as their (already cut) ``Cutting``, each of which carries
    its own timber via ``Cutting.timber`` -- callers never need to pass the bare
    timbers separately.

    Returns ``(updated_cut_cutting, updated_scribed_cutting)`` (matching the order of
    the early-return path when both timbers are perfect).
    """
    timber_to_be_scribed = timber_to_be_scribed_cutting.timber
    timber_to_be_cut = timber_to_be_cut_cutting.timber

    if timber_to_be_scribed.is_perfect_timber() and timber_to_be_cut.is_perfect_timber():
        return timber_to_be_cut_cutting, timber_to_be_scribed_cutting

    scribed_relief_csg_local, cut_relief_csg_local = chop_scribe_relief(
        timber_to_be_scribed_cutting=timber_to_be_scribed_cutting,
        timber_to_be_cut_cutting=timber_to_be_cut_cutting,
    )

    def _union_into(existing: Optional[CutCSG], new: CutCSG, label: CutCSGLabel) -> CutCSG:
        # Only a cutting that already removes something grows a node here; when
        # it removes nothing the relief is the whole cut and keeps its own name.
        if existing is None:
            return new
        return SolidUnion([existing, new], label=label)

    updated_scribed_cutting = replace(
        timber_to_be_scribed_cutting,
        negative_csg=_union_into(
            timber_to_be_scribed_cutting.negative_csg,
            scribed_relief_csg_local,
            CutCSGLabel("scribe_relief_cut"),
        ),
    )
    updated_cut_cutting = replace(
        timber_to_be_cut_cutting,
        negative_csg=_union_into(
            timber_to_be_cut_cutting.negative_csg,
            cut_relief_csg_local,
            CutCSGLabel("scribe_hollow_relief_cut"),
        ),
    )

    return updated_cut_cutting, updated_scribed_cutting

chop_scribe_relief_and_apply_for_butt_joint_arrangement

chop_scribe_relief_and_apply_for_butt_joint_arrangement(relief: ButtJointScribeReliefConfig, butt_cut: Cutting, receiving_cut: Cutting) -> tuple[Cutting, Cutting]

Helper shared by the butt-joint cutting functions: apply scribe relief between the butt and receiving timbers, honoring relief.timber_to_be_scribed to decide which timber is scribed onto the other.

relief is required here -- callers are responsible for skipping this call entirely when relief isn't configured (e.g. it's None).

Source code in kumiki/joints/workshop/shavings/relief.py
def chop_scribe_relief_and_apply_for_butt_joint_arrangement(
    relief: ButtJointScribeReliefConfig,
    butt_cut: Cutting,
    receiving_cut: Cutting,
) -> tuple[Cutting, Cutting]:
    """
    Helper shared by the butt-joint cutting functions: apply scribe relief
    between the butt and receiving timbers, honoring
    ``relief.timber_to_be_scribed`` to decide which timber is scribed onto the
    other.

    ``relief`` is required here -- callers are responsible for skipping this
    call entirely when relief isn't configured (e.g. it's None).
    """
    if relief.timber_to_be_scribed == ArrangementNames.butt_timber:
        scribed_cutting, cut_cutting = butt_cut, receiving_cut
    elif relief.timber_to_be_scribed == ArrangementNames.receiving_timber:
        scribed_cutting, cut_cutting = receiving_cut, butt_cut
    else:
        raise AssertionError(
            f"Unsupported butt-joint relief target: {relief.timber_to_be_scribed}"
        )

    updated_cut_cutting, updated_scribed_cutting = chop_scribe_relief_and_apply(
        timber_to_be_scribed_cutting=scribed_cutting,
        timber_to_be_cut_cutting=cut_cutting,
    )

    if relief.timber_to_be_scribed == ArrangementNames.butt_timber:
        return updated_scribed_cutting, updated_cut_cutting
    else:
        return updated_cut_cutting, updated_scribed_cutting