Skip to content

kumiki.csg.cutcsg

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

kumiki.csg.cutcsg

CutCSG: CSG primitives and operations for timbers and their cuts, and the features they declare.

Values are plain floats compared through the safe_* helpers (see rule.py).

  • Point tests (contains_point, is_point_on_boundary, get_outward_normal) take an optional eps for float error.
  • Feature queries (find_all_features, find_first_feature, CSGFeature.test_point_unbounded) take a FeatureTestTolerances instead: slack for mesh-vs-analytic error and click imprecision.

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)

HALF_SPACE_PLANE module-attribute

HALF_SPACE_PLANE: FeatureKey = side_key(0)

CYLINDER_BARREL module-attribute

CYLINDER_BARREL: FeatureKey = side_key(0)

CYLINDER_AXIS module-attribute

CYLINDER_AXIS: FeatureKey = (FeatureCategory.AXIS, 0)

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()

LocatedFeatureGeometry module-attribute

LocatedFeatureGeometry = Union[Point, Line, Plane]

F_co module-attribute

F_co = TypeVar('F_co', bound=CSGFeature, covariant=True)

K module-attribute

K = TypeVar('K', bound=CSGFeature)

Profile module-attribute

Profile = List[V2]

Profiles module-attribute

Profiles = List[Profile]

AxisAlignedBoundingBox dataclass

Axis-aligned bounding box of a CSG. A None bound is unbounded that way.

When is_empty, the solid has no points and the bounds are meaningless; check it 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

Kind of place on a primitive's boundary a default feature names, shared by every primitive.

Paired with an index where a primitive has several (index 0 for one-offs).

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

AXIS class-attribute instance-attribute

AXIS = 4

EXTRA class-attribute instance-attribute

EXTRA = 5

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.

TOP class-attribute instance-attribute

TOP = 1

BOTTOM class-attribute instance-attribute

BOTTOM = 2

CylinderPart

Bases: Enum

Which surface of a Cylinder; BARREL is the whole curved side as one feature.

TOP class-attribute instance-attribute

TOP = 1

BOTTOM class-attribute instance-attribute

BOTTOM = 2

BARREL class-attribute instance-attribute

BARREL = 3

CSGFeatureType

Bases: Enum

Kind of geometry a feature names: face, curved face, edge or point.

Measurements dispatch on the pair of types.

TODO consider adding DERIVED_POINT and DERIVED_FACE here

FACE class-attribute instance-attribute

FACE = 1

EDGE class-attribute instance-attribute

EDGE = 2

POINT class-attribute instance-attribute

POINT = 3

CURVED_FACE class-attribute instance-attribute

CURVED_FACE = 4

FeatureTestTolerances dataclass

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

Faces need only the mesh-vs-analytic gap; edges and points need more, since they are snapped to. A viewport usually scales these by world units per pixel.

face class-attribute instance-attribute

edge class-attribute instance-attribute

point class-attribute instance-attribute

for_type

for_type(feature_type: CSGFeatureType) -> Numeric

Return the tolerance for feature_type.

Source code in kumiki/csg/cutcsg.py
def for_type(self, feature_type: 'CSGFeatureType') -> Numeric:
    """Return the tolerance for `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

Return these tolerances scaled by factor (e.g. world units per pixel).

Source code in kumiki/csg/cutcsg.py
def __mul__(self, factor: Numeric) -> 'FeatureTestTolerances':
    """Return these tolerances scaled by `factor` (e.g. world units per pixel)."""
    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/csg/cutcsg.py
def __rmul__(self, factor: Numeric) -> 'FeatureTestTolerances':
    return self.__mul__(factor)

__truediv__

__truediv__(divisor: Numeric) -> FeatureTestTolerances
Source code in kumiki/csg/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

Return the same tolerance for every feature type.

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

exact staticmethod

Return analytic tolerances, for geometry that was never triangulated.

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

FeatureGroup

Bases: Enum

Which features a feature may derive an edge with:

SHOULDER_PLANE with PERFECT, ROUGH and TENON
PERFECT        with SHOULDER_PLANE and CROSSING
ROUGH          with SHOULDER_PLANE and CROSSING
TENON          with SHOULDER_PLANE
CROSSING       with PERFECT and ROUGH
NONE           with nothing
  • PERFECT: the perfect timber within's faces, arrises, corners and centerplanes, and rough faces lying on it (rptw.*).
  • ROUGH: the rough body's faces.
  • SHOULDER_PLANE: a joint's shoulder plane. TENON: a joint's tenon faces.
  • CROSSING: the faces a cross lap removes.

PERFECT and ROUGH don't pair with themselves: a timber's own arrises and corners are declared. The order is the pick preference, best first.

SHOULDER_PLANE class-attribute instance-attribute

SHOULDER_PLANE = 1

PERFECT class-attribute instance-attribute

PERFECT = 2

ROUGH class-attribute instance-attribute

ROUGH = 3

TENON class-attribute instance-attribute

TENON = 4

CROSSING class-attribute instance-attribute

CROSSING = 5

NONE class-attribute instance-attribute

NONE = 6

FeatureMarkingStatus

Bases: Enum

Whether a feature must appear on a drawing. Not yet read by the drawing generator.

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 to mark a feature on a drawing, when not the default.

mark_relative_to names the feature to dimension it from: the nearest feature of that name in the CSG tree. Auto dimensioning prefers that measurement; naming each other makes a symmetric pair.

mark class-attribute instance-attribute

mark_relative_to class-attribute instance-attribute

mark_relative_to: Optional[str] = None

FeaturePurpose

Bases: Enum

What a feature is for.

NOT_SPECIFIED class-attribute instance-attribute

NOT_SPECIFIED = 0

ROUGH_RELIEF class-attribute instance-attribute

ROUGH_RELIEF = 1

SHOULDER class-attribute instance-attribute

SHOULDER = 2

ROUGH_FACE class-attribute instance-attribute

ROUGH_FACE = 3

Datum

Bases: Enum

How much auto dimensioning likes measuring from a feature (within its member). Lower is better.

PREFERRED class-attribute instance-attribute

PREFERRED = 0

NEUTRAL class-attribute instance-attribute

NEUTRAL = 1

AVOID class-attribute instance-attribute

AVOID = 2

FeatureNature

Bases: Enum

Where a feature lives, which sets how far it reaches:

  • REAL: on the surface (a face, an arris); cropped by the cuts.
  • AUXILIARY: in a void (a bore's axis); bounded by its own extent.
  • PERFECT_REFERENCE: inside the material (the perfect timber within); cropped to the solid.

REAL class-attribute instance-attribute

REAL = 0

AUXILIARY class-attribute instance-attribute

AUXILIARY = 1

PERFECT_REFERENCE class-attribute instance-attribute

PERFECT_REFERENCE = 2

FeatureProperties dataclass

Metadata every feature carries.

Parameters:

Name Type Description Default
group

which features this one may derive edges with (FeatureGroup). NONE by default.

required
priority

lower wins when several features claim a point.

required
nature

FeatureNature. Only REAL features are tested against the triangulated result.

required
marking_override

how to mark it on a drawing.

required
purpose

what it is for (FeaturePurpose).

required
datum

how much to measure from it (Datum).

required

group class-attribute instance-attribute

priority class-attribute instance-attribute

priority: int = 0

nature class-attribute instance-attribute

marking_override class-attribute instance-attribute

marking_override: Optional[FeatureMarkingSpec] = None

purpose class-attribute instance-attribute

datum class-attribute instance-attribute

datum: Datum = Datum.NEUTRAL

real property

real: bool

CSGFeature dataclass

Bases: ABC

A feature on a CutCSG's boundary, or a non-real one (e.g. a cylinder's axis).

Doesn't know its owner; methods take the owner node to place it in the owner's space.

name instance-attribute

name: str

properties class-attribute instance-attribute

properties: FeatureProperties = field(default_factory=FeatureProperties)

group property

group: FeatureGroup

nature property

nature: FeatureNature

real property

real: bool

priority property

priority: int

feature_key

feature_key() -> Optional[FeatureKey]

Return the default slot this feature fills (so it replaces that default), or None.

Source code in kumiki/csg/cutcsg.py
def feature_key(self) -> Optional['FeatureKey']:
    """Return the default slot this feature fills (so it replaces that default), or None."""
    return None

feature_type abstractmethod

feature_type() -> CSGFeatureType

Return the kind of geometry this feature names.

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

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[LocatedFeatureGeometry]

Return the feature's unbounded Plane, Line or Point in owner's space.

None for a curved surface (a barrel, a lofted side, a curved path side).

Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[LocatedFeatureGeometry]:
    """Return the feature's unbounded Plane, Line or Point in `owner`'s space.

    None for a curved surface (a barrel, a lofted side, a curved path side).
    """
    return None

declared_ends

declared_ends(owner: CutCSG) -> Optional[Tuple[V3, V3]]

Return a line feature's own two ends in owner's space, or None if it has none.

Source code in kumiki/csg/cutcsg.py
def declared_ends(self, owner: 'CutCSG') -> Optional[Tuple[V3, V3]]:
    """Return a line feature's own two ends in `owner`'s space, or None if it has none."""
    return None

solve_recipe

solve_recipe(owner: CutCSG) -> Optional[Recipe]

Return the carriers of owner that produce this feature, or None.

Source code in kumiki/csg/cutcsg.py
def solve_recipe(self, owner: 'CutCSG') -> Optional[Recipe]:
    """Return the carriers of `owner` that produce this feature, or None."""
    key = self.feature_key()
    if key is not None and owner.has_carrier(key):
        return (CarrierRef(owner, key),)
    return None

is_derived

is_derived() -> bool

Return whether this feature was derived from others rather than declared.

Source code in kumiki/csg/cutcsg.py
def is_derived(self) -> bool:
    """Return whether this feature was derived from others rather than declared."""
    return False

group_rank

group_rank() -> int

Return this feature's group's pick preference rank (derived features take their better parent's).

Source code in kumiki/csg/cutcsg.py
def group_rank(self) -> int:
    """Return this feature's group's pick preference rank (derived features take their better parent's)."""
    return self.group.value

test_point_unbounded abstractmethod

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

Return whether point is on this feature's surface, ignoring its bounds.

owner places the feature. collect_feature_hits gates real features on the boundary.

Source code in kumiki/csg/cutcsg.py
@abstractmethod
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    """Return whether `point` is on this feature's surface, ignoring its bounds.

    `owner` places the feature. collect_feature_hits gates real features on the boundary.
    """
    ...

FaceFeature dataclass

Bases: CSGFeature

A flat face. Locates to a Plane.

feature_type

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

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Plane]
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Plane]:
    return None

EdgeFeature dataclass

Bases: CSGFeature

A straight edge or axis. Locates to a Line.

feature_type

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

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Line]
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Line]:
    return None

PointFeature dataclass

Bases: CSGFeature

A vertex. Locates to a Point.

feature_type

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

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Point]
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Point]:
    return None

CurvedFaceFeature dataclass

Bases: CSGFeature

A curved face. Locates to nothing: no plane, line or point describes it.

feature_type

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

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> None
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> None:
    return None

ProgrammableFaceFeature dataclass

Bases: _ProgrammableFeature, FaceFeature

ProgrammableEdgeFeature dataclass

Bases: _ProgrammableFeature, EdgeFeature

ProgrammablePointFeature dataclass

Bases: _ProgrammableFeature, PointFeature

DerivedEdgeFeature dataclass

Bases: EdgeFeature

The edge where two planar face features meet. Owned by their shared_ancestor.

a class-attribute instance-attribute

a: OwnedFeatureHit[FaceFeature] = field(kw_only=True)

b class-attribute instance-attribute

b: OwnedFeatureHit[FaceFeature] = field(kw_only=True)

parents property

test_point_unbounded

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

Return whether point is on the edge's line (tested directly, since near-parallel faces make face tests unreliable).

Source code in kumiki/csg/cutcsg.py
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    """Return whether `point` is on the edge's line (tested directly, since near-parallel faces make face tests unreliable)."""
    line = self.locate_simple_unbounded(owner)
    if isinstance(line, Line):
        return _point_is_on_line(point, line, test_tolerance)
    # debatable if this is the right behavior. In any case, this code path
    # should never get hit since DerivedEdges are only created when there
    # is an actual line.
    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_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Line]
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Line]:
    return intersect_planes(self.a.feature.locate_simple_unbounded(self.a.owner),
                            self.b.feature.locate_simple_unbounded(self.b.owner))

declared_ends

declared_ends(owner: CutCSG) -> Optional[Tuple[V3, V3]]

Return the edge's ends as bounded by its parent faces' corners, or None if they can't bound it.

Exact along a face's own axes (every arris of a box); otherwise an over-estimate.

TODO make it exact for oblique lines

Source code in kumiki/csg/cutcsg.py
def declared_ends(self, owner: 'CutCSG') -> Optional[Tuple[V3, V3]]:
    """Return the edge's ends as bounded by its parent faces' corners, or None if they can't bound it.

    Exact along a face's own axes (every arris of a box); otherwise an over-estimate.

    TODO make it exact for oblique lines
    """
    line = self.locate_simple_unbounded(owner)
    if not isinstance(line, Line):
        return None
    start, along = line.point, line.direction
    bounds = []
    for hit in self.parents:
        corners = getattr(hit.feature, "corners", None)
        found = corners(hit.owner) if corners is not None else None
        if found:
            stations = [float(((corner - start).T * along)[0, 0]) for corner in found]
            bounds.append((min(stations), max(stations)))
    if not bounds:
        return None
    low, high = max(span[0] for span in bounds), min(span[1] for span in bounds)
    if low >= high:
        return None
    return start + along * scalar(repr(low)), start + along * scalar(repr(high))

solve_recipe

solve_recipe(owner: CutCSG) -> Optional[Recipe]
Source code in kumiki/csg/cutcsg.py
def solve_recipe(self, owner: 'CutCSG') -> Optional[Recipe]:
    return _meet_of_parents(self.a, self.b)

derive staticmethod

derive(a: OwnedFeatureHit, b: OwnedFeatureHit, any_groups: bool = False) -> Optional[DerivedEdgeFeature]

Return the edge where a and b meet, or None.

None if either isn't a face, their groups can't meet (unless any_groups), either face isn't there (e.g. an infinite prism's cap), or their planes are parallel.

Source code in kumiki/csg/cutcsg.py
@staticmethod
def derive(a: 'OwnedFeatureHit', b: 'OwnedFeatureHit', any_groups: bool = False) -> Optional['DerivedEdgeFeature']:
    """Return the edge where `a` and `b` meet, or None.

    None if either isn't a face, their groups can't meet (unless `any_groups`), either face isn't there
    (e.g. an infinite prism's cap), or their planes are parallel.
    """
    face_a, face_b = hit_of_kind(a, FaceFeature), hit_of_kind(b, FaceFeature)
    if face_a is None or face_b is None:
        return None
    if not any_groups and not feature_groups_intersect(a.feature.group, b.feature.group):
        return None

    # A face that isn't there (an infinite prism's cap) forms no edge.
    if any(hit.feature.locate_simple_unbounded(hit.owner) is None for hit in (a, b)):
        return None

    if planes_are_parallel(face_a.feature.locate_simple_unbounded(face_a.owner),
                           face_b.feature.locate_simple_unbounded(face_b.owner)):
        return None

    # Deterministic order, so the same edge gets the same identity however traversal reached it.
    first, second = sorted(
        (face_a, face_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.
            nature=_derived_nature(a.feature, b.feature),
            priority=max(a.feature.priority, b.feature.priority),
            # NONE, derived features do not create more derived features for now
            group=FeatureGroup.NONE,
        ),
        a=first,
        b=second,
    )

is_derived

is_derived() -> bool
Source code in kumiki/csg/cutcsg.py
def is_derived(self) -> bool:
    return True

group_rank

group_rank() -> int

Return the better rank of its two faces.

Source code in kumiki/csg/cutcsg.py
def group_rank(self) -> int:
    """Return the better rank of its two faces."""
    ranks = [hit.feature.group.value for hit in self.parents]
    return min(ranks) if ranks else FeatureGroup.NONE.value

DerivedPointFeature dataclass

Bases: PointFeature

The point where an edge feature crosses a face feature.

edge class-attribute instance-attribute

edge: OwnedFeatureHit[EdgeFeature] = field(kw_only=True)

face class-attribute instance-attribute

face: OwnedFeatureHit[FaceFeature] = field(kw_only=True)

parents property

is_derived

is_derived() -> bool
Source code in kumiki/csg/cutcsg.py
def is_derived(self) -> bool:
    return True

group_rank

group_rank() -> int

Return the better rank of its edge and face.

Source code in kumiki/csg/cutcsg.py
def group_rank(self) -> int:
    """Return the better rank of its edge and face."""
    ranks = [hit.feature.group.value for hit in self.parents]
    return min(ranks) if ranks else FeatureGroup.NONE.value

test_point_unbounded

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

Return whether point is on the vertex itself.

Source code in kumiki/csg/cutcsg.py
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    """Return whether `point` is on the vertex itself."""
    vertex = self.locate_simple_unbounded(owner)
    if isinstance(vertex, Point):
        return _point_is_near(point, vertex.position, test_tolerance)
    # debatable if this is the right behavior. In any case, this falls back
    # to the parents only when locate_simple_unbounded returns no vertex,
    # which should never happen.
    return (self.edge.feature.test_point_unbounded(self.edge.owner, point, test_tolerance)
            and self.face.feature.test_point_unbounded(self.face.owner, point, test_tolerance))

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Point]
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Point]:
    return intersect_line_plane(self.edge.feature.locate_simple_unbounded(self.edge.owner),
                                self.face.feature.locate_simple_unbounded(self.face.owner))

solve_recipe

solve_recipe(owner: CutCSG) -> Optional[Recipe]
Source code in kumiki/csg/cutcsg.py
def solve_recipe(self, owner: 'CutCSG') -> Optional[Recipe]:
    return _meet_of_parents(self.edge, self.face)

derive staticmethod

Return the point where a and b cross, or None.

None unless they are one edge and one face whose groups can meet, both are there, and the line pierces the plane at one point (not lying in it).

Source code in kumiki/csg/cutcsg.py
@staticmethod
def derive(a: 'OwnedFeatureHit', b: 'OwnedFeatureHit') -> Optional['DerivedPointFeature']:
    """Return the point where `a` and `b` cross, or None.

    None unless they are one edge and one face whose groups can meet, both are there, and the line
    pierces the plane at one point (not lying in it).
    """
    edge = hit_of_kind(a, EdgeFeature) or hit_of_kind(b, EdgeFeature)
    face = hit_of_kind(a, FaceFeature) or hit_of_kind(b, FaceFeature)
    if edge is None or face is None:
        return None
    if not feature_groups_intersect(a.feature.group, b.feature.group):
        return None

    if any(hit.feature.locate_simple_unbounded(hit.owner) is None for hit in (a, b)):
        return None

    if intersect_line_plane(edge.feature.locate_simple_unbounded(edge.owner),
                            face.feature.locate_simple_unbounded(face.owner)) is None:
        return None

    # Deterministic order, so the same point gets the same identity however
    # traversal reached it.
    first, second = sorted(
        (a, b), key=lambda hit: (hit.feature.group.value, hit.feature.name))
    return DerivedPointFeature(
        name=f"{first.feature.name}\u00d7{second.feature.name}",
        properties=FeatureProperties(
            # A point exists only where both its parents do.
            nature=_derived_nature(a.feature, b.feature),
            priority=max(a.feature.priority, b.feature.priority),
            # Nothing pairs points; see DerivedEdgeFeature on why this is
            # said rather than defaulted.
            group=FeatureGroup.NONE,
        ),
        edge=edge,
        face=face,
    )

HalfSpaceFeature dataclass

Bases: FaceFeature

The boundary plane of a HalfSpace.

feature_key

feature_key() -> Optional[FeatureKey]
Source code in kumiki/csg/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)

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Plane]
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Plane]:
    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/csg/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: FaceFeature

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/csg/cutcsg.py
def feature_key(self) -> Optional[FeatureKey]:
    return prism_face_key(self.face)

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Plane]
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Plane]:
    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)

corners

corners(owner: CutCSG) -> Optional[Tuple[V3, V3, V3, V3]]

Return the face's four corners, or None if unbounded.

Source code in kumiki/csg/cutcsg.py
def corners(self, owner: 'CutCSG') -> Optional[Tuple[V3, V3, V3, V3]]:
    """Return the face's four corners, or None if unbounded."""
    if not isinstance(owner, RectangularPrism):
        return None
    frame = self._face_frame(owner)
    axes = self._face_axes(owner)
    if frame is None or axes is None:
        return None
    _, centre = frame
    (first, first_half), (second, second_half) = axes
    return tuple(
        centre + first * (first_half * scalar(a)) + second * (second_half * scalar(b))
        for a, b in ((1, 1), (1, -1), (-1, -1), (-1, 1))
    )

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/csg/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

PlaneFeature dataclass

Bases: FaceFeature

An extra plane on a primitive, with its own solver carrier. Not real by default.

normal . x = offset in the owner's frame (before its transform).

normal class-attribute instance-attribute

normal: Tuple[Numeric, Numeric, Numeric] = (scalar(1), scalar(0), scalar(0))

offset class-attribute instance-attribute

offset: Numeric = scalar(0)

properties class-attribute instance-attribute

properties: FeatureProperties = field(default_factory=lambda: FeatureProperties(nature=FeatureNature.AUXILIARY))

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Plane]
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Plane]:
    transform = getattr(owner, "transform", None)
    normal = create_v3(*self.normal)
    point = normal * self.offset
    if transform is None:
        return Plane(normal=normal, point=point)
    return Plane(normal=safe_transform_vector(transform.orientation.matrix, normal),
                 point=transform.local_to_global(point))

solve_recipe

solve_recipe(owner: CutCSG) -> Optional[Recipe]
Source code in kumiki/csg/cutcsg.py
def solve_recipe(self, owner: 'CutCSG') -> Optional[Recipe]:
    extras = getattr(owner, "extra_features", ())
    index = next((i for i, extra in enumerate(extras) if extra.name == self.name), None)
    return None if index is None else (CarrierRef(owner, (FeatureCategory.EXTRA, index)),)

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/csg/cutcsg.py
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    plane = self.locate_simple_unbounded(owner)
    return plane is not None and safe_equality_test(
        safe_dot_product(point - plane.point, plane.normal), 0, eps=test_tolerance)

MidplaneFeature dataclass

Bases: FaceFeature

The plane midway between two parallel planes of its owner. Not real by default.

Each of between is a feature name or a default FeatureKey. Solved from those two planes; locates to None if they aren't parallel.

between class-attribute instance-attribute

between: Tuple[Union[str, FeatureKey], Union[str, FeatureKey]] = ('', '')

properties class-attribute instance-attribute

properties: FeatureProperties = field(default_factory=lambda: FeatureProperties(nature=FeatureNature.AUXILIARY))

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Plane]
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Plane]:
    sides = self._sides(owner)
    if sides is None:
        return None
    first, second = (side.locate_simple_unbounded(owner) for side in sides)
    if not isinstance(first, Plane) or not isinstance(second, Plane):
        return None
    if abs(abs(float(safe_dot_product(first.normal, second.normal))) - 1) > 1e-9:
        return None
    return Plane(normal=first.normal, point=(first.point + second.point) / 2)

solve_recipe

solve_recipe(owner: CutCSG) -> Optional[Recipe]
Source code in kumiki/csg/cutcsg.py
def solve_recipe(self, owner: 'CutCSG') -> Optional[Recipe]:
    sides = self._sides(owner)
    if sides is None:
        return None
    first, second = (side.solve_recipe(owner) for side in sides)
    if first is None or second is None or len(first) != 1 or len(second) != 1:
        return None
    front, back = first[0], second[0]
    if not isinstance(front, CarrierRef) or not isinstance(back, CarrierRef):
        return None
    return (Midplane(front=front, back=back),)

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/csg/cutcsg.py
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    plane = self.locate_simple_unbounded(owner)
    return plane is not None and safe_equality_test(
        safe_dot_product(point - plane.point, plane.normal), 0, eps=test_tolerance)

SimpleRectangularPrismEdgeFeature dataclass

Bases: EdgeFeature

An arris of a RectangularPrism, named by its two faces.

faces class-attribute instance-attribute

__post_init__

__post_init__()
Source code in kumiki/csg/cutcsg.py
def __post_init__(self):
    first, second = self.faces
    canonical_ordering = _canonical_ordering_arris_faces(first, second)
    if canonical_ordering is None:
        raise ValueError(f"{self.name!r}: {first} and {second} meet in no arris")
    if (first, second) != canonical_ordering:
        raise ValueError(
            f"{self.name!r}: the faces must be in canonical order, "
            f"{canonical_ordering[0]}, {canonical_ordering[1]}; use SimpleRectangularPrismEdgeFeature.of")

of classmethod

of(name: str, first: PrismFace, second: PrismFace, properties: Optional[FeatureProperties] = None) -> SimpleRectangularPrismEdgeFeature

Return the arris between two faces, given in either order.

Source code in kumiki/csg/cutcsg.py
@classmethod
def of(cls, name: str, first: PrismFace, second: PrismFace,
       properties: Optional[FeatureProperties] = None) -> 'SimpleRectangularPrismEdgeFeature':
    """Return the arris between two faces, given in either order."""
    ordered = _canonical_ordering_arris_faces(first, second)
    if ordered is None:
        raise ValueError(f"{name!r}: {first} and {second} meet in no arris")
    if properties is None:
        return cls(name=name, faces=ordered)
    return cls(name=name, faces=ordered, properties=properties)

feature_key

feature_key() -> Optional[FeatureKey]
Source code in kumiki/csg/cutcsg.py
def feature_key(self) -> Optional[FeatureKey]:
    return _prism_arris_key(*self.faces)

solve_recipe

solve_recipe(owner: CutCSG) -> Optional[Recipe]
Source code in kumiki/csg/cutcsg.py
def solve_recipe(self, owner: 'CutCSG') -> Optional[Recipe]:
    return _meet_of_prism_faces(owner, self.faces)

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/csg/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_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Line]

Return the line the two faces meet in, or None.

Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Line]:
    """Return the line the two faces meet in, or None."""
    first, second = self._sides()
    return intersect_planes(_as_plane(first.locate_simple_unbounded(owner)), _as_plane(second.locate_simple_unbounded(owner)))

declared_ends

declared_ends(owner: CutCSG) -> Optional[Tuple[V3, V3]]

Return the two prism corners the arris runs between, or None if the prism is unbounded there.

Source code in kumiki/csg/cutcsg.py
def declared_ends(self, owner: 'CutCSG') -> Optional[Tuple[V3, V3]]:
    """Return the two prism corners the arris runs between, or None if the prism is unbounded there."""
    first, second = self._sides()
    corners = first.corners(owner)
    if corners is None:
        return None
    shared = [corner for corner in corners if second.test_point_unbounded(owner, corner)]
    return (shared[0], shared[1]) if len(shared) == 2 else None

SimpleRectangularPrismVertexFeature dataclass

Bases: PointFeature

A corner of a RectangularPrism, named by its three faces; numbered to match TimberCorner.

faces class-attribute instance-attribute

__post_init__

__post_init__()
Source code in kumiki/csg/cutcsg.py
def __post_init__(self):
    canonical_ordering = _canonical_ordering_corner_faces(self.faces)
    names = ', '.join(face.name for face in self.faces)
    if canonical_ordering is None:
        raise ValueError(f"{self.name!r}: {names} meet at no corner")
    if tuple(self.faces) != canonical_ordering:
        raise ValueError(
            f"{self.name!r}: the faces must be in canonical order, "
            f"{', '.join(f.name for f in canonical_ordering)}; use SimpleRectangularPrismVertexFeature.of")

of classmethod

of(name: str, *faces: PrismFace, properties: Optional[FeatureProperties] = None) -> SimpleRectangularPrismVertexFeature

Return the corner of three faces, given in any order.

Source code in kumiki/csg/cutcsg.py
@classmethod
def of(cls, name: str, *faces: PrismFace,
       properties: Optional[FeatureProperties] = None) -> 'SimpleRectangularPrismVertexFeature':
    """Return the corner of three faces, given in any order."""
    ordered = _canonical_ordering_corner_faces(faces)
    if ordered is None:
        raise ValueError(f"{name!r}: {', '.join(face.name for face in faces)} meet at no corner")
    if properties is None:
        return cls(name=name, faces=ordered)
    return cls(name=name, faces=ordered, properties=properties)

feature_key

feature_key() -> Optional[FeatureKey]
Source code in kumiki/csg/cutcsg.py
def feature_key(self) -> Optional[FeatureKey]:
    return _prism_corner_key(self.faces)

solve_recipe

solve_recipe(owner: CutCSG) -> Optional[Recipe]
Source code in kumiki/csg/cutcsg.py
def solve_recipe(self, owner: 'CutCSG') -> Optional[Recipe]:
    return _meet_of_prism_faces(owner, self.faces)

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Point]
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Point]:
    at = self._position(owner)
    return None if at is None else Point(position=at)

test_point_unbounded

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

CylinderAxisFeature dataclass

Bases: EdgeFeature

The centre line of a Cylinder.

nature property

nature: FeatureNature

solve_recipe

solve_recipe(owner: CutCSG) -> Optional[Recipe]
Source code in kumiki/csg/cutcsg.py
def solve_recipe(self, owner: 'CutCSG') -> Optional[Recipe]:
    if self._cylinder(owner) is None:
        return None
    return (CarrierRef(owner, CYLINDER_AXIS),)

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Line]
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Line]:
    cylinder = self._cylinder(owner)
    if cylinder is None:
        return None
    return Line(direction=safe_normalize_vector(cylinder.axis_direction),
                point=cylinder.position)

declared_ends

declared_ends(owner: CutCSG) -> Optional[Tuple[V3, V3]]

Return the axis's ends at the cylinder's caps, or None if either is infinite.

Source code in kumiki/csg/cutcsg.py
def declared_ends(self, owner: 'CutCSG') -> Optional[Tuple[V3, V3]]:
    """Return the axis's ends at the cylinder's caps, or None if either is infinite."""
    cylinder = self._cylinder(owner)
    if cylinder is None or cylinder.start_distance is None or cylinder.end_distance is None:
        return None
    axis = safe_normalize_vector(cylinder.axis_direction)
    return (cylinder.position + axis * cylinder.start_distance,
            cylinder.position + axis * cylinder.end_distance)

test_point_unbounded

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

Return whether point is on the axis line (ends not checked).

Source code in kumiki/csg/cutcsg.py
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    """Return whether `point` is on the axis line (ends not checked)."""
    if not isinstance(owner, Cylinder):
        return False
    tolerance = DEFAULT_FEATURE_TEST_TOLERANCES.edge if test_tolerance is None else test_tolerance
    _axial, radial = owner._axial_and_radial(point)
    return safe_compare(radial, tolerance, Comparison.LE)

SimpleCylinderCapFeature dataclass

Bases: FaceFeature

An end cap of a Cylinder; part is TOP or BOTTOM.

part class-attribute instance-attribute

feature_key

feature_key() -> Optional[FeatureKey]
Source code in kumiki/csg/cutcsg.py
def feature_key(self) -> Optional[FeatureKey]:
    return END_CAP if self.part is CylinderPart.TOP else START_CAP

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Plane]
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Plane]:
    if not isinstance(owner, Cylinder):
        return None
    distance = self._distance(owner)
    if distance is None:
        return None
    axis = safe_normalize_vector(owner.axis_direction)
    sign = scalar(1) if self.part is CylinderPart.TOP else scalar(-1)
    return Plane(normal=axis * sign, point=owner.position + axis * distance)

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/csg/cutcsg.py
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    if not isinstance(owner, Cylinder):
        return False
    distance = self._distance(owner)
    axial, _radial = owner._axial_and_radial(point)
    return distance is not None and safe_equality_test(axial, distance, eps=test_tolerance)

SimpleCylinderBarrelFeature dataclass

Bases: CurvedFaceFeature

The curved side of a Cylinder.

feature_key

feature_key() -> Optional[FeatureKey]
Source code in kumiki/csg/cutcsg.py
def feature_key(self) -> Optional[FeatureKey]:
    return (FeatureCategory.SIDE, 0)

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/csg/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)
    return safe_equality_test(radial, owner.radius, eps=test_tolerance)

SimpleConvexPolygonExtrusionFeature dataclass

Bases: FaceFeature

A side (points[key] to points[key+1]) or end cap of a ConvexPolygonExtrusion.

key class-attribute instance-attribute

feature_key

feature_key() -> Optional[FeatureKey]
Source code in kumiki/csg/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))

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Plane]
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Plane]:
    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)

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/csg/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: FaceFeature

A side or end cap of a ConvexPolygonSimpleLoft. Sides are planar only for a pure taper.

key class-attribute instance-attribute

feature_key

feature_key() -> Optional[FeatureKey]
Source code in kumiki/csg/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))

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Plane]
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Plane]:
    if not isinstance(owner, ConvexPolygonSimpleLoft):
        return None

    orientation = owner.transform.orientation.matrix
    length_dir = safe_transform_vector(orientation, Matrix([scalar(0), scalar(0), scalar(1)]))
    if self.key in (ExtrusionCap.TOP, ExtrusionCap.BOTTOM):
        is_top = self.key == ExtrusionCap.TOP
        distance = owner.top_points_z_pos if is_top else owner.bottom_points_z_pos
        sign = scalar(1) if is_top else scalar(-1)
        return Plane(normal=length_dir * sign,
                     point=owner.transform.position + length_dir * distance)

    # A side; planar, since is_valid refuses a twisted loft.
    index = int(self.key)
    count = len(owner.bottom_points)
    if index >= count:
        return None
    following = (index + 1) % count
    def placed(profile_point, z_pos):
        local = Matrix([profile_point[0], profile_point[1], z_pos])
        return owner.transform.position + safe_transform_vector(orientation, local)

    corners = [
        placed(owner.bottom_points[index], owner.bottom_points_z_pos),
        placed(owner.bottom_points[following], owner.bottom_points_z_pos),
        placed(owner.top_points[index], owner.top_points_z_pos),
    ]
    normal = cross_product(corners[1] - corners[0], corners[2] - corners[0])
    if safe_zero_test(giraffe_norm(normal)):
        # A degenerate side -- three corners in line -- lies on no one plane.
        return None
    return Plane(normal=safe_normalize_vector(normal), point=corners[0])

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/csg/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.top_points_z_pos, eps=test_tolerance)
    if self.key == ExtrusionCap.BOTTOM:
        return safe_equality_test(z, owner.bottom_points_z_pos, eps=test_tolerance)
    return owner._point_on_side(self.key, x, y, z, eps=test_tolerance)

ConvexProfileEdgeFeature dataclass

Bases: EdgeFeature

An edge of a ConvexPolygonExtrusion or ConvexPolygonSimpleLoft.

faces is a side and the next side (a long edge), or a side and a cap (a profile edge).

key class-attribute instance-attribute

faces class-attribute instance-attribute

faces: Tuple[int, ExtrusionFeatureKey] = (0, 1)

feature_key

feature_key() -> Optional[FeatureKey]
Source code in kumiki/csg/cutcsg.py
def feature_key(self) -> Optional[FeatureKey]:
    return self.key

solve_recipe

solve_recipe(owner: CutCSG) -> Optional[Recipe]
Source code in kumiki/csg/cutcsg.py
def solve_recipe(self, owner: 'CutCSG') -> Optional[Recipe]:
    keys = [face.feature_key() for face in self._face_features(owner)]
    if not all(key is not None and owner.has_carrier(key) for key in keys):
        return None
    return tuple(CarrierRef(owner, key) for key in keys if key is not None)

test_point_unbounded

test_point_unbounded(owner: CutCSG, point: V3, test_tolerance: Optional[Numeric] = None) -> bool
Source code in kumiki/csg/cutcsg.py
def test_point_unbounded(self, owner: 'CutCSG', point: V3, test_tolerance: Optional[Numeric] = None) -> bool:
    if not isinstance(owner, (ConvexPolygonExtrusion, ConvexPolygonSimpleLoft)):
        return False
    return all(face.test_point_unbounded(owner, point, test_tolerance)
               for face in self._face_features(owner))

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> Optional[Line]
Source code in kumiki/csg/cutcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> Optional[Line]:
    if not isinstance(owner, (ConvexPolygonExtrusion, ConvexPolygonSimpleLoft)):
        return None
    start, end = self._ends(owner)
    if start is not None and end is not None:
        if safe_zero_test(giraffe_norm(end - start)):
            return None
        return Line(direction=safe_normalize_vector(end - start), point=start)
    side, other = self.faces
    if isinstance(other, ExtrusionCap) or not isinstance(owner, ConvexPolygonExtrusion):
        return None  # a profile edge at an infinite end: there is none
    # A long edge of an extrusion open at an end: through its vertex, along the extrusion.
    orientation = owner.transform.orientation.matrix
    vertex = owner.points[other]
    return Line(
        direction=safe_transform_vector(orientation, Matrix([scalar(0), scalar(0), scalar(1)])),
        point=owner.transform.position + safe_transform_vector(
            orientation, Matrix([vertex[0], vertex[1], scalar(0)])))

declared_ends

declared_ends(owner: CutCSG) -> Optional[Tuple[V3, V3]]

Return the edge's two ends, or None if either is at an infinite end.

Source code in kumiki/csg/cutcsg.py
def declared_ends(self, owner: 'CutCSG') -> Optional[Tuple[V3, V3]]:
    """Return the edge's two ends, or None if either is at an infinite end."""
    start, end = self._ends(owner)
    return None if start is None or end is None else (start, end)

FeatureSource

Bases: Flag

Which features a query wants: DEFAULTS, OVERRIDES (authored), or BOTH.

DEFAULTS class-attribute instance-attribute

DEFAULTS = 1

OVERRIDES class-attribute instance-attribute

OVERRIDES = 2

BOTH class-attribute instance-attribute

BOTH = 3

FeatureOverride dataclass

Overrides the name and properties of a default feature.

Parameters:

Name Type Description Default
key

the default, e.g. prism_face_key(PrismFace.RIGHT).

required
name

new name; None keeps the default's.

required
properties

replaces the default's properties; None keeps them.

required

key instance-attribute

name class-attribute instance-attribute

name: Optional[str] = None

properties class-attribute instance-attribute

properties: Optional[FeatureProperties] = None

apply

apply(default: CSGFeature) -> CSGFeature

Return the default feature at key with this override applied.

Source code in kumiki/csg/cutcsg.py
def apply(self, default: 'CSGFeature') -> 'CSGFeature':
    """Return the default feature at `key` with this override applied."""
    return replace(
        default,
        name=default.name if self.name is None else self.name,
        properties=default.properties if self.properties is None else self.properties,
    )

HasFeatures dataclass

Mixin for primitives that declare their own features.

Parameters:

Name Type Description Default
feature_overrides

overrides of this shape's defaults, at most one per key.

required
extra_features

keyless features with their own geometry (a bore's axis, a reference plane).

required

Both are validated on construction.

feature_overrides class-attribute instance-attribute

feature_overrides: Sequence[FeatureOverride] = field(default=(), kw_only=True)

extra_features class-attribute instance-attribute

extra_features: Sequence[CSGFeature] = field(default=(), kw_only=True)

__post_init__

__post_init__()
Source code in kumiki/csg/cutcsg.py
def __post_init__(self):
    if self.feature_overrides or self.extra_features:
        self._check_authored_features()

default_features

default_features() -> Dict[FeatureKey, CSGFeature]

Return this primitive's default features by key. Must all be in FeatureGroup.NONE.

Source code in kumiki/csg/cutcsg.py
def default_features(self) -> Dict['FeatureKey', 'CSGFeature']:
    """Return this primitive's default features by key. Must all be in FeatureGroup.NONE."""
    return {}

get_declared_features

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

Return the features this node declares (defaults with overrides applied, plus extras), cached per node.

Source code in kumiki/csg/cutcsg.py
def get_declared_features(
    self, source: 'FeatureSource' = FeatureSource.BOTH,
) -> List['CSGFeature']:
    """Return the features this node declares (defaults with overrides applied, plus extras), cached per node."""
    return list(_declared_features(self, source))

OwnedFeatureHit dataclass

Bases: Generic[F_co]

A feature with the node that owns it.

feature instance-attribute

feature: F_co

owner instance-attribute

owner: CutCSG

name property

name: str

properties property

properties: FeatureProperties

feature_type

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

locate_simple_unbounded

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

CutCSGLabel dataclass

A CSG node's optional name.

name class-attribute instance-attribute

name: Optional[str] = None

NoLabel staticmethod

NoLabel() -> CutCSGLabel

Return the label of an unnamed node.

Source code in kumiki/csg/cutcsg.py
@staticmethod
def NoLabel() -> 'CutCSGLabel':
    """Return the label of an unnamed node."""
    return CutCSGLabel()

is_labeled

is_labeled() -> bool

Return whether the node has a name.

Source code in kumiki/csg/cutcsg.py
def is_labeled(self) -> bool:
    """Return whether the node has a name."""
    return self.name is not None

__bool__

__bool__() -> bool
Source code in kumiki/csg/cutcsg.py
def __bool__(self) -> bool:
    return self.is_labeled()

__repr__

__repr__() -> str
Source code in kumiki/csg/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 of CSG nodes.

label class-attribute instance-attribute

label: CutCSGLabel = field(default_factory=CutCSGLabel.NoLabel, kw_only=True)

__init_subclass__

__init_subclass__(**kwargs) -> None
Source code in kumiki/csg/cutcsg.py
def __init_subclass__(cls, **kwargs) -> None:
    super().__init_subclass__(**kwargs)
    for name in _POINT_TESTS:
        if name in cls.__dict__:
            setattr(cls, name, _memoized_point_test(cls.__dict__[name]))

__repr__ abstractmethod

__repr__() -> str
Source code in kumiki/csg/cutcsg.py
@abstractmethod
def __repr__(self) -> str:
    pass

display_name classmethod

display_name() -> str
Source code in kumiki/csg/cutcsg.py
@classmethod
def display_name(cls) -> str:
    return re.sub(r"(?<!^)(?=[A-Z])", " ", cls.__name__).lower()

get_declared_features

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

Return the features this node declares. Empty for compound nodes.

Source code in kumiki/csg/cutcsg.py
def get_declared_features(
    self, source: 'FeatureSource' = FeatureSource.BOTH,
) -> List[CSGFeature]:
    """Return the features this node declares. Empty for compound nodes."""
    return []

carriers abstractmethod

carriers() -> Dict[FeatureKey, Carrier]

Return this node's solver carriers by key.

TODO decide whether to drop NEVER_MARK features' carriers, or tag OPTIONAL ones

Source code in kumiki/csg/cutcsg.py
@abstractmethod
def carriers(self) -> Dict[FeatureKey, Carrier]:
    """Return this node's solver carriers by key.

    TODO decide whether to drop NEVER_MARK features' carriers, or tag OPTIONAL ones
    """
    ...

has_carrier

has_carrier(key: FeatureKey) -> bool

Return whether key is one of this node's carriers.

Source code in kumiki/csg/cutcsg.py
def has_carrier(self, key: FeatureKey) -> bool:
    """Return whether `key` is one of this node's carriers."""
    return key in self.carriers()

collect_feature_hits

collect_feature_hits(point: V3, tolerances: FeatureTestTolerances) -> List[OwnedFeatureHit]

Return every declared feature in this subtree that point is on, each at its type's tolerance.

Real features also require point on this node's boundary; non-real ones don't.

Source code in kumiki/csg/cutcsg.py
def collect_feature_hits(
    self,
    point: V3,
    tolerances: FeatureTestTolerances,
) -> List['OwnedFeatureHit']:
    """Return every declared feature in this subtree that `point` is on, each at its type's tolerance.

    Real features also require `point` on this node's boundary; non-real ones don't.
    """
    hits: List['OwnedFeatureHit'] = [
        OwnedFeatureHit(feature=feature, owner=self)
        for feature in self.get_declared_features()
        if feature.test_point_unbounded(
            self, point, tolerances.for_type(feature.feature_type()))
    ]
    for child in csg_children(self):
        hits.extend(child.collect_feature_hits(point, tolerances))
    return _drop_real_hits_if_not_on_boundary(self, hits, point, tolerances)

find_all_features

find_all_features(point: V3, test_tolerances: Optional[FeatureTestTolerances] = None) -> List[OwnedFeatureHit]

Return every feature at point, best first: declared ones in this subtree plus derived edges and points.

Faces are paired into edges at the EDGE tolerance, and declared edges crossed with faces at the POINT tolerance, so edges and points can be picked from further away than their faces. Derived duplicates are dropped at the end.

Source code in kumiki/csg/cutcsg.py
def find_all_features(
    self,
    point: V3,
    test_tolerances: Optional[FeatureTestTolerances] = None,
) -> List['OwnedFeatureHit']:
    """Return every feature at `point`, best first: declared ones in this subtree plus derived edges
    and points.

    Faces are paired into edges at the EDGE tolerance, and declared edges crossed with faces at the
    POINT tolerance, so edges and points can be picked from further away than their faces. Derived
    duplicates are dropped at the end.
    """
    with point_tests_memo():
        return self._find_all_features(point, test_tolerances)

find_first_feature

find_first_feature(point: V3, test_tolerances: Optional[FeatureTestTolerances] = None) -> Optional[OwnedFeatureHit]

Return the best feature at point (see _sort_feature_hits), or None.

Source code in kumiki/csg/cutcsg.py
def find_first_feature(
    self,
    point: V3,
    test_tolerances: Optional[FeatureTestTolerances] = None,
) -> Optional['OwnedFeatureHit']:
    """Return the best feature at `point` (see _sort_feature_hits), or None."""
    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

Return whether point is inside or on the boundary.

Source code in kumiki/csg/cutcsg.py
@abstractmethod
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether `point` is inside or on the boundary."""
    pass

is_point_on_boundary abstractmethod

is_point_on_boundary(point: V3, eps: Optional[Numeric] = None) -> bool

Return whether point is on the boundary.

Source code in kumiki/csg/cutcsg.py
@abstractmethod
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether `point` is on the boundary."""
    pass

get_outward_normal abstractmethod

get_outward_normal(point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]

Return the outward normal at boundary point point, or None. Undefined off the boundary.

Source code in kumiki/csg/cutcsg.py
@abstractmethod
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    """Return the outward normal at boundary point `point`, or None. Undefined off the boundary."""
    pass

get_outward_normals

get_outward_normals(point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]

Return the outward normal of every face meeting at boundary point point (several on an edge or corner).

Shapes that can't tell return get_outward_normal's one.

Source code in kumiki/csg/cutcsg.py
def get_outward_normals(self, point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]:
    """Return the outward normal of every face meeting at boundary point `point` (several on an edge or corner).

    Shapes that can't tell return get_outward_normal's one.
    """
    normal = self.get_outward_normal(point, eps=eps)
    return [] if normal is None else [normal]

get_aabb abstractmethod

get_aabb() -> AxisAlignedBoundingBox

Return the bounding box. An infinite primitive warns and returns all-None bounds.

Source code in kumiki/csg/cutcsg.py
@abstractmethod
def get_aabb(self) -> 'AxisAlignedBoundingBox':
    """Return the bounding box. An infinite primitive warns and returns all-None bounds."""
    pass

get_convex_hull

get_convex_hull() -> ConvexHull

Return this primitive's convex hull.

Source code in kumiki/csg/cutcsg.py
def get_convex_hull(self) -> 'ConvexHull':
    """Return this primitive's convex hull."""
    from .edges_on_csg import UNKNOWN_HULL
    return UNKNOWN_HULL

is_exactly_convex_hull

is_exactly_convex_hull() -> bool

Return whether this primitive is exactly its get_convex_hull (no curved or twisted faces).

Source code in kumiki/csg/cutcsg.py
def is_exactly_convex_hull(self) -> bool:
    """Return whether this primitive is exactly its get_convex_hull (no curved or twisted faces)."""
    return False

exact_line_spans

exact_line_spans(line: Line, seed: Span, tolerance: float, removing: bool) -> Optional[List[Span]]

Return where line runs inside this primitive, or None if it can't be computed exactly.

Parameters:

Name Type Description Default
line Line

the line, in this primitive's space.

required
seed Span

the (low, high) stretch of the line to search, as parameters along its unit direction.

required
tolerance float

how far outside the surface still counts as inside.

required
removing bool

True when this primitive is being subtracted. A line lying on the surface counts as inside either way; this flips the comparison so that holds for a cut too.

required

Returns the parameter spans within seed that are inside. Default: clip seed by the convex hull, when the primitive is exactly its hull.

Source code in kumiki/csg/cutcsg.py
def exact_line_spans(self, line: Line, seed: 'Span', tolerance: float, removing: bool) -> Optional[List['Span']]:
    """Return where `line` runs inside this primitive, or None if it can't be computed exactly.

    Args:
        line: the line, in this primitive's space.
        seed: the (low, high) stretch of the line to search, as parameters along its unit direction.
        tolerance: how far outside the surface still counts as inside.
        removing: True when this primitive is being subtracted. A line lying on the surface counts
            as inside either way; this flips the comparison so that holds for a cut too.

    Returns the parameter spans within `seed` that are inside. Default: clip `seed` by the convex
    hull, when the primitive is exactly its hull.
    """
    from .edges_on_csg import _spans_within_primitive, convex_hull
    if not self.is_exactly_convex_hull():
        return None
    hull = convex_hull(self)
    if hull.is_unknown:
        return None
    return [] if hull.is_empty else _spans_within_primitive(hull.require_faces(), line, seed, tolerance, removing)

angles_around_line

angles_around_line(at: ndarray, direction: ndarray, basis: Tuple[ndarray, ndarray], tolerance: float) -> Optional[AngleSpans]

Return the directions around a line, at one point on it, in which this primitive has material.

Looking along the line at at, the primitive fills some angles around it; they say whether the line runs inside it (all), off it (none), along a flat face (half a turn) or along an edge.

Parameters:

Name Type Description Default
at ndarray

the point on the line (numpy, this primitive's space).

required
direction ndarray

the line's unit direction (numpy).

required
basis Tuple[ndarray, ndarray]

two unit vectors square to direction and each other; angle 0 is the first.

required
tolerance float

how far off a face still counts as on it.

required

Returns (low, high) angle spans in radians within [0, 2pi], or None if it can't be computed exactly. Default: from the convex hull's faces, when the primitive is exactly its hull.

Source code in kumiki/csg/cutcsg.py
def angles_around_line(self, at: np.ndarray, direction: np.ndarray, basis: Tuple[np.ndarray, np.ndarray],
                       tolerance: float) -> Optional['AngleSpans']:
    """Return the directions around a line, at one point on it, in which this primitive has material.

    Looking along the line at `at`, the primitive fills some angles around it; they say whether the
    line runs inside it (all), off it (none), along a flat face (half a turn) or along an edge.

    Args:
        at: the point on the line (numpy, this primitive's space).
        direction: the line's unit direction (numpy).
        basis: two unit vectors square to `direction` and each other; angle 0 is the first.
        tolerance: how far off a face still counts as on it.

    Returns (low, high) angle spans in radians within [0, 2pi], or None if it can't be computed
    exactly. Default: from the convex hull's faces, when the primitive is exactly its hull.
    """
    from .edges_on_csg import _plane_angles, convex_hull
    if not self.is_exactly_convex_hull():
        return None
    hull = convex_hull(self)
    if hull.is_unknown:
        return None
    return [] if hull.is_empty else _plane_angles(hull.require_faces(), at, direction, basis, tolerance)

line_crossings

line_crossings(origin: ndarray, direction: ndarray) -> List[float]

Return the parameters along the line origin + t * direction where it crosses this primitive's surfaces: the places the material around the line can change.

origin and unit direction are numpy, in this primitive's space. Default: where the line crosses each face plane of the convex hull.

Source code in kumiki/csg/cutcsg.py
def line_crossings(self, origin: np.ndarray, direction: np.ndarray) -> List[float]:
    """Return the parameters along the line `origin + t * direction` where it crosses this primitive's
    surfaces: the places the material around the line can change.

    `origin` and unit `direction` are numpy, in this primitive's space. Default: where the line
    crosses each face plane of the convex hull.
    """
    from .edges_on_csg import _plane_stations, convex_hull
    hull = convex_hull(self)
    if hull.is_unknown or hull.is_empty:
        return []
    return _plane_stations(hull.require_faces(), origin, direction)

plane_sides

plane_sides(frame: PlaneFrame, seed: PolygonRegions) -> Sides

Return where this primitive cuts the plane of frame, as (front, back) regions within seed.

front is where it has material just on the side the plane's normal points to, back just on the other side (see cross_sections.Sides). Default: from the convex hull. Raises cross_sections.CannotCrossSection if the primitive can't be cut.

Source code in kumiki/csg/cutcsg.py
def plane_sides(self, frame: 'PlaneFrame', seed: 'PolygonRegions') -> 'Sides':
    """Return where this primitive cuts the plane of `frame`, as (front, back) regions within `seed`.

    front is where it has material just on the side the plane's normal points to, back just on the
    other side (see cross_sections.Sides). Default: from the convex hull. Raises
    cross_sections.CannotCrossSection if the primitive can't be cut.
    """
    from .cross_sections import _CannotCrossSection, _half_space_cross_section
    from .edges_on_csg import convex_hull
    hull = convex_hull(self)
    if hull.is_empty:
        return [], []
    if hull.is_unknown:
        raise _CannotCrossSection(type(self).__name__)
    return _half_space_cross_section(hull.require_faces(), frame, seed)

CSGParity

Bases: Enum

Whether a node adds material to the result (ADDITIVE) or removes it (SUBTRACTIVE).

ADDITIVE class-attribute instance-attribute

ADDITIVE = 0

SUBTRACTIVE class-attribute instance-attribute

SUBTRACTIVE = 1

flipped

flipped() -> CSGParity
Source code in kumiki/csg/cutcsg.py
def flipped(self) -> 'CSGParity':
    return CSGParity.SUBTRACTIVE if self is CSGParity.ADDITIVE else CSGParity.ADDITIVE

EmptyCSG dataclass

Bases: CutCSG

A solid with no points.

display_name classmethod

display_name() -> str
Source code in kumiki/csg/cutcsg.py
@classmethod
def display_name(cls) -> str:
    return "empty"

__repr__

__repr__() -> str
Source code in kumiki/csg/cutcsg.py
def __repr__(self) -> str:
    return "EmptyCSG()"

carriers

carriers() -> Dict[FeatureKey, Carrier]
Source code in kumiki/csg/cutcsg.py
def carriers(self) -> Dict[FeatureKey, Carrier]:
    return {}

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool
Source code in kumiki/csg/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/csg/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/csg/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    return None

get_aabb

get_aabb() -> AxisAlignedBoundingBox
Source code in kumiki/csg/cutcsg.py
def get_aabb(self) -> 'AxisAlignedBoundingBox':
    return AxisAlignedBoundingBox(
        min_x=0,
        min_y=0,
        min_z=0,
        max_x=0,
        max_y=0,
        max_z=0,
        is_empty=True,
    )

get_convex_hull

get_convex_hull() -> ConvexHull
Source code in kumiki/csg/cutcsg.py
def get_convex_hull(self) -> 'ConvexHull':
    from .edges_on_csg import EMPTY_HULL
    return EMPTY_HULL

is_exactly_convex_hull

is_exactly_convex_hull() -> bool
Source code in kumiki/csg/cutcsg.py
def is_exactly_convex_hull(self) -> bool:
    return True

HalfSpace dataclass

Bases: HasFeatures, CutCSG

An infinite half space: all points P with P · normal >= offset.

Parameters:

Name Type Description Default
normal

points into the half space.

required
offset

the plane's signed distance from the origin along normal.

required

normal instance-attribute

normal: Direction3D

offset class-attribute instance-attribute

offset: Numeric = scalar(0)

default_features

default_features() -> Dict[FeatureKey, CSGFeature]

Return its one face.

Source code in kumiki/csg/cutcsg.py
def default_features(self) -> Dict[FeatureKey, CSGFeature]:
    """Return its one face."""
    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/csg/cutcsg.py
@classmethod
def display_name(cls) -> str:
    return "half-space"

__repr__

__repr__() -> str
Source code in kumiki/csg/cutcsg.py
def __repr__(self) -> str:
    return f"HalfSpace(normal={self.normal.T}, offset={self.offset})"

carriers

carriers() -> Dict[FeatureKey, Carrier]
Source code in kumiki/csg/cutcsg.py
def carriers(self) -> Dict[FeatureKey, Carrier]:
    return face_plane_carriers(self)

has_carrier

has_carrier(key: FeatureKey) -> bool
Source code in kumiki/csg/cutcsg.py
def has_carrier(self, key: FeatureKey) -> bool:
    return has_face_plane_carrier(self, key)

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool

Return whether P · normal >= offset.

Source code in kumiki/csg/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether P · normal >= offset."""
    # 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

Return whether P · normal == offset.

Source code in kumiki/csg/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether P · normal == offset."""
    # 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]

Return -normal.

Source code in kumiki/csg/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    """Return -normal."""
    return -self.normal

get_aabb

get_aabb() -> AxisAlignedBoundingBox
Source code in kumiki/csg/cutcsg.py
def get_aabb(self) -> AxisAlignedBoundingBox:
    warnings.warn(
        "get_aabb() called on HalfSpace, which has infinite extent — result is unbounded",
        UserWarning,
        stacklevel=2,
    )
    return AxisAlignedBoundingBox(None, None, None, None, None, None)

get_convex_hull

get_convex_hull() -> ConvexHull
Source code in kumiki/csg/cutcsg.py
def get_convex_hull(self) -> 'ConvexHull':
    from .edges_on_csg import ConvexHull
    # Inside is p . normal >= offset, so the outward normal is the other way.
    normal = unit_vector(self.normal)
    return ConvexHull.of([(-normal, normal * self.offset)])

is_exactly_convex_hull

is_exactly_convex_hull() -> bool
Source code in kumiki/csg/cutcsg.py
def is_exactly_convex_hull(self) -> bool:
    return True

RectangularPrism dataclass

Bases: HasFeatures, CutCSG

A prism with rectangular cross-section, optionally infinite at either end. Parameterized like a Timber.

The cross-section (size = width along local X, height along local Y) is centred at the transform's position; the prism runs from start_distance to end_distance along local Z. None for either distance makes that end infinite.

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]
Source code in kumiki/csg/cutcsg.py
def default_features(self) -> Dict[FeatureKey, CSGFeature]:
    if RectangularPrism._default_features_memo is None:
        RectangularPrism._default_features_memo = self._build_default_features()
    return dict(RectangularPrism._default_features_memo)

display_name classmethod

display_name() -> str
Source code in kumiki/csg/cutcsg.py
@classmethod
def display_name(cls) -> str:
    return "prism"

__post_init__

__post_init__()
Source code in kumiki/csg/cutcsg.py
def __post_init__(self):
    super().__post_init__()
    m = self.transform.orientation.matrix
    axes = (Matrix([m[0, 0], m[1, 0], m[2, 0]]), Matrix([m[0, 1], m[1, 1], m[2, 1]]),
            Matrix([m[0, 2], m[1, 2], m[2, 2]]))
    object.__setattr__(self, "_axes_precomputed", axes)

get_bottom_position

get_bottom_position() -> V3

Return the centre of the start end. Raises ValueError if it is infinite.

Source code in kumiki/csg/cutcsg.py
def get_bottom_position(self) -> V3:
    """Return the centre of the start end. Raises ValueError if it is infinite."""
    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

Return the centre of the far end. Raises ValueError if it is infinite.

Source code in kumiki/csg/cutcsg.py
def get_top_position(self) -> V3:
    """Return the centre of the far end. Raises ValueError if it is infinite."""
    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/csg/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

Return whether every parameter equals other's.

Source code in kumiki/csg/cutcsg.py
def equals_prism(self, other: 'RectangularPrism') -> bool:
    """Return whether every parameter equals `other`'s."""
    # 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

carriers

carriers() -> Dict[FeatureKey, Carrier]
Source code in kumiki/csg/cutcsg.py
def carriers(self) -> Dict[FeatureKey, Carrier]:
    return face_plane_carriers(self)

has_carrier

has_carrier(key: FeatureKey) -> bool

Return whether key is one of this prism's carriers: a side, a finite end, or an extra plane.

Source code in kumiki/csg/cutcsg.py
def has_carrier(self, key: FeatureKey) -> bool:
    """Return whether `key` is one of this prism's carriers: a side, a finite end, or an extra plane."""
    if key == _PRISM_CAP_KEYS[PrismFace.TOP]:
        return self.end_distance is not None
    if key == _PRISM_CAP_KEYS[PrismFace.BOTTOM]:
        return self.start_distance is not None
    if key[0] is FeatureCategory.SIDE:
        return 0 <= key[1] < len(_PRISM_SIDE_ORDER)
    return has_face_plane_carrier(self, key)

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool

Return whether point is inside or on the boundary.

Source code in kumiki/csg/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether `point` is inside or on the boundary."""
    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

Return whether point is on the boundary.

Source code in kumiki/csg/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether `point` is on the boundary."""
    # 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]
Source code in kumiki/csg/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    return next(self._each_outward_normal(point, eps=eps), None)

get_outward_normals

get_outward_normals(point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]
Source code in kumiki/csg/cutcsg.py
def get_outward_normals(self, point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]:
    return list(self._each_outward_normal(point, eps=eps))

get_aabb

get_aabb() -> AxisAlignedBoundingBox
Source code in kumiki/csg/cutcsg.py
def get_aabb(self) -> AxisAlignedBoundingBox:
    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 AxisAlignedBoundingBox(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 AxisAlignedBoundingBox(
        _numeric_min(*xs), _numeric_min(*ys), _numeric_min(*zs),
        _numeric_max(*xs), _numeric_max(*ys), _numeric_max(*zs),
    )

get_convex_hull

get_convex_hull() -> ConvexHull
Source code in kumiki/csg/cutcsg.py
def get_convex_hull(self) -> 'ConvexHull':
    from .edges_on_csg import ConvexHull, _extrusion_caps
    width_dir, height_dir, length_dir = self._local_axes()
    centre = self.transform.position
    half_width, half_height = self.size[0] / scalar(2), self.size[1] / scalar(2)
    return ConvexHull.of([
        (width_dir, centre + width_dir * half_width),
        (-width_dir, centre - width_dir * half_width),
        (height_dir, centre + height_dir * half_height),
        (-height_dir, centre - height_dir * half_height),
        *_extrusion_caps(length_dir, centre, self.start_distance, self.end_distance),
    ])

is_exactly_convex_hull

is_exactly_convex_hull() -> bool
Source code in kumiki/csg/cutcsg.py
def is_exactly_convex_hull(self) -> bool:
    return True

Cylinder dataclass

Bases: HasFeatures, CutCSG

A cylinder, optionally infinite at either end.

The axis runs through position along axis_direction, from start_distance to end_distance; None for either makes that end infinite.

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]

Return its two caps and the barrel.

Source code in kumiki/csg/cutcsg.py
def default_features(self) -> Dict[FeatureKey, CSGFeature]:
    """Return its two caps and the barrel."""
    features: Dict[FeatureKey, CSGFeature] = {
        key: SimpleCylinderCapFeature(name=default_feature_name(key), part=part,
                                      properties=_DEFAULT_FEATURE_PROPERTIES)
        for key, part in ((START_CAP, CylinderPart.BOTTOM), (END_CAP, CylinderPart.TOP))
    }
    features[CYLINDER_BARREL] = SimpleCylinderBarrelFeature(
        name=default_feature_name(CYLINDER_BARREL), properties=_DEFAULT_FEATURE_PROPERTIES)
    return features

carriers

carriers() -> Dict[FeatureKey, Carrier]

Return the axis, the barrel around it, and a plane for each finite cap.

Source code in kumiki/csg/cutcsg.py
def carriers(self) -> Dict[FeatureKey, Carrier]:
    """Return the axis, the barrel around it, and a plane for each finite cap."""
    carriers = face_plane_carriers(self)
    carriers[CYLINDER_AXIS] = CarrierLine(
        Line(direction=safe_normalize_vector(self.axis_direction), point=self.position))
    carriers[CYLINDER_BARREL] = CarrierBarrel(axis=CYLINDER_AXIS, radius=self.radius)
    return carriers

has_carrier

has_carrier(key: FeatureKey) -> bool
Source code in kumiki/csg/cutcsg.py
def has_carrier(self, key: FeatureKey) -> bool:
    return key in (CYLINDER_AXIS, CYLINDER_BARREL) or has_face_plane_carrier(self, key)

__repr__

__repr__() -> str
Source code in kumiki/csg/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

Return whether point is inside or on the boundary.

Source code in kumiki/csg/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether `point` is inside or on the boundary."""
    # 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

Return whether point is on the barrel or a finite cap.

Source code in kumiki/csg/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether `point` is on the barrel or a finite cap."""
    # 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]
Source code in kumiki/csg/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    return next(self._each_outward_normal(point, eps=eps), None)

get_outward_normals

get_outward_normals(point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]
Source code in kumiki/csg/cutcsg.py
def get_outward_normals(self, point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]:
    return list(self._each_outward_normal(point, eps=eps))

get_aabb

get_aabb() -> AxisAlignedBoundingBox
Source code in kumiki/csg/cutcsg.py
def get_aabb(self) -> AxisAlignedBoundingBox:
    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 AxisAlignedBoundingBox(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 AxisAlignedBoundingBox(
        bounds[0][0], bounds[1][0], bounds[2][0],
        bounds[0][1], bounds[1][1], bounds[2][1],
    )

get_convex_hull

get_convex_hull() -> ConvexHull

Return a hexagonal prism circumscribing the cylinder, capped at its finite ends. It contains the cylinder.

Source code in kumiki/csg/cutcsg.py
def get_convex_hull(self) -> 'ConvexHull':
    """Return a hexagonal prism circumscribing the cylinder, capped at its finite ends. It contains the cylinder."""
    from .edges_on_csg import ConvexHull, _extrusion_caps
    axis = unit_vector(self.axis_direction)
    across, up = perpendicular_axes(axis)
    faces = []
    for step in range(6):
        angle = math.pi * step / 3
        normal = across * scalar(math.cos(angle)) + up * scalar(math.sin(angle))
        faces.append((normal, self.position + normal * self.radius))
    return ConvexHull.of(faces + _extrusion_caps(axis, self.position, self.start_distance, self.end_distance))

exact_line_spans

exact_line_spans(line: Line, seed: Span, tolerance: float, removing: bool) -> Optional[List[Span]]

Solved exactly: the barrel as a quadratic along the line, the caps as planes.

Source code in kumiki/csg/cutcsg.py
def exact_line_spans(self, line: Line, seed: 'Span', tolerance: float, removing: bool) -> Optional[List['Span']]:
    """Solved exactly: the barrel as a quadratic along the line, the caps as planes."""
    from .edges_on_csg import _cylinder_spans
    return _cylinder_spans(self, line, seed, tolerance, removing)

angles_around_line

angles_around_line(at: ndarray, direction: ndarray, basis: Tuple[ndarray, ndarray], tolerance: float) -> Optional[AngleSpans]

Solved exactly: the caps, and half a turn about the barrel's tangent plane where at is on it.

Source code in kumiki/csg/cutcsg.py
def angles_around_line(self, at: np.ndarray, direction: np.ndarray, basis: Tuple[np.ndarray, np.ndarray],
                       tolerance: float) -> Optional['AngleSpans']:
    """Solved exactly: the caps, and half a turn about the barrel's tangent plane where `at` is on it."""
    from .edges_on_csg import _IN_PLANE_EPS, _angles_intersection, _extrusion_caps, _half_turn, _plane_angles
    axis = np.array([float(v) for v in unit_vector(self.axis_direction)])
    offset = at - np.array([float(self.position[i, 0]) for i in range(3)])
    radial = offset - axis * float(offset @ axis)
    distance = float(np.linalg.norm(radial)) - float(self.radius)
    if distance > tolerance + _IN_PLANE_EPS:
        return []
    caps = _extrusion_caps(unit_vector(self.axis_direction), self.position, self.start_distance, self.end_distance)
    angles = _plane_angles(caps, at, direction, basis, tolerance)
    if angles and distance >= -(tolerance + _IN_PLANE_EPS):
        across = radial - direction * float(radial @ direction)
        if float(np.linalg.norm(across)) > 1e-12:
            angles = _angles_intersection(angles, _half_turn(across, basis))
    return angles

line_crossings

line_crossings(origin: ndarray, direction: ndarray) -> List[float]

Return the parameters along the line where it crosses the barrel and the finite caps.

Source code in kumiki/csg/cutcsg.py
def line_crossings(self, origin: np.ndarray, direction: np.ndarray) -> List[float]:
    """Return the parameters along the line where it crosses the barrel and the finite caps."""
    from .edges_on_csg import _extrusion_caps, _plane_stations
    axis = np.array([float(v) for v in unit_vector(self.axis_direction)])
    start = origin - np.array([float(self.position[i, 0]) for i in range(3)])
    across, offset = direction - axis * float(direction @ axis), start - axis * float(start @ axis)
    a, b = float(across @ across), 2 * float(offset @ across)
    c = float(offset @ offset) - float(self.radius) ** 2
    found: List[float] = []
    if a > 1e-18 and b * b - 4 * a * c > 0:
        root = (b * b - 4 * a * c) ** 0.5
        found += [(-b - root) / (2 * a), (-b + root) / (2 * a)]
    caps = _extrusion_caps(unit_vector(self.axis_direction), self.position, self.start_distance, self.end_distance)
    return found + _plane_stations(caps, origin, direction)

plane_sides

plane_sides(frame: PlaneFrame, seed: PolygonRegions) -> Sides

Cut exactly: an ellipse (or a strip, for a plane along the axis), then the caps.

Source code in kumiki/csg/cutcsg.py
def plane_sides(self, frame: 'PlaneFrame', seed: 'PolygonRegions') -> 'Sides':
    """Cut exactly: an ellipse (or a strip, for a plane along the axis), then the caps."""
    from .cross_sections import _cylinder_cross_section
    return _cylinder_cross_section(self, frame, seed)

SolidsAtPoint

SolidsAtPoint(solids: Sequence[CutCSG], point: V3, eps: Optional[Numeric] = None)

Caches contains_point and is_point_on_boundary of one point against several solids.

Answers: any_holds, any_encloses, any_on_surface, outward_normals, average_outward_normal, close_around_it.

Source code in kumiki/csg/cutcsg.py
def __init__(
    self,
    solids: Sequence['CutCSG'],
    point: V3,
    eps: Optional[Numeric] = None,
) -> None:
    self.point = point
    self.eps = eps
    self.on_surface: List['CutCSG'] = []
    self.enclosing: List['CutCSG'] = []
    for solid in solids:
        if not solid.contains_point(point, eps=eps):
            continue
        if solid.is_point_on_boundary(point, eps=eps):
            self.on_surface.append(solid)
        else:
            self.enclosing.append(solid)
    self.holding = [*self.on_surface, *self.enclosing]

point instance-attribute

point = point

eps instance-attribute

eps = eps

on_surface instance-attribute

on_surface: List[CutCSG] = []

enclosing instance-attribute

enclosing: List[CutCSG] = []

holding instance-attribute

holding = [*(self.on_surface), *(self.enclosing)]

any_holds

any_holds() -> bool

Return whether any solid contains the point.

Source code in kumiki/csg/cutcsg.py
def any_holds(self) -> bool:
    """Return whether any solid contains the point."""
    return bool(self.holding)

any_encloses

any_encloses() -> bool

Return whether any solid has the point strictly inside.

Source code in kumiki/csg/cutcsg.py
def any_encloses(self) -> bool:
    """Return whether any solid has the point strictly inside."""
    return bool(self.enclosing)

any_on_surface

any_on_surface() -> bool
Source code in kumiki/csg/cutcsg.py
def any_on_surface(self) -> bool:
    return bool(self.on_surface)

outward_normals

outward_normals() -> List[Optional[Direction3D]]

Return each on-surface solid's outward normal at the point.

Source code in kumiki/csg/cutcsg.py
def outward_normals(self) -> List[Optional[Direction3D]]:
    """Return each on-surface solid's outward normal at the point."""
    return [solid.get_outward_normal(self.point, eps=self.eps)
            for solid in self.on_surface]

every_outward_normal

every_outward_normal() -> Optional[List[Direction3D]]

Return each face normal of the single solid the point is on the surface of; None if several or none.

Source code in kumiki/csg/cutcsg.py
def every_outward_normal(self) -> Optional[List[Direction3D]]:
    """Return each face normal of the single solid the point is on the surface of; None if several or none."""
    if len(self.on_surface) != 1:
        return None
    return self.on_surface[0].get_outward_normals(self.point, eps=self.eps)

average_outward_normal

average_outward_normal(negated: bool = False) -> Optional[Direction3D]

Return the mean outward normal (negated if negated), or None if they cancel or are unknown.

Source code in kumiki/csg/cutcsg.py
def average_outward_normal(self, negated: bool = False) -> Optional[Direction3D]:
    """Return the mean outward normal (negated if `negated`), or None if they cancel or are unknown."""
    normals = [normal for normal in self.outward_normals() if normal is not None]
    if negated:
        normals = [-normal for normal in normals]
    if not normals:
        return None
    if len(normals) == 1:
        return normals[0]
    total = sum(normals[1:], normals[0])
    size = safe_norm(total)
    if safe_zero_test(size, eps=self.eps):
        return None
    return total / size

close_around_it

close_around_it() -> bool

Return whether the surfaces at the point enclose it, so it is inside their union.

E.g. a point on the shared face of two touching prisms. True when no direction leaves every surface. Exact for one or two surfaces; False if any normal is unknown.

Source code in kumiki/csg/cutcsg.py
def close_around_it(self) -> bool:
    """Return whether the surfaces at the point enclose it, so it is inside their union.

    E.g. a point on the shared face of two touching prisms. True when no direction leaves every
    surface. Exact for one or two surfaces; False if any normal is unknown.
    """
    normals = [normal for normal in self.outward_normals() if normal is not None]
    if len(normals) < 2 or len(normals) != len(self.on_surface):
        return False

    units = [safe_normalize_vector(normal) for normal in normals]
    candidates = [sum(units[1:], units[0])]
    candidates.extend(units)
    candidates.extend(one + other
                      for index, one in enumerate(units)
                      for other in units[index + 1:])
    for candidate in candidates:
        size = safe_norm(candidate)
        if safe_zero_test(size):
            continue
        direction = candidate / size
        if all(safe_compare(safe_dot_product(direction, unit), 0, Comparison.GT)
               for unit in units):
            return False
    return True

SolidUnion dataclass

Bases: CutCSG

Union of children: points in any child.

children instance-attribute

children: List[CutCSG]

display_name classmethod

display_name() -> str
Source code in kumiki/csg/cutcsg.py
@classmethod
def display_name(cls) -> str:
    return "union"

__post_init__

__post_init__() -> None
Source code in kumiki/csg/cutcsg.py
def __post_init__(self) -> None:
    assert_distinct_children("SolidUnion", self.children)

__repr__

__repr__() -> str
Source code in kumiki/csg/cutcsg.py
def __repr__(self) -> str:
    return f"SolidUnion({len(self.children)} children)"

carriers

carriers() -> Dict[FeatureKey, Carrier]
Source code in kumiki/csg/cutcsg.py
def carriers(self) -> Dict[FeatureKey, Carrier]:
    return {}

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool

Return whether any child contains point.

Source code in kumiki/csg/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether any child contains `point`."""
    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

Return whether point is on some child's boundary and not enclosed by the union.

Source code in kumiki/csg/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether `point` is on some child's boundary and not enclosed by the union."""
    at = SolidsAtPoint(self.children, point, eps=eps)
    if not at.any_holds() or at.any_encloses():
        return False
    # Two children can share a face with neither holding the point inside,
    # and that face is the middle of the union rather than its surface.
    return at.any_on_surface() and not at.close_around_it()

get_outward_normal

get_outward_normal(point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]

Return the average outward normal of the children point is on the boundary of, or None.

Source code in kumiki/csg/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    """Return the average outward normal of the children `point` is on the boundary of, or None."""
    return SolidsAtPoint(self.children, point, eps=eps).average_outward_normal()

get_outward_normals

get_outward_normals(point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]
Source code in kumiki/csg/cutcsg.py
def get_outward_normals(self, point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]:
    own = SolidsAtPoint(self.children, point, eps=eps).every_outward_normal()
    return own if own is not None else super().get_outward_normals(point, eps=eps)

get_aabb

get_aabb() -> AxisAlignedBoundingBox
Source code in kumiki/csg/cutcsg.py
def get_aabb(self) -> AxisAlignedBoundingBox:
    # 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 AxisAlignedBoundingBox(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 AxisAlignedBoundingBox(
        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

Intersection of left and right: points in both.

left instance-attribute

left: CutCSG

right instance-attribute

right: CutCSG

__post_init__

__post_init__() -> None
Source code in kumiki/csg/cutcsg.py
def __post_init__(self) -> None:
    assert_distinct_children("Intersection", (self.left, self.right))

__repr__

__repr__() -> str
Source code in kumiki/csg/cutcsg.py
def __repr__(self) -> str:
    return f"Intersection(left={self.left}, right={self.right})"

carriers

carriers() -> Dict[FeatureKey, Carrier]
Source code in kumiki/csg/cutcsg.py
def carriers(self) -> Dict[FeatureKey, Carrier]:
    return {}

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool
Source code in kumiki/csg/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/csg/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]

Return the outward normal at point, averaged where both sides' surfaces meet.

Source code in kumiki/csg/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    """Return the outward normal at `point`, averaged where both sides' surfaces meet."""
    at = SolidsAtPoint([self.left, self.right], point, eps=eps)
    average = at.average_outward_normal()
    if average is not None:
        return average
    # Two surfaces facing opposite ways average to nothing. Either one
    # describes the corner as well as the other, so take the first.
    for normal in at.outward_normals():
        if normal is not None:
            return normal
    return None

get_outward_normals

get_outward_normals(point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]
Source code in kumiki/csg/cutcsg.py
def get_outward_normals(self, point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]:
    own = SolidsAtPoint([self.left, self.right], point, eps=eps).every_outward_normal()
    return own if own is not None else super().get_outward_normals(point, eps=eps)

get_aabb

get_aabb() -> AxisAlignedBoundingBox
Source code in kumiki/csg/cutcsg.py
def get_aabb(self) -> AxisAlignedBoundingBox:
    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 AxisAlignedBoundingBox(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 AxisAlignedBoundingBox(
        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

base minus every solid in subtract.

base instance-attribute

base: CutCSG

subtract instance-attribute

subtract: List[CutCSG]

__post_init__

__post_init__() -> None
Source code in kumiki/csg/cutcsg.py
def __post_init__(self) -> None:
    assert_distinct_children("Difference", self.subtract)

__repr__

__repr__() -> str
Source code in kumiki/csg/cutcsg.py
def __repr__(self) -> str:
    return f"Difference(base={self.base}, subtract={len(self.subtract)} objects)"

carriers

carriers() -> Dict[FeatureKey, Carrier]
Source code in kumiki/csg/cutcsg.py
def carriers(self) -> Dict[FeatureKey, Carrier]:
    return {}

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool

Return whether point is in base and not strictly inside any subtracted solid (nor on a flush cut).

Source code in kumiki/csg/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether `point` is in `base` and not strictly inside any subtracted solid (nor on a flush cut)."""
    if not self.base.contains_point(point, eps=eps):
        return False

    removed = SolidsAtPoint(self.subtract, point, eps=eps)
    if removed.any_encloses():
        return False
    # Several subtracts can close around a point none of them holds inside
    # -- two mortises meeting share a face in the middle of one cavity.
    if removed.close_around_it():
        return False
    if not removed.any_on_surface():
        return True
    if not self.base.is_point_on_boundary(point, eps=eps):
        return True
    return not self._cut_is_flush_with_the_base(removed, point, eps=eps)

is_point_on_boundary

is_point_on_boundary(point: V3, eps: Optional[Numeric] = None) -> bool

Return whether point is in the difference and on the base's surface or a cut's wall.

Source code in kumiki/csg/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether `point` is in the difference and on the base's surface or a cut's wall."""
    if not self.contains_point(point, eps=eps):
        return False
    if self.base.is_point_on_boundary(point, eps=eps):
        return True
    return SolidsAtPoint(self.subtract, point, eps=eps).any_on_surface()

get_outward_normal

get_outward_normal(point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]

Return the base's normal at point, else the average negated normal of the cuts there; None if unknown.

Source code in kumiki/csg/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    """Return the base's normal at `point`, else the average negated normal of the cuts there; None if unknown."""
    # 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
    return SolidsAtPoint(self.subtract, point, eps=eps).average_outward_normal(negated=True)

get_outward_normals

get_outward_normals(point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]

Return the base's normals at point, plus the negated normals of cuts through it not parallel to one.

Source code in kumiki/csg/cutcsg.py
def get_outward_normals(self, point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]:
    """Return the base's normals at `point`, plus the negated normals of cuts through it not parallel to one."""
    if not self.base.is_point_on_boundary(point, eps=eps):
        return super().get_outward_normals(point, eps=eps)
    base_normals = list(self.base.get_outward_normals(point, eps=eps))
    normals = list(base_normals)
    for solid in SolidsAtPoint(self.subtract, point, eps=eps).on_surface:
        for normal in solid.get_outward_normals(point, eps=eps):
            along_base = any(
                safe_equality_test(safe_dot_product(base_normal, normal), 1, eps=eps)
                or safe_equality_test(safe_dot_product(base_normal, normal), -1, eps=eps)
                for base_normal in base_normals)
            if not along_base:
                normals.append(-normal)
    return normals

get_aabb

get_aabb() -> AxisAlignedBoundingBox
Source code in kumiki/csg/cutcsg.py
def get_aabb(self) -> AxisAlignedBoundingBox:
    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

ConvexPolygonExtrusion dataclass

Bases: HasFeatures, CutCSG

A convex polygon in the local XY plane extruded along local Z, optionally infinite at either end.

Parameters:

Name Type Description Default
points

the polygon's (x, y) points in order; must be convex.

required
transform

the polygon's position and orientation.

required
start_distance, end_distance

extent along local Z; None for 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]

Return its two caps, a side per profile edge, and their edges.

Source code in kumiki/csg/cutcsg.py
def default_features(self) -> Dict[FeatureKey, CSGFeature]:
    """Return its two caps, a side per profile edge, and their edges."""
    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)
    features.update(convex_profile_edge_features(len(self.points)))
    return features

display_name classmethod

display_name() -> str
Source code in kumiki/csg/cutcsg.py
@classmethod
def display_name(cls) -> str:
    return "extrusion"

get_bottom_position

get_bottom_position() -> V3

Return the profile origin at start_distance. Raises ValueError if infinite.

Source code in kumiki/csg/cutcsg.py
def get_bottom_position(self) -> V3:
    """Return the profile origin at `start_distance`. Raises ValueError if infinite."""
    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

Return the profile origin at end_distance. Raises ValueError if infinite.

Source code in kumiki/csg/cutcsg.py
def get_top_position(self) -> V3:
    """Return the profile origin at `end_distance`. Raises ValueError if infinite."""
    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/csg/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

Return whether it has at least 3 points, a convex polygon, and end > start when both are finite.

Source code in kumiki/csg/cutcsg.py
def is_valid(self) -> bool:
    """Return whether it has at least 3 points, a convex polygon, and end > start when both are finite."""
    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)))

carriers

carriers() -> Dict[FeatureKey, Carrier]
Source code in kumiki/csg/cutcsg.py
def carriers(self) -> Dict[FeatureKey, Carrier]:
    return face_plane_carriers(self)

has_carrier

has_carrier(key: FeatureKey) -> bool
Source code in kumiki/csg/cutcsg.py
def has_carrier(self, key: FeatureKey) -> bool:
    return has_face_plane_carrier(self, key)

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool

Return whether point is inside or on the boundary.

Source code in kumiki/csg/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether `point` is inside or on the boundary."""
    # 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, whichever way the
    # profile is wound -- see point_in_convex_profile.
    return point_in_convex_profile(self.points, Matrix([x_coord, y_coord]), eps)

is_point_on_boundary

is_point_on_boundary(point: V3, eps: Optional[Numeric] = None) -> bool

Return whether point is on a cap or a side.

Source code in kumiki/csg/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether `point` is on a cap or a side."""
    # 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]
Source code in kumiki/csg/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    return next(self._each_outward_normal(point, eps=eps), None)

get_outward_normals

get_outward_normals(point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]
Source code in kumiki/csg/cutcsg.py
def get_outward_normals(self, point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]:
    return list(self._each_outward_normal(point, eps=eps))

get_aabb

get_aabb() -> AxisAlignedBoundingBox
Source code in kumiki/csg/cutcsg.py
def get_aabb(self) -> AxisAlignedBoundingBox:
    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 AxisAlignedBoundingBox(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 AxisAlignedBoundingBox(
        _numeric_min(*xs), _numeric_min(*ys), _numeric_min(*zs),
        _numeric_max(*xs), _numeric_max(*ys), _numeric_max(*zs),
    )

get_convex_hull

get_convex_hull() -> ConvexHull
Source code in kumiki/csg/cutcsg.py
def get_convex_hull(self) -> 'ConvexHull':
    from .edges_on_csg import _hull_or_unknown, _extruded_hull_half_spaces
    return _hull_or_unknown(_extruded_hull_half_spaces(
        [(float(x), float(y)) for x, y in self.points], self.transform, self.start_distance, self.end_distance))

is_exactly_convex_hull

is_exactly_convex_hull() -> bool
Source code in kumiki/csg/cutcsg.py
def is_exactly_convex_hull(self) -> bool:
    return True

ConvexPolygonSimpleLoft dataclass

Bases: HasFeatures, CutCSG

A solid lofted between two convex polygons in parallel planes, vertex i to vertex i.

Both profiles must be convex, with the same point count and winding, and every side planar (no twist; see is_valid). Profiles lie in the local XY plane at bottom_points_z_pos and top_points_z_pos along local Z; both must be finite.

bottom_points instance-attribute

bottom_points: Profile

top_points instance-attribute

top_points: Profile

bottom_points_z_pos instance-attribute

bottom_points_z_pos: Numeric

top_points_z_pos instance-attribute

top_points_z_pos: Numeric

transform class-attribute instance-attribute

transform: Transform = field(default_factory=Transform.identity)

default_features

default_features() -> Dict[FeatureKey, CSGFeature]

Return its two caps, a side per profile edge, and their edges.

Source code in kumiki/csg/cutcsg.py
def default_features(self) -> Dict[FeatureKey, CSGFeature]:
    """Return its two caps, a side per profile edge, and their edges."""
    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)
    features.update(convex_profile_edge_features(len(self.bottom_points)))
    return features

display_name classmethod

display_name() -> str
Source code in kumiki/csg/cutcsg.py
@classmethod
def display_name(cls) -> str:
    return "loft"

get_bottom_position

get_bottom_position() -> V3

Return the profile origin at bottom_points_z_pos.

Source code in kumiki/csg/cutcsg.py
def get_bottom_position(self) -> V3:
    """Return the profile origin at `bottom_points_z_pos`."""
    return self.transform.position - safe_transform_vector(self.transform.orientation.matrix, Matrix([scalar(0), scalar(0), self.bottom_points_z_pos]))

get_top_position

get_top_position() -> V3

Return the profile origin at top_points_z_pos.

Source code in kumiki/csg/cutcsg.py
def get_top_position(self) -> V3:
    """Return the profile origin at `top_points_z_pos`."""
    return self.transform.position + safe_transform_vector(self.transform.orientation.matrix, Matrix([scalar(0), scalar(0), self.top_points_z_pos]))

__repr__

__repr__() -> str
Source code in kumiki/csg/cutcsg.py
def __repr__(self) -> str:
    return (f"ConvexPolygonSimpleLoft({len(self.bottom_points)}->{len(self.top_points)} points, "
            f"transform={self.transform}, start={self.bottom_points_z_pos}, end={self.top_points_z_pos})")

is_valid

is_valid() -> bool

Return whether both profiles are convex with 3+ matching points, top is above bottom, and every side is planar.

Source code in kumiki/csg/cutcsg.py
def is_valid(self) -> bool:
    """Return whether both profiles are convex with 3+ matching points, top is above bottom, and every side is planar."""
    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.top_points_z_pos, self.bottom_points_z_pos, 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.
    if bottom_winding is None or bottom_winding != top_winding:
        return False
    return self._sides_are_planar()

carriers

carriers() -> Dict[FeatureKey, Carrier]
Source code in kumiki/csg/cutcsg.py
def carriers(self) -> Dict[FeatureKey, Carrier]:
    return face_plane_carriers(self)

has_carrier

has_carrier(key: FeatureKey) -> bool
Source code in kumiki/csg/cutcsg.py
def has_carrier(self, key: FeatureKey) -> bool:
    return has_face_plane_carrier(self, key)

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool

Return whether point is inside or on the boundary.

Source code in kumiki/csg/cutcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether `point` is inside or on the boundary."""
    x_coord, y_coord, z_coord = self._local_coords(point)

    if safe_compare(z_coord - self.bottom_points_z_pos, 0, Comparison.LT, eps=eps):
        return False
    if safe_compare(z_coord - self.top_points_z_pos, 0, Comparison.GT, eps=eps):
        return False

    cross_section = self._cross_section_at(self._height_fraction(z_coord))

    return point_in_convex_profile(cross_section, Matrix([x_coord, y_coord]), eps)

is_point_on_boundary

is_point_on_boundary(point: V3, eps: Optional[Numeric] = None) -> bool

Return whether point is on the boundary.

Source code in kumiki/csg/cutcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    """Return whether `point` is on the boundary."""
    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.bottom_points_z_pos, eps=eps):
        return True
    if safe_zero_test(z_coord - self.top_points_z_pos, 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]
Source code in kumiki/csg/cutcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    return next(self._each_outward_normal(point, eps=eps), None)

get_outward_normals

get_outward_normals(point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]
Source code in kumiki/csg/cutcsg.py
def get_outward_normals(self, point: V3, eps: Optional[Numeric] = None) -> List[Direction3D]:
    return list(self._each_outward_normal(point, eps=eps))

get_aabb

get_aabb() -> AxisAlignedBoundingBox
Source code in kumiki/csg/cutcsg.py
def get_aabb(self) -> AxisAlignedBoundingBox:
    corners_global = (
        [self.transform.local_to_global(Matrix([pt[0], pt[1], self.bottom_points_z_pos])) for pt in self.bottom_points] +
        [self.transform.local_to_global(Matrix([pt[0], pt[1], self.top_points_z_pos])) 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 AxisAlignedBoundingBox(
        _numeric_min(*xs), _numeric_min(*ys), _numeric_min(*zs),
        _numeric_max(*xs), _numeric_max(*ys), _numeric_max(*zs),
    )

get_convex_hull

get_convex_hull() -> ConvexHull
Source code in kumiki/csg/cutcsg.py
def get_convex_hull(self) -> 'ConvexHull':
    from .edges_on_csg import _hull_or_unknown, _loft_half_spaces
    return _hull_or_unknown(_loft_half_spaces(self))

is_exactly_convex_hull

is_exactly_convex_hull() -> bool
Source code in kumiki/csg/cutcsg.py
def is_exactly_convex_hull(self) -> bool:
    from .edges_on_csg import _loft_sides_are_planar
    return _loft_sides_are_planar(self)

plane_sides

plane_sides(frame: PlaneFrame, seed: PolygonRegions) -> Sides
Source code in kumiki/csg/cutcsg.py
def plane_sides(self, frame: 'PlaneFrame', seed: 'PolygonRegions') -> 'Sides':
    from .cross_sections import _CannotCrossSection
    if not self.is_exactly_convex_hull():
        raise _CannotCrossSection("a loft with twisted sides")
    return super().plane_sides(frame, seed)

arris_against_cap

arris_against_cap(side: int, sides: int, end: bool) -> FeatureKey

Return the key of the arris where side side meets cap cap.

Source code in kumiki/csg/cutcsg.py
def arris_against_cap(side: int, sides: int, end: bool) -> FeatureKey:
    """Return the key of the arris where side `side` meets cap `cap`."""
    return (FeatureCategory.ARRIS, sides * (2 if end else 1) + side)

corner_on_cap

corner_on_cap(vertex: int, vertices: int, end: bool) -> FeatureKey

Return the key of vertex index of the start or end profile.

Source code in kumiki/csg/cutcsg.py
def corner_on_cap(vertex: int, vertices: int, end: bool) -> FeatureKey:
    """Return the key of vertex `index` of the start or end profile."""
    return (FeatureCategory.CORNER, (vertices if end else 0) + vertex)

default_feature_name

default_feature_name(key: FeatureKey) -> str

Return the deterministic name of a default feature, e.g. "arris.5".

Source code in kumiki/csg/cutcsg.py
def default_feature_name(key: FeatureKey) -> str:
    """Return the deterministic name of a default feature, e.g. "arris.5"."""
    category, index = key
    return f"{category.name.lower()}.{index}"

side_key

side_key(index: int) -> FeatureKey

Return the key of side index of an extrusion-like primitive.

Source code in kumiki/csg/cutcsg.py
def side_key(index: int) -> FeatureKey:
    """Return the key of side `index` of an extrusion-like primitive."""
    return (FeatureCategory.SIDE, index)

feature_groups_intersect

feature_groups_intersect(a: FeatureGroup, b: FeatureGroup) -> bool

Return whether features in groups a and b may form an edge.

Source code in kumiki/csg/cutcsg.py
def feature_groups_intersect(a: FeatureGroup, b: FeatureGroup) -> bool:
    """Return whether features in groups `a` and `b` may form an edge."""
    return b in FEATURE_GROUP_PAIRS[a]

measured_by_default

measured_by_default(purpose: FeaturePurpose) -> bool

Return whether features serving purpose are solved for by default (not rough stock or its relief).

Source code in kumiki/csg/cutcsg.py
def measured_by_default(purpose: FeaturePurpose) -> bool:
    """Return whether features serving `purpose` are solved for by default (not rough stock or its relief)."""
    return purpose not in (FeaturePurpose.ROUGH_FACE, FeaturePurpose.ROUGH_RELIEF)

feature_hit_sort_key

feature_hit_sort_key(hit: OwnedFeatureHit) -> Tuple

Return a hit's sort key (see _sort_feature_hits).

Source code in kumiki/csg/cutcsg.py
def feature_hit_sort_key(hit: 'OwnedFeatureHit') -> Tuple:
    """Return a hit's sort key (see _sort_feature_hits)."""
    face = hit.feature.feature_type() is CSGFeatureType.FACE
    return (
        1 if hit.feature.real else (2 if face else 0),
        _FEATURE_TYPE_SPECIFICITY[hit.feature.feature_type()],
        hit.feature.is_derived(),
        hit.feature.group_rank(),
        hit.feature.priority,
        hit.feature.name,
    )

owner_label_trails

owner_label_trails(root: CutCSG) -> Dict[int, Tuple[str, ...]]

Return every node under root, keyed by id, with its label path (shoulder, shoulder#1, ...).

Source code in kumiki/csg/cutcsg.py
def owner_label_trails(root: 'CutCSG') -> Dict[int, Tuple[str, ...]]:
    """Return every node under `root`, keyed by id, with its label path (`shoulder`, `shoulder#1`, ...)."""
    trails: Dict[int, Tuple[str, ...]] = {}
    stack: List[Tuple['CutCSG', Tuple[str, ...]]] = [(root, ())]
    while stack:
        node, trail = stack.pop()
        trails[id(node)] = trail
        seen: Dict[str, int] = {}
        children = csg_children(node)
        for child in children:
            name = getattr(getattr(child, 'label', None), 'name', None)
            if name is None:
                stack.append((child, trail))
                continue
            occurrence = seen.get(name, 0)
            seen[name] = occurrence + 1
            stack.append((child, trail + (name if occurrence == 0 else f"{name}#{occurrence}",)))
    return trails

shared_ancestor

shared_ancestor(some_common_ancestor: CutCSG, first: CutCSG, second: CutCSG) -> Optional[CutCSG]

Return the deepest node under some_common_ancestor containing both first and second, or None.

Source code in kumiki/csg/cutcsg.py
def shared_ancestor(
    some_common_ancestor: 'CutCSG',
    first: 'CutCSG',
    second: 'CutCSG',
) -> Optional['CutCSG']:
    """Return the deepest node under `some_common_ancestor` containing both `first` and `second`, or None."""
    here, there = _trail_within(some_common_ancestor, first), _trail_within(some_common_ancestor, second)
    if here is None or there is None:
        return None
    deepest = None
    for one, other in zip(here, there):
        if one is not other:
            break
        deepest = one
    return deepest

derive_edge_hits

derive_edge_hits(some_common_ancestor: CutCSG, face_hits: List[OwnedFeatureHit]) -> List[OwnedFeatureHit]

Return the edges formed by pairs of face_hits, each owned by the pair's shared ancestor.

O(n^2) in the number of hits.

Source code in kumiki/csg/cutcsg.py
def derive_edge_hits(
    some_common_ancestor: 'CutCSG',
    face_hits: List['OwnedFeatureHit'],
) -> List['OwnedFeatureHit']:
    """Return the edges formed by pairs of `face_hits`, each owned by the pair's shared ancestor.

    O(n^2) in the number of hits.
    """
    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 None:
                continue
            owner = shared_ancestor(some_common_ancestor, face_hits[i].owner, face_hits[j].owner)
            if owner is None:
                # Can't happen: both parents came from one gather.
                warnings.warn(
                    f"Skipping the derived edge {edge.name}: its parents are not "
                    f"both under the node being searched, so there is no node to "
                    f"own it.")
                continue
            hits.append(OwnedFeatureHit(feature=edge, owner=owner))
    return hits

derive_point_hits

derive_point_hits(some_common_ancestor: CutCSG, edge_hits: List[OwnedFeatureHit], face_hits: List[OwnedFeatureHit]) -> List[OwnedFeatureHit]

Return the points where an edge in edge_hits crosses a face in face_hits, each owned by the pair's shared ancestor.

An edge lying in the face's plane gives nothing.

Source code in kumiki/csg/cutcsg.py
def derive_point_hits(
    some_common_ancestor: 'CutCSG',
    edge_hits: List['OwnedFeatureHit'],
    face_hits: List['OwnedFeatureHit'],
) -> List['OwnedFeatureHit']:
    """Return the points where an edge in `edge_hits` crosses a face in `face_hits`, each owned by the pair's shared ancestor.

    An edge lying in the face's plane gives nothing.
    """
    hits: List['OwnedFeatureHit'] = []
    for edge_hit in edge_hits:
        for face_hit in face_hits:
            point = DerivedPointFeature.derive(edge_hit, face_hit)
            if point is None:
                continue
            owner = shared_ancestor(some_common_ancestor, edge_hit.owner, face_hit.owner)
            if owner is None:
                warnings.warn(
                    f"Skipping the derived point {point.name}: its parents are not "
                    f"both under the node being searched, so there is no node to "
                    f"own it.")
                continue
            hits.append(OwnedFeatureHit(feature=point, owner=owner))
    return hits

prism_face_key

prism_face_key(face: PrismFace) -> FeatureKey

Return the default key of a RectangularPrism face.

Source code in kumiki/csg/cutcsg.py
def prism_face_key(face: PrismFace) -> FeatureKey:
    """Return the default key of a RectangularPrism face."""
    if face in _PRISM_CAP_KEYS:
        return _PRISM_CAP_KEYS[face]
    return side_key(_PRISM_SIDE_ORDER.index(face))

prism_arris_key

prism_arris_key(first: PrismFace, second: PrismFace) -> FeatureKey

Return the default key of the arris between two faces, in either order.

Source code in kumiki/csg/cutcsg.py
def prism_arris_key(first: PrismFace, second: PrismFace) -> FeatureKey:
    """Return the default key of the arris between two faces, in either order."""
    key = _prism_arris_key(first, second)
    if key is None:
        raise ValueError(f"{first} and {second} meet in no arris")
    return key

prism_corner_key

prism_corner_key(*faces: PrismFace) -> FeatureKey

Return the default key of the corner of three faces, in any order.

Source code in kumiki/csg/cutcsg.py
def prism_corner_key(*faces: PrismFace) -> FeatureKey:
    """Return the default key of the corner of three faces, in any order."""
    key = _prism_corner_key(_canonical_ordering_corner_faces(faces))
    if key is None:
        raise ValueError(f"{', '.join(face.name for face in faces)} meet at no corner")
    return key

convex_profile_edge_features

convex_profile_edge_features(sides: int) -> Dict[FeatureKey, CSGFeature]

Return the default edges of a convex extrusion or loft with sides sides, in key order: side n against side n+1, then each side against the start cap, then the end cap.

Source code in kumiki/csg/cutcsg.py
def convex_profile_edge_features(sides: int) -> Dict[FeatureKey, CSGFeature]:
    """Return the default edges of a convex extrusion or loft with `sides` sides, in key order:
    side n against side n+1, then each side against the start cap, then the end cap.
    """
    features: Dict[FeatureKey, CSGFeature] = {}
    for side in range(sides):
        for key, faces in (
            ((FeatureCategory.ARRIS, side), (side, (side + 1) % sides)),
            (arris_against_cap(side, sides, end=False), (side, ExtrusionCap.BOTTOM)),
            (arris_against_cap(side, sides, end=True), (side, ExtrusionCap.TOP)),
        ):
            features[key] = ConvexProfileEdgeFeature(
                name=default_feature_name(key), key=key, faces=faces,
                properties=_DEFAULT_FEATURE_PROPERTIES)
    return features

measured_against

measured_against(properties: FeatureProperties, partner: str, datum: Datum = NEUTRAL) -> FeatureProperties

Return properties marked relative to the feature named partner (FeatureMarkingSpec): auto dimensioning prefers measuring between them. Two naming each other are a symmetric pair.

Source code in kumiki/csg/cutcsg.py
def measured_against(properties: FeatureProperties, partner: str, datum: Datum = Datum.NEUTRAL) -> FeatureProperties:
    """Return `properties` marked relative to the feature named `partner` (FeatureMarkingSpec):
    auto dimensioning prefers measuring between them. Two naming each other are a symmetric pair."""
    return replace(properties, marking_override=FeatureMarkingSpec(mark_relative_to=partner), datum=datum)

shoulder_override

shoulder_override(key: FeatureKey, name: Optional[str] = None, group: FeatureGroup = NONE) -> FeatureOverride

Return an override marking the default at key as a shoulder plane, in group group.

Source code in kumiki/csg/cutcsg.py
def shoulder_override(key: FeatureKey, name: Optional[str] = None,
                      group: FeatureGroup = FeatureGroup.NONE) -> FeatureOverride:
    """Return an override marking the default at `key` as a shoulder plane, in group `group`."""
    return FeatureOverride(key, name, FeatureProperties(group=group, purpose=FeaturePurpose.SHOULDER,
                                                        datum=Datum.PREFERRED))

has_face_plane_carrier

has_face_plane_carrier(csg: HasFeatures, key: FeatureKey) -> bool

Return whether face_plane_carriers(csg) has key, locating only that one face.

Source code in kumiki/csg/cutcsg.py
def has_face_plane_carrier(csg: 'HasFeatures', key: FeatureKey) -> bool:
    """Return whether face_plane_carriers(csg) has `key`, locating only that one face."""
    node = cast(CutCSG, csg)
    if key[0] is FeatureCategory.EXTRA:
        index = key[1]
        extras = csg.extra_features
        return (0 <= index < len(extras) and isinstance(extras[index], PlaneFeature)
                and extras[index].locate_simple_unbounded(node) is not None)
    feature = csg.default_features().get(key)
    return isinstance(feature, FaceFeature) and feature.locate_simple_unbounded(node) is not None

face_plane_carriers

face_plane_carriers(csg: HasFeatures) -> Dict[FeatureKey, Carrier]

Return a CarrierPlane per default face of csg with a plane, and per extra PlaneFeature, by key.

Not cached: a solve keeps the carriers it uses in its CarrierMap.

Source code in kumiki/csg/cutcsg.py
def face_plane_carriers(csg: 'HasFeatures') -> Dict[FeatureKey, Carrier]:
    """Return a CarrierPlane per default face of `csg` with a plane, and per extra PlaneFeature, by key.

    Not cached: a solve keeps the carriers it uses in its CarrierMap.
    """
    carriers: Dict[FeatureKey, Carrier] = {}
    for key, feature in csg.default_features().items():
        if not isinstance(feature, FaceFeature):
            continue
        plane = feature.locate_simple_unbounded(cast(CutCSG, csg))
        if plane is not None:
            carriers[key] = CarrierPlane(plane)
    for index, extra in enumerate(csg.extra_features):
        if isinstance(extra, PlaneFeature):
            plane = extra.locate_simple_unbounded(cast(CutCSG, csg))
            if plane is not None:
                carriers[(FeatureCategory.EXTRA, index)] = CarrierPlane(plane)
    return carriers

hit_of_kind

hit_of_kind(hit: OwnedFeatureHit[CSGFeature], kind: Type[K]) -> Optional[OwnedFeatureHit[K]]

Return the hit typed as kind, or None if its feature isn't one.

Source code in kumiki/csg/cutcsg.py
def hit_of_kind(hit: OwnedFeatureHit[CSGFeature], kind: Type[K]) -> Optional[OwnedFeatureHit[K]]:
    """Return the hit typed as `kind`, or None if its feature isn't one."""
    return cast(OwnedFeatureHit[K], hit) if isinstance(hit.feature, kind) else None

point_tests_memo

point_tests_memo() -> Iterator[None]

Context manager caching point tests (_POINT_TESTS) until the block ends.

Source code in kumiki/csg/cutcsg.py
@contextmanager
def point_tests_memo() -> Iterator[None]:
    """Context manager caching point tests (_POINT_TESTS) until the block ends."""
    if _point_tests_memo.get() is not None:
        yield
        return
    token = _point_tests_memo.set({})
    try:
        yield
    finally:
        _point_tests_memo.reset(token)

csg_children

csg_children(csg: CutCSG) -> List[CutCSG]

Return the direct children of csg; empty for a primitive.

Source code in kumiki/csg/cutcsg.py
def csg_children(csg: CutCSG) -> List[CutCSG]:
    """Return the direct children of `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 []

map_primitives

map_primitives(csg: CutCSG, change: Callable[[HasFeatures], CutCSG]) -> CutCSG

Return csg with change applied to every feature-declaring primitive.

Source code in kumiki/csg/cutcsg.py
def map_primitives(csg: CutCSG, change: Callable[['HasFeatures'], CutCSG]) -> CutCSG:
    """Return `csg` with `change` applied to every feature-declaring primitive."""
    if isinstance(csg, SolidUnion):
        return replace(csg, children=[map_primitives(child, change) for child in csg.children])
    if isinstance(csg, Intersection):
        return replace(csg, left=map_primitives(csg.left, change), right=map_primitives(csg.right, change))
    if isinstance(csg, Difference):
        return replace(csg, base=map_primitives(csg.base, change),
                       subtract=[map_primitives(child, change) for child in csg.subtract])
    if isinstance(csg, HasFeatures):
        return change(csg)
    return csg

strip_features

strip_features(csg: CutCSG) -> CutCSG

Return csg with every override and extra feature removed, all the way down; only defaults remain.

For copies of geometry already in another tree (e.g. a relief built from another timber).

Source code in kumiki/csg/cutcsg.py
def strip_features(csg: CutCSG) -> CutCSG:
    """Return `csg` with every override and extra feature removed, all the way down; only defaults remain.

    For copies of geometry already in another tree (e.g. a relief built from another timber).
    """
    return map_primitives(csg, lambda primitive: replace(primitive, feature_overrides=(), extra_features=())
                          if primitive.feature_overrides or primitive.extra_features else cast(CutCSG, primitive))

with_purpose

with_purpose(csg: CutCSG, purpose: FeaturePurpose) -> CutCSG

Return csg with every feature, all the way down, given purpose purpose.

Source code in kumiki/csg/cutcsg.py
def with_purpose(csg: CutCSG, purpose: 'FeaturePurpose') -> CutCSG:
    """Return `csg` with every feature, all the way down, given purpose `purpose`."""
    def change(primitive: 'HasFeatures') -> CutCSG:
        authored = {override.key: override for override in primitive.feature_overrides}
        overrides = []
        for key, default in primitive.default_features().items():
            override = authored.get(key, FeatureOverride(key))
            properties = override.properties if override.properties is not None else default.properties
            overrides.append(replace(override, properties=replace(properties, purpose=purpose)))
        extras = [replace(extra, properties=replace(extra.properties, purpose=purpose))
                  for extra in primitive.extra_features]
        return cast(CutCSG, replace(primitive, feature_overrides=overrides, extra_features=extras))

    return map_primitives(csg, change)

csg_children_with_parity

csg_children_with_parity(csg: CutCSG, parity: CSGParity = ADDITIVE) -> List[Tuple[CutCSG, CSGParity]]

Return the children of csg with their parity, in csg_children order. Only a Difference's subtract children invert.

Source code in kumiki/csg/cutcsg.py
def csg_children_with_parity(
    csg: CutCSG,
    parity: CSGParity = CSGParity.ADDITIVE,
) -> List[Tuple[CutCSG, CSGParity]]:
    """Return the children of `csg` with their parity, in csg_children order. Only a Difference's subtract children invert."""
    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]]

Yield every node under root, including it, with its parity.

Source code in kumiki/csg/cutcsg.py
def walk_csg_with_parity(
    root: CutCSG,
    parity: CSGParity = CSGParity.ADDITIVE,
) -> Iterator[Tuple[CutCSG, CSGParity]]:
    """Yield every node under `root`, including it, with its parity."""
    yield root, parity
    for child, child_parity in csg_children_with_parity(root, parity):
        yield from walk_csg_with_parity(child, child_parity)

carrier_map

carrier_map(root: CutCSG) -> CarrierMap

Return the CarrierMap of every primitive carrier in the tree, coincident planes merged.

Source code in kumiki/csg/cutcsg.py
def carrier_map(root: CutCSG) -> CarrierMap:
    """Return the CarrierMap of every primitive carrier in the tree, coincident planes merged."""
    def walk(node: CutCSG) -> Iterator[CutCSG]:
        yield node
        for child in csg_children(node):
            yield from walk(child)
    nodes = list(walk(root))
    carriers = {CarrierRef(node, key): carrier for node in nodes for key, carrier in node.carriers().items()}
    # Coincident planes are solved as the one best to measure from, e.g. a shoulder flush with a face.
    rank = {CarrierRef(node, feature.feature_key()): feature.properties.datum.value - Datum.NEUTRAL.value
            for node in nodes if isinstance(node, HasFeatures) for feature in node.get_declared_features()
            if feature.feature_key() is not None and feature.properties.datum is not Datum.NEUTRAL}
    return CarrierMap(carriers, merge_coincident_planes(carriers, rank))

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

Return a finite prism standing in for half_space near its boundary.

Its start face lies on the boundary plane, it runs depth_of_space into the half space, and its square size_of_space cross-section is centred where the normal through the origin meets the plane.

Source code in kumiki/csg/cutcsg.py
def make_finite_rectangular_prism_from_half_space(half_space: HalfSpace, size_of_space: Numeric, depth_of_space: Numeric) -> RectangularPrism:
    """Return a finite prism standing in for `half_space` near its boundary.

    Its start face lies on the boundary plane, it runs `depth_of_space` into the half space, and its
    square `size_of_space` cross-section is centred where the normal through the origin meets the plane.
    """
    # 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,
    )

assert_distinct_children

assert_distinct_children(kind: str, children: Sequence[CutCSG]) -> None

Raise if the same CSG object appears twice among children.

Source code in kumiki/csg/cutcsg.py
def assert_distinct_children(kind: str, children: Sequence[CutCSG]) -> None:
    """Raise if the same CSG object appears twice among `children`."""
    seen = set()
    for child in children:
        assert id(child) not in seen, (
            f"{kind} was given the same {type(child).__name__} object twice. The same "
            f"node reachable by two paths is not two operations -- build a copy for the "
            f"second use.")
        seen.add(id(child))

translate_profile

translate_profile(profile: Profile, translation: V2) -> Profile

Return profile translated by translation.

Source code in kumiki/csg/cutcsg.py
def translate_profile(profile: Profile, translation: V2) -> Profile:
    """Return `profile` translated by `translation`."""
    return [point + translation for point in profile]

translate_profiles

translate_profiles(profiles: Profiles, translation: V2) -> Profiles

Return each profile translated by translation.

Source code in kumiki/csg/cutcsg.py
def translate_profiles(profiles: Profiles, translation: V2) -> Profiles:
    """Return each profile translated by `translation`."""
    return [translate_profile(profile, translation) for profile in profiles]

point_in_convex_profile

point_in_convex_profile(points: Sequence[V2], point_2d: V2, eps: Optional[Numeric] = None) -> bool

Return whether a 2D point is inside a convex profile, wound either way.

Source code in kumiki/csg/cutcsg.py
def point_in_convex_profile(points: Sequence[V2], point_2d: V2, eps: Optional[Numeric] = None) -> bool:
    """Return whether a 2D point is inside a convex profile, wound either way."""
    signed_area = 0.0
    for index, current in enumerate(points):
        following = points[(index + 1) % len(points)]
        signed_area += (float(current[0]) * float(following[1])
                        - float(following[0]) * float(current[1]))
    inside_is_positive = signed_area >= 0

    for index, p1 in enumerate(points):
        p2 = points[(index + 1) % len(points)]
        edge = p2 - p1
        to_point = point_2d - p1
        # Divided by the edge's length, giving the point's distance from the edge.
        edge_length = safe_norm(Matrix([edge[0], edge[1]]))
        if safe_zero_test(edge_length):
            continue
        distance = (edge[0] * to_point[1] - edge[1] * to_point[0]) / edge_length
        wrong_side = Comparison.GT if not inside_is_positive else Comparison.LT
        if safe_compare(distance, 0, wrong_side, eps=eps):
            return False
    return True

decompose_simple_polygon_into_convex_pieces

decompose_simple_polygon_into_convex_pieces(points: Profile) -> List[Profile]

Split a simple polygon of (u, v) points into convex quads and triangles, by horizontal bands.

Within each band between vertex v's, the active edges' crossings are paired left to right (even-odd). pathcsg.decompose_path_into_convex_pieces is the same sweep over a FancyPath.

Source code in kumiki/csg/cutcsg.py
def decompose_simple_polygon_into_convex_pieces(points: Profile) -> List[Profile]:
    """Split a simple polygon of (u, v) points into convex quads and triangles, by horizontal bands.

    Within each band between vertex v's, the active edges' crossings are paired left to right
    (even-odd). pathcsg.decompose_path_into_convex_pieces is the same sweep over a FancyPath.
    """
    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 csg translated by translation.

Source code in kumiki/csg/cutcsg.py
def translate_csg(csg: CutCSG, translation: V3) -> CutCSG:
    """Return a copy of `csg` 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

adopt_csg

adopt_csg(orig_transform: Optional[Transform], adopting_transform: Optional[Transform], csg_in_orig_space: CutCSG) -> CutCSG

Return csg_in_orig_space re-expressed from orig_transform's local space in adopting_transform's.

Either transform may be None for global space.

Example

cut_on_b = adopt_csg(timber_a.transform, timber_b.transform, cut_csg) csg_in_global = adopt_csg(timber_a.transform, None, cut_csg)

Source code in kumiki/csg/cutcsg.py
def adopt_csg(
    orig_transform: Optional[Transform],
    adopting_transform: Optional[Transform],
    csg_in_orig_space: CutCSG,
) -> CutCSG:
    """Return `csg_in_orig_space` re-expressed from `orig_transform`'s local space in `adopting_transform`'s.

    Either transform may be None for global space.

    Example:
        >>> cut_on_b = adopt_csg(timber_a.transform, timber_b.transform, cut_csg)
        >>> 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