capistry

Capistry

A Python package for parametric 3D modeling of keyboard keycaps using build123d.

Docs License: MPL 2.0 PyPI Version Python 3.13+

Rendered keycap model created with Capistry

Table of Contents

Overview

  • Parametric Design: Create custom keycaps with precise control over dimensions, angles, and styling.
  • Shapes: Rectangular, slanted, skewed, and trapezoidal keycap shapes.
  • Stems: Compatible with MX and Choc switches.
  • Advanced Geometry: Tapers, fillets, and surface modeling.
  • Batching: Generate panels of multiple keycaps for 3D printing.
  • Exporting: Export to any format supported by build123d, including STL, STEP, DXF, SVG, and more.
  • Ergogen: Export Ergogen configurations consisting of keycap positions and outlines.
  • Extensible: Designed to be extensible - you can create custom stems, cap classes, fillet strategies, and sprue designs by extending the base classes.

Installation

pip install capistry

Requirements

  • Python 3.13+
  • Dependencies:
    • build123d
    • rich
    • mashumaro
    • more-itertools
    • attrs
    • tzdata

Quick Start

from build123d import *
from capistry import *
import logging

# Initialize logging
init_logger(level=logging.INFO)

# Create a basic rectangular keycap
cap = RectangularCap(
    width=18,
    length=18,
    height=5,
    wall=1.25,
    roof=1.25,
    taper=Taper.uniform(7),
    stem=MXStem(center_at=CenterOf.GEOMETRY),
    fillet_strategy=FilletUniform()
)

# Export as STL
export_stl(cap.compound, "keycap.stl")

# Create a sprued grid-like panel for 3D printing
panel = Panel(
    items=[
        PanelItem(cap, quantity=10, mirror=True),
    ],
    sprue=SprueCylinder(),
)

# Export panel as STL
export_stl(panel.compound, "panel.stl")
Tip

You can use ocp-vscode to preview your parts in VS-Code during development.

Features

Keycap Shapes

As of now, all keycap shapes are quadrilaterals, i.e. four-sided polygons.

RectangularCap

Standard rectangular keycap:

cap = RectangularCap(width=18, length=18, height=5)

SlantedCap

An asymmetric keycap where two sides are angled evenly away from their respective orientations. The resulting shape has two orthogonal (90°) corners, one corner at 90 + v° and another at 90 – v°, where v is the slant angle.

cap = SlantedCap(width=18, length=18, height=5, angle=7)

SkewedCap

Keycap with a parallelogram shape:

cap = SkewedCap(width=18, length=18, height=5, skew=5, skew_width=True)

TrapezoidCap

Keycap with a trapezoidal shape:

cap = TrapezoidCap(width=18, length=18, height=5, angle=5)

Stems

Support for different stems:

# MX-style stem
stem = MXStem()

# Choc-style stem
stem = ChocStem()

cap = RectangularCap(stem=stem)

Tapering

Control the slope of keycap sides:

# Uniform taper on all sides
taper = Taper.uniform(7)

# Side-specific tapering
taper = Taper(front=10, back=7, left=4, right=4)

cap = RectangularCap(taper=taper)

Fillet Strategies

Choose how edges are rounded:

# Uniform
strat = FilletUniform()

# Front-to-back, then left-to-right
strat = FilletDepthWidth()

# Left-to-right, then front-to-back
strat = FilletWidthDepth()

# Mid-height edges, then top-perimeter
strat = FilletMiddleTop()

cap = RectangularCap(fillet_strategy=strat)
Warning

Due to the nature of CAD modeling, some fillet configurations simply won't be compatible with every combination of cap, taper and surface due to geometric constraints. If you run into issues building a certain cap, try to adjust these parameters to resolve the problem. FilletUniform tends to be the least error-prone strategy.

Surface Modeling

The top face of keycaps can be precisely modeled by defining a Surface. A Surface is represented by a matrix (a 2-dimensional list), specifying the offset values which will be used to model the top face. Optionally, a weights matrix may also be supplied.

surface = Surface(
        [
            [4, 4, 4, 4],
            [2, -1, -1, 2],
            [0, -1, -1, 0],
            [0, 0, 0, 0],
        ]
    )

cap = TrapezoidCap(surface=surface)

Comparisons

You can compare caps, fillets, stems, and surfaces using the capistry.Comparer class. This is useful for identifying both dimensional differences and property variations.

# Create two keycaps with different parameters
c1 = RectangularCap(width=18, length=18, height=5)
c2 = RectangularCap(width=19*2 - 1, length=18, height=5)

# Create a comparer
comparer = Comparer(c2, c2)

# Show the comparison, outputs a rich table in the console
comparer.show(show_deltas=True)

Panel Generation

Create grid-like panels of keycaps for simpler 3D printing by creating a capistry.Panel:

cap = RectangularCap()

# Create a 4x4 panel of a keycap
panel = Panel(
    items=[PanelItem(cap, quantity=16)],
    sprue=SprueCylinder(),
    cols=4
)

# Export the entire panel
export_stl(panel.compound, "keycap_panel.stl")

Ergogen Export

Export your keycaps in their current locations to an Ergogen configuration file.

# Create two rectangular keycaps
c1 = RectangularCap()
c2 = RectangularCap()

# Position the second keycap to the right of the first
c2.locate(c1.top_right)

#  Create an ergogen instance, and write the configuration to a YAML file
ergogen = Ergogen(c1, c2)
ergogen.write_yaml("config.yaml", precision=3)

Documentation

Full API documentation is available and generated using pdoc.

👉 View the full documentation here

Examples

For more detailed examples, see the examples/ directory in the repository.

Development

Building from Source

git clone https://github.com/larssont/capistry.git
cd capistry
uv sync

License

This project is licensed under the Mozilla Public License 2.0 - see the LICENSE.md file for details.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Acknowledgments

  • build123d — CAD library powering Capistry
 1"""
 2.. include:: ../../README.md
 3"""  # noqa: D200, D400
 4
 5from .cap import Cap, RectangularCap, SkewedCap, SlantedCap, TrapezoidCap
 6from .compare import BaseComparer, Comparer
 7from .ergogen import Ergogen, ErgogenSchema
 8from .fillet import (
 9    FilletDepthWidth,
10    FilletMiddleTop,
11    FilletStrategy,
12    FilletUniform,
13    FilletWidthDepth,
14    fillet_safe,
15)
16from .logger import init_logger
17from .panel import Panel, PanelItem
18from .sprue import Sprue, SprueCylinder, SpruePolygon
19from .stem import ChocStem, MXStem, Stem
20from .surface import Surface
21from .taper import Taper
22
23__all__ = [
24    "BaseComparer",
25    "Cap",
26    "ChocStem",
27    "Comparer",
28    "Ergogen",
29    "ErgogenSchema",
30    "FilletDepthWidth",
31    "FilletMiddleTop",
32    "FilletStrategy",
33    "FilletUniform",
34    "FilletWidthDepth",
35    "MXStem",
36    "Panel",
37    "PanelItem",
38    "RectangularCap",
39    "SkewedCap",
40    "SlantedCap",
41    "Sprue",
42    "SprueCylinder",
43    "SpruePolygon",
44    "Stem",
45    "Surface",
46    "Taper",
47    "TrapezoidCap",
48    "fillet_safe",
49    "init_logger",
50]
class BaseComparer(typing.Generic[T]):
253class BaseComparer[T]:
254    """
255    Abstract base class for all comparers.
256
257    Provides the core functionality for comparing multiple objects that
258    implement the Comparable ABC. Builds comparison tables by aligning
259    metrics across objects and handling missing metrics gracefully.
260
261    Parameters
262    ----------
263    *comparables : T
264        Variable number of comparable objects to compare.
265        At least one object is required.
266
267    Attributes
268    ----------
269    table : tuple[TableSection, ...]
270        The comparison table sections.
271    comparables : tuple[T, ...]
272        The objects being compared.
273
274    Raises
275    ------
276    ValueError
277        If no comparable objects are provided.
278
279    Examples
280    --------
281    >>> class MyComparer(BaseComparer):
282    ...     pass  # Implement abstract methods
283    >>> comparer = MyComparer(obj1, obj2, obj3)
284    """
285
286    def __init__(self, *comparables: T):
287        if not comparables:
288            raise ValueError("At least one comparable required")
289
290        self._comparables = tuple(comparables)
291        self._table = self._build_table()
292
293    @property
294    def table(self) -> tuple[TableSection, ...]:
295        """
296        The comparison table.
297
298        Returns
299        -------
300        tuple[TableSection, ...]
301            Tuple of table sections containing the comparison data.
302        """
303        return self._table
304
305    @property
306    def comparables(self) -> tuple[T, ...]:
307        """
308        The objects being compared.
309
310        Returns
311        -------
312        tuple[T, ...]
313            Tuple of comparable objects passed to the constructor.
314        """
315        return self._comparables
316
317    @cached_property
318    def _group_orders(self) -> dict[str, int]:
319        """
320        Map each group title to its minimum order found across comparables.
321
322        For each group title that appears across multiple objects, uses the
323        minimum order value found, defaulting to 0 if no explicit order found.
324
325        Returns
326        -------
327        dict[str, int]
328            Mapping from group title to minimum order value.
329        """
330        order_map: dict[str, int] = {}
331        for c in self._comparables:
332            for group in c.metrics.groups:
333                current_order = order_map.get(group.title, 0)
334                # Keep minimum order if multiple
335                if group.order < current_order or group.title not in order_map:
336                    order_map[group.title] = group.order
337        return order_map
338
339    @cached_property
340    def _metrics_map(self) -> list[dict[tuple[str, str], Metric]]:
341        """
342        For each comparable, map (group_title, metric_name) → Metric.
343
344        Creates a mapping structure that allows efficient lookup of metrics
345        by group title and metric name for each comparable object.
346
347        Returns
348        -------
349        list[dict[tuple[str, str], Metric]]
350            List of dictionaries, one per comparable, mapping
351            (group_title, metric_name) tuples to Metric objects.
352        """
353        metrics_map: list[dict[tuple[str, str], Metric]] = []
354        for c in self._comparables:
355            metric_map: dict[tuple[str, str], Metric] = {}
356            for group in c.metrics.groups:
357                for metric in group.metrics:
358                    metric_map[(group.title, metric.name)] = metric
359            metrics_map.append(metric_map)
360        return metrics_map
361
362    @cached_property
363    def _group_titles(self) -> set[str]:
364        """
365        Collect all unique group titles across comparables.
366
367        Returns
368        -------
369        set[str]
370            Set of unique group titles found across all comparable objects.
371        """
372        titles: set[str] = set()
373        for c in self._comparables:
374            for group in c.metrics.groups:
375                titles.add(group.title)
376        return titles
377
378    def _collect_metric_names_for_group(self, group_title: str) -> list[str]:
379        """
380        For a given group title, get all unique metric names across comparables.
381
382        Preserves the order in which metric names are first encountered
383        across the comparable objects.
384
385        Parameters
386        ----------
387        group_title : str
388            The group title to collect metric names for.
389
390        Returns
391        -------
392        list[str]
393            List of unique metric names in first-encountered order.
394        """
395        seen: set[str] = set()
396        names: list[str] = []
397        for metric_map in self._metrics_map:
398            # Find keys for this group title
399            for g_title, metric_name in metric_map:
400                if g_title == group_title and metric_name not in seen:
401                    seen.add(metric_name)
402                    names.append(metric_name)
403        return names
404
405    def _build_table(self) -> tuple[TableSection, ...]:
406        """
407        Build comparison table sections with rows.
408
409        Constructs the complete comparison table by organizing metrics into
410        sections, aligning metrics across objects, and handling missing metrics.
411        Groups are sorted by `MetricGroup.order` value, then alphabetically by title.
412
413        Returns
414        -------
415        tuple[TableSection, ...]
416            Complete table structure ready for display.
417        """
418        metric_maps = self._metrics_map
419        group_orders = self._group_orders
420
421        sections: list[TableSection] = []
422
423        titles = sorted(self._group_titles, key=lambda title: (group_orders.get(title, 0), title))
424
425        for title in titles:
426            names = self._collect_metric_names_for_group(title)
427            rows: list[TableRow] = []
428
429            for name in names:
430                computes: list[Callable[[], Any] | None] = []
431                unit: str | None = None
432
433                for metric_map in metric_maps:
434                    metric = metric_map.get((title, name))
435                    computes.append(metric.compute if metric else None)
436                    unit = unit or (metric.unit if metric else None)
437
438                rows.append(TableRow(name, tuple(computes), unit))
439            sections.append(TableSection(title, tuple(rows)))
440
441        return tuple(sections)

Abstract base class for all comparers.

Provides the core functionality for comparing multiple objects that implement the Comparable ABC. Builds comparison tables by aligning metrics across objects and handling missing metrics gracefully.

Parameters
  • *comparables (T): Variable number of comparable objects to compare. At least one object is required.
Attributes
  • table (tuple[TableSection, ...]): The comparison table sections.
  • comparables (tuple[T, ...]): The objects being compared.
Raises
  • ValueError: If no comparable objects are provided.
Examples
>>> class MyComparer(BaseComparer):
...     pass  # Implement abstract methods
>>> comparer = MyComparer(obj1, obj2, obj3)
BaseComparer(*comparables: T)
286    def __init__(self, *comparables: T):
287        if not comparables:
288            raise ValueError("At least one comparable required")
289
290        self._comparables = tuple(comparables)
291        self._table = self._build_table()
table: tuple[capistry.compare.TableSection, ...]
293    @property
294    def table(self) -> tuple[TableSection, ...]:
295        """
296        The comparison table.
297
298        Returns
299        -------
300        tuple[TableSection, ...]
301            Tuple of table sections containing the comparison data.
302        """
303        return self._table

The comparison table.

Returns
  • tuple[TableSection, ...]: Tuple of table sections containing the comparison data.
comparables: tuple[T, ...]
305    @property
306    def comparables(self) -> tuple[T, ...]:
307        """
308        The objects being compared.
309
310        Returns
311        -------
312        tuple[T, ...]
313            Tuple of comparable objects passed to the constructor.
314        """
315        return self._comparables

The objects being compared.

Returns
  • tuple[T, ...]: Tuple of comparable objects passed to the constructor.
@dataclass
class Cap(capistry.compare.Comparable, abc.ABC):
 88@dataclass
 89class Cap(Comparable, ABC):
 90    """
 91    Abstract base class for all keycap types.
 92
 93    Provides common functionality for building 3D keycap geometry including
 94    shell construction, inner cavity creation, stem attachment, tapering, filleting,
 95    and surface mapping. This class serves as the foundation for all specific
 96    keycap implementations.
 97
 98    Parameters
 99    ----------
100    width : float, default=18
101        Width of the keycap in millimeters.
102    length : float, default=18
103        Length of the keycap in millimeters.
104    height : float, default=4
105        Total height of the keycap in millimeters. Includes roof thickness.
106    wall : float, default=1
107        Thickness of the keycap walls in millimeters.
108    roof : float, default=1
109        Thickness of the keycap top surface in millimeters (assuming no surface mapping).
110    taper : Taper, default=Taper()
111        Taper configuration for the keycap sides.
112    surface : Surface or None, default=None
113        Optional surface mapping for the keycap top.
114    stem : Stem, default=MXStem()
115        Stem which will be attached to the the keycap's body.
116    fillet_strategy : FilletStrategy, default=FilletUniform()
117        Strategy for applying fillets to the keycap geometry.
118
119    Attributes
120    ----------
121    compound : Compound
122        The complete 3D geometry including shell and stem as a compound object.
123        Automatically built and cached when accessed.
124    outline : Sketch
125        2D outline sketch of the keycap base defining the footprint shape.
126        Generated by subclass implementation of _draw_outline().
127    top : Edge
128        The top edge of the keycap outline (+Y direction).
129    right : Edge
130        The right edge of the keycap outline (+X direction).
131    bottom : Edge
132        The bottom edge of the keycap outline (-Y direction).
133    left : Edge
134        The left edge of the keycap outline (-X direction).
135    top_left : Location
136        Location of the top-left corner of the keycap outline.
137    top_right : Location
138        Location of the top-right corner of the keycap outline.
139    bottom_left : Location
140        Location of the bottom-left corner of the keycap outline.
141    bottom_right : Location
142        Location of the bottom-right corner of the keycap outline.
143    bbox : BoundBox
144        Bounding box of the complete compound including stem.
145    size : Vector
146        Dimensions of the bounding box as a 3D vector (width, length, height).
147    metrics : MetricLayout[Self]
148        Structured metrics for the keycap including dimensions and component
149        properties, used for comparison.
150
151    Examples
152    --------
153    Create and manipulate a basic keycap:
154
155    >>> taper = Taper(front=1.5, back=1.0, left=1.2, right=1.2)
156    >>> cap = RectangularCap(width=18, length=18, height=7, taper=taper)
157    >>>
158    >>> # Transform the keycap
159    >>> cap.move(Pos(19, 0, 0))  # Move to the right
160    >>> cap.rotate(Axis.Z, 5)  # Slight rotation
161    >>>
162    >>> # Create taller variant
163    >>> taller_cap = cap.clone()
164    >>> taller_cap.height = 9.0
165    >>> taller_cap.build()
166    >>>
167    >>> # Move taller keycap next to the first one
168    >>> taller_cap.locate(Rot(0, 0, 5) * cap.top_right)  # Rotate then move
169
170    Notes
171    -----
172    This is an abstract base class and cannot be instantiated directly.
173    Use concrete implementations like RectangularCap, TrapezoidCap, etc.
174
175    The geometry building process is triggered automatically in the __post_init__ method,
176    but can be manually triggered with the build() method if parameters are changed.
177    """
178
179    width: float = 18
180    length: float = 18
181    height: float = 4
182    wall: float = 1
183    roof: float = 1
184
185    taper: Taper = field(default_factory=Taper)
186    surface: Surface | None = None
187
188    stem: Stem = field(default_factory=MXStem)
189    fillet_strategy: FilletStrategy = field(default_factory=FilletUniform)
190
191    def __post_init__(self) -> None:
192        """
193        Build the keycap after dataclass construction.
194
195        This method is automatically called after the dataclass is initialized.
196        It creates a copy of the stem to avoid sharing location references and
197        triggers the initial build process.
198        """
199        self.stem = copy(self.stem)
200        _ = self.build()
201
202    @abstractmethod
203    def _draw_outline(self) -> Sketch:
204        """
205        Draw the 2D outline of the keycap base shape.
206
207        This abstract method must be implemented by subclasses to define
208        the specific shape profile of the keycap. The outline defines the
209        base footprint that will be extruded to create the 3D geometry.
210
211        Returns
212        -------
213        Sketch
214            2D sketch defining the keycap's base outline. The sketch should
215            be oriented with the top edge in the +Y direction and right edge
216            in the +X direction.
217
218        Notes
219        -----
220        The outline should be a closed profile suitable for extrusion.
221        The coordinate system assumes (0,0) as the top-left corner.
222        """
223
224    def build(self) -> Self:
225        """
226        Build the complete keycap geometry.
227
228        Clears cached properties and triggers geometry construction by accessing
229        the compound property. This is a convenience method for manual rebuilds
230        after parameter changes.
231
232        Returns
233        -------
234        Self
235            The keycap instance with built geometry for method chaining.
236
237        Notes
238        -----
239        This method clears all cached properties to ensure fresh geometry
240        construction. It's automatically called during initialization but
241        should be called manually if parameters are modified after creation.
242        The actual geometry construction and error handling occurs in the
243        compound property.
244        """
245        self._clear_cache()
246        _ = self.compound
247        logger.debug("Successfully built %s", type(self).__name__)
248        return self
249
250    def _clear_cache(self):
251        """Clear cached geometry properties."""
252        for attr in ("compound", "outline"):
253            if attr in self.__dict__:
254                delattr(self, attr)
255
256    @cached_property
257    def compound(self) -> Compound:
258        """
259        Build and return the complete 3D keycap geometry.
260
261        This cached property constructs the full keycap including shell,
262        inner cavity, fillets, and attached stem as a compound object.
263        The compound is built once and cached until it's been cleared
264        by _clear_cache() via build().
265
266        Returns
267        -------
268        Compound
269            Complete keycap geometry with shell and stem components.
270
271        Raises
272        ------
273        Exception
274            If geometry construction fails during any stage of the build process.
275            The original exception is re-raised with additional context about
276            which keycap type failed to build.
277
278        Notes
279        -----
280        A manual rebuild of this property can be triggered by calling `capistry.Cap.build`.
281        """
282        logger.info(
283            "Creating part geometry for %s",
284            type(self).__name__,
285            extra={"dimensions": {"w": self.width, "l": self.length, "h": self.height}},
286        )
287
288        try:
289            with BuildPart() as cap:
290                self._build_shell(cap)
291                self._build_inner(cap)
292                self.fillet_strategy.apply_skirt(cap)
293                self._attach_stem(cap)
294
295            cap.part.label = "Shell"
296            logger.debug("Finished building part geometry")
297
298            return Compound(label=str(self), children=[cap.part, self.stem])
299
300        except Exception as e:
301            logger.exception(
302                "Failed to build %s geometry",
303                type(self).__name__,
304                exc_info=e,
305            )
306            raise
307
308    @cached_property
309    def outline(self) -> Sketch:
310        """
311        Get the 2D outline sketch of the keycap.
312
313        This cached property returns the 2D outline defining the keycap's
314        base shape. The outline is built once and cached until it's been cleared
315        by _clear_cache() via build().
316
317        Returns
318        -------
319        Sketch
320            2D outline defining the keycap's base shape. The sketch is
321            oriented with the top edge in the +Y direction and right edge
322            in the +X direction.
323
324        Notes
325        -----
326        The outline computation is delegated to the abstract _draw_outline()
327        method which must be implemented by subclasses to define the specific
328        keycap shape profile.
329
330        A manual rebuild of this property can be triggered by calling `capistry.Cap.build`.
331        """
332        return self._draw_outline()
333
334    @property
335    def top(self) -> Edge:
336        """
337        Get the top edge of the keycap outline.
338
339        Returns
340        -------
341        Edge
342            Top edge in the +Y direction, transformed by the compound location.
343        """
344        return self.compound.location * self.outline.edges()[0]
345
346    @property
347    def right(self) -> Edge:
348        """
349        Get the right edge of the keycap outline.
350
351        Returns
352        -------
353        Edge
354            Right edge in the +X direction, transformed by the compound location.
355        """
356        return self.compound.location * self.outline.edges()[1]
357
358    @property
359    def bottom(self) -> Edge:
360        """
361        Get the bottom edge of the keycap outline.
362
363        Returns
364        -------
365        Edge
366            Bottom edge in the -Y direction, transformed by the compound location.
367        """
368        return self.compound.location * self.outline.edges()[2]
369
370    @property
371    def left(self) -> Edge:
372        """
373        Get the left edge of the keycap outline.
374
375        Returns
376        -------
377        Edge
378            Left edge in the -X direction, transformed by the compound location.
379        """
380        return self.compound.location * self.outline.edges()[3]
381
382    @property
383    def top_left(self) -> Location:
384        """
385        Get the top-left corner location of the keycap outline.
386
387        Returns
388        -------
389        Location
390            Location of the top-left corner.
391        """
392        return Pos(self.top @ 0)
393
394    @property
395    def top_right(self) -> Location:
396        """
397        Get the top-right corner location of the keycap outline.
398
399        Returns
400        -------
401        Location
402            Location of the top-right corner.
403        """
404        return Pos(self.top @ 1)
405
406    @property
407    def bottom_right(self) -> Location:
408        """
409        Get the bottom-right corner location of the keycap outline.
410
411        Returns
412        -------
413        Location
414            Location of the bottom-right corner.
415        """
416        return Pos(self.bottom @ 0)
417
418    @property
419    def bottom_left(self) -> Location:
420        """
421        Get the bottom-left corner location of the keycap outline.
422
423        Returns
424        -------
425        Location
426            Location of the bottom-left corner.
427        """
428        return Pos(self.bottom @ 1)
429
430    @property
431    def bbox(self) -> BoundBox:
432        """
433        Get the bounding box of the complete keycap geometry.
434
435        Returns
436        -------
437        BoundBox
438            Bounding box encompassing the entire compound including stem.
439        """
440        return self.compound.bounding_box()
441
442    @property
443    def size(self) -> Vector:
444        """
445        Get the dimensions of the keycap bounding box.
446
447        Returns
448        -------
449        Vector
450            Size vector containing width, length, and height dimensions.
451        """
452        return self.bbox.size
453
454    def _build_inner(self, p: BuildPart) -> None:
455        """
456        Build the inner cavity of the keycap.
457
458        Creates the hollow interior by subtracting a smaller version of the
459        keycap shape from the solid shell. The cavity size is determined by
460        the wall thickness parameter.
461
462        Parameters
463        ----------
464        p : BuildPart
465            The build context for part construction operations.
466
467        Notes
468        -----
469        The cavity height is calculated as total height minus roof thickness.
470        The cavity walls are inset by the wall thickness parameter.
471        """
472        height = self.height - self.roof
473        off = -self.wall
474
475        logger.debug("Subtracting inner cavity")
476        self._build_skeleton(height, off=off, mode=Mode.SUBTRACT)
477
478        self.fillet_strategy.apply_inner(p)
479
480    def _build_skeleton(
481        self,
482        height: float,
483        off: float = 0,
484        mode: Mode = Mode.PRIVATE,
485    ) -> Part:
486        """
487        Build the basic skeleton shape with optional offset.
488
489        Creates the fundamental extruded shape of the keycap with optional
490        outline offset and taper application. This method is used for both
491        the outer shell and inner cavity construction.
492
493        Parameters
494        ----------
495        height : float
496            Height of the extruded shape in millimeters.
497        off : float, default=0
498            Offset amount for the outline in millimeters. Positive values
499            expand the outline, negative values contract it.
500        mode : Mode, default=Mode.PRIVATE
501            Build mode for the operation (ADD, SUBTRACT, or PRIVATE).
502
503        Returns
504        -------
505        Part
506            The constructed extruded and tapered part.
507
508        Notes
509        -----
510        The taper is applied to each face according to the taper configuration.
511        Faces are automatically identified and sorted by their orientations.
512        Zero or negative taper values are skipped.
513        """
514        logger.debug("Building skeleton", extra={"h": height, "offset": off})
515
516        with BuildPart() as p:
517            offset(objects=self.outline, amount=off, mode=Mode.ADD)
518            extrude(amount=height)
519
520            faces = p.part.faces().filter_by(Axis.Z, reverse=True)
521            front, back = faces.sort_by(Axis.X)[1:3].sort_by(Axis.Y)
522            left, right = faces.sort_by(Axis.Y)[1:3].sort_by(Axis.X)
523
524            taper_by_face = {
525                front: self.taper.front,
526                back: self.taper.back,
527                left: self.taper.left,
528                right: self.taper.right,
529            }
530
531            logger.debug("Applying taper", extra={"taper": self.taper})
532
533            for f, taper in taper_by_face.items():
534                if taper <= 0:
535                    continue
536
537                edge = f.edges().sort_by()[0]
538                cut = f.rotate(Axis(origin=edge @ 1, direction=edge % 1), taper)
539                split(p.part, bisect_by=Plane(cut), keep=Keep.BOTTOM)
540
541        add(p, mode=mode)
542        return p.part
543
544    def _build_shell(self, p: BuildPart) -> None:
545        """
546        Build the outer shell of the keycap.
547
548        Creates the main solid body of the keycap by extruding the outline
549        to the full height and applying any surface mapping if configured.
550
551        Parameters
552        ----------
553        p : BuildPart
554            The build context for part construction operations.
555        """
556        logger.debug("Building outer shell")
557        outer = self._build_skeleton(self.height)
558
559        if self.surface is not None:
560            outer = self._apply_surface_map(p)
561
562        add(outer)
563        self.fillet_strategy.apply_outer(p)
564
565    def _attach_stem(self, p: BuildPart) -> None:
566        """
567        Attach the stem to the keycap.
568
569        Positions and connects the stem component to the bottom center of
570        the keycap using a rigid joint connection. The stem is oriented
571        upward into the keycap cavity.
572
573        Parameters
574        ----------
575        p : BuildPart
576            The build context containing the keycap shell geometry.
577
578        Notes
579        -----
580        The stem position is calculated based on the outline center and
581        the inner cavity face of the keycap. The connection uses a rigid joint
582        with 180-degree X rotation to orient the stem correctly.
583        """
584        logger.info("Attaching %s to %s", type(self.stem).__name__, type(self).__name__)
585
586        bottomf = self.outline.faces()[0]
587        topf = p.faces().filter_by(Axis.Z).sort_by(Axis.Z)[1]
588
589        loc = bottomf.center(self.stem.center_at)
590        loc.Z += topf.distance_to(loc)
591
592        joint = RigidJoint(
593            label=CAP_STEM_JOINT,
594            joint_location=Pos(loc) * Rot(X=180) * self.stem.offset,
595        )
596
597        joint.connect_to(self.stem.joint)
598
599    def _apply_surface_map(self, _: BuildPart) -> Part:
600        """
601        Apply surface mapping to the keycap top.
602
603        Modifies the top surface of the keycap according to the configured
604        surface mapping.
605
606        Parameters
607        ----------
608        _ : BuildPart
609            The build context (unused in current implementation).
610
611        Returns
612        -------
613        Part
614            The part with applied surface mapping modifications.
615
616        Raises
617        ------
618        TypeError
619            If the split operation doesn't return a Part object as expected.
620
621        Notes
622        -----
623        The surface mapping process involves creating an extended geometry
624        and splitting it with the surface shape to achieve the desired
625        top surface profile.
626        """
627        logger.debug("Applying surface map to %s", type(self).__name__)
628
629        bottom = self._build_skeleton(self.height - self.roof)
630        bottomf = bottom.faces().sort_by(Axis.Z)[-1]
631
632        surfacef = -self.surface.form_face(bottomf.vertices())
633
634        height = surfacef.bounding_box().max.Z + self.roof + 10
635        extended_part = self._build_skeleton(height)
636
637        res = split(
638            extended_part,
639            bisect_by=Pos(0, 0, self.roof) * surfacef,
640            keep=Keep.TOP,
641            mode=Mode.PRIVATE,
642        )
643
644        if not isinstance(res, Part):
645            raise TypeError(f"Expected Part from split operation, got {type(res)}")
646        return res
647
648    def clone(self) -> Self:
649        """
650        Create a deep copy of the keycap.
651
652        Returns
653        -------
654        Self
655            A new keycap instance with identical parameters but independent
656            geometry that can be modified without affecting the original.
657        """
658        cloned = deepcopy(self)
659        logger.debug("Created clone of %s", type(self).__name__)
660        return cloned
661
662    def mirrored(self) -> Self:
663        """
664        Create a mirrored (XY-axis) version of the keycap.
665
666        Produces a mirror-image copy of the keycap, useful for creating
667        left/right pairs or opposite-handed versions of asymmetric keycaps.
668
669        Returns
670        -------
671        Self
672            A new mirrored keycap instance with geometry rebuilt.
673
674        Notes
675        -----
676        The mirroring behavior is implemented by subclasses in the _mirror()
677        method. Some keycap types may not change when mirrored (e.g., symmetric
678        rectangular caps), while others will have their angles or skews inverted.
679        """
680        m = self.clone()
681        m._mirror()
682        if m.surface is not None:
683            m.surface = m.surface.mirrored()
684        m.build()
685        logger.debug("Created mirrored clone of %s", type(self).__name__)
686        return m
687
688    def _mirror(self) -> Self:
689        """
690        Apply mirroring transformation to the keycap.
691
692        Base implementation does nothing since rectangular caps are symmetric.
693        Subclasses should override this method to implement specific mirroring
694        behavior such as inverting angles or skew parameters.
695
696        Returns
697        -------
698        Self
699            The keycap instance after applying mirroring transformations.
700
701        Notes
702        -----
703        This method modifies the keycap parameters in-place. It is called
704        by the mirrored() method as part of the cloning process.
705        """
706        logger.debug("Mirroring %s with no changes", type(self).__name__)
707        return self
708
709    def locate(self, loc: Location) -> Self:
710        """
711        Set the absolute location of the keycap.
712
713        Moves the keycap to the specified absolute position and orientation
714        in 3D space, replacing any previous transformations.
715
716        Parameters
717        ----------
718        loc : Location
719            The target absolute location including position and rotation.
720
721        Returns
722        -------
723        Self
724            The keycap instance at the new location for method chaining.
725
726        Examples
727        --------
728        >>> cap.locate(Pos(10, 20, 5) * Rot(0, 0, 45))
729        """
730        logger.debug(
731            "Applying absolute location to %s",
732            type(self).__name__,
733            extra={"location": loc},
734        )
735        self.compound.locate(loc)
736        return self
737
738    def rotate(self, axis: Axis, angle: float) -> Self:
739        """
740        Rotate the keycap around an axis.
741
742        Applies a rotation transformation around the specified axis by the
743        given angle. This is a relative transformation that combines with
744        existing rotations.
745
746        Parameters
747        ----------
748        axis : Axis
749            The rotation axis (e.g., Axis.X, Axis.Y, Axis.Z).
750        angle : float
751            Rotation angle in degrees. Positive values follow right-hand rule.
752
753        Returns
754        -------
755        Self
756            The rotated keycap instance for method chaining.
757
758        Examples
759        --------
760        >>> cap.rotate(Axis.Z, 10)  # Rotate 10 degrees around Z-axis
761        """
762        logger.debug("Rotating %s", type(self).__name__, extra={"axis": axis, "angle": angle})
763        self.compound.rotate(axis, angle)
764        return self
765
766    def move(self, loc: Location) -> Self:
767        """
768        Apply relative movement to the keycap.
769
770        Moves the keycap by the specified relative offset, adding to any
771        existing position. This is useful for incremental positioning.
772
773        Parameters
774        ----------
775        loc : Location
776            The relative movement location.
777
778        Returns
779        -------
780        Self
781            The moved keycap instance for method chaining.
782
783        Examples
784        --------
785        >>> cap.move(Pos(5, 0, 0))  # Move 5mm in X direction
786        >>> cap.move(Pos(0, 0, -2) * Rot(Y=10))  # Move down and tilt
787        """
788        logger.debug(
789            "Applying relative location to %s",
790            type(self).__name__,
791            extra={"location": loc},
792        )
793        self.compound.move(loc)
794        return self
795
796    @override
797    def __str__(self) -> str:
798        """
799        Return string representation of the keycap.
800
801        Returns
802        -------
803        str
804            Human-readable string with class name and dimensions.
805        """
806        return f"{type(self).__name__} ({self.width:.2f}x{self.length:.2f})"
807
808    def __copy__(self) -> Self:
809        """Create a shallow copy of the keycap."""
810        cls = type(self)
811        result = cls.__new__(cls)
812
813        for k, v in self.__dict__.items():
814            result.__dict__[k] = copy(v)
815
816        return result
817
818    def __deepcopy__(self, memo: dict) -> Self:
819        """Create a deep copy of the keycap."""
820        cls = type(self)
821        obj = cls.__new__(cls)
822        memo[id(self)] = obj
823
824        for k, v in self.__dict__.items():
825            setattr(obj, k, deepcopy(v, memo))
826
827        return obj
828
829    @property
830    def metrics(self) -> MetricLayout[Self]:
831        """
832        Expose keycap dimensions through the `capistry.Comparable` system for comparison.
833
834        Metrics include keycap dimensions combined with those from comparable
835        class fields such as taper, surface, fillet_strategy, and stem.
836
837        Returns
838        -------
839        MetricLayout[Self]
840            Organized metrics including dimensions and component properties
841            for comparison.
842        """
843        return MetricLayout(
844            owner=self,
845            groups=(
846                MetricGroup(
847                    "Dimensions",
848                    (
849                        Metric("Width", lambda: self.width, "mm"),
850                        Metric("Length", lambda: self.length, "mm"),
851                        Metric("Height", lambda: self.height, "mm"),
852                        Metric("Wall", lambda: self.wall, "mm"),
853                        Metric("Roof", lambda: self.roof, "mm"),
854                    ),
855                    order=-2,
856                ),
857                *MetricGroup.from_comparables(
858                    self.taper, self.surface, self.fillet_strategy, self.stem
859                ),
860            ),
861        )

Abstract base class for all keycap types.

Provides common functionality for building 3D keycap geometry including shell construction, inner cavity creation, stem attachment, tapering, filleting, and surface mapping. This class serves as the foundation for all specific keycap implementations.

Parameters
  • width (float, default=18): Width of the keycap in millimeters.
  • length (float, default=18): Length of the keycap in millimeters.
  • height (float, default=4): Total height of the keycap in millimeters. Includes roof thickness.
  • wall (float, default=1): Thickness of the keycap walls in millimeters.
  • roof (float, default=1): Thickness of the keycap top surface in millimeters (assuming no surface mapping).
  • taper (Taper, default=Taper()): Taper configuration for the keycap sides.
  • surface (Surface or None, default=None): Optional surface mapping for the keycap top.
  • stem (Stem, default=MXStem()): Stem which will be attached to the the keycap's body.
  • fillet_strategy (FilletStrategy, default=FilletUniform()): Strategy for applying fillets to the keycap geometry.
Attributes
  • compound (Compound): The complete 3D geometry including shell and stem as a compound object. Automatically built and cached when accessed.
  • outline (Sketch): 2D outline sketch of the keycap base defining the footprint shape. Generated by subclass implementation of _draw_outline().
  • top (Edge): The top edge of the keycap outline (+Y direction).
  • right (Edge): The right edge of the keycap outline (+X direction).
  • bottom (Edge): The bottom edge of the keycap outline (-Y direction).
  • left (Edge): The left edge of the keycap outline (-X direction).
  • top_left (Location): Location of the top-left corner of the keycap outline.
  • top_right (Location): Location of the top-right corner of the keycap outline.
  • bottom_left (Location): Location of the bottom-left corner of the keycap outline.
  • bottom_right (Location): Location of the bottom-right corner of the keycap outline.
  • bbox (BoundBox): Bounding box of the complete compound including stem.
  • size (Vector): Dimensions of the bounding box as a 3D vector (width, length, height).
  • metrics (MetricLayout[Self]): Structured metrics for the keycap including dimensions and component properties, used for comparison.
Examples

Create and manipulate a basic keycap:

>>> taper = Taper(front=1.5, back=1.0, left=1.2, right=1.2)
>>> cap = RectangularCap(width=18, length=18, height=7, taper=taper)
>>>
>>> # Transform the keycap
>>> cap.move(Pos(19, 0, 0))  # Move to the right
>>> cap.rotate(Axis.Z, 5)  # Slight rotation
>>>
>>> # Create taller variant
>>> taller_cap = cap.clone()
>>> taller_cap.height = 9.0
>>> taller_cap.build()
>>>
>>> # Move taller keycap next to the first one
>>> taller_cap.locate(Rot(0, 0, 5) * cap.top_right)  # Rotate then move
Notes

This is an abstract base class and cannot be instantiated directly. Use concrete implementations like RectangularCap, TrapezoidCap, etc.

The geometry building process is triggered automatically in the __post_init__ method, but can be manually triggered with the build() method if parameters are changed.

width: float = 18
length: float = 18
height: float = 4
wall: float = 1
roof: float = 1
taper: Taper
surface: Surface | None = None
stem: Stem
fillet_strategy: FilletStrategy
def build(self) -> Self:
224    def build(self) -> Self:
225        """
226        Build the complete keycap geometry.
227
228        Clears cached properties and triggers geometry construction by accessing
229        the compound property. This is a convenience method for manual rebuilds
230        after parameter changes.
231
232        Returns
233        -------
234        Self
235            The keycap instance with built geometry for method chaining.
236
237        Notes
238        -----
239        This method clears all cached properties to ensure fresh geometry
240        construction. It's automatically called during initialization but
241        should be called manually if parameters are modified after creation.
242        The actual geometry construction and error handling occurs in the
243        compound property.
244        """
245        self._clear_cache()
246        _ = self.compound
247        logger.debug("Successfully built %s", type(self).__name__)
248        return self

Build the complete keycap geometry.

Clears cached properties and triggers geometry construction by accessing the compound property. This is a convenience method for manual rebuilds after parameter changes.

Returns
  • Self: The keycap instance with built geometry for method chaining.
Notes

This method clears all cached properties to ensure fresh geometry construction. It's automatically called during initialization but should be called manually if parameters are modified after creation. The actual geometry construction and error handling occurs in the compound property.

compound: build123d.topology.composite.Compound
256    @cached_property
257    def compound(self) -> Compound:
258        """
259        Build and return the complete 3D keycap geometry.
260
261        This cached property constructs the full keycap including shell,
262        inner cavity, fillets, and attached stem as a compound object.
263        The compound is built once and cached until it's been cleared
264        by _clear_cache() via build().
265
266        Returns
267        -------
268        Compound
269            Complete keycap geometry with shell and stem components.
270
271        Raises
272        ------
273        Exception
274            If geometry construction fails during any stage of the build process.
275            The original exception is re-raised with additional context about
276            which keycap type failed to build.
277
278        Notes
279        -----
280        A manual rebuild of this property can be triggered by calling `capistry.Cap.build`.
281        """
282        logger.info(
283            "Creating part geometry for %s",
284            type(self).__name__,
285            extra={"dimensions": {"w": self.width, "l": self.length, "h": self.height}},
286        )
287
288        try:
289            with BuildPart() as cap:
290                self._build_shell(cap)
291                self._build_inner(cap)
292                self.fillet_strategy.apply_skirt(cap)
293                self._attach_stem(cap)
294
295            cap.part.label = "Shell"
296            logger.debug("Finished building part geometry")
297
298            return Compound(label=str(self), children=[cap.part, self.stem])
299
300        except Exception as e:
301            logger.exception(
302                "Failed to build %s geometry",
303                type(self).__name__,
304                exc_info=e,
305            )
306            raise

Build and return the complete 3D keycap geometry.

This cached property constructs the full keycap including shell, inner cavity, fillets, and attached stem as a compound object. The compound is built once and cached until it's been cleared by _clear_cache() via build().

Returns
  • Compound: Complete keycap geometry with shell and stem components.
Raises
  • Exception: If geometry construction fails during any stage of the build process. The original exception is re-raised with additional context about which keycap type failed to build.
Notes

A manual rebuild of this property can be triggered by calling capistry.Cap.build.

outline: build123d.topology.composite.Sketch
308    @cached_property
309    def outline(self) -> Sketch:
310        """
311        Get the 2D outline sketch of the keycap.
312
313        This cached property returns the 2D outline defining the keycap's
314        base shape. The outline is built once and cached until it's been cleared
315        by _clear_cache() via build().
316
317        Returns
318        -------
319        Sketch
320            2D outline defining the keycap's base shape. The sketch is
321            oriented with the top edge in the +Y direction and right edge
322            in the +X direction.
323
324        Notes
325        -----
326        The outline computation is delegated to the abstract _draw_outline()
327        method which must be implemented by subclasses to define the specific
328        keycap shape profile.
329
330        A manual rebuild of this property can be triggered by calling `capistry.Cap.build`.
331        """
332        return self._draw_outline()

Get the 2D outline sketch of the keycap.

This cached property returns the 2D outline defining the keycap's base shape. The outline is built once and cached until it's been cleared by _clear_cache() via build().

Returns
  • Sketch: 2D outline defining the keycap's base shape. The sketch is oriented with the top edge in the +Y direction and right edge in the +X direction.
Notes

The outline computation is delegated to the abstract _draw_outline() method which must be implemented by subclasses to define the specific keycap shape profile.

A manual rebuild of this property can be triggered by calling capistry.Cap.build.

top: build123d.topology.one_d.Edge
334    @property
335    def top(self) -> Edge:
336        """
337        Get the top edge of the keycap outline.
338
339        Returns
340        -------
341        Edge
342            Top edge in the +Y direction, transformed by the compound location.
343        """
344        return self.compound.location * self.outline.edges()[0]

Get the top edge of the keycap outline.

Returns
  • Edge: Top edge in the +Y direction, transformed by the compound location.
right: build123d.topology.one_d.Edge
346    @property
347    def right(self) -> Edge:
348        """
349        Get the right edge of the keycap outline.
350
351        Returns
352        -------
353        Edge
354            Right edge in the +X direction, transformed by the compound location.
355        """
356        return self.compound.location * self.outline.edges()[1]

Get the right edge of the keycap outline.

Returns
  • Edge: Right edge in the +X direction, transformed by the compound location.
bottom: build123d.topology.one_d.Edge
358    @property
359    def bottom(self) -> Edge:
360        """
361        Get the bottom edge of the keycap outline.
362
363        Returns
364        -------
365        Edge
366            Bottom edge in the -Y direction, transformed by the compound location.
367        """
368        return self.compound.location * self.outline.edges()[2]

Get the bottom edge of the keycap outline.

Returns
  • Edge: Bottom edge in the -Y direction, transformed by the compound location.
left: build123d.topology.one_d.Edge
370    @property
371    def left(self) -> Edge:
372        """
373        Get the left edge of the keycap outline.
374
375        Returns
376        -------
377        Edge
378            Left edge in the -X direction, transformed by the compound location.
379        """
380        return self.compound.location * self.outline.edges()[3]

Get the left edge of the keycap outline.

Returns
  • Edge: Left edge in the -X direction, transformed by the compound location.
top_left: build123d.geometry.Location
382    @property
383    def top_left(self) -> Location:
384        """
385        Get the top-left corner location of the keycap outline.
386
387        Returns
388        -------
389        Location
390            Location of the top-left corner.
391        """
392        return Pos(self.top @ 0)

Get the top-left corner location of the keycap outline.

Returns
  • Location: Location of the top-left corner.
top_right: build123d.geometry.Location
394    @property
395    def top_right(self) -> Location:
396        """
397        Get the top-right corner location of the keycap outline.
398
399        Returns
400        -------
401        Location
402            Location of the top-right corner.
403        """
404        return Pos(self.top @ 1)

Get the top-right corner location of the keycap outline.

Returns
  • Location: Location of the top-right corner.
bottom_right: build123d.geometry.Location
406    @property
407    def bottom_right(self) -> Location:
408        """
409        Get the bottom-right corner location of the keycap outline.
410
411        Returns
412        -------
413        Location
414            Location of the bottom-right corner.
415        """
416        return Pos(self.bottom @ 0)

Get the bottom-right corner location of the keycap outline.

Returns
  • Location: Location of the bottom-right corner.
bottom_left: build123d.geometry.Location
418    @property
419    def bottom_left(self) -> Location:
420        """
421        Get the bottom-left corner location of the keycap outline.
422
423        Returns
424        -------
425        Location
426            Location of the bottom-left corner.
427        """
428        return Pos(self.bottom @ 1)

Get the bottom-left corner location of the keycap outline.

Returns
  • Location: Location of the bottom-left corner.
bbox: build123d.geometry.BoundBox
430    @property
431    def bbox(self) -> BoundBox:
432        """
433        Get the bounding box of the complete keycap geometry.
434
435        Returns
436        -------
437        BoundBox
438            Bounding box encompassing the entire compound including stem.
439        """
440        return self.compound.bounding_box()

Get the bounding box of the complete keycap geometry.

Returns
  • BoundBox: Bounding box encompassing the entire compound including stem.
size: build123d.geometry.Vector
442    @property
443    def size(self) -> Vector:
444        """
445        Get the dimensions of the keycap bounding box.
446
447        Returns
448        -------
449        Vector
450            Size vector containing width, length, and height dimensions.
451        """
452        return self.bbox.size

Get the dimensions of the keycap bounding box.

Returns
  • Vector: Size vector containing width, length, and height dimensions.
def clone(self) -> Self:
648    def clone(self) -> Self:
649        """
650        Create a deep copy of the keycap.
651
652        Returns
653        -------
654        Self
655            A new keycap instance with identical parameters but independent
656            geometry that can be modified without affecting the original.
657        """
658        cloned = deepcopy(self)
659        logger.debug("Created clone of %s", type(self).__name__)
660        return cloned

Create a deep copy of the keycap.

Returns
  • Self: A new keycap instance with identical parameters but independent geometry that can be modified without affecting the original.
def mirrored(self) -> Self:
662    def mirrored(self) -> Self:
663        """
664        Create a mirrored (XY-axis) version of the keycap.
665
666        Produces a mirror-image copy of the keycap, useful for creating
667        left/right pairs or opposite-handed versions of asymmetric keycaps.
668
669        Returns
670        -------
671        Self
672            A new mirrored keycap instance with geometry rebuilt.
673
674        Notes
675        -----
676        The mirroring behavior is implemented by subclasses in the _mirror()
677        method. Some keycap types may not change when mirrored (e.g., symmetric
678        rectangular caps), while others will have their angles or skews inverted.
679        """
680        m = self.clone()
681        m._mirror()
682        if m.surface is not None:
683            m.surface = m.surface.mirrored()
684        m.build()
685        logger.debug("Created mirrored clone of %s", type(self).__name__)
686        return m

Create a mirrored (XY-axis) version of the keycap.

Produces a mirror-image copy of the keycap, useful for creating left/right pairs or opposite-handed versions of asymmetric keycaps.

Returns
  • Self: A new mirrored keycap instance with geometry rebuilt.
Notes

The mirroring behavior is implemented by subclasses in the _mirror() method. Some keycap types may not change when mirrored (e.g., symmetric rectangular caps), while others will have their angles or skews inverted.

def locate(self, loc: build123d.geometry.Location) -> Self:
709    def locate(self, loc: Location) -> Self:
710        """
711        Set the absolute location of the keycap.
712
713        Moves the keycap to the specified absolute position and orientation
714        in 3D space, replacing any previous transformations.
715
716        Parameters
717        ----------
718        loc : Location
719            The target absolute location including position and rotation.
720
721        Returns
722        -------
723        Self
724            The keycap instance at the new location for method chaining.
725
726        Examples
727        --------
728        >>> cap.locate(Pos(10, 20, 5) * Rot(0, 0, 45))
729        """
730        logger.debug(
731            "Applying absolute location to %s",
732            type(self).__name__,
733            extra={"location": loc},
734        )
735        self.compound.locate(loc)
736        return self

Set the absolute location of the keycap.

Moves the keycap to the specified absolute position and orientation in 3D space, replacing any previous transformations.

Parameters
  • loc (Location): The target absolute location including position and rotation.
Returns
  • Self: The keycap instance at the new location for method chaining.
Examples
>>> cap.locate(Pos(10, 20, 5) * Rot(0, 0, 45))
def rotate(self, axis: build123d.geometry.Axis, angle: float) -> Self:
738    def rotate(self, axis: Axis, angle: float) -> Self:
739        """
740        Rotate the keycap around an axis.
741
742        Applies a rotation transformation around the specified axis by the
743        given angle. This is a relative transformation that combines with
744        existing rotations.
745
746        Parameters
747        ----------
748        axis : Axis
749            The rotation axis (e.g., Axis.X, Axis.Y, Axis.Z).
750        angle : float
751            Rotation angle in degrees. Positive values follow right-hand rule.
752
753        Returns
754        -------
755        Self
756            The rotated keycap instance for method chaining.
757
758        Examples
759        --------
760        >>> cap.rotate(Axis.Z, 10)  # Rotate 10 degrees around Z-axis
761        """
762        logger.debug("Rotating %s", type(self).__name__, extra={"axis": axis, "angle": angle})
763        self.compound.rotate(axis, angle)
764        return self

Rotate the keycap around an axis.

Applies a rotation transformation around the specified axis by the given angle. This is a relative transformation that combines with existing rotations.

Parameters
  • axis (Axis): The rotation axis (e.g., Axis.X, Axis.Y, Axis.Z).
  • angle (float): Rotation angle in degrees. Positive values follow right-hand rule.
Returns
  • Self: The rotated keycap instance for method chaining.
Examples
>>> cap.rotate(Axis.Z, 10)  # Rotate 10 degrees around Z-axis
def move(self, loc: build123d.geometry.Location) -> Self:
766    def move(self, loc: Location) -> Self:
767        """
768        Apply relative movement to the keycap.
769
770        Moves the keycap by the specified relative offset, adding to any
771        existing position. This is useful for incremental positioning.
772
773        Parameters
774        ----------
775        loc : Location
776            The relative movement location.
777
778        Returns
779        -------
780        Self
781            The moved keycap instance for method chaining.
782
783        Examples
784        --------
785        >>> cap.move(Pos(5, 0, 0))  # Move 5mm in X direction
786        >>> cap.move(Pos(0, 0, -2) * Rot(Y=10))  # Move down and tilt
787        """
788        logger.debug(
789            "Applying relative location to %s",
790            type(self).__name__,
791            extra={"location": loc},
792        )
793        self.compound.move(loc)
794        return self

Apply relative movement to the keycap.

Moves the keycap by the specified relative offset, adding to any existing position. This is useful for incremental positioning.

Parameters
  • loc (Location): The relative movement location.
Returns
  • Self: The moved keycap instance for method chaining.
Examples
>>> cap.move(Pos(5, 0, 0))  # Move 5mm in X direction
>>> cap.move(Pos(0, 0, -2) * Rot(Y=10))  # Move down and tilt
metrics: capistry.compare.MetricLayout[typing.Self]
829    @property
830    def metrics(self) -> MetricLayout[Self]:
831        """
832        Expose keycap dimensions through the `capistry.Comparable` system for comparison.
833
834        Metrics include keycap dimensions combined with those from comparable
835        class fields such as taper, surface, fillet_strategy, and stem.
836
837        Returns
838        -------
839        MetricLayout[Self]
840            Organized metrics including dimensions and component properties
841            for comparison.
842        """
843        return MetricLayout(
844            owner=self,
845            groups=(
846                MetricGroup(
847                    "Dimensions",
848                    (
849                        Metric("Width", lambda: self.width, "mm"),
850                        Metric("Length", lambda: self.length, "mm"),
851                        Metric("Height", lambda: self.height, "mm"),
852                        Metric("Wall", lambda: self.wall, "mm"),
853                        Metric("Roof", lambda: self.roof, "mm"),
854                    ),
855                    order=-2,
856                ),
857                *MetricGroup.from_comparables(
858                    self.taper, self.surface, self.fillet_strategy, self.stem
859                ),
860            ),
861        )

Expose keycap dimensions through the capistry.Comparable system for comparison.

Metrics include keycap dimensions combined with those from comparable class fields such as taper, surface, fillet_strategy, and stem.

Returns
  • MetricLayout[Self]: Organized metrics including dimensions and component properties for comparison.
@dataclass
class ChocStem(build123d.topology.three_d.Mixin3D, build123d.topology.shape_core.Shape[OCP.OCP.TopoDS.TopoDS_Compound]):
341@dataclass
342class ChocStem(Stem):
343    """
344    Concrete implementation of a Kailh Choc V1 compatible keycap stem.
345
346    Stem design compatible with Kailh Choc V1 low-profile switches,
347    featuring dual legs with optional arched profiles and a cross-shaped support
348    structure.
349
350    Parameters
351    ----------
352    leg_spacing : float, default=5.7
353        Distance between the centers of the two legs in millimeters.
354    leg_height : float, default=3.0
355        Height of the legs in millimeters.
356    leg_length : float, default=3.0
357        Length (Y-dimension) of each leg in millimeters. Controls the
358        front-to-back size of the legs.
359    leg_width : float, default=0.95
360        Width (X-dimension) of each leg in millimeters. Determines the
361        thickness of each leg.
362    arc_length_ratio : float, default=0.45
363        Ratio of leg length where the arched profile exists (0.0 to 1.0).
364        Only used when include_arc is True. Higher values create arcs
365        closer to the leg tips.
366    arc_width_ratio : float, default=0.25
367        Ratio of leg width for arc sagitta depth (0.0 to 1.0). Higher values create
368        arcs with an apex closer to the center of the legs.
369    cross_length : float, default=3.7
370        Length of the cross support arms (Y-dimension) in millimeters.
371    cross_width : float, default=0.45
372        Width of the cross support arms in millimeters.
373    cross_spacing : float, default=4.3
374        Spacing between cross support elements in millimeters.
375    cross_height : float, default=0.1
376        Height of the cross support structure in millimeters. This is typically
377        much smaller than the leg height.
378    fillet_legs_top : float, default=0.06
379        Fillet radius for the top edges of the legs in millimeters.
380    fillet_legs_side : float, default=0.08
381        Fillet radius for the side edges of the legs in millimeters.
382    fillet_legs_bottom : float, default=0.1
383        Fillet radius for the bottom edges of the legs in millimeters. Provides
384        smooth transitions where legs meet the base.
385    include_cross : bool, default=True
386        Whether to include the cross support structure.
387        Helps raise the keycap to avoid contacting the top housing on key presses.
388    include_arc : bool, default=True
389        Whether to use arched leg profiles instead of rectangular. Arched
390        profiles can reduce stress by avoiding a vacuum forming when trying to remove keycaps.
391    center_at : CenterOf, default=CenterOf.GEOMETRY
392        Centering mode for the stem geometry.
393    offset : Location, default=Location()
394        Spatial offset for the stem.
395
396    Examples
397    --------
398    Create a standard Choc stem:
399    >>> choc_stem = ChocStem()
400
401    Create a minimal Choc stem without optional features:
402    >>> minimal_choc = ChocStem(include_cross=False, include_arc=False)
403
404    Create a custom Choc stem with taller legs:
405    >>> tall_choc = ChocStem(leg_height=3.5, fillet_legs_top=0.1)
406
407    Create a stem with custom arc curvature:
408    >>> curved_choc = ChocStem(arc_length_ratio=0.6, arc_width_ratio=0.3, include_arc=True)
409
410    Notes
411    -----
412    The Choc stem design is specifically tailored for Kailh Choc V1 low-profile
413    switches.
414
415    The optional arched leg profiles can reduce stress by avoiding a
416    vacuum forming when trying to remove keycaps, which may be a problem
417    with certain plastics.
418
419    Cross support structures helps raise the keycap a bit to avoid unwanted
420    contact upon keypresses.
421    """
422
423    leg_spacing: float = 5.7
424    leg_height: float = 3
425    leg_length: float = 3
426    leg_width: float = 0.95
427
428    arc_length_ratio: float = 0.45
429    arc_width_ratio: float = 0.25
430
431    cross_length: float = 3.7
432    cross_width: float = 0.45
433    cross_spacing: float = 4.3
434    cross_height: float = 0.1
435
436    fillet_legs_top: float = 0.06
437    fillet_legs_side: float = 0.08
438    fillet_legs_bottom: float = 0.1
439
440    include_cross: bool = True
441    include_arc: bool = True
442
443    def __post_init__(self):
444        """Initialize the Choc stem after dataclass creation."""
445        super().__post_init__()
446
447    @cached_property
448    def _legs_sketch(self) -> Sketch:
449        """
450        Create the sketch for the stem legs.
451
452        Generates either rectangular or arched leg profiles based on the
453        include_arc setting, positioned symmetrically according to leg_spacing.
454
455        Returns
456        -------
457        Sketch
458            A sketch containing two leg profiles positioned symmetrically about
459            the origin. Legs can be either rectangular or arched depending on
460            the include_arc parameter.
461
462        Notes
463        -----
464        The sketch is cached to avoid regeneration on multiple accesses.
465        When include_arc is True, the method delegates to _legs_arched for
466        the more complex curved profile generation.
467        """
468        locs = GridLocations(self.leg_spacing, 0, 2, 1).locations
469        if self.include_arc:
470            return self._legs_arched(*locs)
471
472        with BuildSketch(*locs) as sk:
473            Rectangle(self.leg_width, self.leg_length)
474        return sk.sketch
475
476    def _legs_arched(self, *locs: Location) -> Sketch:
477        """
478        Create arched leg profiles.
479
480        Generates leg profiles with curved sides using sagitta arcs instead of
481        straight edges.
482
483        Parameters
484        ----------
485        *locs : Location
486            Variable number of Location objects specifying where the arched
487            legs should be created. Typically two locations.
488
489        Returns
490        -------
491        Sketch
492            A sketch containing arched leg profiles at the specified locations.
493            Each leg has curved sides defined by sagitta arcs with curvature
494            controlled by arc_length_ratio and arc_width_ratio.
495
496        Notes
497        -----
498        The arching process:
499        1. Calculates arc start position based on arc_length_ratio
500        2. Determines sagitta (arc depth) from arc_width_ratio
501        3. Creates a polyline and sagitta arc for one side
502        4. Mirrors the profile to create the complete symmetric leg shape
503        """
504        w = self.leg_width / 2
505        h = self.leg_length / 2
506
507        arc_h = self.arc_length_ratio * h
508        sagitta = self.arc_width_ratio * w
509
510        with BuildSketch(locs) as sk:
511            with BuildLine():
512                Polyline((0, h), (-w, h), (-w, arc_h))
513                SagittaArc((-w, arc_h), (-w, -arc_h), sagitta)
514                mirror(about=Plane.XZ)
515                mirror(about=Plane.YZ)
516            make_face()
517        return sk.sketch
518
519    @cached_property
520    def _cross_sketch(self) -> Sketch:
521        """
522        Create the cross support structure sketch.
523
524        Generates a cross-shaped support structure. The cross structure is
525        typically much thinner than the main legs but covers a wider area.
526
527        Returns
528        -------
529        Sketch
530            A sketch containing the cross support structure pattern with arms
531            extending in both X and Y directions. The pattern is symmetric and
532            sized according to cross_spacing, cross_width, and cross_length parameters.
533
534        Notes
535        -----
536        The cross pattern is created by:
537        1. Creating a main horizontal rectangle aligned to one side
538        2. Adding vertical rectangles at specific locations
539        3. Mirroring the entire pattern for symmetry
540
541        The sketch is cached for performance optimization. The cross support
542        is designed to complement the main legs without interfering with the switch interface.
543        """
544        with BuildSketch(mode=Mode.PRIVATE) as sk:
545            Rectangle(self.cross_spacing, self.cross_width, align=(Align.MAX, Align.CENTER))
546            with Locations((0, 0), (-self.cross_spacing, 0)):
547                Rectangle(self.cross_width, self.cross_length)
548            mirror(about=Plane.YZ)
549        return sk.sketch
550
551    def _builder(self) -> BuildPart:
552        """
553        Construct the Choc stem geometry.
554
555        Creates the complete Choc stem including legs with optional arched
556        profiles, filleted edges, and optional cross
557        support structure.
558
559        Returns
560        -------
561        BuildPart
562            The BuildPart context containing the complete Choc stem geometry with:
563            - Dual legs (rectangular or arched)
564            - Filleted leg edges
565            - Optional cross support structure
566
567        Examples
568        --------
569        The builder is called automatically during initialization:
570        >>> stem = ChocStem()
571        >>> # Geometry is built automatically
572
573        Notes
574        -----
575        The construction process:
576        1. Creates a foundation base sized to accommodate all features
577        2. Extrudes the leg geometry to the specified height
578        3. Applies three different fillet operations:
579           - Side edges
580           - Top edges
581           - Bottom edges
582        4. Optionally adds cross support structure if enabled
583        """
584        with BuildPart() as p:
585            with BuildSketch():
586                size = max(self.leg_spacing, self.cross_spacing) ** 2
587                Rectangle(size, size)
588
589            extrude(amount=-1)
590            extrude(self._legs_sketch, amount=self.leg_height)
591
592            fillet_safe(p.edges().group_by(Axis.Z)[-2], self.fillet_legs_side)
593            fillet_safe(p.edges().group_by(Axis.Z)[-1], self.fillet_legs_top)
594
595            fillet_safe(
596                p.part.faces()
597                .filter_by(lambda f: f.normal_at() == Vector(0, 0, 1))
598                .sort_by(SortBy.AREA)[-1]
599                .inner_wires()
600                .edges(),
601                self.fillet_legs_bottom,
602            )
603
604            if self.include_cross:
605                extrude(self._cross_sketch, amount=self.cross_height)
606
607        return p

Concrete implementation of a Kailh Choc V1 compatible keycap stem.

Stem design compatible with Kailh Choc V1 low-profile switches, featuring dual legs with optional arched profiles and a cross-shaped support structure.

Parameters
  • leg_spacing (float, default=5.7): Distance between the centers of the two legs in millimeters.
  • leg_height (float, default=3.0): Height of the legs in millimeters.
  • leg_length (float, default=3.0): Length (Y-dimension) of each leg in millimeters. Controls the front-to-back size of the legs.
  • leg_width (float, default=0.95): Width (X-dimension) of each leg in millimeters. Determines the thickness of each leg.
  • arc_length_ratio (float, default=0.45): Ratio of leg length where the arched profile exists (0.0 to 1.0). Only used when include_arc is True. Higher values create arcs closer to the leg tips.
  • arc_width_ratio (float, default=0.25): Ratio of leg width for arc sagitta depth (0.0 to 1.0). Higher values create arcs with an apex closer to the center of the legs.
  • cross_length (float, default=3.7): Length of the cross support arms (Y-dimension) in millimeters.
  • cross_width (float, default=0.45): Width of the cross support arms in millimeters.
  • cross_spacing (float, default=4.3): Spacing between cross support elements in millimeters.
  • cross_height (float, default=0.1): Height of the cross support structure in millimeters. This is typically much smaller than the leg height.
  • fillet_legs_top (float, default=0.06): Fillet radius for the top edges of the legs in millimeters.
  • fillet_legs_side (float, default=0.08): Fillet radius for the side edges of the legs in millimeters.
  • fillet_legs_bottom (float, default=0.1): Fillet radius for the bottom edges of the legs in millimeters. Provides smooth transitions where legs meet the base.
  • include_cross (bool, default=True): Whether to include the cross support structure. Helps raise the keycap to avoid contacting the top housing on key presses.
  • include_arc (bool, default=True): Whether to use arched leg profiles instead of rectangular. Arched profiles can reduce stress by avoiding a vacuum forming when trying to remove keycaps.
  • center_at (CenterOf, default=CenterOf.GEOMETRY): Centering mode for the stem geometry.
  • offset (Location, default=Location()): Spatial offset for the stem.
Examples

Create a standard Choc stem:

>>> choc_stem = ChocStem()

Create a minimal Choc stem without optional features:

>>> minimal_choc = ChocStem(include_cross=False, include_arc=False)

Create a custom Choc stem with taller legs:

>>> tall_choc = ChocStem(leg_height=3.5, fillet_legs_top=0.1)

Create a stem with custom arc curvature:

>>> curved_choc = ChocStem(arc_length_ratio=0.6, arc_width_ratio=0.3, include_arc=True)
Notes

The Choc stem design is specifically tailored for Kailh Choc V1 low-profile switches.

The optional arched leg profiles can reduce stress by avoiding a vacuum forming when trying to remove keycaps, which may be a problem with certain plastics.

Cross support structures helps raise the keycap a bit to avoid unwanted contact upon keypresses.

ChocStem( center_at: build123d.build_enums.CenterOf = <CenterOf.GEOMETRY>, offset: build123d.geometry.Location = <factory>, leg_spacing: float = 5.7, leg_height: float = 3, leg_length: float = 3, leg_width: float = 0.95, arc_length_ratio: float = 0.45, arc_width_ratio: float = 0.25, cross_length: float = 3.7, cross_width: float = 0.45, cross_spacing: float = 4.3, cross_height: float = 0.1, fillet_legs_top: float = 0.06, fillet_legs_side: float = 0.08, fillet_legs_bottom: float = 0.1, include_cross: bool = True, include_arc: bool = True)

Build a Compound from Shapes

Args: obj (TopoDS_Compound | Iterable[Shape], optional): OCCT Compound or shapes label (str, optional): Defaults to ''. color (Color, optional): Defaults to None. material (str, optional): tag for external tools. Defaults to ''. joints (dict[str, Joint], optional): names joints. Defaults to None. parent (Compound, optional): assembly parent. Defaults to None. children (Sequence[Shape], optional): assembly children. Defaults to None.

leg_spacing: float = 5.7
leg_height: float = 3
leg_length: float = 3
leg_width: float = 0.95
arc_length_ratio: float = 0.45
arc_width_ratio: float = 0.25
cross_length: float = 3.7
cross_width: float = 0.45
cross_spacing: float = 4.3
cross_height: float = 0.1
fillet_legs_top: float = 0.06
fillet_legs_side: float = 0.08
fillet_legs_bottom: float = 0.1
include_cross: bool = True
include_arc: bool = True
class Comparer(capistry.BaseComparer[~T]):
444class Comparer(BaseComparer[T]):
445    """
446    Default rich comparison implementation with table output.
447
448    Provides a complete implementation of the comparison framework with
449    rich console output capabilities. Handles formatting, delta calculations,
450    and visual presentation of comparison data.
451
452    Parameters
453    ----------
454    *comparables : T
455        Variable number of comparable objects to compare.
456        At least one object is required.
457
458    Examples
459    --------
460    >>> comparer = Comparer(car1, car2, car3)
461    >>> comparer.show(precision=2, show_deltas=True)
462    >>> rich_table = comparer.to_table(title="Vehicle Comparison")
463    """
464
465    def to_table(
466        self, precision: int = 3, show_deltas: bool = True, title: str = "🔍 Comparison Table"
467    ) -> RichTable:
468        """
469        Format comparison as a rich table.
470
471        Creates a formatted Rich table suitable for console display, with
472        optional delta calculations between consecutive values.
473
474        Parameters
475        ----------
476        precision : int, default=3
477            Number of decimal places for numeric values.
478        show_deltas : bool, default=True
479            Whether to show differences between consecutive values.
480        title : str, default="🔍 Comparison Table"
481            Title to display at the top of the table.
482
483        Returns
484        -------
485        RichTable
486            Formatted table ready for console display.
487
488        Examples
489        --------
490        >>> table = comparer.to_table(precision=2, show_deltas=False)
491        >>> console.print(table)
492        """
493        table = RichTable(title=title, box=box.SIMPLE_HEAVY)
494        table.add_column("Metric", style="bold cyan", no_wrap=True)
495
496        # Add columns for each comparable
497        for obj in self._comparables:
498            label = self._get_label(obj)
499            table.add_column(label, style="bold white", justify="right")
500
501        # Add rows
502        for section in self.table:
503            # Section header
504            table.add_row(
505                f"[bold magenta]{section.title}[/bold magenta]", *[""] * len(self._comparables)
506            )
507
508            # Rows in this section
509            for row in section.rows:
510                table_row: list[RenderableType] = [
511                    f"{row.name} ({row.unit})" if row.unit else row.name
512                ]
513                prev_val = None
514
515                for compute in row.computes:
516                    if compute is None:
517                        # Missing metric
518                        formatted = "[dim]-[/dim]"
519                    else:
520                        val = compute()
521                        if show_deltas and prev_val is not None:
522                            delta_str = self._format_delta(val, prev_val, precision)
523                        else:
524                            delta_str = ""
525
526                        formatted = delta_str + self._format_value(val, precision)
527                        prev_val = val
528
529                    table_row.append(Align.right(formatted))
530
531                table.add_row(*table_row)
532
533        return table
534
535    def show(
536        self,
537        precision: int = 3,
538        show_deltas: bool = True,
539        title: str = "🔍 Comparison Table",
540        console: Console | None = None,
541    ) -> None:
542        """
543        Print the comparison table to console.
544
545        Displays the formatted comparison table directly to the console
546        using Rich's console output capabilities.
547
548        Parameters
549        ----------
550        precision : int, default=3
551            Number of decimal places for numeric values.
552        show_deltas : bool, default=True
553            Whether to show differences between consecutive values.
554        title : str, default="🔍 Comparison Table"
555            Title to display above the table.
556        console : Console or None, default=None
557            Rich Console instance to use. If None, creates a new one.
558
559        Examples
560        --------
561        >>> comparer.show()
562        >>> comparer.show(precision=1, show_deltas=False, title="My Comparison")
563        >>> comparer.show(console=my_console)
564        """
565        console = console or Console()
566        table = self.to_table(precision=precision, show_deltas=show_deltas, title=title)
567        console.print(table)
568
569    def _get_label(self, obj: T) -> str:
570        """
571        Get a display label for an object.
572
573        Attempts to find a suitable display label by checking for common
574        attributes like 'name' or 'label', falling back to string representation.
575
576        Parameters
577        ----------
578        obj : T
579            The object to get a label for.
580
581        Returns
582        -------
583        str
584            Display label for the object.
585        """
586        for attr in ("name", "label"):
587            if hasattr(obj, attr):
588                val = getattr(obj, attr)
589                if callable(val):
590                    try:
591                        result = val()
592                        if isinstance(result, str) and result:
593                            return result
594                    except Exception:
595                        continue
596                if isinstance(val, str) and val:
597                    return val
598        return str(obj)
599
600    def _format_value(self, val: Any, precision: int) -> str:
601        """
602        Format a value for display.
603
604        Applies appropriate formatting rules based on the value type,
605        with special handling for booleans, numbers, sequences, and None.
606
607        Parameters
608        ----------
609        val : Any
610            The value to format.
611        precision : int
612            Number of decimal places for numeric values.
613
614        Returns
615        -------
616        str
617            Formatted string representation of the value.
618        """
619        match val:
620            case bool() as b:
621                return "✔" if b else "✖"
622            case Number() as n:
623                return str(round(Dec(str(n)), precision))
624            case list() | tuple() as seq:
625                return ", ".join(str(v) for v in seq)
626            case None:
627                return "-"
628            case _:
629                return str(val)
630
631    def _format_delta(self, current: Any, previous: Any, precision: int) -> str:
632        """
633        Format the difference between two values.
634
635        Calculates and formats the delta between consecutive values,
636        with color coding for positive (green) and negative (red) changes.
637
638        Parameters
639        ----------
640        current : Any
641            The current value.
642        previous : Any
643            The previous value to compare against.
644        precision : int
645            Number of decimal places for the delta value.
646
647        Returns
648        -------
649        str
650            Formatted delta string with color markup, or empty string
651            if delta cannot be calculated or is zero.
652        """
653        try:
654            delta = current - previous
655            if not isinstance(delta, int | float | Dec):
656                return ""
657            if delta == 0:
658                return ""
659        except TypeError:
660            return ""
661
662        rounded = round(Dec(str(abs(delta))), precision)
663        sign = "+" if delta > 0 else "-"
664        color = "green" if delta > 0 else "red"
665        return f"[{color}]{sign}{rounded}[/{color}] "

Default rich comparison implementation with table output.

Provides a complete implementation of the comparison framework with rich console output capabilities. Handles formatting, delta calculations, and visual presentation of comparison data.

Parameters
  • *comparables (T): Variable number of comparable objects to compare. At least one object is required.
Examples
>>> comparer = Comparer(car1, car2, car3)
>>> comparer.show(precision=2, show_deltas=True)
>>> rich_table = comparer.to_table(title="Vehicle Comparison")
def to_table( self, precision: int = 3, show_deltas: bool = True, title: str = '🔍 Comparison Table') -> rich.table.Table:
465    def to_table(
466        self, precision: int = 3, show_deltas: bool = True, title: str = "🔍 Comparison Table"
467    ) -> RichTable:
468        """
469        Format comparison as a rich table.
470
471        Creates a formatted Rich table suitable for console display, with
472        optional delta calculations between consecutive values.
473
474        Parameters
475        ----------
476        precision : int, default=3
477            Number of decimal places for numeric values.
478        show_deltas : bool, default=True
479            Whether to show differences between consecutive values.
480        title : str, default="🔍 Comparison Table"
481            Title to display at the top of the table.
482
483        Returns
484        -------
485        RichTable
486            Formatted table ready for console display.
487
488        Examples
489        --------
490        >>> table = comparer.to_table(precision=2, show_deltas=False)
491        >>> console.print(table)
492        """
493        table = RichTable(title=title, box=box.SIMPLE_HEAVY)
494        table.add_column("Metric", style="bold cyan", no_wrap=True)
495
496        # Add columns for each comparable
497        for obj in self._comparables:
498            label = self._get_label(obj)
499            table.add_column(label, style="bold white", justify="right")
500
501        # Add rows
502        for section in self.table:
503            # Section header
504            table.add_row(
505                f"[bold magenta]{section.title}[/bold magenta]", *[""] * len(self._comparables)
506            )
507
508            # Rows in this section
509            for row in section.rows:
510                table_row: list[RenderableType] = [
511                    f"{row.name} ({row.unit})" if row.unit else row.name
512                ]
513                prev_val = None
514
515                for compute in row.computes:
516                    if compute is None:
517                        # Missing metric
518                        formatted = "[dim]-[/dim]"
519                    else:
520                        val = compute()
521                        if show_deltas and prev_val is not None:
522                            delta_str = self._format_delta(val, prev_val, precision)
523                        else:
524                            delta_str = ""
525
526                        formatted = delta_str + self._format_value(val, precision)
527                        prev_val = val
528
529                    table_row.append(Align.right(formatted))
530
531                table.add_row(*table_row)
532
533        return table

Format comparison as a rich table.

Creates a formatted Rich table suitable for console display, with optional delta calculations between consecutive values.

Parameters
  • precision (int, default=3): Number of decimal places for numeric values.
  • show_deltas (bool, default=True): Whether to show differences between consecutive values.
  • title (str, default="🔍 Comparison Table"): Title to display at the top of the table.
Returns
  • RichTable: Formatted table ready for console display.
Examples
>>> table = comparer.to_table(precision=2, show_deltas=False)
>>> console.print(table)
def show( self, precision: int = 3, show_deltas: bool = True, title: str = '🔍 Comparison Table', console: rich.console.Console | None = None) -> None:
535    def show(
536        self,
537        precision: int = 3,
538        show_deltas: bool = True,
539        title: str = "🔍 Comparison Table",
540        console: Console | None = None,
541    ) -> None:
542        """
543        Print the comparison table to console.
544
545        Displays the formatted comparison table directly to the console
546        using Rich's console output capabilities.
547
548        Parameters
549        ----------
550        precision : int, default=3
551            Number of decimal places for numeric values.
552        show_deltas : bool, default=True
553            Whether to show differences between consecutive values.
554        title : str, default="🔍 Comparison Table"
555            Title to display above the table.
556        console : Console or None, default=None
557            Rich Console instance to use. If None, creates a new one.
558
559        Examples
560        --------
561        >>> comparer.show()
562        >>> comparer.show(precision=1, show_deltas=False, title="My Comparison")
563        >>> comparer.show(console=my_console)
564        """
565        console = console or Console()
566        table = self.to_table(precision=precision, show_deltas=show_deltas, title=title)
567        console.print(table)

Print the comparison table to console.

Displays the formatted comparison table directly to the console using Rich's console output capabilities.

Parameters
  • precision (int, default=3): Number of decimal places for numeric values.
  • show_deltas (bool, default=True): Whether to show differences between consecutive values.
  • title (str, default="🔍 Comparison Table"): Title to display above the table.
  • console (Console or None, default=None): Rich Console instance to use. If None, creates a new one.
Examples
>>> comparer.show()
>>> comparer.show(precision=1, show_deltas=False, title="My Comparison")
>>> comparer.show(console=my_console)
@dataclass(init=False)
class Ergogen:
527@dataclass(init=False)
528class Ergogen:
529    """
530    Represents an Ergogen keyboard layout configuration builder.
531
532    This class serves as a container and builder for Ergogen-compatible YAML
533    configurations based on individual `Cap` elements. It collects Caps,
534    applies a naming schema, and produces structured layout and
535    outline data conforming to Ergogen's configuration format.
536
537    Parameters
538    ----------
539    *caps : Cap
540        One or more `Cap` instances representing individual keys or units.
541    schema : ErgogenSchema, optional
542        Schema defining naming conventions for zones, outlines, rows, and columns.
543        Defaults to a standard schema if not provided.
544
545    Attributes
546    ----------
547    caps : list[Cap]
548        The list of Caps used to build the layout.
549    schema : ErgogenSchema
550        Naming schema applied during generation (not serialized).
551    _config : ErgogenConfig
552        Internal YAML-ready configuration, lazily built on access.
553
554    Raises
555    ------
556    ValueError
557        If no Caps are provided at initialization.
558
559    Examples
560    --------
561    Create 2 caps and export an Ergogen config
562    >>> cap1 = Cap(...)
563    >>> cap2 = Cap(...)
564    >>> ergo = Ergogen(cap1, cap2)
565    >>>
566    >>> # Print the YAML string
567    >>> yaml_str = ergo.to_yaml()
568    >>> print(yaml_str)
569    >>>
570    >>> # Write the YAML to file
571    >>> ergo.write_yaml("config.yaml")
572    """
573
574    caps: list[Cap] = field(default_factory=list)
575    schema: ErgogenSchema = field(default_factory=ErgogenSchema)
576
577    def __init__(self, *caps: Cap, schema: ErgogenSchema | None = None):
578        self.caps = list(caps)
579        self.schema = schema or ErgogenSchema()
580
581        if not self.caps:
582            raise ValueError("At least one Cap must be provided.")
583
584    @property
585    def _config(self) -> ErgogenConfig:
586        logger.info("Creating %s config for %s cap(s)", type(self).__name__, len(self.caps))
587
588        schema = self.schema
589        zone = Zone(key=KeyAttrs(spread=0, padding=0))
590        offset: Vector | None = None
591
592        points = Points.from_zone(schema.zone_name, zone)
593        outlines: dict[str, list[OutlineShape]] = {schema.outline_name: []}
594
595        for i, cap in enumerate(self.caps):
596            outline = deepcopy(cap.outline)
597            outline.position = -cap.stem.position
598
599            key_loc = gloc(cap.stem)
600            offset = offset or key_loc.position
601            key_loc.position -= offset
602
603            rot = Rot(cap.outline.orientation - cap.stem.orientation)
604            col_name = f"{schema.column_prefix}{i + 1}"
605
606            zone.columns[col_name] = Column(key=KeyAttrs.from_location(key_loc))
607            zone.rows[schema.row_name] = Row()
608
609            where = f"{schema.zone_name}_{col_name}_{schema.row_name}"
610
611            outlines[schema.outline_name].append(
612                OutlineShape.from_sketch(
613                    sketch=outline,
614                    where=where,
615                    adjust=Adjust.from_location(rot),
616                )
617            )
618
619        return ErgogenConfig(points=points, outlines=outlines)
620
621    def to_yaml(self, precision: int = 3) -> EncodedData:
622        """
623        Convert the Ergogen configuration to a YAML-formatted string.
624
625        Parameters
626        ----------
627        precision : int, default=3
628            Number of decimal places for floating-point values in the output.
629
630        Returns
631        -------
632        EncodedData
633            A string-like object containing the YAML configuration.
634        """
635        return self._config.to_yaml(encoder=_yaml_encoder(float_precision=precision))
636
637    def write_yaml(self, filepath: PathLike | str = "config.yaml", precision: int = 3) -> None:
638        """
639        Write the Ergogen configuration to a YAML file.
640
641        Parameters
642        ----------
643        filepath : str, default="config.yaml"
644            Path to the output file.
645        precision : int, default=3
646            Number of decimal places for floating-point values in the file.
647        """
648        yaml_str = self.to_yaml(precision=precision)
649        with open(filepath, "w", encoding="utf-8") as f:
650            f.write(str(yaml_str))

Represents an Ergogen keyboard layout configuration builder.

This class serves as a container and builder for Ergogen-compatible YAML configurations based on individual Cap elements. It collects Caps, applies a naming schema, and produces structured layout and outline data conforming to Ergogen's configuration format.

Parameters
  • *caps (Cap): One or more Cap instances representing individual keys or units.
  • schema (ErgogenSchema, optional): Schema defining naming conventions for zones, outlines, rows, and columns. Defaults to a standard schema if not provided.
Attributes
  • caps (list[Cap]): The list of Caps used to build the layout.
  • schema (ErgogenSchema): Naming schema applied during generation (not serialized).
  • _config (ErgogenConfig): Internal YAML-ready configuration, lazily built on access.
Raises
  • ValueError: If no Caps are provided at initialization.
Examples

Create 2 caps and export an Ergogen config

>>> cap1 = Cap(...)
>>> cap2 = Cap(...)
>>> ergo = Ergogen(cap1, cap2)
>>>
>>> # Print the YAML string
>>> yaml_str = ergo.to_yaml()
>>> print(yaml_str)
>>>
>>> # Write the YAML to file
>>> ergo.write_yaml("config.yaml")
Ergogen( *caps: Cap, schema: ErgogenSchema | None = None)
577    def __init__(self, *caps: Cap, schema: ErgogenSchema | None = None):
578        self.caps = list(caps)
579        self.schema = schema or ErgogenSchema()
580
581        if not self.caps:
582            raise ValueError("At least one Cap must be provided.")
caps: list[Cap]
schema: ErgogenSchema
def to_yaml(self, precision: int = 3) -> Union[str, bytes]:
621    def to_yaml(self, precision: int = 3) -> EncodedData:
622        """
623        Convert the Ergogen configuration to a YAML-formatted string.
624
625        Parameters
626        ----------
627        precision : int, default=3
628            Number of decimal places for floating-point values in the output.
629
630        Returns
631        -------
632        EncodedData
633            A string-like object containing the YAML configuration.
634        """
635        return self._config.to_yaml(encoder=_yaml_encoder(float_precision=precision))

Convert the Ergogen configuration to a YAML-formatted string.

Parameters
  • precision (int, default=3): Number of decimal places for floating-point values in the output.
Returns
  • EncodedData: A string-like object containing the YAML configuration.
def write_yaml( self, filepath: os.PathLike | str = 'config.yaml', precision: int = 3) -> None:
637    def write_yaml(self, filepath: PathLike | str = "config.yaml", precision: int = 3) -> None:
638        """
639        Write the Ergogen configuration to a YAML file.
640
641        Parameters
642        ----------
643        filepath : str, default="config.yaml"
644            Path to the output file.
645        precision : int, default=3
646            Number of decimal places for floating-point values in the file.
647        """
648        yaml_str = self.to_yaml(precision=precision)
649        with open(filepath, "w", encoding="utf-8") as f:
650            f.write(str(yaml_str))

Write the Ergogen configuration to a YAML file.

Parameters
  • filepath (str, default="config.yaml"): Path to the output file.
  • precision (int, default=3): Number of decimal places for floating-point values in the file.
@dataclass
class ErgogenSchema:
475@dataclass
476class ErgogenSchema:
477    """
478    Schema configuration for Ergogen config generation.
479
480    Parameters
481    ----------
482    zone_name : str, default="capistry"
483        Name used for the zone.
484    outline_name : str, default="capistry"
485        Name used for outline.
486    row_name : str, default="row"
487        Default row name.
488    column_prefix : str, default="col_"
489        Prefix used to generate column names.
490    """
491
492    zone_name: str = "capistry"
493    outline_name: str = "capistry"
494    row_name: str = "row"
495    column_prefix: str = "col_"

Schema configuration for Ergogen config generation.

Parameters
  • zone_name (str, default="capistry"): Name used for the zone.
  • outline_name (str, default="capistry"): Name used for outline.
  • row_name (str, default="row"): Default row name.
  • column_prefix (str, default="col_"): Prefix used to generate column names.
ErgogenSchema( zone_name: str = 'capistry', outline_name: str = 'capistry', row_name: str = 'row', column_prefix: str = 'col_')
zone_name: str = 'capistry'
outline_name: str = 'capistry'
row_name: str = 'row'
column_prefix: str = 'col_'
@dataclass
class FilletDepthWidth(capistry.FilletStrategy):
372@dataclass
373class FilletDepthWidth(FilletStrategy):
374    """
375    Directional fillet strategy applying depth edges (front/back) before width edges (left/right).
376
377    Parameters
378    ----------
379    front : float, default=2.0
380        Radius in mm for front edge fillets (negative Y direction).
381    back : float, default=1.0
382        Radius in mm for back edge fillets (positive Y direction).
383    left : float, default=3.0
384        Radius in mm for left side fillets (negative X direction).
385    right : float, default=3.0
386        Radius in mm for right side fillets (positive X direction).
387    skirt : float, default=0.25
388        Inherited from FilletStrategy. Radius for bottom face edge fillets.
389    inner : float, default 1.0
390        Inherited from FilletStrategy. Radius for internal Z-axis edge fillets.
391    """
392
393    front: float = 2
394    back: float = 1
395    left: float = 3
396    right: float = 3
397
398    def apply_outer(self, p: BuildPart):
399        """
400        Apply outer fillet radii with (left/right) processed last.
401
402        Parameters
403        ----------
404        p : BuildPart
405            The BuildPart representing a `capistry.Cap` instance to
406            which outer fillets should be applied.
407        """
408        logger.debug(
409            "Applying outer fillets (sides-last)",
410            extra={
411                "front": self.front,
412                "back": self.back,
413                "left": self.left,
414                "right": self.right,
415            },
416        )
417
418        fillet_safe(p.faces().sort_by(Axis.Z)[-1].edges().sort_by(Axis.X)[0], self.left)
419        fillet_safe(p.faces().sort_by(Axis.Z)[-1].edges().sort_by(Axis.X)[-1], self.right)
420        fillet_safe(p.faces().sort_by(Axis.Y)[0].edges().sort_by(Axis.Z)[1:], self.front)
421        fillet_safe(p.faces().sort_by(Axis.Y)[-1].edges().sort_by(Axis.Z)[1:], self.back)

Directional fillet strategy applying depth edges (front/back) before width edges (left/right).

Parameters
  • front (float, default=2.0): Radius in mm for front edge fillets (negative Y direction).
  • back (float, default=1.0): Radius in mm for back edge fillets (positive Y direction).
  • left (float, default=3.0): Radius in mm for left side fillets (negative X direction).
  • right (float, default=3.0): Radius in mm for right side fillets (positive X direction).
  • skirt (float, default=0.25): Inherited from FilletStrategy. Radius for bottom face edge fillets.
  • inner (float, default 1.0): Inherited from FilletStrategy. Radius for internal Z-axis edge fillets.
FilletDepthWidth( skirt: float = 0.25, inner: float = 1, front: float = 2, back: float = 1, left: float = 3, right: float = 3)
front: float = 2
back: float = 1
left: float = 3
right: float = 3
def apply_outer(self, p: build123d.build_part.BuildPart):
398    def apply_outer(self, p: BuildPart):
399        """
400        Apply outer fillet radii with (left/right) processed last.
401
402        Parameters
403        ----------
404        p : BuildPart
405            The BuildPart representing a `capistry.Cap` instance to
406            which outer fillets should be applied.
407        """
408        logger.debug(
409            "Applying outer fillets (sides-last)",
410            extra={
411                "front": self.front,
412                "back": self.back,
413                "left": self.left,
414                "right": self.right,
415            },
416        )
417
418        fillet_safe(p.faces().sort_by(Axis.Z)[-1].edges().sort_by(Axis.X)[0], self.left)
419        fillet_safe(p.faces().sort_by(Axis.Z)[-1].edges().sort_by(Axis.X)[-1], self.right)
420        fillet_safe(p.faces().sort_by(Axis.Y)[0].edges().sort_by(Axis.Z)[1:], self.front)
421        fillet_safe(p.faces().sort_by(Axis.Y)[-1].edges().sort_by(Axis.Z)[1:], self.back)

Apply outer fillet radii with (left/right) processed last.

Parameters
  • p (BuildPart): The BuildPart representing a capistry.Cap instance to which outer fillets should be applied.
@dataclass
class FilletMiddleTop(capistry.FilletStrategy):
424@dataclass
425class FilletMiddleTop(FilletStrategy):
426    """
427    Fillet strategy that applies middle-height edges first, then top face edges.
428
429    Parameters
430    ----------
431    top : float, default=0.75
432        Radius for the topmost edge fillets (upper Z direction).
433    middle : float, default=2.5
434        Radius for edges located in the mid-section of the part.
435    skirt : float, default=0.25
436        Inherited from FilletStrategy. Radius for bottom face edge fillets.
437    inner : float, default=1.0
438        Inherited from FilletStrategy. Radius for internal Z-axis edge fillets.
439    """
440
441    top: float = 0.75
442    middle: float = 2.5
443
444    def apply_outer(self, p: BuildPart):
445        """
446        Apply mid-section and top edge fillets to the outer geometry.
447
448        First applies fillets to mid-height vertical or slanted edges,
449        followed by fillets on the uppermost perimeter.
450
451        Parameters
452        ----------
453        p : BuildPart
454            The BuildPart representing a Cap instance (e.g., MXStem or Choc).
455        """
456        logger.debug(
457            "Applying outer fillets (top-to-middle)",
458            extra={"top": self.top, "middle": self.middle},
459        )
460
461        def faces() -> ShapeList[Face]:
462            # Sort faces by height (Z) and prioritize side faces
463            # (i.e., those with normals not closely aligned with Z).
464            return (
465                p.faces()
466                .sort_by(Axis.Z, reverse=True)
467                .sort_by(lambda f: abs(f.normal_at().dot(Axis.Z.direction)))
468            )
469
470        # Fillet mid-height edges on side faces:
471        #   - Exclude top/bottom Z-aligned faces (last two in list).
472        #   - Use `% 0.5` to find the edge tangent at midpoint.
473        #   - Keep edges with significant vertical (Z) tangent component.
474        fillet_safe(
475            faces()[:-2].edges().filter_by(lambda e: abs((e % 0.5).Z) > 0.5),  # noqa: PLR2004
476            self.middle,
477        )
478
479        # Fillet the topmost usable face's edges:
480        #   - From last two faces, pick the highest one by Z position.
481        fillet_safe(
482            faces()[-2:].sort_by(Axis.Z)[-1].edges(),
483            self.top,
484        )

Fillet strategy that applies middle-height edges first, then top face edges.

Parameters
  • top (float, default=0.75): Radius for the topmost edge fillets (upper Z direction).
  • middle (float, default=2.5): Radius for edges located in the mid-section of the part.
  • skirt (float, default=0.25): Inherited from FilletStrategy. Radius for bottom face edge fillets.
  • inner (float, default=1.0): Inherited from FilletStrategy. Radius for internal Z-axis edge fillets.
FilletMiddleTop( skirt: float = 0.25, inner: float = 1, top: float = 0.75, middle: float = 2.5)
top: float = 0.75
middle: float = 2.5
def apply_outer(self, p: build123d.build_part.BuildPart):
444    def apply_outer(self, p: BuildPart):
445        """
446        Apply mid-section and top edge fillets to the outer geometry.
447
448        First applies fillets to mid-height vertical or slanted edges,
449        followed by fillets on the uppermost perimeter.
450
451        Parameters
452        ----------
453        p : BuildPart
454            The BuildPart representing a Cap instance (e.g., MXStem or Choc).
455        """
456        logger.debug(
457            "Applying outer fillets (top-to-middle)",
458            extra={"top": self.top, "middle": self.middle},
459        )
460
461        def faces() -> ShapeList[Face]:
462            # Sort faces by height (Z) and prioritize side faces
463            # (i.e., those with normals not closely aligned with Z).
464            return (
465                p.faces()
466                .sort_by(Axis.Z, reverse=True)
467                .sort_by(lambda f: abs(f.normal_at().dot(Axis.Z.direction)))
468            )
469
470        # Fillet mid-height edges on side faces:
471        #   - Exclude top/bottom Z-aligned faces (last two in list).
472        #   - Use `% 0.5` to find the edge tangent at midpoint.
473        #   - Keep edges with significant vertical (Z) tangent component.
474        fillet_safe(
475            faces()[:-2].edges().filter_by(lambda e: abs((e % 0.5).Z) > 0.5),  # noqa: PLR2004
476            self.middle,
477        )
478
479        # Fillet the topmost usable face's edges:
480        #   - From last two faces, pick the highest one by Z position.
481        fillet_safe(
482            faces()[-2:].sort_by(Axis.Z)[-1].edges(),
483            self.top,
484        )

Apply mid-section and top edge fillets to the outer geometry.

First applies fillets to mid-height vertical or slanted edges, followed by fillets on the uppermost perimeter.

Parameters
  • p (BuildPart): The BuildPart representing a Cap instance (e.g., MXStem or Choc).
@dataclass
class FilletStrategy(capistry.compare.Comparable, abc.ABC):
147@dataclass
148class FilletStrategy(Comparable, ABC):
149    """
150    Abstract base class for `capistry.Cap` fillet strategies.
151
152    Defines the interface for applying various types of fillets to caps
153    such as `capistry.TrapezoidCap`, `capistry.RectangularCap`, or any other `capistry.Cap`
154    subclasses. Provides common parameters and methods for inner and skirt filleting
155    while leaving outer fillet implementation to concrete subclasses.
156
157    Parameters
158    ----------
159    skirt : float, default=0.25
160        Radius in mm for skirt fillets applied to the bottom-most face edges.
161        Creates a subtle rounded transition on the bottom wall edges.
162    inner : float, default=1.0
163        Radius in mm for inner fillets applied to Z-axis edges at the last step.
164        Smooths internal corners within the keycap shell.
165
166    Methods
167    -------
168    apply_outer(p)
169        Apply outer fillets to the cap (must be implemented by subclasses).
170    apply_inner(p)
171        Apply inner fillets to Z-axis edges.
172    apply_skirt(p)
173        Apply skirt fillet to bottom face edges.
174    metrics
175        Returns MetricLayout describing the strategy's parameters.
176
177    Notes
178    -----
179    This class is designed specifically for cap filleting and expects
180    BuildPart objects that represent `capistry.Cap` shapes.
181    Fillet operations are only meant to be called from `capistry.Cap.compound`.
182    """
183
184    skirt: float = 0.25
185    inner: float = 1
186
187    @abstractmethod
188    def apply_outer(self, p: BuildPart):
189        """
190        Apply outer fillets to the `capistry.Cap`.
191
192        This method must be implemented by concrete subclasses to define how
193        outside edges fillets are applied.
194
195        Parameters
196        ----------
197        p : BuildPart
198            The BuildPart representing a Cap instance (MXStem, choc, etc.) to
199            which outer fillets should be applied.
200
201        Notes
202        -----
203        Implementations should use fillet_safe() for robust error handling and
204        should take into consideration the different geometries of different caps.
205        """
206        pass
207
208    def apply_inner(self, p: BuildPart):
209        """
210        Apply inner fillets to Z-axis edges of the keyboard cap.
211
212        Applies fillets to internal edges along the Z-axis, typically smoothing
213        the inner cavity.
214
215        Parameters
216        ----------
217        p : BuildPart
218            The BuildPart representing a Cap to
219            which inner fillets should be applied.
220        """
221        logger.debug("Applying inner fillet", extra={"radius": self.inner})
222        fillet_safe(p.edges(Select.LAST).group_by(Axis.Z)[1:], self.inner)
223
224    def apply_skirt(self, p: BuildPart):
225        """
226        Apply skirt fillet to the bottom face edges of the keycap.
227
228        Creates a subtle rounded transition at the bottom edge of the keycap's walls.
229        Improves aesthetics and reduces stress concentrations during 3D-printing.
230
231        Parameters
232        ----------
233        p : BuildPart
234            The BuildPart representing a Cap instance (MXStem, choc, etc.) to
235            which the skirt fillet should be applied.
236        """
237        logger.debug("Applying skirt fillet", extra={"radius": self.skirt})
238        fillet_safe(p.faces(Select.LAST).sort_by()[0].edges(), self.skirt)
239
240    @property
241    def metrics(self) -> MetricLayout[Self]:
242        """
243        Expose all numeric parameters of this strategy through the `capistry.Comparable` system.
244
245        Automatically discovers all numeric fields (int, float) that don't start
246        with underscore and creates corresponding Metric objects with proper
247        formatting and units.
248
249        Returns
250        -------
251        MetricLayout
252            A MetricLayout containing all numeric fields organized under a
253            "Fillet" group, with units in millimeters and human-readable names.
254        """
255        numerics = [
256            f.name
257            for f in fields(self)
258            if not f.name.startswith("_") and isinstance(getattr(self, f.name), int | float)
259        ]
260
261        return MetricLayout(
262            owner=self,
263            groups=(
264                MetricGroup(
265                    "Fillet",
266                    tuple(
267                        Metric(
268                            name.replace("_", " ").title(),
269                            lambda n=name: getattr(self, n),
270                            unit="mm",
271                        )
272                        for name in numerics
273                    ),
274                ),
275            ),
276        )
277
278    def __str__(self) -> str:
279        """Return the class name as the string representation."""
280        return type(self).__name__

Abstract base class for capistry.Cap fillet strategies.

Defines the interface for applying various types of fillets to caps such as capistry.TrapezoidCap, capistry.RectangularCap, or any other capistry.Cap subclasses. Provides common parameters and methods for inner and skirt filleting while leaving outer fillet implementation to concrete subclasses.

Parameters
  • skirt (float, default=0.25): Radius in mm for skirt fillets applied to the bottom-most face edges. Creates a subtle rounded transition on the bottom wall edges.
  • inner (float, default=1.0): Radius in mm for inner fillets applied to Z-axis edges at the last step. Smooths internal corners within the keycap shell.
Methods

apply_outer(p) Apply outer fillets to the cap (must be implemented by subclasses). apply_inner(p) Apply inner fillets to Z-axis edges. apply_skirt(p) Apply skirt fillet to bottom face edges. metrics Returns MetricLayout describing the strategy's parameters.

Notes

This class is designed specifically for cap filleting and expects BuildPart objects that represent capistry.Cap shapes. Fillet operations are only meant to be called from capistry.Cap.compound.

skirt: float = 0.25
inner: float = 1
@abstractmethod
def apply_outer(self, p: build123d.build_part.BuildPart):
187    @abstractmethod
188    def apply_outer(self, p: BuildPart):
189        """
190        Apply outer fillets to the `capistry.Cap`.
191
192        This method must be implemented by concrete subclasses to define how
193        outside edges fillets are applied.
194
195        Parameters
196        ----------
197        p : BuildPart
198            The BuildPart representing a Cap instance (MXStem, choc, etc.) to
199            which outer fillets should be applied.
200
201        Notes
202        -----
203        Implementations should use fillet_safe() for robust error handling and
204        should take into consideration the different geometries of different caps.
205        """
206        pass

Apply outer fillets to the capistry.Cap.

This method must be implemented by concrete subclasses to define how outside edges fillets are applied.

Parameters
  • p (BuildPart): The BuildPart representing a Cap instance (MXStem, choc, etc.) to which outer fillets should be applied.
Notes

Implementations should use fillet_safe() for robust error handling and should take into consideration the different geometries of different caps.

def apply_inner(self, p: build123d.build_part.BuildPart):
208    def apply_inner(self, p: BuildPart):
209        """
210        Apply inner fillets to Z-axis edges of the keyboard cap.
211
212        Applies fillets to internal edges along the Z-axis, typically smoothing
213        the inner cavity.
214
215        Parameters
216        ----------
217        p : BuildPart
218            The BuildPart representing a Cap to
219            which inner fillets should be applied.
220        """
221        logger.debug("Applying inner fillet", extra={"radius": self.inner})
222        fillet_safe(p.edges(Select.LAST).group_by(Axis.Z)[1:], self.inner)

Apply inner fillets to Z-axis edges of the keyboard cap.

Applies fillets to internal edges along the Z-axis, typically smoothing the inner cavity.

Parameters
  • p (BuildPart): The BuildPart representing a Cap to which inner fillets should be applied.
def apply_skirt(self, p: build123d.build_part.BuildPart):
224    def apply_skirt(self, p: BuildPart):
225        """
226        Apply skirt fillet to the bottom face edges of the keycap.
227
228        Creates a subtle rounded transition at the bottom edge of the keycap's walls.
229        Improves aesthetics and reduces stress concentrations during 3D-printing.
230
231        Parameters
232        ----------
233        p : BuildPart
234            The BuildPart representing a Cap instance (MXStem, choc, etc.) to
235            which the skirt fillet should be applied.
236        """
237        logger.debug("Applying skirt fillet", extra={"radius": self.skirt})
238        fillet_safe(p.faces(Select.LAST).sort_by()[0].edges(), self.skirt)

Apply skirt fillet to the bottom face edges of the keycap.

Creates a subtle rounded transition at the bottom edge of the keycap's walls. Improves aesthetics and reduces stress concentrations during 3D-printing.

Parameters
  • p (BuildPart): The BuildPart representing a Cap instance (MXStem, choc, etc.) to which the skirt fillet should be applied.
metrics: capistry.compare.MetricLayout[typing.Self]
240    @property
241    def metrics(self) -> MetricLayout[Self]:
242        """
243        Expose all numeric parameters of this strategy through the `capistry.Comparable` system.
244
245        Automatically discovers all numeric fields (int, float) that don't start
246        with underscore and creates corresponding Metric objects with proper
247        formatting and units.
248
249        Returns
250        -------
251        MetricLayout
252            A MetricLayout containing all numeric fields organized under a
253            "Fillet" group, with units in millimeters and human-readable names.
254        """
255        numerics = [
256            f.name
257            for f in fields(self)
258            if not f.name.startswith("_") and isinstance(getattr(self, f.name), int | float)
259        ]
260
261        return MetricLayout(
262            owner=self,
263            groups=(
264                MetricGroup(
265                    "Fillet",
266                    tuple(
267                        Metric(
268                            name.replace("_", " ").title(),
269                            lambda n=name: getattr(self, n),
270                            unit="mm",
271                        )
272                        for name in numerics
273                    ),
274                ),
275            ),
276        )

Expose all numeric parameters of this strategy through the capistry.Comparable system.

Automatically discovers all numeric fields (int, float) that don't start with underscore and creates corresponding Metric objects with proper formatting and units.

Returns
  • MetricLayout: A MetricLayout containing all numeric fields organized under a "Fillet" group, with units in millimeters and human-readable names.
@dataclass
class FilletUniform(capistry.FilletStrategy):
283@dataclass
284class FilletUniform(FilletStrategy):
285    """
286    Uniform outer fillet strategy for `capistry.Cap`.
287
288    Applies the same fillet radius to all outer edges of keyboard caps,
289    creating a consistent, symmetrical appearance.
290
291    Parameters
292    ----------
293    outer : float, default=1.5
294        Radius in mm for outer fillets applied to all Z-axis edge groups
295        (excluding the bottom-most group). Creates uniform rounding on all
296        main visible edges of the keycap.
297    skirt : float, default=0.25
298        Inherited from FilletStrategy. Radius for bottom face edge fillets.
299    inner : float, default=1.0
300        Inherited from FilletStrategy. Radius for internal Z-axis edge fillets.
301    """
302
303    outer: float = 1.5
304    """Fillet radius for outer edges."""
305
306    def apply_outer(self, p: BuildPart):
307        """
308        Apply uniform outer fillets to all Z-axis edge groups, excluding the bottom-most group.
309
310        Parameters
311        ----------
312        p : BuildPart
313            The BuildPart representing a `capistry.Cap` instance to
314            which uniform outer fillets should be applied.
315        """
316        logger.debug("Applying outer fillets (uniform)", extra={"radius": self.outer})
317        fillet_safe(p.edges().group_by(Axis.Z)[1:], self.outer)

Uniform outer fillet strategy for capistry.Cap.

Applies the same fillet radius to all outer edges of keyboard caps, creating a consistent, symmetrical appearance.

Parameters
  • outer (float, default=1.5): Radius in mm for outer fillets applied to all Z-axis edge groups (excluding the bottom-most group). Creates uniform rounding on all main visible edges of the keycap.
  • skirt (float, default=0.25): Inherited from FilletStrategy. Radius for bottom face edge fillets.
  • inner (float, default=1.0): Inherited from FilletStrategy. Radius for internal Z-axis edge fillets.
FilletUniform(skirt: float = 0.25, inner: float = 1, outer: float = 1.5)
outer: float = 1.5

Fillet radius for outer edges.

def apply_outer(self, p: build123d.build_part.BuildPart):
306    def apply_outer(self, p: BuildPart):
307        """
308        Apply uniform outer fillets to all Z-axis edge groups, excluding the bottom-most group.
309
310        Parameters
311        ----------
312        p : BuildPart
313            The BuildPart representing a `capistry.Cap` instance to
314            which uniform outer fillets should be applied.
315        """
316        logger.debug("Applying outer fillets (uniform)", extra={"radius": self.outer})
317        fillet_safe(p.edges().group_by(Axis.Z)[1:], self.outer)

Apply uniform outer fillets to all Z-axis edge groups, excluding the bottom-most group.

Parameters
  • p (BuildPart): The BuildPart representing a capistry.Cap instance to which uniform outer fillets should be applied.
@dataclass
class FilletWidthDepth(capistry.FilletStrategy):
320@dataclass
321class FilletWidthDepth(FilletStrategy):
322    """
323    Directional fillet strategy applying width edges (left/right) before depth edges (front/back).
324
325    Parameters
326    ----------
327    front : float, default=3.0
328        Radius in mm for front edge fillets (negative Y direction).
329    back : float, default=2.0
330        Radius in mm for back edge fillets (positive Y direction).
331    left : float, default=1.0
332        Radius in mm for left side fillets (negative X direction).
333    right : float, default=1.0
334        Radius in mm for right side fillets (positive X direction).
335    skirt : float, default=0.25
336        Inherited from FilletStrategy. Radius for bottom face edge fillets.
337    inner : float, default=1.0
338        Inherited from FilletStrategy. Radius for internal Z-axis edge fillets.
339    """
340
341    front: float = 3
342    back: float = 2
343    left: float = 1
344    right: float = 1
345
346    def apply_outer(self, p: BuildPart):
347        """
348        Apply outer fillet radii with (left/right) processed first.
349
350        Parameters
351        ----------
352        p : BuildPart
353            The BuildPart representing a `capistry.Cap` instance to
354            which outer fillets should be applied.
355        """
356        logger.debug(
357            "Applying outer fillets (sides-first)",
358            extra={
359                "front": self.front,
360                "back": self.back,
361                "left": self.left,
362                "right": self.right,
363            },
364        )
365
366        fillet_safe(p.faces().sort_by(Axis.Z)[-1].edges().sort_by(Axis.Y)[0], self.front)
367        fillet_safe(p.faces().sort_by(Axis.Z)[-1].edges().sort_by(Axis.Y)[-1], self.back)
368        fillet_safe(p.faces().sort_by(Axis.X)[-1].edges().sort_by(Axis.Z)[1:], self.left)
369        fillet_safe(p.faces().sort_by(Axis.X)[0].edges().sort_by(Axis.Z)[1:], self.right)

Directional fillet strategy applying width edges (left/right) before depth edges (front/back).

Parameters
  • front (float, default=3.0): Radius in mm for front edge fillets (negative Y direction).
  • back (float, default=2.0): Radius in mm for back edge fillets (positive Y direction).
  • left (float, default=1.0): Radius in mm for left side fillets (negative X direction).
  • right (float, default=1.0): Radius in mm for right side fillets (positive X direction).
  • skirt (float, default=0.25): Inherited from FilletStrategy. Radius for bottom face edge fillets.
  • inner (float, default=1.0): Inherited from FilletStrategy. Radius for internal Z-axis edge fillets.
FilletWidthDepth( skirt: float = 0.25, inner: float = 1, front: float = 3, back: float = 2, left: float = 1, right: float = 1)
front: float = 3
back: float = 2
left: float = 1
right: float = 1
def apply_outer(self, p: build123d.build_part.BuildPart):
346    def apply_outer(self, p: BuildPart):
347        """
348        Apply outer fillet radii with (left/right) processed first.
349
350        Parameters
351        ----------
352        p : BuildPart
353            The BuildPart representing a `capistry.Cap` instance to
354            which outer fillets should be applied.
355        """
356        logger.debug(
357            "Applying outer fillets (sides-first)",
358            extra={
359                "front": self.front,
360                "back": self.back,
361                "left": self.left,
362                "right": self.right,
363            },
364        )
365
366        fillet_safe(p.faces().sort_by(Axis.Z)[-1].edges().sort_by(Axis.Y)[0], self.front)
367        fillet_safe(p.faces().sort_by(Axis.Z)[-1].edges().sort_by(Axis.Y)[-1], self.back)
368        fillet_safe(p.faces().sort_by(Axis.X)[-1].edges().sort_by(Axis.Z)[1:], self.left)
369        fillet_safe(p.faces().sort_by(Axis.X)[0].edges().sort_by(Axis.Z)[1:], self.right)

Apply outer fillet radii with (left/right) processed first.

Parameters
  • p (BuildPart): The BuildPart representing a capistry.Cap instance to which outer fillets should be applied.
@dataclass
class MXStem(build123d.topology.three_d.Mixin3D, build123d.topology.shape_core.Shape[OCP.OCP.TopoDS.TopoDS_Compound]):
221@dataclass
222class MXStem(Stem):
223    """
224    Concrete implementation of a Cherry MX-compatible keycap stem.
225
226    Creates a cylindrical stem with a cross-shaped cavity designed for Cherry MX
227    and compatible mechanical keyboard switches. The stem features a main cylinder
228    with rounded edges and a cross slot for attachment to MX-style switches.
229
230    Parameters
231    ----------
232    cylinder_height : float, default=3.8
233        Height of the main cylindrical portion of the stem in millimeters.
234    cylinder_radius : float, default=2.75
235        Radius of the main cylinder in millimeters. Standard value is 5.5mm
236        diameter (2.75mm radius) for MX compatibility.
237    cross_length : float, default=4.1
238        Length of the cross arms in millimeters. This determines the size of
239        the cross cavity that interfaces with the switch.
240    cross_width : float, default=1.17
241        Width of the cross arms in millimeters. Controls the thickness of the
242        cross slot for proper switch engagement.
243    fillet_stem : float, default=0.25
244        Fillet radius for the cross cavity corners in millimeters.
245    fillet_outer : float, default=0.25
246        Fillet radius for the outer bottom cylinder edge in millimeters.
247    center_at : CenterOf, default=CenterOf.GEOMETRY
248        Centering mode for the stem geometry.
249    offset : Location, default=Location()
250        Spatial offset for the stem.
251
252    Examples
253    --------
254    Create a standard MX stem:
255    >>> mx_stem = MXStem()
256
257    Create an MX stem with custom dimensions:
258    >>> custom_mx = MXStem(cylinder_height=4.0, cross_width=1.2, fillet_outer=0.3)
259    """
260
261    cylinder_height: float = 3.8
262    cylinder_radius: float = 5.5 / 2
263    cross_length: float = 4.1
264    cross_width: float = 1.17
265
266    fillet_stem: float = 0.25
267    fillet_outer: float = 0.25
268
269    def __post_init__(self):
270        """Initialize the MX stem after dataclass creation."""
271        super().__post_init__()
272
273    @cached_property
274    def _cross_sketch(self) -> Sketch:
275        """
276        Create the cross-shaped cavity sketch.
277
278        Generates a cross-shaped sketch by combining two perpendicular rectangles,
279        with filleted corners at the intersection points. This sketch is used to
280        subtract material from the stem cylinder to create the switch interface cavity.
281
282        Returns
283        -------
284        Sketch
285            A cross-shaped sketch with filleted corners, sized according to the
286            cross_length and cross_width parameters.
287
288        Notes
289        -----
290        The fillet operation targets the four vertices closest to the origin,
291        which are the inner corners of the cross where stress concentration
292        would be highest. The sketch is cached for performance optimization.
293        """
294        with BuildSketch() as sk:
295            Rectangle(self.cross_length, self.cross_width)
296            Rectangle(self.cross_width, self.cross_length)
297            fillet_safe(
298                sk.vertices().sort_by(lambda v: v.distance_to(Vertex(0, 0, 0)))[:4],
299                self.fillet_stem,
300            )
301        return sk.sketch
302
303    def _builder(self) -> BuildPart:
304        """
305        Construct the MX stem geometry.
306
307        Creates the complete MX stem by extruding the cylindrical base, applying
308        outer fillets for smooth edges, and subtracting the cross cavity.
309        The process includes creating a foundation below the XY plane and the main stem above it.
310
311        Returns
312        -------
313        BuildPart
314            The BuildPart context containing the complete MX stem geometry with:
315            - Cylindrical main body with specified height and radius
316            - Filleted outer bottom edge
317            - Cross-shaped cavity for switch interface
318        """
319        with BuildSketch() as cyl:
320            Circle(self.cylinder_radius)
321
322        with BuildPart() as p:
323            extrude(cyl.sketch, self.cylinder_height)
324            extrude(offset(cyl.sketch, 2 * self.fillet_outer), -1)
325
326            fillet_safe(
327                p.faces()
328                .filter_by(lambda f: f.normal_at() == Vector(0, 0, 1))
329                .sort_by(Axis.Z)[0]
330                .inner_wires()
331                .edges(),
332                self.fillet_outer,
333            )
334
335            extrude(self._cross_sketch, amount=self.cylinder_height, mode=Mode.SUBTRACT)
336            split(bisect_by=Plane.XY, keep=Keep.TOP)
337
338        return p

Concrete implementation of a Cherry MX-compatible keycap stem.

Creates a cylindrical stem with a cross-shaped cavity designed for Cherry MX and compatible mechanical keyboard switches. The stem features a main cylinder with rounded edges and a cross slot for attachment to MX-style switches.

Parameters
  • cylinder_height (float, default=3.8): Height of the main cylindrical portion of the stem in millimeters.
  • cylinder_radius (float, default=2.75): Radius of the main cylinder in millimeters. Standard value is 5.5mm diameter (2.75mm radius) for MX compatibility.
  • cross_length (float, default=4.1): Length of the cross arms in millimeters. This determines the size of the cross cavity that interfaces with the switch.
  • cross_width (float, default=1.17): Width of the cross arms in millimeters. Controls the thickness of the cross slot for proper switch engagement.
  • fillet_stem (float, default=0.25): Fillet radius for the cross cavity corners in millimeters.
  • fillet_outer (float, default=0.25): Fillet radius for the outer bottom cylinder edge in millimeters.
  • center_at (CenterOf, default=CenterOf.GEOMETRY): Centering mode for the stem geometry.
  • offset (Location, default=Location()): Spatial offset for the stem.
Examples

Create a standard MX stem:

>>> mx_stem = MXStem()

Create an MX stem with custom dimensions:

>>> custom_mx = MXStem(cylinder_height=4.0, cross_width=1.2, fillet_outer=0.3)
MXStem( center_at: build123d.build_enums.CenterOf = <CenterOf.GEOMETRY>, offset: build123d.geometry.Location = <factory>, cylinder_height: float = 3.8, cylinder_radius: float = 2.75, cross_length: float = 4.1, cross_width: float = 1.17, fillet_stem: float = 0.25, fillet_outer: float = 0.25)

Build a Compound from Shapes

Args: obj (TopoDS_Compound | Iterable[Shape], optional): OCCT Compound or shapes label (str, optional): Defaults to ''. color (Color, optional): Defaults to None. material (str, optional): tag for external tools. Defaults to ''. joints (dict[str, Joint], optional): names joints. Defaults to None. parent (Compound, optional): assembly parent. Defaults to None. children (Sequence[Shape], optional): assembly children. Defaults to None.

cylinder_height: float = 3.8
cylinder_radius: float = 2.75
cross_length: float = 4.1
cross_width: float = 1.17
fillet_stem: float = 0.25
fillet_outer: float = 0.25
@dataclass
class Panel:
279@dataclass
280class Panel:
281    """
282    Represents a 2D layout of caps and optional sprues arranged in rows and columns.
283
284    Automatically determines optimal column count when not specified and provides
285    progress tracking during generation. The panel arranges caps in a grid pattern
286    with optional sprue connections between adjacent parts for manufacturing support.
287
288    Parameters
289    ----------
290    items : list of PanelItem
291        List of panel items to be arranged in the layout.
292    col_count : int or None, default=None
293        Number of columns in the grid. If None, automatically determined based
294        on total item count and maximum column limit.
295    sprue : Sprue or None, default=SprueCylinder()
296        Sprue object for connecting adjacent caps. Set to None to disable sprues.
297    gap : float, default 1.0
298        Spacing between adjacent caps in the layout.
299    show_progress : bool, default=True
300        Whether to display progress bars during panel generation.
301
302    Attributes
303    ----------
304    _max_cols : int
305        Maximum number of columns allowed in automatic layout (default 10).
306
307    Examples
308    --------
309    >>> # Create panel with automatic column detection
310    >>> items = [PanelItem(Cap(), quantity=12)]
311    >>> panel = Panel(items)
312    >>> compound = panel.compound
313    >>>
314    >>> # Create panel with specific layout
315    >>> panel = Panel(items, col_count=4, gap=2.0, show_progress=False)
316    >>> compound = panel.compound
317    """
318
319    _max_cols: int = field(default=10, init=False)
320
321    items: list[PanelItem]
322    col_count: int | None = None
323    sprue: Sprue | None = field(default_factory=SprueCylinder)
324    gap: float = 1
325    show_progress: bool = True
326
327    def __post_init__(self):
328        """
329        Post-initialization processing.
330
331        Sets the column count to the optimal value if not explicitly provided,
332        ensuring reasonable grid proportions for the given number of items.
333        """
334        if self.col_count is None:
335            self.col_count = self._optimal_col_count
336
337    @property
338    def _optimal_col_count(self) -> int:
339        """
340        Compute a reasonable number of columns based on item count and max limit.
341
342        Uses divisors to try and find column counts that create
343        rectangular grids without uneven rows, while respecting
344        the maximum column constraint.
345
346        Returns
347        -------
348        int
349            Optimal number of columns for the current item count.
350
351        Notes
352        -----
353        The algorithm finds divisors of the total item count, filters out
354        those that would create too many columns or rows, and selects the
355        largest valid divisor.
356        """
357        count = len(self._flattened)
358        divs = list(
359            lstrip(
360                rstrip(divisors(count), lambda n: n > self._max_cols),
361                lambda n: count // n > self._max_cols,
362            )
363        )
364        return last(divs, self._max_cols)
365
366    @cached_property
367    def _flattened(self) -> list[Cap]:
368        """
369        Flatten and expand all caps from panel items into a single list.
370
371        Returns
372        -------
373        list of Cap
374            All individual cap objects that will be placed in the panel.
375        """
376        return list(flatten(i.expanded for i in self.items))
377
378    @cached_property
379    def compound(self) -> Compound:
380        """
381        Construct the full compound of the panel, including sprues if configured.
382
383        Generates the complete 3D compound by arranging caps in a grid layout
384        and adding sprue connections between adjacent parts. Progress is tracked
385        and displayed unless disabled.
386
387        Returns
388        -------
389        build123d.Compound
390            Complete panel compound containing all caps and sprues arranged
391            in the specified layout.
392
393        Notes
394        -----
395        The compound is cached after first generation to avoid recomputation.
396        Horizontal sprues connect caps within each row, while vertical sprues
397        connect the first cap of each row to create a connected structure.
398        """
399        rows = list(chunked(self._flattened, self.col_count))
400
401        caps = len(self._flattened)
402        sprues = sum(len(r) - 1 for r in rows) + len(rows) - 1
403
404        with PanelProgress(not self.show_progress, caps, sprues) as p:
405            return self._assemble(rows, p)
406
407    def _assemble(self, rows: list[list[Cap]], progress: PanelProgress) -> Compound:
408        """
409        Assemble the layout row-by-row, placing caps and connecting sprues.
410
411        Places each cap at the appropriate grid position and generates sprue
412        connections between adjacent caps both horizontally and vertically.
413
414        Parameters
415        ----------
416        rows : list of list of Cap
417            Caps organized into rows for grid placement.
418        progress : PanelProgress
419            Progress tracker for visual feedback during assembly.
420
421        Returns
422        -------
423        build123d.Compound
424            Assembled compound containing all positioned caps and sprues.
425        """
426        sprued = Compound(label="Sprues")
427        cursor = Vector()
428
429        logger.info(
430            "Assembling panel with %d cap(s) and %d sprue(s)", progress.caps, progress.sprues
431        )
432
433        for row in rows:
434            y_diff = 0
435            for i, cap in enumerate(row):
436                y_diff = max(y_diff, cap.size.Y)
437                self._place(cap, cursor)
438                cursor += Vector(cap.size.X + self.gap)
439                progress.arrange()
440
441                if self.sprue and i > 0:
442                    self.sprue.connect_horizontally(row[i - 1], cap).parent = sprued
443                    progress.sprue()
444
445            cursor.X = 0
446            cursor.Y -= y_diff + self.gap
447
448        if self.sprue:
449            for a, b in pairwise(rows):
450                self.sprue.connect_vertically(a[0], b[0]).parent = sprued
451                progress.sprue()
452
453        return Compound(children=[c.compound for c in flatten(rows)] + [sprued])
454
455    def _place(self, cap: Cap, pos: Vector):
456        """
457        Place a cap in the layout at the given position.
458
459        Positions the cap by calculating the appropriate offset based on its
460        bounding box to ensure consistent alignment within the panel.
461
462        Parameters
463        ----------
464        cap : Cap
465            The cap object to be positioned.
466        pos : build123d.Vector
467            Target position for the cap placement.
468        """
469        bbox = cap.compound.bounding_box()
470        offset = Vector(bbox.min.X, bbox.max.Y)
471        cap.locate(Pos(pos - offset))

Represents a 2D layout of caps and optional sprues arranged in rows and columns.

Automatically determines optimal column count when not specified and provides progress tracking during generation. The panel arranges caps in a grid pattern with optional sprue connections between adjacent parts for manufacturing support.

Parameters
  • items (list of PanelItem): List of panel items to be arranged in the layout.
  • col_count (int or None, default=None): Number of columns in the grid. If None, automatically determined based on total item count and maximum column limit.
  • sprue (Sprue or None, default=SprueCylinder()): Sprue object for connecting adjacent caps. Set to None to disable sprues.
  • gap (float, default 1.0): Spacing between adjacent caps in the layout.
  • show_progress (bool, default=True): Whether to display progress bars during panel generation.
Attributes
  • _max_cols (int): Maximum number of columns allowed in automatic layout (default 10).
Examples
>>> # Create panel with automatic column detection
>>> items = [PanelItem(Cap(), quantity=12)]
>>> panel = Panel(items)
>>> compound = panel.compound
>>>
>>> # Create panel with specific layout
>>> panel = Panel(items, col_count=4, gap=2.0, show_progress=False)
>>> compound = panel.compound
Panel( items: list[PanelItem], col_count: int | None = None, sprue: Sprue | None = <factory>, gap: float = 1, show_progress: bool = True)
items: list[PanelItem]
col_count: int | None = None
sprue: Sprue | None
gap: float = 1
show_progress: bool = True
compound: build123d.topology.composite.Compound
378    @cached_property
379    def compound(self) -> Compound:
380        """
381        Construct the full compound of the panel, including sprues if configured.
382
383        Generates the complete 3D compound by arranging caps in a grid layout
384        and adding sprue connections between adjacent parts. Progress is tracked
385        and displayed unless disabled.
386
387        Returns
388        -------
389        build123d.Compound
390            Complete panel compound containing all caps and sprues arranged
391            in the specified layout.
392
393        Notes
394        -----
395        The compound is cached after first generation to avoid recomputation.
396        Horizontal sprues connect caps within each row, while vertical sprues
397        connect the first cap of each row to create a connected structure.
398        """
399        rows = list(chunked(self._flattened, self.col_count))
400
401        caps = len(self._flattened)
402        sprues = sum(len(r) - 1 for r in rows) + len(rows) - 1
403
404        with PanelProgress(not self.show_progress, caps, sprues) as p:
405            return self._assemble(rows, p)

Construct the full compound of the panel, including sprues if configured.

Generates the complete 3D compound by arranging caps in a grid layout and adding sprue connections between adjacent parts. Progress is tracked and displayed unless disabled.

Returns
  • build123d.Compound: Complete panel compound containing all caps and sprues arranged in the specified layout.
Notes

The compound is cached after first generation to avoid recomputation. Horizontal sprues connect caps within each row, while vertical sprues connect the first cap of each row to create a connected structure.

@dataclass
class PanelItem:
180@dataclass
181class PanelItem:
182    """
183    Represents a group of identical caps with optional mirrored variants.
184
185    Handles duplication and mirroring of caps based on configuration,
186    supporting both regular and mirrored versions of the same cap design.
187
188    Parameters
189    ----------
190    cap : Cap
191        The base cap object to be duplicated and/or mirrored.
192    quantity : int, default=1
193        Number of regular (non-mirrored) caps to include.
194    mirror : bool, default=False
195        If True, include mirrored versions in addition to regular caps.
196    mirror_only : bool, default=False
197        If True, include only mirrored versions (ignores quantity for regular caps).
198    mirror_quantity : int or None, default=None
199        Number of mirrored caps to include. If None, uses same as quantity.
200
201    Attributes
202    ----------
203    cap : Cap
204        Cloned and repositioned version of the input cap.
205
206    Examples
207    --------
208    >>> cap = Cap()
209    >>> # Create 3 regular caps
210    >>> item1 = PanelItem(cap, quantity=3)
211    >>>
212    >>> # Create 2 regular + 2 mirrored caps
213    >>> item2 = PanelItem(cap, quantity=2, mirror=True)
214    >>>
215    >>> # Create only 1 mirrored cap
216    >>> item3 = PanelItem(cap, mirror_only=True)
217    >>>
218    >>> # Get all expanded caps
219    >>> all_caps = item2.expanded  # Returns list of 4 Cap objects
220    """
221
222    cap: Cap
223    quantity: int = 1
224    mirror: bool = False
225    mirror_only: bool = False
226    mirror_quantity: int | None = None
227
228    def __post_init__(self):
229        """
230        Post-initialization processing.
231
232        Clones the input cap and resets its position to origin to ensure
233        each panel item starts from a clean state.
234        """
235        self.cap = self.cap.clone()
236        self.cap.locate(Pos())
237
238    @property
239    def expanded(self) -> list[Cap]:
240        """
241        Return a list of all Cap instances, including mirrored ones if configured.
242
243        Generates the full list of caps based on quantity settings and mirroring
244        options. Each cap is a separate copy to avoid shared location issues.
245
246        Returns
247        -------
248        list of Cap
249            All cap instances that should be included in the panel layout.
250            Order is regular caps first, then mirrored caps.
251
252        Notes
253        -----
254        The method respects the mirror_only flag by excluding regular caps
255        when it's True. Mirror quantity defaults to the regular quantity if
256        not explicitly specified.
257        """
258        caps: list[Cap] = []
259        logger.debug(
260            "Expanding %s",
261            type(self).__name__,
262            extra={
263                "quantity": self.quantity,
264                "mirror": self.mirror,
265                "mirror_only": self.mirror_only,
266                "cap": type(self.cap).__name__,
267            },
268        )
269        if not self.mirror_only:
270            caps.extend(copy(self.cap) for _ in range(self.quantity))
271
272        if self.mirror or self.mirror_only:
273            qty = self.quantity if self.mirror_quantity is None else self.mirror_quantity
274            mirrored = self.cap.mirrored()
275            caps.extend(copy(mirrored) for _ in range(qty))
276        return caps

Represents a group of identical caps with optional mirrored variants.

Handles duplication and mirroring of caps based on configuration, supporting both regular and mirrored versions of the same cap design.

Parameters
  • cap (Cap): The base cap object to be duplicated and/or mirrored.
  • quantity (int, default=1): Number of regular (non-mirrored) caps to include.
  • mirror (bool, default=False): If True, include mirrored versions in addition to regular caps.
  • mirror_only (bool, default=False): If True, include only mirrored versions (ignores quantity for regular caps).
  • mirror_quantity (int or None, default=None): Number of mirrored caps to include. If None, uses same as quantity.
Attributes
  • cap (Cap): Cloned and repositioned version of the input cap.
Examples
>>> cap = Cap()
>>> # Create 3 regular caps
>>> item1 = PanelItem(cap, quantity=3)
>>>
>>> # Create 2 regular + 2 mirrored caps
>>> item2 = PanelItem(cap, quantity=2, mirror=True)
>>>
>>> # Create only 1 mirrored cap
>>> item3 = PanelItem(cap, mirror_only=True)
>>>
>>> # Get all expanded caps
>>> all_caps = item2.expanded  # Returns list of 4 Cap objects
PanelItem( cap: Cap, quantity: int = 1, mirror: bool = False, mirror_only: bool = False, mirror_quantity: int | None = None)
cap: Cap
quantity: int = 1
mirror: bool = False
mirror_only: bool = False
mirror_quantity: int | None = None
expanded: list[Cap]
238    @property
239    def expanded(self) -> list[Cap]:
240        """
241        Return a list of all Cap instances, including mirrored ones if configured.
242
243        Generates the full list of caps based on quantity settings and mirroring
244        options. Each cap is a separate copy to avoid shared location issues.
245
246        Returns
247        -------
248        list of Cap
249            All cap instances that should be included in the panel layout.
250            Order is regular caps first, then mirrored caps.
251
252        Notes
253        -----
254        The method respects the mirror_only flag by excluding regular caps
255        when it's True. Mirror quantity defaults to the regular quantity if
256        not explicitly specified.
257        """
258        caps: list[Cap] = []
259        logger.debug(
260            "Expanding %s",
261            type(self).__name__,
262            extra={
263                "quantity": self.quantity,
264                "mirror": self.mirror,
265                "mirror_only": self.mirror_only,
266                "cap": type(self.cap).__name__,
267            },
268        )
269        if not self.mirror_only:
270            caps.extend(copy(self.cap) for _ in range(self.quantity))
271
272        if self.mirror or self.mirror_only:
273            qty = self.quantity if self.mirror_quantity is None else self.mirror_quantity
274            mirrored = self.cap.mirrored()
275            caps.extend(copy(mirrored) for _ in range(qty))
276        return caps

Return a list of all Cap instances, including mirrored ones if configured.

Generates the full list of caps based on quantity settings and mirroring options. Each cap is a separate copy to avoid shared location issues.

Returns
  • list of Cap: All cap instances that should be included in the panel layout. Order is regular caps first, then mirrored caps.
Notes

The method respects the mirror_only flag by excluding regular caps when it's True. Mirror quantity defaults to the regular quantity if not explicitly specified.

@dataclass
class RectangularCap(capistry.Cap):
1241@dataclass
1242class RectangularCap(Cap):
1243    """
1244    Basic rectangular keycap with no angular modifications.
1245
1246    The simplest keycap implementation with a standard rectangular profile.
1247    The shape is completely symmetric with straight vertical sides and no
1248    angular modifications.
1249
1250    Examples
1251    --------
1252    Create a standard keycap:
1253
1254    >>> cap = RectangularCap()  # Uses default 18x18x4mm dimensions
1255
1256    Create a larger keycap for special keys:
1257
1258    >>> spacebar = RectangularCap(width=18 * 6.25 - 1)
1259    """
1260
1261    def __post_init__(self) -> None:
1262        """Initialize the rectangular keycap."""
1263        return super().__post_init__()
1264
1265    def _draw_outline(self) -> Sketch:
1266        """
1267        Draw a simple rectangular outline.
1268
1269        Creates a basic rectangle with the specified width and length
1270        dimensions, with the top-left corner at the origin.
1271
1272        Returns
1273        -------
1274        Sketch
1275            2D sketch of the rectangular outline.
1276        """
1277        logger.debug("Drawing outline of %s", type(self).__name__)
1278
1279        with BuildSketch():
1280            with BuildLine():
1281                l1 = Line((0, 0), (self.width, 0))
1282                l2 = Line(l1 @ 1, l1 @ 1 - (0, self.length))
1283                l3 = Line(l2 @ 1, l1 @ 0 - (0, self.length))
1284                Line(l3 @ 1, l1 @ 0)
1285            return make_face()

Basic rectangular keycap with no angular modifications.

The simplest keycap implementation with a standard rectangular profile. The shape is completely symmetric with straight vertical sides and no angular modifications.

Examples

Create a standard keycap:

>>> cap = RectangularCap()  # Uses default 18x18x4mm dimensions

Create a larger keycap for special keys:

>>> spacebar = RectangularCap(width=18 * 6.25 - 1)
RectangularCap( width: float = 18, length: float = 18, height: float = 4, wall: float = 1, roof: float = 1, taper: Taper = <factory>, surface: Surface | None = None, stem: Stem = <factory>, fillet_strategy: FilletStrategy = <factory>)
@dataclass
class SkewedCap(capistry.Cap):
1105@dataclass
1106class SkewedCap(Cap):
1107    """
1108    Keycap with skewed profile.
1109
1110    A keycap where the outline is skewed at an angle, creating
1111    a parallelogram-like profile. The skew can be applied while maintaining
1112    a width based on the length of the bottom/top edge, or by basing the width
1113    on the perpendicular length between the two vertical sides.
1114
1115    Parameters
1116    ----------
1117    skew : float, default=0
1118        Skew angle in degrees. Positive values skew toward the right,
1119        negative values skew toward the left. Zero creates rectangular profile.
1120    skew_width : bool, default=True
1121        If True, the width is kept as the length of the bottom/top edge.
1122        If False, the width is the perpendicular distance between left and right edges.
1123
1124    Attributes
1125    ----------
1126    All attributes inherited from Cap, plus:
1127
1128    skew : float
1129        The skew angle in degrees.
1130    skew_width : bool
1131        Whether width is maintained during skewing.
1132    metrics : MetricLayout[Self]
1133        Extended metrics including skew parameters.
1134
1135    Examples
1136    --------
1137    Create skewed key with width compensation:
1138
1139    >>> compensated = SkewedCap(skew=20, skew_width=False)
1140    """
1141
1142    skew: float = 0
1143    skew_width: bool = True
1144
1145    def __post_init__(self) -> None:
1146        """
1147        Initialize the skewed keycap with width adjustment if needed.
1148
1149        Notes
1150        -----
1151        If skew_width is False, the width is divided by cos(skew) to
1152        compensate for the skewing transformation.
1153        """
1154        if not self.skew_width:
1155            self.width /= cos(radians(self.skew))
1156
1157        return super().__post_init__()
1158
1159    @override
1160    def _draw_outline(self) -> Sketch:
1161        """
1162        Draw the skewed outline.
1163
1164        Creates a parallelogram where the vertical edges are skewed
1165        at the specified angle while maintaining parallel relationships.
1166
1167        Returns
1168        -------
1169        Sketch
1170            2D sketch of the skewed outline.
1171        """
1172        logger.debug("Drawing outline of %s", type(self).__name__, extra={"skew": self.skew})
1173
1174        with BuildSketch():
1175            with BuildLine():
1176                l1 = PolarLine(
1177                    start=(0, 0),
1178                    length=self.width,
1179                    angle=-self.skew,
1180                    length_mode=LengthMode.HORIZONTAL,
1181                )
1182                l2 = Line(l1 @ 1, l1 @ 1 - (0, self.length))
1183                l3 = Line(l2 @ 1, l1 @ 0 - (0, self.length))
1184                Line(l3 @ 1, l1 @ 0)
1185            return make_face()
1186
1187    @override
1188    def _mirror(self) -> Self:
1189        """
1190        Apply mirroring by inverting the skew angle.
1191
1192        Returns
1193        -------
1194        Self
1195            The keycap with inverted skew for mirroring effect.
1196
1197        Notes
1198        -----
1199        Mirroring a skewed cap inverts the skew angle sign,
1200        creating the opposite-handed version.
1201        """
1202        logger.debug(
1203            "Mirroring %s by inverting",
1204            type(self).__name__,
1205            extra={"skew": self.skew, "new_skew": -self.skew},
1206        )
1207        self.skew *= -1
1208        return self
1209
1210    @property
1211    @override
1212    def metrics(self) -> MetricLayout[Self]:
1213        """
1214        Expose metrics including skew parameters.
1215
1216        Returns
1217        -------
1218        MetricLayout[Self]
1219            Metrics with skew-specific parameters.
1220        """
1221        return MetricLayout(
1222            owner=self,
1223            groups=(
1224                MetricGroup(
1225                    "Skew",
1226                    metrics=(
1227                        Metric("Angle", lambda: self.skew, "°"),
1228                        Metric("Skewed Width", lambda: self.skew_width, ""),
1229                    ),
1230                    order=-1,
1231                ),
1232                *super().metrics.groups,
1233            ),
1234        )
1235
1236    def __str__(self) -> str:
1237        """Return string representation including skew angle."""
1238        return f"{super().__str__()} ({self.skew}°)"

Keycap with skewed profile.

A keycap where the outline is skewed at an angle, creating a parallelogram-like profile. The skew can be applied while maintaining a width based on the length of the bottom/top edge, or by basing the width on the perpendicular length between the two vertical sides.

Parameters
  • skew (float, default=0): Skew angle in degrees. Positive values skew toward the right, negative values skew toward the left. Zero creates rectangular profile.
  • skew_width (bool, default=True): If True, the width is kept as the length of the bottom/top edge. If False, the width is the perpendicular distance between left and right edges.
Attributes
  • All attributes inherited from Cap, plus:
  • skew (float): The skew angle in degrees.
  • skew_width (bool): Whether width is maintained during skewing.
  • metrics (MetricLayout[Self]): Extended metrics including skew parameters.
Examples

Create skewed key with width compensation:

>>> compensated = SkewedCap(skew=20, skew_width=False)
SkewedCap( width: float = 18, length: float = 18, height: float = 4, wall: float = 1, roof: float = 1, taper: Taper = <factory>, surface: Surface | None = None, stem: Stem = <factory>, fillet_strategy: FilletStrategy = <factory>, skew: float = 0, skew_width: bool = True)
skew: float = 0
skew_width: bool = True
metrics: capistry.compare.MetricLayout[typing.Self]
1210    @property
1211    @override
1212    def metrics(self) -> MetricLayout[Self]:
1213        """
1214        Expose metrics including skew parameters.
1215
1216        Returns
1217        -------
1218        MetricLayout[Self]
1219            Metrics with skew-specific parameters.
1220        """
1221        return MetricLayout(
1222            owner=self,
1223            groups=(
1224                MetricGroup(
1225                    "Skew",
1226                    metrics=(
1227                        Metric("Angle", lambda: self.skew, "°"),
1228                        Metric("Skewed Width", lambda: self.skew_width, ""),
1229                    ),
1230                    order=-1,
1231                ),
1232                *super().metrics.groups,
1233            ),
1234        )

Expose metrics including skew parameters.

Returns
  • MetricLayout[Self]: Metrics with skew-specific parameters.
@dataclass
class SlantedCap(capistry.Cap):
 958@dataclass
 959class SlantedCap(Cap):
 960    """
 961    Keycap with slanted profile.
 962
 963    A keycap where one side is angled relative to the other, creating an
 964    asymmetric profile. This design is commonly used for thumb keys or
 965    ergonomic layouts where the key needs to match the natural arc of the thumbs.
 966
 967    Parameters
 968    ----------
 969    angle : float, default=0
 970        Angle in degrees defining the slant direction and magnitude.
 971        Positive values slant toward the bottom-right, negative values
 972        slant toward the bottom-left. Zero creates a rectangular profile.
 973
 974    Attributes
 975    ----------
 976    All attributes inherited from Cap, plus:
 977
 978    angle : float
 979        The slant angle in degrees.
 980    metrics : MetricLayout[Self]
 981        Extended metrics including the slant angle parameter.
 982
 983    Examples
 984    --------
 985    Create slanted thumb caps:
 986
 987    >>> left_thumb = SlantedCap(width=18, length=18, angle=-10)
 988    >>> right_thumb = left_thumb.mirrored()  # Creates +10° angle
 989
 990    Create a set of slanted caps:
 991
 992    >>> angles = [-5, -2.5, 0, 2.5, 5]
 993    >>> caps = [SlantedCap(angle=a) for a in angles]
 994
 995    Notes
 996    -----
 997    The slant affects the top width calculation and creates an asymmetric
 998    profile. Mirroring inverts the angle sign to create opposite-handed versions.
 999    """
1000
1001    angle: float = 0
1002
1003    def __post_init__(self) -> None:
1004        """Initialize the slanted keycap."""
1005        super().__post_init__()
1006
1007    @override
1008    def _draw_outline(self) -> Sketch:
1009        """
1010        Draw the slanted outline.
1011
1012        Creates an asymmetric quadrilateral where one side is angled
1013        relative to the vertical, creating the slanted profile.
1014
1015        Returns
1016        -------
1017        Sketch
1018            2D sketch of the slanted outline.
1019
1020        Notes
1021        -----
1022        The slant is applied by modifying the top edge position and
1023        adjusting the side angles accordingly. Positive angles create
1024        a rightward slant, negative angles create a leftward slant.
1025        """
1026        logger.debug("Drawing outline of %s", type(self).__name__, extra={"angle": self.angle})
1027
1028        width_top = (
1029            self.width / cos(radians(self.angle)) + tan(radians(abs(self.angle))) * self.length
1030        )
1031
1032        with BuildSketch() as sk:
1033            with BuildLine():
1034                l1 = Line((0, 0), (width_top, 0))
1035
1036                _l2 = Line(l1 @ 1, (width_top, -self.length), mode=Mode.PRIVATE)
1037                _l3 = PolarLine(
1038                    start=_l2 @ 1,
1039                    length=self.width,
1040                    angle=180 + abs(self.angle),
1041                    mode=Mode.PRIVATE,
1042                )
1043
1044                l2, l3 = _l2, _l3
1045
1046                if self.angle <= 0:
1047                    add([l2, l3])
1048                else:
1049                    l2 = Line(l1 @ 1, (width_top - (_l3 @ 1).X, (_l3 @ 1).Y))
1050                    l3 = Line(l2 @ 1, (width_top - (_l2 @ 1).X, (_l2 @ 1).Y))
1051
1052                Line(l3 @ 1, l1 @ 0)
1053            make_face()
1054
1055        return sk.sketch
1056
1057    @override
1058    def _mirror(self) -> Self:
1059        """
1060        Apply mirroring by inverting the slant angle.
1061
1062        Returns
1063        -------
1064        Self
1065            The keycap with inverted angle for mirroring effect.
1066
1067        Notes
1068        -----
1069        Mirroring a slanted cap simply inverts the angle sign,
1070        creating the opposite-handed version.
1071        """
1072        logger.debug(
1073            "Mirroring %s by inverting angle",
1074            type(self).__name__,
1075            extra={"angle": self.angle, "new_angle": -self.angle},
1076        )
1077        self.angle *= -1
1078        return self
1079
1080    @override
1081    def __str__(self) -> str:
1082        """Return string representation including angle."""
1083        return f"{super().__str__()} ({self.angle}°)"
1084
1085    @property
1086    @override
1087    def metrics(self) -> MetricLayout[Self]:
1088        """
1089        Expose metrics including slant angle.
1090
1091        Returns
1092        -------
1093        MetricLayout[Self]
1094            Metrics with slant-specific parameters.
1095        """
1096        return MetricLayout(
1097            owner=self,
1098            groups=(
1099                MetricGroup("Slant", metrics=(Metric("Angle", lambda: self.angle, "°"),), order=-1),
1100                *super().metrics.groups,
1101            ),
1102        )

Keycap with slanted profile.

A keycap where one side is angled relative to the other, creating an asymmetric profile. This design is commonly used for thumb keys or ergonomic layouts where the key needs to match the natural arc of the thumbs.

Parameters
  • angle (float, default=0): Angle in degrees defining the slant direction and magnitude. Positive values slant toward the bottom-right, negative values slant toward the bottom-left. Zero creates a rectangular profile.
Attributes
  • All attributes inherited from Cap, plus:
  • angle (float): The slant angle in degrees.
  • metrics (MetricLayout[Self]): Extended metrics including the slant angle parameter.
Examples

Create slanted thumb caps:

>>> left_thumb = SlantedCap(width=18, length=18, angle=-10)
>>> right_thumb = left_thumb.mirrored()  # Creates +10° angle

Create a set of slanted caps:

>>> angles = [-5, -2.5, 0, 2.5, 5]
>>> caps = [SlantedCap(angle=a) for a in angles]
Notes

The slant affects the top width calculation and creates an asymmetric profile. Mirroring inverts the angle sign to create opposite-handed versions.

SlantedCap( width: float = 18, length: float = 18, height: float = 4, wall: float = 1, roof: float = 1, taper: Taper = <factory>, surface: Surface | None = None, stem: Stem = <factory>, fillet_strategy: FilletStrategy = <factory>, angle: float = 0)
angle: float = 0
metrics: capistry.compare.MetricLayout[typing.Self]
1085    @property
1086    @override
1087    def metrics(self) -> MetricLayout[Self]:
1088        """
1089        Expose metrics including slant angle.
1090
1091        Returns
1092        -------
1093        MetricLayout[Self]
1094            Metrics with slant-specific parameters.
1095        """
1096        return MetricLayout(
1097            owner=self,
1098            groups=(
1099                MetricGroup("Slant", metrics=(Metric("Angle", lambda: self.angle, "°"),), order=-1),
1100                *super().metrics.groups,
1101            ),
1102        )

Expose metrics including slant angle.

Returns
  • MetricLayout[Self]: Metrics with slant-specific parameters.
@dataclass(frozen=True)
class Sprue(abc.ABC):
 39@dataclass(frozen=True)
 40class Sprue(ABC):
 41    """
 42    Abstract base class for a sprue element used to connect caps.
 43
 44    Parameters
 45    ----------
 46    diameter : float, default=1.5
 47        Diameter of the sprue cross-section.
 48    inset : float or None, default=None
 49        Optional distance that the sprue extends into the cap.
 50        If None, a default inset of 2 units is used.
 51
 52    Attributes
 53    ----------
 54    diameter : float
 55        Diameter in mm of the sprue cross-section.
 56    inset : float or None
 57        Custom inset value in mm or None.
 58    """
 59
 60    diameter: float = 1.5
 61    inset: float | None = None
 62
 63    def _get_inset(self, cap: Cap) -> float:
 64        """
 65        Compute how far the sprue should extend into the cap.
 66
 67        Parameters
 68        ----------
 69        cap : Cap
 70            The cap to calculate inset for.
 71
 72        Returns
 73        -------
 74        float
 75            Effective inset distance.
 76        """
 77        return cap.wall + (self.inset if self.inset is not None else cap.wall)
 78
 79    @abstractmethod
 80    def _create(self, length: float) -> Part:
 81        """
 82        Create a sprue segment of given length.
 83
 84        Parameters
 85        ----------
 86        length : float
 87            Length of the sprue segment.
 88
 89        Returns
 90        -------
 91        Part
 92            The constructed 3D sprue part.
 93        """
 94
 95    def _between(self, start: Vector, end: Vector) -> Part:
 96        """
 97        Generate a sprue segment between two points.
 98
 99        Parameters
100        ----------
101        start : Vector
102            Starting point of the sprue.
103        end : Vector
104            Ending point of the sprue.
105
106        Returns
107        -------
108        Part
109            The 3D sprue geometry between the two points.
110
111        Raises
112        ------
113        ValueError
114            If the points are too close or aligned with the Z axis.
115        TypeError
116            If the result is not a valid build123d Part.
117        """
118        logger.debug(
119            "Creating sprue segment",
120            extra={
121                "start": start.to_tuple(),
122                "end": end.to_tuple(),
123                "length": (end - start).length,
124            },
125        )
126
127        min_length = 1e-4
128        x_dir = end - start
129
130        if x_dir.length < min_length:
131            raise ValueError(
132                f"Sprue segment too short: distance between "
133                f"start {start} and end {end} is nearly zero."
134            )
135
136        if x_dir.cross(Vector(0, 0, 1)).length < min_length:
137            raise ValueError(
138                f"Invalid direction: start {start} and end {end} vectors "
139                f"are parallel to the Z axis, which is not allowed."
140            )
141
142        p = self._create(x_dir.length)
143        p.label = str(self)
144
145        res = Plane(origin=start, x_dir=x_dir) * p
146
147        if not isinstance(res, Part):
148            raise TypeError(f"Expected Shape from Plane * Shape, got {type(res)}")
149
150        return res
151
152    def connect_horizontally(self, c1: Cap, c2: Cap) -> Part:
153        """
154        Connect two caps using a horizontal sprue.
155
156        Parameters
157        ----------
158        c1 : Cap
159            Left cap.
160        c2 : Cap
161            Right cap.
162
163        Returns
164        -------
165        Part
166            The connecting sprue part.
167        """
168        y_max = min(c1.right.bounding_box().max.Y, c2.left.bounding_box().max.Y)
169        y_min = max(c1.right.bounding_box().min.Y, c2.left.bounding_box().min.Y)
170
171        v = c1.right @ 0.5
172        v.Y = (y_max + y_min) / 2
173
174        l1 = IntersectingLine(start=v, direction=Vector(1), other=c2.left)
175        l2 = IntersectingLine(start=l1 @ 1, direction=Vector(-1), other=c1.right)
176
177        return self._between(l2 @ 0 + (self._get_inset(c1), 0), l2 @ 1 - (self._get_inset(c2), 0))
178
179    def connect_vertically(self, c1: Cap, c2: Cap) -> Part:
180        """
181        Connect two caps using a vertical sprue.
182
183        Parameters
184        ----------
185        c1 : Cap
186            Top cap.
187        c2 : Cap
188            Bottom cap.
189
190        Returns
191        -------
192        Part
193            The connecting sprue part.
194        """
195        x_max = min(c1.bottom.bounding_box().max.X, c2.top.bounding_box().max.X)
196        x_min = max(c1.bottom.bounding_box().min.X, c2.top.bounding_box().min.X)
197
198        v = c1.bottom @ 0.5
199        v.X = (x_max + x_min) / 2
200
201        l1 = IntersectingLine(start=v, direction=Vector(0, -1), other=c2.top)
202        l2 = IntersectingLine(start=l1 @ 1, direction=Vector(0, 1), other=c1.bottom)
203
204        return self._between(l2 @ 0 - (0, self._get_inset(c1)), l2 @ 1 + (0, self._get_inset(c2)))
205
206    def __str__(self):
207        """Return a string representation showing the class name and diameter."""
208        return f"{type(self).__name__}{self.diameter})"

Abstract base class for a sprue element used to connect caps.

Parameters
  • diameter (float, default=1.5): Diameter of the sprue cross-section.
  • inset (float or None, default=None): Optional distance that the sprue extends into the cap. If None, a default inset of 2 units is used.
Attributes
  • diameter (float): Diameter in mm of the sprue cross-section.
  • inset (float or None): Custom inset value in mm or None.
diameter: float = 1.5
inset: float | None = None
def connect_horizontally( self, c1: Cap, c2: Cap) -> build123d.topology.composite.Part:
152    def connect_horizontally(self, c1: Cap, c2: Cap) -> Part:
153        """
154        Connect two caps using a horizontal sprue.
155
156        Parameters
157        ----------
158        c1 : Cap
159            Left cap.
160        c2 : Cap
161            Right cap.
162
163        Returns
164        -------
165        Part
166            The connecting sprue part.
167        """
168        y_max = min(c1.right.bounding_box().max.Y, c2.left.bounding_box().max.Y)
169        y_min = max(c1.right.bounding_box().min.Y, c2.left.bounding_box().min.Y)
170
171        v = c1.right @ 0.5
172        v.Y = (y_max + y_min) / 2
173
174        l1 = IntersectingLine(start=v, direction=Vector(1), other=c2.left)
175        l2 = IntersectingLine(start=l1 @ 1, direction=Vector(-1), other=c1.right)
176
177        return self._between(l2 @ 0 + (self._get_inset(c1), 0), l2 @ 1 - (self._get_inset(c2), 0))

Connect two caps using a horizontal sprue.

Parameters
  • c1 (Cap): Left cap.
  • c2 (Cap): Right cap.
Returns
  • Part: The connecting sprue part.
def connect_vertically( self, c1: Cap, c2: Cap) -> build123d.topology.composite.Part:
179    def connect_vertically(self, c1: Cap, c2: Cap) -> Part:
180        """
181        Connect two caps using a vertical sprue.
182
183        Parameters
184        ----------
185        c1 : Cap
186            Top cap.
187        c2 : Cap
188            Bottom cap.
189
190        Returns
191        -------
192        Part
193            The connecting sprue part.
194        """
195        x_max = min(c1.bottom.bounding_box().max.X, c2.top.bounding_box().max.X)
196        x_min = max(c1.bottom.bounding_box().min.X, c2.top.bounding_box().min.X)
197
198        v = c1.bottom @ 0.5
199        v.X = (x_max + x_min) / 2
200
201        l1 = IntersectingLine(start=v, direction=Vector(0, -1), other=c2.top)
202        l2 = IntersectingLine(start=l1 @ 1, direction=Vector(0, 1), other=c1.bottom)
203
204        return self._between(l2 @ 0 - (0, self._get_inset(c1)), l2 @ 1 + (0, self._get_inset(c2)))

Connect two caps using a vertical sprue.

Parameters
  • c1 (Cap): Top cap.
  • c2 (Cap): Bottom cap.
Returns
  • Part: The connecting sprue part.
@dataclass(frozen=True)
class SprueCylinder(capistry.Sprue):
261@dataclass(frozen=True)
262class SprueCylinder(Sprue):
263    """
264    Sprue with a circular (cylindrical) cross-section.
265
266    Parameters
267    ----------
268    diameter : float, default=1.5
269        Diameter of the cylindrical sprue.
270    inset : float or None, default=None
271        Optional inset in mm into the cap.
272    """
273
274    def _create(self, length: float) -> Part:
275        """
276        Create a cylindrical sprue segment of the specified length.
277
278        Parameters
279        ----------
280        length : float
281            Length of the sprue segment.
282
283        Returns
284        -------
285        Part
286            The cylindrical 3D sprue geometry.
287
288        Raises
289        ------
290        TypeError
291            If extrusion did not result in a valid Part.
292        """
293        logger.debug("Creating cylindrical sprue", extra={"length": length})
294        res = Plane.YZ * Cylinder(
295            radius=self.diameter / 2,
296            height=length,
297            align=(Align.CENTER, Align.MAX, Align.MIN),
298        )
299        if not isinstance(res, Part):
300            raise TypeError(f"Expected Part from Plane * Shape, got {type(res)}")
301        return res

Sprue with a circular (cylindrical) cross-section.

Parameters
  • diameter (float, default=1.5): Diameter of the cylindrical sprue.
  • inset (float or None, default=None): Optional inset in mm into the cap.
SprueCylinder(diameter: float = 1.5, inset: float | None = None)
@dataclass(frozen=True)
class SpruePolygon(capistry.Sprue):
211@dataclass(frozen=True)
212class SpruePolygon(Sprue):
213    """
214    Sprue with a polygonal cross-section.
215
216    Parameters
217    ----------
218    diameter : float, default=1.5
219        Diameter of the circumscribed circle around the polygon.
220    inset : float or None, default=None
221        Optional inset in mm into the cap.
222    sides : int, default=4
223        Number of sides in the polygon.
224    """
225
226    sides: int = 4
227
228    def _create(self, length: float) -> Part:
229        """
230        Create a polygonal sprue segment of the specified length.
231
232        Parameters
233        ----------
234        length : float
235            Length of the sprue segment.
236
237        Returns
238        -------
239        Part
240            The polygonal 3D sprue geometry.
241
242        Raises
243        ------
244        TypeError
245            If extrusion did not result in a valid Part.
246        """
247        logger.debug("Creating polygon sprue", extra={"sides": self.sides, "length": length})
248        poly = RegularPolygon(
249            radius=self.diameter / 2,
250            side_count=self.sides,
251            rotation=90 - 180 / self.sides,
252            major_radius=False,
253            align=(Align.CENTER, Align.MAX),
254        )
255        res = Plane.YZ * extrude(to_extrude=poly, amount=length)
256        if not isinstance(res, Part):
257            raise TypeError(f"Expected Shape from Plane * Shape, got {type(res)}")
258        return res

Sprue with a polygonal cross-section.

Parameters
  • diameter (float, default=1.5): Diameter of the circumscribed circle around the polygon.
  • inset (float or None, default=None): Optional inset in mm into the cap.
  • sides (int, default=4): Number of sides in the polygon.
SpruePolygon(diameter: float = 1.5, inset: float | None = None, sides: int = 4)
sides: int = 4
@dataclass
class Stem(build123d.topology.three_d.Mixin3D, build123d.topology.shape_core.Shape[OCP.OCP.TopoDS.TopoDS_Compound]):
 71@dataclass
 72class Stem(Comparable, Compound, ABC):
 73    """
 74    Abstract base class for keycap stems.
 75
 76    This class provides the foundation for creating the stem part of keycaps.
 77    It handles the common functionality of stem creation including geometry centering, spatial
 78    positioning, and joint creation for integration into keycap bodies.
 79
 80    Parameters
 81    ----------
 82    center_at : CenterOf, default=CenterOf.GEOMETRY
 83        Centering mode for the stem geometry. Determines how the stem is positioned
 84        relative to its reference point within the keycap. Must be either `CenterOf.GEOMETRY`
 85        or `CenterOf.BOUNDING_BOX`.
 86    offset : Location, default=Location()
 87        Spatial offset for the stem. Allows translation and rotation of the stem
 88        from its default position within the keycap. Useful for angular adjustments in
 89        asymmetric keycap designs.
 90
 91    Attributes
 92    ----------
 93    joint : RigidJoint
 94        The joint connection point for integrating the stem into a keycap body.
 95
 96    Examples
 97    --------
 98    Create a basic MX stem:
 99    >>> mx_stem = MXStem()
100
101    Create a stem with custom positioning:
102    >>> offset_stem = MXStem(offset=Location((0, 0, 1)))
103
104    Notes
105    -----
106    The Stem class inherits from both Comparable and Compound, enabling comparison
107    operations and 3D geometry representation. All concrete implementations must
108    provide a _builder method that defines the specific stem geometry.
109
110    Stems are automatically split at the XY plane at the end of the  build phase
111    to remove excess support material.
112    """
113
114    center_at: CenterOf = field(default=CenterOf.GEOMETRY)
115    offset: Location = field(default_factory=Location)
116
117    def __post_init__(self):
118        """
119        Initialize the keycap stem after dataclass creation.
120
121        Creates the stem geometry using the concrete implementation's _builder
122        method, splits it at the XY plane, and establishes the rigid joint for
123        integration into keycap bodies.
124        """
125        logger.debug("Creating %s", type(self).__name__, extra={"offset": self.offset})
126        with self._builder() as p:
127            split(bisect_by=Plane.XY, keep=Keep.TOP)
128            RigidJoint(STEM_CAP_JOINT)
129        super().__init__(obj=p.part.wrapped, label=str(self), joints=p.joints)
130
131    @property
132    def joint(self) -> RigidJoint:
133        """
134        Get the stem's joint for keycap body integration.
135
136        Provides access to the rigid joint that connects the stem to the keycap
137        body.
138
139        Returns
140        -------
141        RigidJoint
142            The rigid joint connection point for integrating into keycap bodies.
143        """
144        return self.joints[STEM_CAP_JOINT]
145
146    @abstractmethod
147    def _builder(self) -> BuildPart:
148        """
149        Construct the stem geometry using BuildPart.
150
151        This abstract method must be implemented by concrete stem classes to
152        define the specific geometry for each stem type. The method should
153        return a BuildPart context containing the complete stem geometry.
154
155        Returns
156        -------
157        BuildPart
158            The BuildPart context containing the constructed stem geometry.
159            This geometry will be processed by the base class to create the
160            final stem with proper orientation and joints.
161
162        Notes
163        -----
164        Implementations may create geometry that extends above and/or below
165        the XY plane as needed. The base class will automatically split the
166        geometry and retain only the top portion. This allows implementations
167        to create base geometry below the XY plane to support CAD operations
168        like fillets at the stem base, which require existing geometry to
169        fillet against, then removes this support geometry after processing.
170        """
171
172    @property
173    def metrics(self) -> MetricLayout[Self]:
174        """
175        Expose stem properties and dimensions through the `capistry.Comparable` system.
176
177        Provides detailed information about the stem's position, orientation,
178        and type.
179
180        Returns
181        -------
182        MetricLayout
183            A metric layout containing a single "Stem" group with metrics for:
184            - Type: The stem class name
185            - X, Y, Z: Position coordinates in millimeters
186            - Rotation: Z-axis rotation in degrees
187
188        """
189        glocation = gloc(self)
190        return MetricLayout(
191            owner=self,
192            groups=(
193                MetricGroup(
194                    "Stem",
195                    (
196                        Metric("Type", lambda: type(self).__name__),
197                        Metric("X", lambda: glocation.position.X, "mm"),
198                        Metric("Y", lambda: glocation.position.Y, "mm"),
199                        Metric("Z", lambda: glocation.position.Z, "mm"),
200                        Metric("Rotation", lambda: glocation.orientation.Z, "°"),
201                    ),
202                ),
203            ),
204        )
205
206    def __str__(self) -> str:
207        """
208        Return a string representation of the stem.
209
210        Provides a simple string representation using the class name,
211        which helps identify the stem type during debugging and display.
212
213        Returns
214        -------
215        str
216            The class name of the stem type (e.g., "MXStem", "ChocStem").
217        """
218        return type(self).__name__

Abstract base class for keycap stems.

This class provides the foundation for creating the stem part of keycaps. It handles the common functionality of stem creation including geometry centering, spatial positioning, and joint creation for integration into keycap bodies.

Parameters
  • center_at (CenterOf, default=CenterOf.GEOMETRY): Centering mode for the stem geometry. Determines how the stem is positioned relative to its reference point within the keycap. Must be either CenterOf.GEOMETRY or CenterOf.BOUNDING_BOX.
  • offset (Location, default=Location()): Spatial offset for the stem. Allows translation and rotation of the stem from its default position within the keycap. Useful for angular adjustments in asymmetric keycap designs.
Attributes
  • joint (RigidJoint): The joint connection point for integrating the stem into a keycap body.
Examples

Create a basic MX stem:

>>> mx_stem = MXStem()

Create a stem with custom positioning:

>>> offset_stem = MXStem(offset=Location((0, 0, 1)))
Notes

The Stem class inherits from both Comparable and Compound, enabling comparison operations and 3D geometry representation. All concrete implementations must provide a _builder method that defines the specific stem geometry.

Stems are automatically split at the XY plane at the end of the build phase to remove excess support material.

center_at: build123d.build_enums.CenterOf = <CenterOf.GEOMETRY>
offset: build123d.geometry.Location
joint: build123d.joints.RigidJoint
131    @property
132    def joint(self) -> RigidJoint:
133        """
134        Get the stem's joint for keycap body integration.
135
136        Provides access to the rigid joint that connects the stem to the keycap
137        body.
138
139        Returns
140        -------
141        RigidJoint
142            The rigid joint connection point for integrating into keycap bodies.
143        """
144        return self.joints[STEM_CAP_JOINT]

Get the stem's joint for keycap body integration.

Provides access to the rigid joint that connects the stem to the keycap body.

Returns
  • RigidJoint: The rigid joint connection point for integrating into keycap bodies.
metrics: capistry.compare.MetricLayout[typing.Self]
172    @property
173    def metrics(self) -> MetricLayout[Self]:
174        """
175        Expose stem properties and dimensions through the `capistry.Comparable` system.
176
177        Provides detailed information about the stem's position, orientation,
178        and type.
179
180        Returns
181        -------
182        MetricLayout
183            A metric layout containing a single "Stem" group with metrics for:
184            - Type: The stem class name
185            - X, Y, Z: Position coordinates in millimeters
186            - Rotation: Z-axis rotation in degrees
187
188        """
189        glocation = gloc(self)
190        return MetricLayout(
191            owner=self,
192            groups=(
193                MetricGroup(
194                    "Stem",
195                    (
196                        Metric("Type", lambda: type(self).__name__),
197                        Metric("X", lambda: glocation.position.X, "mm"),
198                        Metric("Y", lambda: glocation.position.Y, "mm"),
199                        Metric("Z", lambda: glocation.position.Z, "mm"),
200                        Metric("Rotation", lambda: glocation.orientation.Z, "°"),
201                    ),
202                ),
203            ),
204        )

Expose stem properties and dimensions through the capistry.Comparable system.

Provides detailed information about the stem's position, orientation, and type.

Returns
  • MetricLayout: A metric layout containing a single "Stem" group with metrics for:
    • Type: The stem class name
    • X, Y, Z: Position coordinates in millimeters
    • Rotation: Z-axis rotation in degrees
@define
class Surface(capistry.compare.Comparable):
102@define
103class Surface(Comparable):
104    """
105    A deformable 3D surface defined by offset heights and optional weights.
106
107    Represents a parametric surface that can be deformed by height offsets at
108    grid points and influenced by optional weight values. The surface supports
109    various transformation operations and can be converted to Build123D Face
110    objects.
111
112    Parameters
113    ----------
114    offsets : list[list[float | int]]
115        2D matrix of height offset values. Must be at least 2x2 with uniform
116        row lengths. These values define the surface deformation at grid points.
117    weights : list[list[float | int]] or None, optional
118        Optional 2D matrix of weight values with same dimensions as offsets.
119        Controls the influence of each control point in Bezier surface generation.
120        Non-positive weights are automatically adjusted, by default None.
121
122    Examples
123    --------
124    Create a simple 2x2 surface:
125    >>> surface = Surface([[0.0, 1.0], [2.0, 3.0]])
126    >>> print(surface)
127    Surface (2x2)
128
129    Create surface with weights:
130    >>> surface = Surface([[0, 1], [2, 3]], weights=[[1.0, 1.5], [1.2, 1.0]])
131
132    Notes
133    -----
134    The Surface class automatically adjusts weights if any are non-positive
135    to preserve relative influence during Bezier surface generation. All
136    transformation methods return new instances without modifying the original.
137    """
138
139    offsets: list[list[float | int]] = field(validator=_matrix_validator)
140    weights: list[list[float | int]] | None = field(
141        default=None, validator=optional(_matrix_validator)
142    )
143
144    @weights.validator
145    def _validate_weights(self, attribute: Attribute, value: list[list[float | int]] | None):
146        """Ensure weights matrix matches offsets dimensions."""
147        if value is None:
148            return
149
150        expected_shape = (len(self.offsets), len(self.offsets[0]))
151        actual_shape = (len(value), len(value[0]))
152
153        if actual_shape != expected_shape:
154            raise ValueError(
155                f"{type(self).__name__} '{attribute.name}' must have the same shape as 'offsets' "
156                f"({expected_shape[0]}x{expected_shape[1]}), "
157                f"but got {actual_shape[0]}x{actual_shape[1]} instead."
158            )
159
160    def __attrs_post_init__(self):
161        """Initialize surface and adjust weights if necessary."""
162        self._adjust_weights()
163
164    def _adjust_weights(self):
165        """Shift weights if any are non-positive to preserve relative influence."""
166        if self.weights is None:
167            return
168
169        flat_weights = list(flatten(self.weights))
170        min_weight = min(flat_weights)
171
172        if min_weight < 1:
173            shift = 1 - min_weight
174            logger.warning(
175                "Non-positive weights detected (min = %s); "
176                "shifting all weights by +%s to preserve relative influence.",
177                min_weight,
178                shift,
179            )
180
181            self.weights = [[w + shift for w in row] for row in self.weights]
182
183    @classmethod
184    def flat(cls, rows: int = 3, cols: int = 3) -> "Surface":
185        """
186        Create a flat surface with all offsets set to zero.
187
188        Generates a Surface instance with uniform zero offsets, representing
189        a flat, undeformed surface. Useful as a starting point for surface
190        modifications or as a reference baseline.
191
192        Parameters
193        ----------
194        rows : int, default=3
195            Number of rows in the offset matrix
196        cols : int, default=3
197            Number of columns in the offset matrix
198
199        Returns
200        -------
201        Surface
202            A new Surface instance with all offsets set to 0.0.
203
204        Examples
205        --------
206        Create default 3x3 flat surface:
207        >>> flat_surface = Surface.flat()
208        >>> print(flat_surface)
209        Surface (3x3)
210
211        Create custom size flat surface:
212        >>> large_flat = Surface.flat(rows=5, cols=4)
213        >>> print(large_flat)
214        Surface (5x4)
215        """
216        logger.debug("Creating flat %s", cls.__name__, extra={"rows": rows, "cols": cols})
217        return cls([[0.0] * cols for _ in range(rows)])
218
219    def scaled(self, offset_factor: float = 1.0) -> "Surface":
220        """
221        Return a new surface with all offsets scaled by the given factor.
222
223        Multiplies all offset values by the scaling factor while preserving
224        the weights unchanged. This is useful for amplifying or reducing
225        surface deformations proportionally.
226
227        Parameters
228        ----------
229        offset_factor : float, default=1.0
230            Factor to multiply all offset values by.
231            Values > 1.0 amplify deformations, values < 1.0 reduce them.
232
233        Returns
234        -------
235        Surface
236            A new Surface instance with scaled offsets and original weights.
237
238        Examples
239        --------
240        Double the surface deformation:
241        >>> surface = Surface([[0, 1], [2, 3]])
242        >>> scaled = surface.scaled(2.0)
243        >>> scaled.offsets
244        [[0.0, 2.0], [4.0, 6.0]]
245
246        Reduce deformation by half:
247        >>> reduced = surface.scaled(0.5)
248        >>> reduced.offsets
249        [[0.0, 0.5], [1.0, 1.5]]
250        """
251        logger.debug("Scaling %s", self.__class__.__name__, extra={"factor": offset_factor})
252        new_offsets = [[z * offset_factor for z in row] for row in self.offsets]
253        return Surface(new_offsets, self.weights)
254
255    def tilted(
256        self, amount: float, horizontally: bool = False, ascending: bool = True
257    ) -> "Surface":
258        """
259        Tilt the surface by adding a linear gradient to the offsets.
260
261        Applies a linear height gradient across the surface in either horizontal
262        or vertical direction. The gradient creates a planar tilt effect that
263        can simulate sloped surfaces.
264
265        Parameters
266        ----------
267        amount : float
268            The maximum vertical offset change across the tilt axis.
269        horizontally : bool, default=False
270            If True, tilt from left to right. If False, tilt from top to bottom.
271        ascending : bool, default=True
272            If True, tilt upward in the direction of travel. If False, tilt
273            downward.
274
275        Returns
276        -------
277        Surface
278            A new Surface instance with the linear tilt applied to offsets.
279
280        Examples
281        --------
282        Tilt vertically upward by 2 units:
283        >>> surface = Surface([[0, 0], [0, 0]])
284        >>> tilted = surface.tilted(2.0, horizontally=False, ascending=True)
285
286        Tilt horizontally downward:
287        >>> tilted = surface.tilted(1.5, horizontally=True, ascending=False)
288        """
289        logger.debug(
290            "Tilting %s",
291            type(self).__name__,
292            extra={"amount": amount, "horizontal": horizontally, "ascending": ascending},
293        )
294
295        rows, cols = len(self.offsets), len(self.offsets[0])
296
297        def tilt_factor(i: int, j: int) -> float:
298            """Calculate the tilt factor for position (i, j)."""
299            if horizontally:
300                return (cols - j - 1) if ascending else j
301            return (rows - i - 1) if ascending else i
302
303        new_offsets = []
304        for i, row in enumerate(self.offsets):
305            new_row = []
306            for j, offset in enumerate(row):
307                new_row.append(offset + amount * tilt_factor(i, j))
308            new_offsets.append(new_row)
309
310        return Surface(new_offsets, self.weights)
311
312    def normalized(self, minimum: float = 0.0) -> "Surface":
313        """
314        Shift the surface so the minimum offset matches the specified value.
315
316        Applies a uniform vertical translation to all offsets so that the
317        lowest point on the surface equals the target minimum value. This
318        is useful for establishing consistent baseline heights or ensuring
319        non-negative offsets.
320
321        Parameters
322        ----------
323        minimum : float, default=0.0
324            The desired value for the minimum offset after normalization.
325
326        Returns
327        -------
328        Surface
329            A new Surface instance with normalized offsets.
330
331        Examples
332        --------
333        Normalize to zero baseline:
334        >>> surface = Surface([[-2, -1], [0, 1]])
335        >>> normalized = surface.normalized(0.0)
336        >>> normalized.offsets
337        [[0.0, 1.0], [2.0, 3.0]]
338
339        Set minimum to specific value:
340        >>> normalized = surface.normalized(5.0)
341        >>> min(flatten(normalized.offsets))
342        5.0
343        """
344        current_min = min(v for row in self.offsets for v in row)
345        shift = minimum - current_min
346        logger.debug(
347            "Normalizing %s",
348            type(self).__name__,
349            extra={"minimum": minimum, "shift": shift},
350        )
351        new_offsets = [[v + shift for v in row] for row in self.offsets]
352        return Surface(new_offsets, self.weights)
353
354    def rotated(self, turns: int) -> "Surface":
355        """
356        Rotate the surface clockwise by 90° increments.
357
358        Rotates both the offset matrix and weights matrix (if present) by
359        the specified number of 90-degree clockwise turns. This preserves
360        the surface shape while changing its orientation.
361
362        Parameters
363        ----------
364        turns : int
365            Number of 90° clockwise rotations. Values are taken modulo 4,
366            so turns=5 is equivalent to turns=1.
367
368        Returns
369        -------
370        Surface
371            A new Surface instance with rotated offsets and weights.
372
373        Examples
374        --------
375        Rotate 90 degrees clockwise:
376        >>> surface = Surface([[1, 2], [3, 4]])
377        >>> rotated = surface.rotated(1)
378
379        Rotate 180 degrees:
380        >>> rotated = surface.rotated(2)
381        """
382        turns = turns % 4
383        logger.debug(
384            "Rotating %s",
385            type(self).__name__,
386            extra={"turns": turns},
387        )
388
389        offsets = rotate_matrix(self.offsets, turns)
390        weights = self.weights
391        if weights is not None:
392            weights = rotate_matrix(weights, n=turns)
393
394        return Surface(offsets=offsets, weights=weights)
395
396    def mirrored(self, horizontal: bool = True, include_weights: bool = True) -> "Surface":
397        """
398        Mirror the surface across the vertical or horizontal axis.
399
400        Creates a mirror image of the surface by reversing the order of
401        elements along the specified axis. Can optionally preserve original
402        weights while mirroring only the offsets.
403
404        Parameters
405        ----------
406        horizontal : bool, default=True
407            If True, mirror left-to-right. If False, mirror top-to-bottom.
408        include_weights : bool, default=True
409            If True, mirror both offsets and weights. If False, only mirror
410            offsets while preserving original weights.
411
412        Returns
413        -------
414        Surface
415            A new Surface instance with mirrored data.
416
417        Examples
418        --------
419        Mirror horizontally (left-right):
420        >>> surface = Surface([[1, 2], [3, 4]])
421        >>> mirrored = surface.mirrored(horizontal=True)
422
423        Mirror vertically, preserve weights:
424        >>> mirrored = surface.mirrored(horizontal=False, include_weights=False)
425        """
426        logger.debug(
427            "Mirroring %s",
428            type(self).__name__,
429            extra={"vertical": horizontal, "include_weights": include_weights},
430        )
431
432        offsets = mirror_matrix(self.offsets, horizontal)
433        weights = self.weights
434        if include_weights and weights is not None:
435            weights = mirror_matrix(weights, horizontal)
436
437        return Surface(offsets, weights if include_weights else self.weights)
438
439    def form_face(self, face: Face) -> Face:
440        """
441        Create a Bezier surface Face by deforming the input Face  with offset control points.
442
443        Generates a Build123D Face object by interpolating between the vertices of the
444        provided face and applying the surface offsets as control point deformations to
445        form a bezier surface.
446
447        Parameters
448        ----------
449        face | Face
450            A Build123D Face object with exactly `_NUM_FACE_VERTICES` (4) corner vertices
451            that define the surface boundaries.
452
453        Returns
454        -------
455        Face
456            A Build123D Face representing the Bezier surface.
457
458        Raises
459        ------
460        ValueError
461            If the input Face does not have exactly four vertices.
462
463        Examples
464        --------
465        Form face from a rectangle:
466        >>
467        >>> rectangle = Rectangle(10, 10)
468        >>> face = rectangle.faces()[0]
469        >>> surface = Surface([[0, 1], [2, 3]])
470        >>> bezier_face = surface.form_face(face)
471
472        Notes
473        -----
474        The vertices are automatically sorted by Y-coordinate (top to bottom)
475        then by X-coordinate (left to right) to ensure consistent orientation.
476        """
477        vertices = face.vertices()
478        if len(vertices) != _NUM_FACE_VERTICES:
479            raise ValueError(
480                f"Expected exactly {_NUM_FACE_VERTICES} vertices to define the surface corners, "
481                f"but received {len(vertices)}."
482            )
483
484        logger.debug(
485            "Creating bezier surface from %s",
486            self.__class__.__name__,
487            extra={"rows": len(self.offsets), "cols": len(self.offsets[0])},
488        )
489
490        tl, tr, br, bl = [Vector(c) for c in self._sort_corners(vertices)]
491
492        m, n = len(self.offsets), len(self.offsets[0])
493
494        us = [j / (n - 1) for j in range(n)]
495        vs = [i / (m - 1) for i in range(m)]
496
497        points: list[list[VectorLike]] = [
498            [
499                (
500                    tl * (1 - u) * (1 - v)
501                    + tr * u * (1 - v)
502                    + bl * (1 - u) * v
503                    + br * u * v
504                    + Vector(0, 0, self.offsets[i][j])
505                )
506                for j, u in enumerate(us)
507            ]
508            for i, v in enumerate(vs)
509        ]
510
511        face = Face.make_bezier_surface(points=points, weights=self.weights)
512
513        logger.debug(
514            "Successfully created bezier surface",
515            extra={"control_points": m * n},
516        )
517
518        return face
519
520    def _sort_corners(self, vertices: list[Vertex]) -> list[Vertex]:
521        """
522        Sort corner vertices into consistent order.
523
524        Arranges vertices in the order required for Bezier surface creation:
525        [top_left, top_right, bottom_right, bottom_left] based on their
526        Y and X coordinates.
527
528        Parameters
529        ----------
530        vertices : list[Vertex]
531            List of exactly `_NUM_FACE_VERTICES` (4) vertices to sort.
532
533        Returns
534        -------
535        list[Vertex]
536            Vertices sorted as [top_left, top_right, bottom_right, bottom_left].
537
538        Raises
539        ------
540        ValueError
541            If the number of vertices is not exactly 4.
542        """
543        if len(vertices) != _NUM_FACE_VERTICES:
544            raise ValueError(
545                f"Expected exactly {_NUM_FACE_VERTICES} vertices to sort, "
546                f"but received {len(vertices)}."
547            )
548        by_y = sorted(vertices, key=lambda v: v.Y, reverse=True)
549        top, bottom = by_y[:2], by_y[2:]
550        tl, tr = sorted(top, key=lambda v: v.X)
551        bl, br = sorted(bottom, key=lambda v: v.X)
552        return [tl, tr, br, bl]
553
554    def __str__(self) -> str:
555        """Return a string representation showing surface dimensions."""
556        return f"{type(self).__name__} ({len(self.offsets)}x{len(self.offsets[0])})"
557
558    @property
559    def metrics(self) -> MetricLayout[Self]:
560        """
561        Expose surface offset and weight extrema through the `capistry.Comparable` system.
562
563        Provides access to minimum and maximum values for both offsets and
564        weights.
565
566        Returns
567        -------
568        MetricLayout
569            A metric layout containing a single "Surface" group with the following metrics:
570            - Offset - Max: The largest numeric offset value found in the 'offsets' list.
571            - Offset - Min: The smallest numeric offset value found in the 'offsets' list.
572            - Weights - Max: The largest numeric weight value found in the 'weights' list.
573            - Weights - Min: The smallest numeric weight value found in the 'weights' list.
574
575        Notes
576        -----
577        The metrics use "mm" units for display purposes. Weight metrics
578        return empty strings if no weights are defined.
579        """
580        return MetricLayout(
581            owner=self,
582            groups=(
583                MetricGroup(
584                    "Surface",
585                    (
586                        Metric(
587                            "Offset - Max",
588                            lambda: max(collapse(self.offsets, base_type=Number), default=""),
589                            "mm",
590                        ),
591                        Metric(
592                            "Offset - Min",
593                            lambda: min(collapse(self.offsets, base_type=Number), default=""),
594                            "mm",
595                        ),
596                        Metric(
597                            "Weights - Max",
598                            lambda: max(collapse(self.weights or [], base_type=Number), default=""),
599                            "mm",
600                        ),
601                        Metric(
602                            "Weights - Min",
603                            lambda: min(collapse(self.weights or [], base_type=Number), default=""),
604                            "mm",
605                        ),
606                    ),
607                ),
608            ),
609        )

A deformable 3D surface defined by offset heights and optional weights.

Represents a parametric surface that can be deformed by height offsets at grid points and influenced by optional weight values. The surface supports various transformation operations and can be converted to Build123D Face objects.

Parameters
  • offsets (list[list[float | int]]): 2D matrix of height offset values. Must be at least 2x2 with uniform row lengths. These values define the surface deformation at grid points.
  • weights (list[list[float | int]] or None, optional): Optional 2D matrix of weight values with same dimensions as offsets. Controls the influence of each control point in Bezier surface generation. Non-positive weights are automatically adjusted, by default None.
Examples

Create a simple 2x2 surface:

>>> surface = Surface([[0.0, 1.0], [2.0, 3.0]])
>>> print(surface)
Surface (2x2)

Create surface with weights:

>>> surface = Surface([[0, 1], [2, 3]], weights=[[1.0, 1.5], [1.2, 1.0]])
Notes

The Surface class automatically adjusts weights if any are non-positive to preserve relative influence during Bezier surface generation. All transformation methods return new instances without modifying the original.

Surface( offsets: list[list[float | int]], weights: list[list[float | int]] | None = None)
24def __init__(self, offsets, weights=attr_dict['weights'].default):
25    _setattr = _cached_setattr_get(self)
26    _setattr('offsets', offsets)
27    _setattr('weights', weights)
28    if _config._run_validators is True:
29        __attr_validator_offsets(self, __attr_offsets, self.offsets)
30        __attr_validator_weights(self, __attr_weights, self.weights)
31    self.__attrs_post_init__()

Method generated by attrs for class Surface.

offsets: list[list[float | int]]
weights: list[list[float | int]] | None
@classmethod
def flat(cls, rows: int = 3, cols: int = 3) -> Surface:
183    @classmethod
184    def flat(cls, rows: int = 3, cols: int = 3) -> "Surface":
185        """
186        Create a flat surface with all offsets set to zero.
187
188        Generates a Surface instance with uniform zero offsets, representing
189        a flat, undeformed surface. Useful as a starting point for surface
190        modifications or as a reference baseline.
191
192        Parameters
193        ----------
194        rows : int, default=3
195            Number of rows in the offset matrix
196        cols : int, default=3
197            Number of columns in the offset matrix
198
199        Returns
200        -------
201        Surface
202            A new Surface instance with all offsets set to 0.0.
203
204        Examples
205        --------
206        Create default 3x3 flat surface:
207        >>> flat_surface = Surface.flat()
208        >>> print(flat_surface)
209        Surface (3x3)
210
211        Create custom size flat surface:
212        >>> large_flat = Surface.flat(rows=5, cols=4)
213        >>> print(large_flat)
214        Surface (5x4)
215        """
216        logger.debug("Creating flat %s", cls.__name__, extra={"rows": rows, "cols": cols})
217        return cls([[0.0] * cols for _ in range(rows)])

Create a flat surface with all offsets set to zero.

Generates a Surface instance with uniform zero offsets, representing a flat, undeformed surface. Useful as a starting point for surface modifications or as a reference baseline.

Parameters
  • rows (int, default=3): Number of rows in the offset matrix
  • cols (int, default=3): Number of columns in the offset matrix
Returns
  • Surface: A new Surface instance with all offsets set to 0.0.
Examples

Create default 3x3 flat surface:

>>> flat_surface = Surface.flat()
>>> print(flat_surface)
Surface (3x3)

Create custom size flat surface:

>>> large_flat = Surface.flat(rows=5, cols=4)
>>> print(large_flat)
Surface (5x4)
def scaled(self, offset_factor: float = 1.0) -> Surface:
219    def scaled(self, offset_factor: float = 1.0) -> "Surface":
220        """
221        Return a new surface with all offsets scaled by the given factor.
222
223        Multiplies all offset values by the scaling factor while preserving
224        the weights unchanged. This is useful for amplifying or reducing
225        surface deformations proportionally.
226
227        Parameters
228        ----------
229        offset_factor : float, default=1.0
230            Factor to multiply all offset values by.
231            Values > 1.0 amplify deformations, values < 1.0 reduce them.
232
233        Returns
234        -------
235        Surface
236            A new Surface instance with scaled offsets and original weights.
237
238        Examples
239        --------
240        Double the surface deformation:
241        >>> surface = Surface([[0, 1], [2, 3]])
242        >>> scaled = surface.scaled(2.0)
243        >>> scaled.offsets
244        [[0.0, 2.0], [4.0, 6.0]]
245
246        Reduce deformation by half:
247        >>> reduced = surface.scaled(0.5)
248        >>> reduced.offsets
249        [[0.0, 0.5], [1.0, 1.5]]
250        """
251        logger.debug("Scaling %s", self.__class__.__name__, extra={"factor": offset_factor})
252        new_offsets = [[z * offset_factor for z in row] for row in self.offsets]
253        return Surface(new_offsets, self.weights)

Return a new surface with all offsets scaled by the given factor.

Multiplies all offset values by the scaling factor while preserving the weights unchanged. This is useful for amplifying or reducing surface deformations proportionally.

Parameters
  • offset_factor (float, default=1.0): Factor to multiply all offset values by. Values > 1.0 amplify deformations, values < 1.0 reduce them.
Returns
  • Surface: A new Surface instance with scaled offsets and original weights.
Examples

Double the surface deformation:

>>> surface = Surface([[0, 1], [2, 3]])
>>> scaled = surface.scaled(2.0)
>>> scaled.offsets
[[0.0, 2.0], [4.0, 6.0]]

Reduce deformation by half:

>>> reduced = surface.scaled(0.5)
>>> reduced.offsets
[[0.0, 0.5], [1.0, 1.5]]
def tilted( self, amount: float, horizontally: bool = False, ascending: bool = True) -> Surface:
255    def tilted(
256        self, amount: float, horizontally: bool = False, ascending: bool = True
257    ) -> "Surface":
258        """
259        Tilt the surface by adding a linear gradient to the offsets.
260
261        Applies a linear height gradient across the surface in either horizontal
262        or vertical direction. The gradient creates a planar tilt effect that
263        can simulate sloped surfaces.
264
265        Parameters
266        ----------
267        amount : float
268            The maximum vertical offset change across the tilt axis.
269        horizontally : bool, default=False
270            If True, tilt from left to right. If False, tilt from top to bottom.
271        ascending : bool, default=True
272            If True, tilt upward in the direction of travel. If False, tilt
273            downward.
274
275        Returns
276        -------
277        Surface
278            A new Surface instance with the linear tilt applied to offsets.
279
280        Examples
281        --------
282        Tilt vertically upward by 2 units:
283        >>> surface = Surface([[0, 0], [0, 0]])
284        >>> tilted = surface.tilted(2.0, horizontally=False, ascending=True)
285
286        Tilt horizontally downward:
287        >>> tilted = surface.tilted(1.5, horizontally=True, ascending=False)
288        """
289        logger.debug(
290            "Tilting %s",
291            type(self).__name__,
292            extra={"amount": amount, "horizontal": horizontally, "ascending": ascending},
293        )
294
295        rows, cols = len(self.offsets), len(self.offsets[0])
296
297        def tilt_factor(i: int, j: int) -> float:
298            """Calculate the tilt factor for position (i, j)."""
299            if horizontally:
300                return (cols - j - 1) if ascending else j
301            return (rows - i - 1) if ascending else i
302
303        new_offsets = []
304        for i, row in enumerate(self.offsets):
305            new_row = []
306            for j, offset in enumerate(row):
307                new_row.append(offset + amount * tilt_factor(i, j))
308            new_offsets.append(new_row)
309
310        return Surface(new_offsets, self.weights)

Tilt the surface by adding a linear gradient to the offsets.

Applies a linear height gradient across the surface in either horizontal or vertical direction. The gradient creates a planar tilt effect that can simulate sloped surfaces.

Parameters
  • amount (float): The maximum vertical offset change across the tilt axis.
  • horizontally (bool, default=False): If True, tilt from left to right. If False, tilt from top to bottom.
  • ascending (bool, default=True): If True, tilt upward in the direction of travel. If False, tilt downward.
Returns
  • Surface: A new Surface instance with the linear tilt applied to offsets.
Examples

Tilt vertically upward by 2 units:

>>> surface = Surface([[0, 0], [0, 0]])
>>> tilted = surface.tilted(2.0, horizontally=False, ascending=True)

Tilt horizontally downward:

>>> tilted = surface.tilted(1.5, horizontally=True, ascending=False)
def normalized(self, minimum: float = 0.0) -> Surface:
312    def normalized(self, minimum: float = 0.0) -> "Surface":
313        """
314        Shift the surface so the minimum offset matches the specified value.
315
316        Applies a uniform vertical translation to all offsets so that the
317        lowest point on the surface equals the target minimum value. This
318        is useful for establishing consistent baseline heights or ensuring
319        non-negative offsets.
320
321        Parameters
322        ----------
323        minimum : float, default=0.0
324            The desired value for the minimum offset after normalization.
325
326        Returns
327        -------
328        Surface
329            A new Surface instance with normalized offsets.
330
331        Examples
332        --------
333        Normalize to zero baseline:
334        >>> surface = Surface([[-2, -1], [0, 1]])
335        >>> normalized = surface.normalized(0.0)
336        >>> normalized.offsets
337        [[0.0, 1.0], [2.0, 3.0]]
338
339        Set minimum to specific value:
340        >>> normalized = surface.normalized(5.0)
341        >>> min(flatten(normalized.offsets))
342        5.0
343        """
344        current_min = min(v for row in self.offsets for v in row)
345        shift = minimum - current_min
346        logger.debug(
347            "Normalizing %s",
348            type(self).__name__,
349            extra={"minimum": minimum, "shift": shift},
350        )
351        new_offsets = [[v + shift for v in row] for row in self.offsets]
352        return Surface(new_offsets, self.weights)

Shift the surface so the minimum offset matches the specified value.

Applies a uniform vertical translation to all offsets so that the lowest point on the surface equals the target minimum value. This is useful for establishing consistent baseline heights or ensuring non-negative offsets.

Parameters
  • minimum (float, default=0.0): The desired value for the minimum offset after normalization.
Returns
  • Surface: A new Surface instance with normalized offsets.
Examples

Normalize to zero baseline:

>>> surface = Surface([[-2, -1], [0, 1]])
>>> normalized = surface.normalized(0.0)
>>> normalized.offsets
[[0.0, 1.0], [2.0, 3.0]]

Set minimum to specific value:

>>> normalized = surface.normalized(5.0)
>>> min(flatten(normalized.offsets))
5.0
def rotated(self, turns: int) -> Surface:
354    def rotated(self, turns: int) -> "Surface":
355        """
356        Rotate the surface clockwise by 90° increments.
357
358        Rotates both the offset matrix and weights matrix (if present) by
359        the specified number of 90-degree clockwise turns. This preserves
360        the surface shape while changing its orientation.
361
362        Parameters
363        ----------
364        turns : int
365            Number of 90° clockwise rotations. Values are taken modulo 4,
366            so turns=5 is equivalent to turns=1.
367
368        Returns
369        -------
370        Surface
371            A new Surface instance with rotated offsets and weights.
372
373        Examples
374        --------
375        Rotate 90 degrees clockwise:
376        >>> surface = Surface([[1, 2], [3, 4]])
377        >>> rotated = surface.rotated(1)
378
379        Rotate 180 degrees:
380        >>> rotated = surface.rotated(2)
381        """
382        turns = turns % 4
383        logger.debug(
384            "Rotating %s",
385            type(self).__name__,
386            extra={"turns": turns},
387        )
388
389        offsets = rotate_matrix(self.offsets, turns)
390        weights = self.weights
391        if weights is not None:
392            weights = rotate_matrix(weights, n=turns)
393
394        return Surface(offsets=offsets, weights=weights)

Rotate the surface clockwise by 90° increments.

Rotates both the offset matrix and weights matrix (if present) by the specified number of 90-degree clockwise turns. This preserves the surface shape while changing its orientation.

Parameters
  • turns (int): Number of 90° clockwise rotations. Values are taken modulo 4, so turns=5 is equivalent to turns=1.
Returns
  • Surface: A new Surface instance with rotated offsets and weights.
Examples

Rotate 90 degrees clockwise:

>>> surface = Surface([[1, 2], [3, 4]])
>>> rotated = surface.rotated(1)

Rotate 180 degrees:

>>> rotated = surface.rotated(2)
def mirrored( self, horizontal: bool = True, include_weights: bool = True) -> Surface:
396    def mirrored(self, horizontal: bool = True, include_weights: bool = True) -> "Surface":
397        """
398        Mirror the surface across the vertical or horizontal axis.
399
400        Creates a mirror image of the surface by reversing the order of
401        elements along the specified axis. Can optionally preserve original
402        weights while mirroring only the offsets.
403
404        Parameters
405        ----------
406        horizontal : bool, default=True
407            If True, mirror left-to-right. If False, mirror top-to-bottom.
408        include_weights : bool, default=True
409            If True, mirror both offsets and weights. If False, only mirror
410            offsets while preserving original weights.
411
412        Returns
413        -------
414        Surface
415            A new Surface instance with mirrored data.
416
417        Examples
418        --------
419        Mirror horizontally (left-right):
420        >>> surface = Surface([[1, 2], [3, 4]])
421        >>> mirrored = surface.mirrored(horizontal=True)
422
423        Mirror vertically, preserve weights:
424        >>> mirrored = surface.mirrored(horizontal=False, include_weights=False)
425        """
426        logger.debug(
427            "Mirroring %s",
428            type(self).__name__,
429            extra={"vertical": horizontal, "include_weights": include_weights},
430        )
431
432        offsets = mirror_matrix(self.offsets, horizontal)
433        weights = self.weights
434        if include_weights and weights is not None:
435            weights = mirror_matrix(weights, horizontal)
436
437        return Surface(offsets, weights if include_weights else self.weights)

Mirror the surface across the vertical or horizontal axis.

Creates a mirror image of the surface by reversing the order of elements along the specified axis. Can optionally preserve original weights while mirroring only the offsets.

Parameters
  • horizontal (bool, default=True): If True, mirror left-to-right. If False, mirror top-to-bottom.
  • include_weights (bool, default=True): If True, mirror both offsets and weights. If False, only mirror offsets while preserving original weights.
Returns
  • Surface: A new Surface instance with mirrored data.
Examples

Mirror horizontally (left-right):

>>> surface = Surface([[1, 2], [3, 4]])
>>> mirrored = surface.mirrored(horizontal=True)

Mirror vertically, preserve weights:

>>> mirrored = surface.mirrored(horizontal=False, include_weights=False)
def form_face( self, face: build123d.topology.two_d.Face) -> build123d.topology.two_d.Face:
439    def form_face(self, face: Face) -> Face:
440        """
441        Create a Bezier surface Face by deforming the input Face  with offset control points.
442
443        Generates a Build123D Face object by interpolating between the vertices of the
444        provided face and applying the surface offsets as control point deformations to
445        form a bezier surface.
446
447        Parameters
448        ----------
449        face | Face
450            A Build123D Face object with exactly `_NUM_FACE_VERTICES` (4) corner vertices
451            that define the surface boundaries.
452
453        Returns
454        -------
455        Face
456            A Build123D Face representing the Bezier surface.
457
458        Raises
459        ------
460        ValueError
461            If the input Face does not have exactly four vertices.
462
463        Examples
464        --------
465        Form face from a rectangle:
466        >>
467        >>> rectangle = Rectangle(10, 10)
468        >>> face = rectangle.faces()[0]
469        >>> surface = Surface([[0, 1], [2, 3]])
470        >>> bezier_face = surface.form_face(face)
471
472        Notes
473        -----
474        The vertices are automatically sorted by Y-coordinate (top to bottom)
475        then by X-coordinate (left to right) to ensure consistent orientation.
476        """
477        vertices = face.vertices()
478        if len(vertices) != _NUM_FACE_VERTICES:
479            raise ValueError(
480                f"Expected exactly {_NUM_FACE_VERTICES} vertices to define the surface corners, "
481                f"but received {len(vertices)}."
482            )
483
484        logger.debug(
485            "Creating bezier surface from %s",
486            self.__class__.__name__,
487            extra={"rows": len(self.offsets), "cols": len(self.offsets[0])},
488        )
489
490        tl, tr, br, bl = [Vector(c) for c in self._sort_corners(vertices)]
491
492        m, n = len(self.offsets), len(self.offsets[0])
493
494        us = [j / (n - 1) for j in range(n)]
495        vs = [i / (m - 1) for i in range(m)]
496
497        points: list[list[VectorLike]] = [
498            [
499                (
500                    tl * (1 - u) * (1 - v)
501                    + tr * u * (1 - v)
502                    + bl * (1 - u) * v
503                    + br * u * v
504                    + Vector(0, 0, self.offsets[i][j])
505                )
506                for j, u in enumerate(us)
507            ]
508            for i, v in enumerate(vs)
509        ]
510
511        face = Face.make_bezier_surface(points=points, weights=self.weights)
512
513        logger.debug(
514            "Successfully created bezier surface",
515            extra={"control_points": m * n},
516        )
517
518        return face

Create a Bezier surface Face by deforming the input Face with offset control points.

Generates a Build123D Face object by interpolating between the vertices of the provided face and applying the surface offsets as control point deformations to form a bezier surface.

Parameters
  • face | Face: A Build123D Face object with exactly _NUM_FACE_VERTICES (4) corner vertices that define the surface boundaries.
Returns
  • Face: A Build123D Face representing the Bezier surface.
Raises
  • ValueError: If the input Face does not have exactly four vertices.
Examples

Form face from a rectangle:

>

>>> rectangle = Rectangle(10, 10)
>>> face = rectangle.faces()[0]
>>> surface = Surface([[0, 1], [2, 3]])
>>> bezier_face = surface.form_face(face)
Notes

The vertices are automatically sorted by Y-coordinate (top to bottom) then by X-coordinate (left to right) to ensure consistent orientation.

metrics: capistry.compare.MetricLayout[typing.Self]
558    @property
559    def metrics(self) -> MetricLayout[Self]:
560        """
561        Expose surface offset and weight extrema through the `capistry.Comparable` system.
562
563        Provides access to minimum and maximum values for both offsets and
564        weights.
565
566        Returns
567        -------
568        MetricLayout
569            A metric layout containing a single "Surface" group with the following metrics:
570            - Offset - Max: The largest numeric offset value found in the 'offsets' list.
571            - Offset - Min: The smallest numeric offset value found in the 'offsets' list.
572            - Weights - Max: The largest numeric weight value found in the 'weights' list.
573            - Weights - Min: The smallest numeric weight value found in the 'weights' list.
574
575        Notes
576        -----
577        The metrics use "mm" units for display purposes. Weight metrics
578        return empty strings if no weights are defined.
579        """
580        return MetricLayout(
581            owner=self,
582            groups=(
583                MetricGroup(
584                    "Surface",
585                    (
586                        Metric(
587                            "Offset - Max",
588                            lambda: max(collapse(self.offsets, base_type=Number), default=""),
589                            "mm",
590                        ),
591                        Metric(
592                            "Offset - Min",
593                            lambda: min(collapse(self.offsets, base_type=Number), default=""),
594                            "mm",
595                        ),
596                        Metric(
597                            "Weights - Max",
598                            lambda: max(collapse(self.weights or [], base_type=Number), default=""),
599                            "mm",
600                        ),
601                        Metric(
602                            "Weights - Min",
603                            lambda: min(collapse(self.weights or [], base_type=Number), default=""),
604                            "mm",
605                        ),
606                    ),
607                ),
608            ),
609        )

Expose surface offset and weight extrema through the capistry.Comparable system.

Provides access to minimum and maximum values for both offsets and weights.

Returns
  • MetricLayout: A metric layout containing a single "Surface" group with the following metrics:
    • Offset - Max: The largest numeric offset value found in the 'offsets' list.
    • Offset - Min: The smallest numeric offset value found in the 'offsets' list.
    • Weights - Max: The largest numeric weight value found in the 'weights' list.
    • Weights - Min: The smallest numeric weight value found in the 'weights' list.
Notes

The metrics use "mm" units for display purposes. Weight metrics return empty strings if no weights are defined.

@dataclass
class Taper(capistry.compare.Comparable):
 22@dataclass
 23class Taper(Comparable):
 24    """
 25    Represents a four-sided directional taper with transformation utilities.
 26
 27    The class provides methods for creating uniform tapers, scaling all values
 28    proportionally, clamping values to safe ranges, and exposing values through
 29    a metrics system for comparison.
 30
 31    Parameters
 32    ----------
 33    front : float, default=0.0
 34        Taper value for the front side
 35    back : float, default=0.0
 36        Taper value for the back side
 37    left : float, default=0.0
 38        Taper value for the left side
 39    right : float, default=0.0
 40        Taper value for the right side
 41
 42    Examples
 43    --------
 44    Create a taper with individual side values:
 45    >>> taper = Taper(front=2.5, back=1.0, left=0.5, right=0.8)
 46    >>> print(taper)
 47    Taper (2.50, 1.00, 0.50, 0.80)
 48
 49    Create a uniform taper:
 50    >>> uniform_taper = Taper.uniform(1.5)
 51    >>> print(uniform_taper)
 52    Taper (1.50, 1.50, 1.50, 1.50)
 53
 54    Scale an existing taper:
 55    >>> scaled = taper.scaled(2.0)
 56    >>> print(scaled)
 57    Taper (5.00, 2.00, 1.00, 1.60)
 58
 59    Clamp values to a safe range:
 60    >>> clamped = taper.clamp(0.0, 3.0)
 61    >>> print(clamped)
 62    Taper (2.50, 1.00, 0.50, 0.80)
 63
 64    Notes
 65    -----
 66    The Taper class inherits from Comparable, enabling comparison operations
 67    and integration with metric systems. All transformation methods return
 68    new instances rather than modifying the original taper in-place.
 69    """
 70
 71    front: float = 0.0
 72    back: float = 0.0
 73    left: float = 0.0
 74    right: float = 0.0
 75
 76    @classmethod
 77    def uniform(cls, value: float) -> Self:
 78        """
 79        Create a taper with all sides set to the same value.
 80
 81        This is a convenience constructor for creating symmetric tapers where
 82        all four sides have identical taper values. Commonly used for uniform
 83        draft angles in manufacturing or symmetric scaling operations.
 84
 85        Parameters
 86        ----------
 87        value : float
 88            The taper value to apply to all four sides (front, back, left, right).
 89
 90        Returns
 91        -------
 92        Taper
 93            A new Taper instance with all sides set to the specified value.
 94
 95        Notes
 96        -----
 97        This method is equivalent to calling Taper(value, value, value, value)
 98        but provides clearer intent.
 99        """
100        return cls(front=value, back=value, left=value, right=value)
101
102    def scaled(self, factor: float) -> "Taper":
103        """
104        Return a new Taper instance with all values scaled by a factor.
105
106        Multiplies each taper value (front, back, left, right) by the given
107        scaling factor.
108
109        Parameters
110        ----------
111        factor : float
112            The scaling factor to apply to all taper values. Values greater
113            than 1.0 increase the taper, values between 0.0 and 1.0 decrease
114            the taper, and negative values reverse the taper direction.
115
116        Returns
117        -------
118        Taper
119            A new Taper instance with all values multiplied by the factor.
120            The original instance is unchanged.
121
122        Examples
123        --------
124        Double all taper values:
125        >>> original = Taper(front=1.0, back=2.0, left=0.5, right=1.5)
126        >>> doubled = original.scaled(2.0)
127        >>> print(doubled)
128        Taper (2.00, 4.00, 1.00, 3.00)
129
130        Notes
131        -----
132        This method creates a new instance and does not modify the original
133        taper. The scaling is applied independently to each side.
134        """
135        return Taper(
136            front=self.front * factor,
137            back=self.back * factor,
138            left=self.left * factor,
139            right=self.right * factor,
140        )
141
142    def clamp(self, min_value: float, max_value: float) -> "Taper":
143        """
144        Clamp all taper values to the specified range.
145
146        Constrains each taper value to lie within [min_value, max_value] by
147        setting values below `min_value` to `min_value` and values above `max_value`
148        to `max_value`.
149
150        Parameters
151        ----------
152        min_value : float
153            The minimum allowed value for any taper side. Values below this
154            threshold will be set to min_value.
155        max_value : float
156            The maximum allowed value for any taper side. Values above this
157            threshold will be set to max_value.
158
159        Returns
160        -------
161        Taper
162            A new Taper instance with all values clamped to the specified range.
163            The original instance is unchanged.
164
165        Raises
166        ------
167        ValueError
168            If min_value > max_value (invalid range).
169
170        Examples
171        --------
172        Clamp to symmetric range:
173        >>> extreme_taper = Taper(front=-10.0, back=10.0, left=0.0, right=5.0)
174        >>> balanced = extreme_taper.clamp(-3.0, 3.0)
175        >>> print(balanced)
176        Taper (-3.00, 3.00, 0.00, 3.00)
177
178        Notes
179        -----
180        This method applies the standard clamping operation:
181        min(max(value, min_value), max_value) to each taper side independently.
182        """
183        if min_value > max_value:
184            raise ValueError(f"min_value ({min_value}) must be <= max_value ({max_value})")
185
186        return Taper(
187            front=min(max(self.front, min_value), max_value),
188            back=min(max(self.back, min_value), max_value),
189            left=min(max(self.left, min_value), max_value),
190            right=min(max(self.right, min_value), max_value),
191        )
192
193    @property
194    def metrics(self) -> MetricLayout[Self]:
195        """
196        Expose taper values through the `capistry.Comparable` system for comparison.
197
198        Provides a structured interface for accessing taper values through
199        the `capistry.Comparable` system.
200
201        Returns
202        -------
203        MetricLayout
204            A metric layout containing a single "Taper" group with four metrics
205            representing the front, back, left, and right taper values.
206        """
207        return MetricLayout(
208            owner=self,
209            groups=(
210                MetricGroup(
211                    "Taper",
212                    (
213                        Metric("Front", lambda: self.front, "°"),
214                        Metric("Back", lambda: self.back, "°"),
215                        Metric("Left", lambda: self.left, "°"),
216                        Metric("Right", lambda: self.right, "°"),
217                    ),
218                ),
219            ),
220        )
221
222    def __str__(self) -> str:
223        """
224        Format the class name and taper values to 2 decimal places.
225
226        Returns
227        -------
228        str
229            String like "ClassName (front, back, left, right)".
230        """
231        return (
232            f"{type(self).__name__} "
233            f"({self.front:.2f}, {self.back:.2f}, {self.left:.2f}, {self.right:.2f})"
234        )

Represents a four-sided directional taper with transformation utilities.

The class provides methods for creating uniform tapers, scaling all values proportionally, clamping values to safe ranges, and exposing values through a metrics system for comparison.

Parameters
  • front (float, default=0.0): Taper value for the front side
  • back (float, default=0.0): Taper value for the back side
  • left (float, default=0.0): Taper value for the left side
  • right (float, default=0.0): Taper value for the right side
Examples

Create a taper with individual side values:

>>> taper = Taper(front=2.5, back=1.0, left=0.5, right=0.8)
>>> print(taper)
Taper (2.50, 1.00, 0.50, 0.80)

Create a uniform taper:

>>> uniform_taper = Taper.uniform(1.5)
>>> print(uniform_taper)
Taper (1.50, 1.50, 1.50, 1.50)

Scale an existing taper:

>>> scaled = taper.scaled(2.0)
>>> print(scaled)
Taper (5.00, 2.00, 1.00, 1.60)

Clamp values to a safe range:

>>> clamped = taper.clamp(0.0, 3.0)
>>> print(clamped)
Taper (2.50, 1.00, 0.50, 0.80)
Notes

The Taper class inherits from Comparable, enabling comparison operations and integration with metric systems. All transformation methods return new instances rather than modifying the original taper in-place.

Taper( front: float = 0.0, back: float = 0.0, left: float = 0.0, right: float = 0.0)
front: float = 0.0
back: float = 0.0
left: float = 0.0
right: float = 0.0
@classmethod
def uniform(cls, value: float) -> Self:
 76    @classmethod
 77    def uniform(cls, value: float) -> Self:
 78        """
 79        Create a taper with all sides set to the same value.
 80
 81        This is a convenience constructor for creating symmetric tapers where
 82        all four sides have identical taper values. Commonly used for uniform
 83        draft angles in manufacturing or symmetric scaling operations.
 84
 85        Parameters
 86        ----------
 87        value : float
 88            The taper value to apply to all four sides (front, back, left, right).
 89
 90        Returns
 91        -------
 92        Taper
 93            A new Taper instance with all sides set to the specified value.
 94
 95        Notes
 96        -----
 97        This method is equivalent to calling Taper(value, value, value, value)
 98        but provides clearer intent.
 99        """
100        return cls(front=value, back=value, left=value, right=value)

Create a taper with all sides set to the same value.

This is a convenience constructor for creating symmetric tapers where all four sides have identical taper values. Commonly used for uniform draft angles in manufacturing or symmetric scaling operations.

Parameters
  • value (float): The taper value to apply to all four sides (front, back, left, right).
Returns
  • Taper: A new Taper instance with all sides set to the specified value.
Notes

This method is equivalent to calling Taper(value, value, value, value) but provides clearer intent.

def scaled(self, factor: float) -> Taper:
102    def scaled(self, factor: float) -> "Taper":
103        """
104        Return a new Taper instance with all values scaled by a factor.
105
106        Multiplies each taper value (front, back, left, right) by the given
107        scaling factor.
108
109        Parameters
110        ----------
111        factor : float
112            The scaling factor to apply to all taper values. Values greater
113            than 1.0 increase the taper, values between 0.0 and 1.0 decrease
114            the taper, and negative values reverse the taper direction.
115
116        Returns
117        -------
118        Taper
119            A new Taper instance with all values multiplied by the factor.
120            The original instance is unchanged.
121
122        Examples
123        --------
124        Double all taper values:
125        >>> original = Taper(front=1.0, back=2.0, left=0.5, right=1.5)
126        >>> doubled = original.scaled(2.0)
127        >>> print(doubled)
128        Taper (2.00, 4.00, 1.00, 3.00)
129
130        Notes
131        -----
132        This method creates a new instance and does not modify the original
133        taper. The scaling is applied independently to each side.
134        """
135        return Taper(
136            front=self.front * factor,
137            back=self.back * factor,
138            left=self.left * factor,
139            right=self.right * factor,
140        )

Return a new Taper instance with all values scaled by a factor.

Multiplies each taper value (front, back, left, right) by the given scaling factor.

Parameters
  • factor (float): The scaling factor to apply to all taper values. Values greater than 1.0 increase the taper, values between 0.0 and 1.0 decrease the taper, and negative values reverse the taper direction.
Returns
  • Taper: A new Taper instance with all values multiplied by the factor. The original instance is unchanged.
Examples

Double all taper values:

>>> original = Taper(front=1.0, back=2.0, left=0.5, right=1.5)
>>> doubled = original.scaled(2.0)
>>> print(doubled)
Taper (2.00, 4.00, 1.00, 3.00)
Notes

This method creates a new instance and does not modify the original taper. The scaling is applied independently to each side.

def clamp(self, min_value: float, max_value: float) -> Taper:
142    def clamp(self, min_value: float, max_value: float) -> "Taper":
143        """
144        Clamp all taper values to the specified range.
145
146        Constrains each taper value to lie within [min_value, max_value] by
147        setting values below `min_value` to `min_value` and values above `max_value`
148        to `max_value`.
149
150        Parameters
151        ----------
152        min_value : float
153            The minimum allowed value for any taper side. Values below this
154            threshold will be set to min_value.
155        max_value : float
156            The maximum allowed value for any taper side. Values above this
157            threshold will be set to max_value.
158
159        Returns
160        -------
161        Taper
162            A new Taper instance with all values clamped to the specified range.
163            The original instance is unchanged.
164
165        Raises
166        ------
167        ValueError
168            If min_value > max_value (invalid range).
169
170        Examples
171        --------
172        Clamp to symmetric range:
173        >>> extreme_taper = Taper(front=-10.0, back=10.0, left=0.0, right=5.0)
174        >>> balanced = extreme_taper.clamp(-3.0, 3.0)
175        >>> print(balanced)
176        Taper (-3.00, 3.00, 0.00, 3.00)
177
178        Notes
179        -----
180        This method applies the standard clamping operation:
181        min(max(value, min_value), max_value) to each taper side independently.
182        """
183        if min_value > max_value:
184            raise ValueError(f"min_value ({min_value}) must be <= max_value ({max_value})")
185
186        return Taper(
187            front=min(max(self.front, min_value), max_value),
188            back=min(max(self.back, min_value), max_value),
189            left=min(max(self.left, min_value), max_value),
190            right=min(max(self.right, min_value), max_value),
191        )

Clamp all taper values to the specified range.

Constrains each taper value to lie within [min_value, max_value] by setting values below min_value to min_value and values above max_value to max_value.

Parameters
  • min_value (float): The minimum allowed value for any taper side. Values below this threshold will be set to min_value.
  • max_value (float): The maximum allowed value for any taper side. Values above this threshold will be set to max_value.
Returns
  • Taper: A new Taper instance with all values clamped to the specified range. The original instance is unchanged.
Raises
  • ValueError: If min_value > max_value (invalid range).
Examples

Clamp to symmetric range:

>>> extreme_taper = Taper(front=-10.0, back=10.0, left=0.0, right=5.0)
>>> balanced = extreme_taper.clamp(-3.0, 3.0)
>>> print(balanced)
Taper (-3.00, 3.00, 0.00, 3.00)
Notes

This method applies the standard clamping operation: min(max(value, min_value), max_value) to each taper side independently.

metrics: capistry.compare.MetricLayout[typing.Self]
193    @property
194    def metrics(self) -> MetricLayout[Self]:
195        """
196        Expose taper values through the `capistry.Comparable` system for comparison.
197
198        Provides a structured interface for accessing taper values through
199        the `capistry.Comparable` system.
200
201        Returns
202        -------
203        MetricLayout
204            A metric layout containing a single "Taper" group with four metrics
205            representing the front, back, left, and right taper values.
206        """
207        return MetricLayout(
208            owner=self,
209            groups=(
210                MetricGroup(
211                    "Taper",
212                    (
213                        Metric("Front", lambda: self.front, "°"),
214                        Metric("Back", lambda: self.back, "°"),
215                        Metric("Left", lambda: self.left, "°"),
216                        Metric("Right", lambda: self.right, "°"),
217                    ),
218                ),
219            ),
220        )

Expose taper values through the capistry.Comparable system for comparison.

Provides a structured interface for accessing taper values through the capistry.Comparable system.

Returns
  • MetricLayout: A metric layout containing a single "Taper" group with four metrics representing the front, back, left, and right taper values.
@dataclass
class TrapezoidCap(capistry.Cap):
864@dataclass
865class TrapezoidCap(Cap):
866    """
867    Keycap with trapezoidal profile.
868
869    A symmetric keycap where the top surface is wider than the base, with the width
870    expansion determined by the angle parameter.
871
872    Parameters
873    ----------
874    angle : float, default=0
875        Angle in degrees defining the trapezoid slope. Positive values
876        create wider tops, zero creates rectangular profile. The angle
877        determines how much the top edge expands beyond the base width.
878
879    Attributes
880    ----------
881    All attributes inherited from Cap, plus:
882
883    angle : float
884        The trapezoid angle in degrees.
885    metrics : MetricLayout[Self]
886        Extended metrics including the trapezoid angle parameter.
887    """
888
889    angle: float = 0
890
891    def __post_init__(self) -> None:
892        """Initialize the trapezoid keycap with logging."""
893        logger.info("Creating %s", type(self).__name__, extra={"angle": self.angle})
894        return super().__post_init__()
895
896    @override
897    def _draw_outline(self) -> Sketch:
898        """
899        Draw the trapezoidal outline.
900
901        Creates a symmetric trapezoid where the top edge is wider than the
902        bottom edge by an amount determined by the angle parameter.
903
904        Returns
905        -------
906        Sketch
907            2D sketch of the trapezoidal outline.
908
909        Notes
910        -----
911        The trapezoid is constructed with the bottom edge as the base and
912        the top edge expanded symmetrically on both sides.
913        """
914        logger.debug("Drawing outline of %s", type(self).__name__)
915
916        width_top = 2 * self.length * tan(radians(self.angle)) + self.width
917
918        with BuildSketch():
919            with BuildLine():
920                l1 = Line((0, 0), (width_top, 0))
921                l2 = PolarLine(
922                    start=l1 @ 1,
923                    length=-self.length,
924                    angle=90 - self.angle,
925                    length_mode=LengthMode.VERTICAL,
926                )
927                l3 = Line(l2 @ 1, ((l2 @ 1) - Vector(self.width)))
928                Line(l3 @ 1, l1 @ 0)
929            return make_face()
930
931    @property
932    @override
933    def metrics(self) -> MetricLayout[Self]:
934        """
935        Expose metrics including trapezoid angle.
936
937        Returns
938        -------
939        MetricLayout[Self]
940            Metrics with trapezoid-specific parameters.
941        """
942        return MetricLayout(
943            owner=self,
944            groups=(
945                MetricGroup(
946                    "Trapezoid", metrics=(Metric("Angle", lambda: self.angle, "°"),), order=-1
947                ),
948                *super().metrics.groups,
949            ),
950        )
951
952    @override
953    def __str__(self) -> str:
954        """Return string representation including angle."""
955        return f"{super().__str__()} ({self.angle}°)"

Keycap with trapezoidal profile.

A symmetric keycap where the top surface is wider than the base, with the width expansion determined by the angle parameter.

Parameters
  • angle (float, default=0): Angle in degrees defining the trapezoid slope. Positive values create wider tops, zero creates rectangular profile. The angle determines how much the top edge expands beyond the base width.
Attributes
  • All attributes inherited from Cap, plus:
  • angle (float): The trapezoid angle in degrees.
  • metrics (MetricLayout[Self]): Extended metrics including the trapezoid angle parameter.
TrapezoidCap( width: float = 18, length: float = 18, height: float = 4, wall: float = 1, roof: float = 1, taper: Taper = <factory>, surface: Surface | None = None, stem: Stem = <factory>, fillet_strategy: FilletStrategy = <factory>, angle: float = 0)
angle: float = 0
metrics: capistry.compare.MetricLayout[typing.Self]
931    @property
932    @override
933    def metrics(self) -> MetricLayout[Self]:
934        """
935        Expose metrics including trapezoid angle.
936
937        Returns
938        -------
939        MetricLayout[Self]
940            Metrics with trapezoid-specific parameters.
941        """
942        return MetricLayout(
943            owner=self,
944            groups=(
945                MetricGroup(
946                    "Trapezoid", metrics=(Metric("Angle", lambda: self.angle, "°"),), order=-1
947                ),
948                *super().metrics.groups,
949            ),
950        )

Expose metrics including trapezoid angle.

Returns
  • MetricLayout[Self]: Metrics with trapezoid-specific parameters.
def fillet_safe( objects: build123d.topology.one_d.Edge | build123d.topology.zero_d.Vertex | Iterable[build123d.topology.one_d.Edge | build123d.topology.zero_d.Vertex], radius: float, threshold: float = 1e-06, err: bool = True) -> build123d.topology.composite.Sketch | build123d.topology.composite.Part | build123d.topology.composite.Curve | None:
 88def fillet_safe(
 89    objects: ChamferFilletType | Iterable[ChamferFilletType],
 90    radius: float,
 91    threshold: float = 1e-6,
 92    err: bool = True,
 93) -> Sketch | Part | Curve | None:
 94    """
 95    Safely apply a fillet to objects with error handling.
 96
 97    Attempts to apply a fillet operation to the specified `ChamferFilletType`s (i.e. edges).
 98    Only applies the fillet if it is above the threshold.
 99
100    Parameters
101    ----------
102    objects : ChamferFilletType or Iterable[ChamferFilletType]
103        The Build123d objects (edges, faces) to fillet.
104    radius : float
105        The fillet radius in millimeters.
106    threshold : float, default=1e-6
107        The minimum radius required to attempt a fillet operation.
108        Values less than or equal to this will skip the operation.
109    err : bool, default=True
110        Whether to raise FilletError on failure. If False, returns None on failure.
111
112    Returns
113    -------
114    Sketch, Part, Curve, or None
115        The filleted object on success, or None if radius is too small or
116        operation fails with err=False.
117
118    Raises
119    ------
120    FilletError
121        When fillet operation fails and err=True.
122    """
123    if radius > threshold:
124        try:
125            return fillet(objects=objects, radius=radius)
126        except Exception as e:
127            logger.exception(
128                "Failed to apply fillet",
129                extra={
130                    "radius": radius,
131                    "object_type": type(objects).__name__,
132                },
133                exc_info=e,
134            )
135            if err:
136                raise FilletError(radius, type(objects).__name__, e) from e
137    else:
138        logger.debug(
139            "Radius %.6f is below threshold %.6f — skipping fillet",
140            radius,
141            threshold,
142        )
143
144    return None

Safely apply a fillet to objects with error handling.

Attempts to apply a fillet operation to the specified ChamferFilletTypes (i.e. edges). Only applies the fillet if it is above the threshold.

Parameters
  • objects (ChamferFilletType or Iterable[ChamferFilletType]): The Build123d objects (edges, faces) to fillet.
  • radius (float): The fillet radius in millimeters.
  • threshold (float, default=1e-6): The minimum radius required to attempt a fillet operation. Values less than or equal to this will skip the operation.
  • err (bool, default=True): Whether to raise FilletError on failure. If False, returns None on failure.
Returns
  • Sketch, Part, Curve, or None: The filleted object on success, or None if radius is too small or operation fails with err=False.
Raises
  • FilletError: When fillet operation fails and err=True.
def init_logger( level=20, fmt: str = '%(asctime)s | %(name)s | %(levelname)s | %(message)s', datefmt: str = '%Y-%m-%d %H:%M:%S') -> logging.Logger:
 93def init_logger(level=logging.INFO, fmt: str = FORMAT, datefmt: str = DATEFMT) -> logging.Logger:
 94    """
 95    Initialize enhanced logging with RichHandler and ExtraFormatter.
 96
 97    Sets up logging with Rich's colorized output and structured logging support.
 98    Not required but provides a simple way to add logging when using Capistry.
 99
100    Parameters
101    ----------
102    level : int, default=logging.INFO
103        Logging level threshold.
104    fmt : str, default=FORMAT
105        Format string for the log message.
106    datefmt : str, default=DATEFMT
107        Date format string.
108    """
109    formatter = ExtraFormatter(fmt=fmt, datefmt=datefmt)
110    handler = RichHandler(rich_tracebacks=True)
111    handler.setFormatter(formatter)
112    logger = _get_pkg_logger()
113    logger.setLevel(level)
114    logger.handlers.clear()
115    logger.addHandler(handler)
116    return logger

Initialize enhanced logging with RichHandler and ExtraFormatter.

Sets up logging with Rich's colorized output and structured logging support. Not required but provides a simple way to add logging when using Capistry.

Parameters
  • level (int, default=logging.INFO): Logging level threshold.
  • fmt (str, default=FORMAT): Format string for the log message.
  • datefmt (str, default=DATEFMT): Date format string.