Hero image for Building a Component Registry for Parametric CAD with Python

Building a Component Registry for Parametric CAD with Python


When building parametric CAD systems, one of the most frustrating challenges is managing components from multiple sources. Your project might need custom-designed parts, standardized fasteners from a library, electronic components for enclosure design, and community-contributed models from a package manager. Without a unified interface, you end up with inconsistent APIs, scattered imports, and code that becomes increasingly difficult to maintain.

This article explores how to solve this problem using the Registry pattern - a design approach that aggregates multiple component sources behind a single, consistent interface. The code examples come from a real-world parametric CAD system built with CadQuery, demonstrating how this pattern handles everything from M3 screws to drone motors to Raspberry Pi boards.

By the end, you will understand how to design a component registry that supports lazy loading, LRU caching, and graceful degradation when optional dependencies are missing.

The problem with multiple component sources

Consider a typical parametric CAD project. You might have:

  • Custom components defined in Python files (motors, frames, brackets)
  • cq_warehouse providing ISO/DIN-compliant fasteners and bearings
  • cq_electronics with PCB footprints and electronic enclosures
  • PartCAD as a package manager accessing community-contributed models

Each source has its own API:

inconsistent-apis.py
# Custom component - function call
from scripts.components import COMPONENTS
motor = COMPONENTS["motor_2207"]["func"](**COMPONENTS["motor_2207"]["args"])
# cq_warehouse - class instantiation
from cq_warehouse.fastener import SocketHeadCapScrew
screw = SocketHeadCapScrew(size="M3-0.5", length=10, fastener_type="iso4762")
geometry = screw.cq_object
# cq_electronics - different return types
from cq_electronics.boards import RPi3b
rpi = RPi3b() # Returns Assembly, not Workplane
# PartCAD - hierarchical paths and context management
import partcad as pc
ctx = pc.init()
bolt = ctx.get_part("//pub/std/metric/cqwarehouse:fastener/hexhead-iso4017")

This inconsistency creates several problems:

  1. Cognitive overhead - developers must remember different APIs for each source
  2. Tight coupling - code depends directly on specific libraries
  3. No caching - repeated access to the same component rebuilds geometry
  4. Brittle dependencies - missing optional libraries break the entire application

Designing the ComponentSource abstraction

The first step is defining an abstract interface that all component sources must implement. This follows the Dependency Inversion Principle: depend on abstractions, not concrete implementations.

Component registry architecture showing unified access to multiple CAD libraries

registry.py
from abc import ABC, abstractmethod
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
@dataclass
class ComponentSpec:
"""Metadata about a component without geometry."""
name: str
source: str
category: str
params: dict[str, Any]
description: str = ""
class ComponentSource:
"""Abstract interface for component sources."""
@property
def name(self) -> str:
"""Source identifier (e.g., 'cq_warehouse', 'custom')."""
raise NotImplementedError
def list_components(self) -> Iterator[ComponentSpec]:
"""Yield all available component specs from this source."""
raise NotImplementedError
def get_component(self, name: str, **params: Any) -> Component:
"""Load a component by name with optional parameters."""
raise NotImplementedError
def search(self, query: str) -> Iterator[ComponentSpec]:
"""Search components by name/description."""
query_lower = query.lower()
for spec in self.list_components():
if query_lower in spec.name.lower() or query_lower in spec.description.lower():
yield spec

The ComponentSpec dataclass separates metadata from geometry - you can browse the catalog without loading heavy 3D models. The default search() implementation provides basic filtering that sources can override for more sophisticated queries.

Implementing source adapters

Each component source needs an adapter that implements the ComponentSource interface. Here is the adapter for cq_warehouse fasteners:

warehouse.py
from semicad.core.component import Component, ComponentSpec
from semicad.core.registry import ComponentSource
FASTENER_CLASSES = {
"SocketHeadCapScrew": ("iso4762", "Socket head cap screw (ISO 4762)"),
"ButtonHeadScrew": ("iso7380_1", "Button head screw (ISO 7380-1)"),
"HexHeadScrew": ("iso4014", "Hex head screw (ISO 4014)"),
"HexNut": ("iso4032", "Hex nut (ISO 4032)"),
}
class WarehouseSource(ComponentSource):
"""Source for cq_warehouse components."""
def __init__(self) -> None:
self._fasteners: dict[str, tuple[type, str, str]] = {}
self._load_fasteners()
@property
def name(self) -> str:
return "cq_warehouse"
def _load_fasteners(self) -> None:
"""Load fastener classes from cq_warehouse."""
try:
from cq_warehouse import fastener as f
for class_name, (default_type, desc) in FASTENER_CLASSES.items():
if hasattr(f, class_name):
cls = getattr(f, class_name)
self._fasteners[class_name] = (cls, default_type, desc)
except ImportError:
pass # cq_warehouse not installed
def get_component(self, name: str, **params: Any) -> Component:
if name in self._fasteners:
cls, default_type, desc = self._fasteners[name]
size = params.get("size", "M3-0.5")
length = params.get("length")
fastener_type = params.get("fastener_type", default_type)
spec = ComponentSpec(
name=f"{name}_{size}_{length}mm" if length else f"{name}_{size}",
source=self.name,
category="fastener",
description=desc,
)
return WarehouseFastener(spec, cls, fastener_type, size, length)
raise KeyError(f"Component not found in cq_warehouse: {name}")

The adapter handles the translation between our unified interface and cq_warehouse’s specific API. The try/except ImportError pattern allows the system to work even when cq_warehouse is not installed.

The central registry with LRU caching

The ComponentRegistry class aggregates all sources and provides caching for frequently accessed components:

LRU cache visualization showing component geometry storage and retrieval optimization

registry.py
from collections import OrderedDict
def _make_cache_key(name: str, params: dict[str, Any]) -> str:
"""Generate a deterministic cache key from name and parameters."""
if not params:
return name
sorted_items = sorted(params.items())
params_str = ",".join(f"{k}={v!r}" for k, v in sorted_items)
return f"{name}:{params_str}"
class ComponentRegistry:
"""Central registry for all component sources."""
DEFAULT_CACHE_SIZE = 128
def __init__(self, cache_size: int | None = None):
self._sources: dict[str, ComponentSource] = {}
self._cache: OrderedDict[str, Component] = OrderedDict()
self._cache_max_size = cache_size or self.DEFAULT_CACHE_SIZE
self._cache_hits = 0
self._cache_misses = 0
def register_source(self, source: ComponentSource) -> None:
"""Register a new component source."""
self._sources[source.name] = source
def get(self, full_name: str, use_cache: bool = True, **params: Any) -> Component:
"""Get component by name with optional caching."""
cache_key = _make_cache_key(full_name, params)
if use_cache and cache_key in self._cache:
self._cache_hits += 1
self._cache.move_to_end(cache_key) # LRU ordering
return self._cache[cache_key]
self._cache_misses += 1
component = self._get_uncached(full_name, **params)
if use_cache:
self._cache[cache_key] = component
self._cache.move_to_end(cache_key)
while len(self._cache) > self._cache_max_size:
self._cache.popitem(last=False) # Evict oldest
return component

The caching strategy uses Python’s OrderedDict to implement LRU (Least Recently Used) eviction. Cache keys incorporate both the component name and parameters, ensuring that get("SocketHeadCapScrew", size="M3-0.5", length=10) caches separately from get("SocketHeadCapScrew", size="M4-0.7", length=16).

Pro Tip: The use_cache=False parameter allows forcing a fresh instance when needed, useful for testing or when geometry must not be shared between assemblies.

Graceful degradation with optional dependencies

The registry initialization demonstrates how to handle optional dependencies gracefully:

registry.py
def _init_default_sources(registry: ComponentRegistry) -> None:
"""Initialize registry with default sources."""
from semicad.sources import custom, electronics, partcad_source, warehouse
# Always available - built-in components
try:
registry.register_source(custom.CustomSource())
except (ImportError, OSError, RuntimeError) as e:
logging.debug("Custom source not available: %s", e)
# Optional - requires cq_warehouse
try:
registry.register_source(warehouse.WarehouseSource())
except ImportError:
pass # Expected if not using fasteners
# Optional - requires cq_electronics
try:
registry.register_source(electronics.ElectronicsSource())
except ImportError:
pass # Expected if not using electronics
# Optional - requires partcad
try:
registry.register_source(partcad_source.PartCADSource())
except ImportError:
pass # Expected if not using package manager

This pattern ensures the registry works with any combination of installed dependencies. A minimal installation with only CadQuery can still use custom components. Adding cq_warehouse enables fasteners. The system never crashes due to missing optional libraries.

Practical usage examples

With the registry in place, accessing any component uses the same interface:

usage-examples.py
from semicad import get_registry
registry = get_registry()
# Custom drone motor
motor = registry.get("motor_2207")
motor_geometry = motor.geometry # Lazy-loads on first access
# ISO 4762 socket head cap screw (M3 x 10mm)
screw = registry.get("SocketHeadCapScrew", size="M3-0.5", length=10)
# Raspberry Pi 3B for enclosure design
rpi = registry.get("RPi3b")
print(rpi.dimensions) # (85.0, 56.0, 1.5)
print(rpi.mounting_holes) # [(24.5, 19.0), (-24.5, -39.0), ...]
# PartCAD community model
bolt = registry.get(
"//pub/std/metric/cqwarehouse:fastener/hexhead-iso4017",
size="M10-1.5",
length=30
)
# Search across all sources
for spec in registry.search("motor"):
print(f"{spec.source}/{spec.category}/{spec.name}")

The Component class provides lazy geometry loading - the 3D model is only built when you access the geometry property. This means browsing the catalog is fast, and expensive CAD operations are deferred until actually needed.

Cache performance monitoring

The registry tracks cache statistics to help tune performance:

cache-stats.py
from dataclasses import dataclass
@dataclass
class CacheStats:
"""Statistics about component cache usage."""
hits: int
misses: int
size: int
max_size: int
@property
def hit_rate(self) -> float:
"""Cache hit rate as a percentage."""
total = self.hits + self.misses
return (self.hits / total * 100) if total > 0 else 0.0
# Monitor cache performance
stats = registry.cache_stats()
print(f"Cache hit rate: {stats.hit_rate:.1f}%")
print(f"Cache size: {stats.size}/{stats.max_size}")
# Clear cache when memory constrained
cleared = registry.clear_cache()
print(f"Cleared {cleared} cached components")

In practice, a cache size of 128 components works well for most projects. Assemblies that repeatedly access the same fastener (think: 20 identical screws) see significant speedups from caching.

Architecture overview

The complete architecture follows a layered design:

┌─────────────────────────────────────────────────────────┐
│ Application Code │
│ registry.get("motor_2207") │
└─────────────────────────┬───────────────────────────────┘
┌─────────────────────────▼───────────────────────────────┐
│ ComponentRegistry │
│ - Aggregates sources - LRU caching │
│ - Unified get() API - Search across all │
└───────┬─────────┬─────────┬─────────┬───────────────────┘
│ │ │ │
┌───────▼───┐ ┌───▼───┐ ┌───▼───┐ ┌───▼───────┐
│ Custom │ │ Ware- │ │ Elec- │ │ PartCAD │
│ Source │ │ house │ │ tronics│ │ Source │
│ │ │ Source│ │ Source │ │ │
└───────────┘ └───────┘ └────────┘ └───────────┘
│ │ │ │
┌────▼────┐ ┌─────▼────┐ ┌───▼────┐ ┌────▼──────┐
│scripts/ │ │cq_ware- │ │cq_elec-│ │Community │
│compo- │ │house │ │tronics │ │CAD Repo │
│nents.py │ │library │ │library │ │(network) │
└─────────┘ └──────────┘ └────────┘ └───────────┘

Each layer has a single responsibility:

  • Application code uses the unified get() API
  • ComponentRegistry handles caching and source aggregation
  • Source adapters translate between the registry interface and specific libraries
  • Underlying libraries provide the actual CAD geometry

Key takeaways

Building a component registry for parametric CAD taught several valuable lessons:

  • The Registry pattern provides a clean solution for aggregating multiple component sources behind a unified interface
  • Lazy loading defers expensive geometry generation until actually needed, keeping catalog browsing fast
  • LRU caching with deterministic keys (incorporating parameters) significantly speeds up assemblies with repeated components
  • Graceful degradation through try/except around source initialization allows the system to work with any subset of optional dependencies
  • Separating specs from geometry via ComponentSpec enables fast catalog browsing without loading 3D models

The pattern scales well - adding a new component source requires only implementing the ComponentSource interface. The registry, caching, and search functionality work automatically with any conforming source.

For projects combining custom designs with standard parts libraries, this architecture eliminates the cognitive overhead of multiple APIs while providing performance optimizations that matter for complex assemblies.