capistry
Capistry
A Python package for parametric 3D modeling of keyboard keycaps using build123d.
Table of Contents
- Overview
- Installation
- Quick Start
- Features
- Documentation
- Examples
- License
- Contributing
- Acknowledgments
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")
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)
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]
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)
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.
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.
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.
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.
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
.
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
.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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))
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
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
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.
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.
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.
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")
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)
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)
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")
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.
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.
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.
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.
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.
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.
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).
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
.
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.
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.
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.
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.
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.
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.
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.
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.
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)
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.
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
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.
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
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.
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)
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)
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.
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.
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.
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.
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.
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.
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.
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.
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
orCenterOf.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.
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.
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
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.
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.
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)
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]]
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)
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
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)
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)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 ChamferFilletType
s (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.
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.