Skip to content

kumiki.drawings.drawing

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

kumiki.drawings.drawing

Drawings: Drawing, Viewport, Subdivision and page sizes. The dimensions on a drawing are in measure.py.

ELEVATION_IDS module-attribute

ELEVATION_IDS: Mapping[str, ViewportId] = {'front': ViewportId('0.0.0'), 'top': ViewportId('0.0.1'), 'right': ViewportId('0.1.0'), 'preview': ViewportId('0.1.1')}

SHOP_DRAWING_IDS module-attribute

SHOP_DRAWING_IDS: Mapping[str, ViewportId] = {'front': ViewportId('0.0.0'), 'right': ViewportId('0.0.1'), 'back': ViewportId('0.0.2'), 'left': ViewportId('0.0.3'), 'preview': ViewportId('0.1')}

Drawing dataclass

Drawing(name: str, timbers: Sequence[PerfectTimberWithin] = (), drawing_id: Optional[DrawingId] = None, viewports: Optional[Sequence[Viewport]] = None, page: Optional[Page] = None, measurements: Mapping[ViewportId, Sequence[Measure]] = dict())

A drawing: a name, the timbers it draws, and its viewports.

name instance-attribute

name: str

timbers class-attribute instance-attribute

timbers: Sequence[PerfectTimberWithin] = ()

drawing_id class-attribute instance-attribute

drawing_id: Optional[DrawingId] = None

viewports class-attribute instance-attribute

viewports: Optional[Sequence[Viewport]] = None

page class-attribute instance-attribute

page: Optional[Page] = None

measurements class-attribute instance-attribute

measurements: Mapping[ViewportId, Sequence[Measure]] = field(default_factory=dict)

roots property

roots: ViewportList

Return the root (floating) viewports.

__post_init__

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

    # None gets the default layout; () is kept.
    viewports = (default_viewports_for(len(self.timbers))
                 if self.viewports is None else tuple(self.viewports))
    object.__setattr__(self, 'viewports', viewports)
    self._check_placement()
    self._check_each_viewport_appears_once()

walk

walk() -> Iterator[Tuple[ViewportId, Viewport]]

Yield every viewport with its id, depth first, parents before children.

Source code in kumiki/drawings/drawing.py
def walk(self) -> Iterator[Tuple[ViewportId, 'Viewport']]:
    """Yield every viewport with its id, depth first, parents before children."""
    def descend(viewport: 'Viewport', path: Tuple[int, ...]):
        yield (ViewportId(".".join(str(step) for step in path)), viewport)
        for index, child in enumerate(viewport.children):
            yield from descend(child, path + (index,))

    for index, root in enumerate(self.roots):
        yield from descend(root, (index,))

measurements_by_viewport

measurements_by_viewport() -> Dict[str, Tuple[Measure, ...]]

Return the drawing's measurements, keyed by viewport id.

Source code in kumiki/drawings/drawing.py
def measurements_by_viewport(self) -> Dict[str, Tuple[Measure, ...]]:
    """Return the drawing's measurements, keyed by viewport id."""
    collected: Dict[str, Tuple[Measure, ...]] = {}
    for viewport_id, viewport in self.walk():
        if viewport.measurements:
            collected[str(viewport_id)] = tuple(viewport.measurements)
    for viewport_id, measures in self.measurements.items():
        key = str(viewport_id)
        collected[key] = collected.get(key, ()) + tuple(measures)
    return collected

leaves

leaves() -> Iterator[Tuple[ViewportId, Viewport]]

Return every leaf viewport (one with a camera) with its id.

Source code in kumiki/drawings/drawing.py
def leaves(self) -> Iterator[Tuple[ViewportId, 'Viewport']]:
    """Return every leaf viewport (one with a camera) with its id."""
    return ((id, viewport) for id, viewport in self.walk() if viewport.is_leaf)

get_viewport_id

get_viewport_id(viewport: Viewport) -> ViewportId

Return the id of viewport (its position in the tree).

Source code in kumiki/drawings/drawing.py
def get_viewport_id(self, viewport: 'Viewport') -> ViewportId:
    """Return the id of `viewport` (its position in the tree)."""
    for found, candidate in self.walk():
        if candidate is viewport:
            return found
    raise KeyError(f"{viewport!r} is not a viewport of drawing {self.drawing_id}")

viewport_at

viewport_at(viewport_id: ViewportId) -> Optional[Viewport]

Return the viewport with id viewport_id, or None.

Source code in kumiki/drawings/drawing.py
def viewport_at(self, viewport_id: ViewportId) -> Optional['Viewport']:
    """Return the viewport with id `viewport_id`, or None."""
    wanted = str(viewport_id)
    for found, viewport in self.walk():
        if str(found) == wanted:
            return viewport
    return None

Length dataclass

Length(value: Numeric)

A size in page units, e.g. a 40mm title block.

value instance-attribute

value: Numeric

__post_init__

__post_init__()
Source code in kumiki/drawings/drawing.py
def __post_init__(self):
    if not (self.value > 0):
        raise ValueError(f"A length is positive, got {self.value}")

Page dataclass

Page(width: Numeric, height: Numeric)

The sheet's size in real units.

width instance-attribute

width: Numeric

height instance-attribute

height: Numeric

__post_init__

__post_init__()
Source code in kumiki/drawings/drawing.py
def __post_init__(self):
    if not (self.width > 0 and self.height > 0):
        raise ValueError(f"A page has a positive size, got {self.width} x {self.height}")

Portion dataclass

Portion(viewport: Viewport, size: Size = Share())

One child of a subdivision, with the size it takes.

viewport instance-attribute

viewport: Viewport

size class-attribute instance-attribute

size: Size = field(default_factory=Share)

Share dataclass

Share(value: Numeric = 1)

A share of the room left by fixed-size siblings: Share(1), Share(1) split it in half.

value class-attribute instance-attribute

value: Numeric = 1

__post_init__

__post_init__()
Source code in kumiki/drawings/drawing.py
def __post_init__(self):
    if not (self.value > 0):
        raise ValueError(f"A share is positive, got {self.value}")

SplitDirection

Bases: Enum

Which way a subdivision divides.

ROWS class-attribute instance-attribute

ROWS = 'rows'

COLUMNS class-attribute instance-attribute

COLUMNS = 'columns'

Subdivision dataclass

Subdivision(direction: SplitDirection, portions: Sequence[Portion], gap: Numeric = 0)

How a viewport divides between its children: rows top to bottom, or columns left to right.

direction instance-attribute

direction: SplitDirection

portions instance-attribute

portions: Sequence[Portion]

gap class-attribute instance-attribute

gap: Numeric = 0

__post_init__

__post_init__()
Source code in kumiki/drawings/drawing.py
def __post_init__(self):
    object.__setattr__(self, 'portions', tuple(self.portions))
    if not self.portions:
        raise ValueError("A subdivision divides a cell between portions, and has none")

taking

taking(size: Size) -> Portion

Return this subdivision wrapped in a viewport, as a portion of size: columns(rows(a, b).taking(Share(2)), c).

Source code in kumiki/drawings/drawing.py
def taking(self, size: 'Size') -> 'Portion':
    """Return this subdivision wrapped in a viewport, as a portion of `size`: `columns(rows(a, b).taking(Share(2)), c)`."""
    return Portion(viewport=Viewport(subdivision=self), size=size)

Viewport dataclass

Viewport(label: Optional[str] = None, padding: Numeric = 0, subdivision: Optional[Subdivision] = None, measurements: Sequence[Measure] = (), rect: Optional[Rect] = None, z: int = 0)

One view on a sheet, and the views inside it.

A leaf (no subdivision) gets a camera; a container's children fill its cell. rect and z place a floating root viewport and are ignored inside a subdivision. A viewport doesn't know its id or position; Drawing does. Compared by identity.

label class-attribute instance-attribute

label: Optional[str] = None

padding class-attribute instance-attribute

padding: Numeric = 0

subdivision class-attribute instance-attribute

subdivision: Optional[Subdivision] = None

measurements class-attribute instance-attribute

measurements: Sequence[Measure] = ()

rect class-attribute instance-attribute

rect: Optional[Rect] = None

z class-attribute instance-attribute

z: int = 0

is_leaf property

is_leaf: bool

Return whether this is a view (gets a camera) rather than a container.

children property

children: ViewportList

Return the viewports inside this one, in order.

__post_init__

__post_init__()
Source code in kumiki/drawings/drawing.py
def __post_init__(self):
    object.__setattr__(self, 'measurements', tuple(self.measurements))
    if self.rect is not None:
        rect = tuple(float(value) for value in self.rect)
        if len(rect) != 4:
            raise ValueError(f"A rect is [x, y, width, height], got {self.rect!r}")
        x, y, width, height = rect
        if width <= 0 or height <= 0:
            raise ValueError(f"A floating viewport has a positive size, got {width} x {height}")
        if x < 0 or y < 0 or x + width > 1 or y + height > 1:
            raise ValueError(
                f"A floating viewport sits on the page: {rect} runs off it. A rect is "
                f"fractions of the page, [x, y, width, height] from the top left."
            )
        object.__setattr__(self, 'rect', rect)

taking

taking(size: Size) -> Portion

Return Portion(self, size): plan.taking(Share(2)).

Source code in kumiki/drawings/drawing.py
def taking(self, size: Size) -> Portion:
    """Return Portion(self, size): `plan.taking(Share(2))`."""
    return Portion(viewport=self, size=size)

Measure dataclass

Measure(anchor_a: FeatureHandle, anchor_b: FeatureHandle, kind: Optional[MeasurementKind] = None, measure_id: Optional[MeasurementId] = None, placement: Optional[MeasurementPlacement] = None, plane: MeasurementPlaneSpec = FROM_VIEWPORT)

A dimension between two features in one viewport.

Identity is the two anchors, the kind, and measure_id (for the same pair measured more than once). Changing a kind changes identity.

anchor_a instance-attribute

anchor_a: FeatureHandle

anchor_b instance-attribute

anchor_b: FeatureHandle

kind class-attribute instance-attribute

kind: Optional[MeasurementKind] = None

measure_id class-attribute instance-attribute

measure_id: Optional[MeasurementId] = None

placement class-attribute instance-attribute

placement: Optional[MeasurementPlacement] = None

plane class-attribute instance-attribute

plane: MeasurementPlaneSpec = FROM_VIEWPORT

wire_anchors

wire_anchors(frame: Frame) -> Optional[Tuple[FeaturePath, FeaturePath, Optional[MeasurementPlacement]]]

Return both anchors as wire paths in sorted order, with the placement to match (offset negated if swapped), or None if either isn't in frame.

NOTE Some day we may support asymmetric measurements.

Source code in kumiki/drawings/measure.py
def wire_anchors(
    self, frame: 'Frame',
) -> Optional[Tuple[FeaturePath, FeaturePath, Optional[MeasurementPlacement]]]:
    """Return both anchors as wire paths in sorted order, with the placement to match (offset negated if
    swapped), or None if either isn't in `frame`.

    NOTE Some day we may support asymmetric measurements.
    """
    from ..csg.feature_paths import to_feature_path

    first, second = to_feature_path(self.anchor_a, frame), to_feature_path(self.anchor_b, frame)
    if first is None or second is None:
        return None
    if first.sort_key <= second.sort_key:
        return first, second, self.placement
    placement = self.placement
    if placement is not None and placement.offset is not None:
        placement = replace(placement, offset=-placement.offset)
    return second, first, placement

pair_identity

pair_identity(frame: Frame) -> Optional[Tuple[Tuple, Tuple]]

Return the two anchors' identities in wire order, or None if either isn't in frame.

Source code in kumiki/drawings/measure.py
def pair_identity(self, frame: 'Frame') -> Optional[Tuple[Tuple, Tuple]]:
    """Return the two anchors' identities in wire order, or None if either isn't in `frame`."""
    anchors = self.wire_anchors(frame)
    return None if anchors is None else pair_identity(anchors[0], anchors[1])

identity

identity(frame: Frame) -> Optional[Tuple[Tuple, Tuple, Tuple, str]]

Return (pair, kind, measure_id), or None if either anchor isn't in frame.

Source code in kumiki/drawings/measure.py
def identity(self, frame: 'Frame') -> Optional[Tuple[Tuple, Tuple, Tuple, str]]:
    """Return (pair, kind, measure_id), or None if either anchor isn't in `frame`."""
    pair = self.pair_identity(frame)
    return None if pair is None else measurement_identity(pair, self.kind, self.measure_id)

MeasurementDirection

Bases: Enum

Which direction a distance is taken along.

PERPENDICULAR class-attribute instance-attribute

PERPENDICULAR = 'perpendicular'

HORIZONTAL class-attribute instance-attribute

HORIZONTAL = 'horizontal'

VERTICAL class-attribute instance-attribute

VERTICAL = 'vertical'

MeasurementFeature

Bases: Enum

What a feature acts as, after any projection, for measuring.

POINT class-attribute instance-attribute

POINT = 'point'

LINE class-attribute instance-attribute

LINE = 'line'

PLANE class-attribute instance-attribute

PLANE = 'plane'

AREA class-attribute instance-attribute

AREA = 'area'

MeasurementKind dataclass

MeasurementKind(operation: MeasurementOperation, space: MeasurementSpace, direction: MeasurementDirection = PERPENDICULAR)

What a dimension measures: operation, space and (for a distance) direction.

operation instance-attribute

space instance-attribute

direction class-attribute instance-attribute

name property

name: str

Return the composed name, e.g. projected_horizontal_distance.

__post_init__

__post_init__()
Source code in kumiki/drawings/measure.py
def __post_init__(self):
    for field_name, kind in (("operation", MeasurementOperation),
                             ("space", MeasurementSpace),
                             ("direction", MeasurementDirection)):
        value = getattr(self, field_name)
        if isinstance(value, str):
            object.__setattr__(self, field_name, kind(value))
    if (self.space is MeasurementSpace.THREE_D
            and self.direction is not MeasurementDirection.PERPENDICULAR):
        raise ValueError(
            f"{self.direction.value} is a direction of the sheet, so it only exists "
            "projected. Space itself has no up."
        )

__str__

__str__() -> str
Source code in kumiki/drawings/measure.py
def __str__(self) -> str:
    return self.name

as_wire

as_wire() -> dict
Source code in kumiki/drawings/measure.py
def as_wire(self) -> dict:
    return {
        "operation": self.operation.value,
        "space": self.space.value,
        "direction": self.direction.value,
    }

from_wire classmethod

from_wire(value) -> Optional[MeasurementKind]
Source code in kumiki/drawings/measure.py
@classmethod
def from_wire(cls, value) -> Optional['MeasurementKind']:
    if value is None:
        return None
    if isinstance(value, Mapping):
        return cls(
            MeasurementOperation(value.get("operation", "distance")),
            MeasurementSpace(value.get("space", "projected")),
            MeasurementDirection(value.get("direction", "perpendicular")),
        )
    try:
        return cls.parse(str(value))
    except ValueError:
        warnings.warn(f"Ignoring an unreadable measurement kind: {value!r}")
        return None

parse classmethod

parse(text: str) -> MeasurementKind

Parse a kind from its composed name.

Source code in kumiki/drawings/measure.py
@classmethod
def parse(cls, text: str) -> 'MeasurementKind':
    """Parse a kind from its composed name."""
    parts = str(text).split("_")
    space = MeasurementSpace.PROJECTED if parts[:1] == ["projected"] else MeasurementSpace.THREE_D
    if space is MeasurementSpace.PROJECTED:
        parts = parts[1:]
    if parts == ["angle"]:
        return cls(MeasurementOperation.ANGLE, space)
    if len(parts) == 2 and parts[1] == "distance":
        return cls(MeasurementOperation.DISTANCE, space, MeasurementDirection(parts[0]))
    raise ValueError(f"not a measurement kind: {text!r}")

MeasurementOperation

Bases: Enum

DISTANCE class-attribute instance-attribute

DISTANCE = 'distance'

ANGLE class-attribute instance-attribute

ANGLE = 'angle'

MeasurementPlacement dataclass

MeasurementPlacement(offset: Optional[float] = None, along: Optional[float] = None, flip: bool = False)

Where a dimension is drawn: offset from the features, along them, and flip for an angle's supplement.

offset class-attribute instance-attribute

offset: Optional[float] = None

along class-attribute instance-attribute

along: Optional[float] = None

flip class-attribute instance-attribute

flip: bool = False

from_wire classmethod

from_wire(value) -> Optional[MeasurementPlacement]

Return a placement from {"offset", "along", "flip"}, each optional.

Source code in kumiki/drawings/measure.py
@classmethod
def from_wire(cls, value) -> Optional['MeasurementPlacement']:
    """Return a placement from {"offset", "along", "flip"}, each optional."""
    if value is None or isinstance(value, cls):
        return value
    if isinstance(value, Mapping):
        offset, along = value.get("offset"), value.get("along")
        return cls(offset=None if offset is None else float(offset),
                   along=None if along is None else float(along),
                   flip=bool(value.get("flip", False)))
    raise TypeError(f"Expected a placement or a mapping, got {type(value).__name__}")

MeasurementSpace

Bases: Enum

Whether a measurement is taken projected onto a viewport or in 3D.

PROJECTED class-attribute instance-attribute

PROJECTED = 'projected'

THREE_D class-attribute instance-attribute

THREE_D = '3d'

columns

columns(*children: Union[Viewport, Subdivision, Portion], gap: Numeric = 0) -> Subdivision

Return a subdivision setting children left to right. A bare Viewport takes Share(1).

Source code in kumiki/drawings/drawing.py
def columns(*children: Union[Viewport, 'Subdivision', Portion], gap: Numeric = 0) -> Subdivision:
    """Return a subdivision setting `children` left to right. A bare Viewport takes Share(1)."""
    return Subdivision(direction=SplitDirection.COLUMNS, gap=gap,
                       portions=tuple(_as_portion(child) for child in children))

covering_page

covering_page(subdivision_or_viewport: Union[Viewport, Subdivision], **options) -> Viewport

Return a floating viewport covering the whole sheet.

Source code in kumiki/drawings/drawing.py
def covering_page(subdivision_or_viewport: Union[Viewport, Subdivision],
                  **options) -> Viewport:
    """Return a floating viewport covering the whole sheet."""
    if isinstance(subdivision_or_viewport, Subdivision):
        return Viewport(rect=(0.0, 0.0, 1.0, 1.0), subdivision=subdivision_or_viewport,
                        **options)
    return replace(subdivision_or_viewport, rect=(0.0, 0.0, 1.0, 1.0), **options)

default_viewports_for

default_viewports_for(timber_count: int) -> ViewportList

Return the default layout: a shop drawing for one timber, elevations for several.

Source code in kumiki/drawings/drawing.py
def default_viewports_for(timber_count: int) -> ViewportList:
    """Return the default layout: a shop drawing for one timber, elevations for several."""
    return shop_drawing_viewports() if timber_count == 1 else elevation_viewports()

elevation_viewports

elevation_viewports() -> ViewportList

Return three world elevations and a preview, two rows of two.

Source code in kumiki/drawings/drawing.py
def elevation_viewports() -> ViewportList:
    """Return three world elevations and a preview, two rows of two."""
    return (covering_page(rows(
        columns(Viewport(label="Front"), Viewport(label="Top")),
        columns(Viewport(label="Right"), Viewport(label="Preview")),
    )),)

rows

rows(*children: Union[Viewport, Subdivision, Portion], gap: Numeric = 0) -> Subdivision

Return a subdivision stacking children top to bottom. A bare Viewport takes Share(1).

Source code in kumiki/drawings/drawing.py
def rows(*children: Union[Viewport, 'Subdivision', Portion], gap: Numeric = 0) -> Subdivision:
    """Return a subdivision stacking `children` top to bottom. A bare Viewport takes Share(1)."""
    return Subdivision(direction=SplitDirection.ROWS, gap=gap,
                       portions=tuple(_as_portion(child) for child in children))

shop_drawing_viewports

shop_drawing_viewports() -> ViewportList

Return a shop drawing layout: a piece's four long faces down the left, a preview beside them.

Source code in kumiki/drawings/drawing.py
def shop_drawing_viewports() -> ViewportList:
    """Return a shop drawing layout: a piece's four long faces down the left, a preview beside them."""
    return (covering_page(columns(
        rows(Viewport(label="Front"), Viewport(label="Right"),
             Viewport(label="Back"), Viewport(label="Left")),
        Viewport(label="Preview"),
    )),)

kinds_for

kinds_for(feature_a: MeasurementFeature, feature_b: MeasurementFeature, space: MeasurementSpace, parallel: Optional[bool] = None) -> Tuple[MeasurementKind, ...]

Return the kinds a pair admits, best first. Projected-space features must already be projected.

Source code in kumiki/drawings/measure.py
def kinds_for(
    feature_a: MeasurementFeature,
    feature_b: MeasurementFeature,
    space: MeasurementSpace,
    parallel: Optional[bool] = None,
) -> Tuple[MeasurementKind, ...]:
    """Return the kinds a pair admits, best first. Projected-space features must already be projected."""
    pair = {feature_a, feature_b}

    if MeasurementFeature.AREA in pair:
        # A face seen at an angle covers the view: nothing to measure to, and
        # its middle is a point about nothing.
        return ()
    if MeasurementFeature.PLANE in pair and space is MeasurementSpace.PROJECTED:
        raise ValueError("a plane is a 3D feature; project it first")
    if MeasurementFeature.AREA in pair and space is MeasurementSpace.THREE_D:
        raise ValueError("an area is a projected feature; it has no 3D counterpart")

    flat = {MeasurementFeature.LINE, MeasurementFeature.PLANE}
    both_flat = feature_a in flat and feature_b in flat
    if both_flat and parallel is False:
        return (_projected(MeasurementOperation.ANGLE) if space is MeasurementSpace.PROJECTED
                else _three_d(MeasurementOperation.ANGLE),)

    if space is MeasurementSpace.THREE_D:
        return (_three_d(MeasurementOperation.DISTANCE),)

    perpendicular = _projected(MeasurementOperation.DISTANCE)
    if pair == {MeasurementFeature.POINT}:
        # Both points: the sheet's own directions are as good a question as the
        # distance itself, and often the one wanted.
        return (
            perpendicular,
            _projected(MeasurementOperation.DISTANCE, MeasurementDirection.HORIZONTAL),
            _projected(MeasurementOperation.DISTANCE, MeasurementDirection.VERTICAL),
        )
    # Point to line, or two parallel lines.
    return (perpendicular,)