Shape Editing Architecture#

Internal architecture of the defeaturing module for developers.

Note

This document is intended for developers who want to extend the shape_editing module or understand its internal algorithms. For user-facing documentation, see the Shape Editing.

Shape Editing Architecture#

This document provides internal documentation for developers working on or extending the shape_editing module. It covers the architecture, key algorithms, and extension patterns.

Module Structure#

volmdlr_tools/shape_editing/
├── __init__.py              # Public API exports
├── history_graph.py         # DAG-based history tracking
└── defeaturing/
    ├── __init__.py          # Submodule exports
    ├── defeaturer.py        # High-level fluent API
    ├── face.py              # FaceRemover (base utility)
    ├── feature.py           # FeatureRemover (unified)
    ├── isolated_feature.py  # Soft feature removal
    ├── complex_feature.py   # Hard feature removal
    ├── blend_chain.py       # Blend chain removal
    └── incremental_blend.py # Iterative blend removal

Layer Responsibilities#

High Level (defeaturer.py):

  • User-facing API

  • Operation orchestration

  • History management

  • Fluent interface

Mid Level (feature.py, blend_chain.py, incremental_blend.py):

  • Feature-type-specific logic

  • Strategy selection (soft vs hard)

  • Chain detection for blends

Low Level (face.py, isolated_feature.py, complex_feature.py):

  • Direct OpenCascade operations

  • BRepTools_ReShape for soft removal

  • BRepAlgoAPI_Defeaturing for hard removal

Design Principles#

1. Layered Abstraction#

Each layer has a single responsibility:

User Code
    │
    ▼
Defeaturer (orchestration + history)
    │
    ▼
FeatureRemover (unified interface)
    │
    ├──► IsolatedFeatureRemover (soft)
    │         │
    │         ▼
    │    FaceRemover (BRepTools_ReShape)
    │
    └──► ComplexFeatureRemover (hard)
              │
              ▼
         BRepAlgoAPI_Defeaturing (OCC)

2. Automatic Fallback#

FeatureRemover implements a fallback strategy:

def remove_features(self, face_indices, try_complex=True):
    # Step 1: Try soft removal
    soft_remover = IsolatedFeatureRemover(...)
    soft_remover.remove_features(face_indices)

    # Step 2: Check for unremoved faces
    if soft_remover.unremoved_faces and try_complex:
        # Step 3: Fall back to hard removal
        complex_remover = ComplexFeatureRemover(...)
        complex_remover.remove_features(list(soft_remover.unremoved_faces))

3. History Preservation#

Every remover preserves OpenCascade’s BRepTools_History:

class SomeRemover:
    @property
    def history(self) -> BRepTools_History:
        return self._history

The Defeaturer concatenates these into a unified HistoryGraph.

4. Lazy AAG Initialization#

The AttributedAdjacencyGraph is expensive to compute. All removers follow the lazy pattern:

def __init__(self, shape, aag=None):
    self._shape = shape
    self._aag = aag  # May be None

@property
def aag(self):
    if self._aag is None:
        self._aag = AttributedAdjacencyGraph(shape=self._shape)
    return self._aag

5. Fluent Interface#

Defeaturer returns self from all operations:

def remove_cavities(self) -> "Defeaturer":
    # ... implementation ...
    return self

Enabling: Defeaturer(solid).remove_cavities().remove_blends().result

Key Algorithms#

Soft vs Hard Feature Detection#

The classify_insertion_type() function in isolated_feature.py determines how a feature face is inserted into a master face:

def classify_insertion_type(feature_face, master_face) -> InsertionType:
    # 1. Find common edges
    common_edges = find_common_edges(feature_face, master_face)
    if not common_edges:
        return InsertionType.UNDEFINED

    # 2. Get master's outer wire
    outer_wire = ShapeAnalysis.OuterWire(master_face)

    # 3. For each common edge, check if it's in an inner wire
    for edge in common_edges:
        if not is_edge_in_inner_wire(edge, master_face, outer_wire):
            return InsertionType.HARD

    return InsertionType.SOFT

Soft insertion: All common edges lie on inner wires (holes/openings). Hard insertion: At least one common edge touches the outer boundary.

History Concatenation#

The concatenate() method in HistoryGraph merges two histories:

Before concatenation:

History 1:          History 2:
  A ─► A'             A' ─► A''
  B ─► B'             B' ─► B''
                      C  ─► C'  (new root)

After concatenation:

  A ─► A' ─► A''
  B ─► B' ─► B''
  C ─► C'

Algorithm:

  1. Get leaves of history 1 (nodes with no outgoing edges)

  2. Get roots of history 2

  3. Match using shape.IsPartner() (topological identity)

  4. For matches: copy history 2’s subtree as children of history 1’s leaf

  5. For unmatched roots: add as new roots to history 1

def concatenate(self, other):
    this_leaves = self.get_leaves()
    other_roots = other.roots

    for leaf_node_id, leaf_shape in this_leaves:
        if is_deleted(leaf_node_id):
            continue

        for root_id in other_roots:
            root_shape = other.get_shape(root_id)
            if leaf_shape.IsPartner(root_shape):
                # Merge: copy root's children to leaf
                self._deep_copy_subtree(other, root_id, leaf_node_id)
                merged_roots.add(root_id)
                break

    # Add unmerged roots as new roots
    for root_id in other_roots:
        if root_id not in merged_roots:
            new_root = self._deep_copy_subtree(other, root_id, None)
            self._roots.append(new_root)

Blend Chain Suppressibility#

BlendChainRemover validates that a blend chain can be suppressed:

Condition 1: Angular span For cylindrical blends, the angular span must be ≤ 180°:

def is_blend_suppressible(face):
    if is_cylindrical(face):
        angular_span = compute_angular_span(face)
        if angular_span > math.pi:
            return False
    return True

Condition 2: Cross-edge reference count Edges between blend faces must be referenced exactly twice (by two blend faces):

def check_cross_edges(blend_chain, aag):
    for edge in get_cross_edges(blend_chain):
        ref_count = count_blend_references(edge, blend_chain, aag)
        if ref_count != 2:
            return False
    return True

OpenCascade Integration#

BRepTools_ReShape#

Used by FaceRemover for soft feature removal:

from OCP.BRepTools import BRepTools_ReShape

reshaper = BRepTools_ReShape()
for face in faces_to_remove:
    reshaper.Remove(face)
result = reshaper.Apply(original_shape)

BRepAlgoAPI_Defeaturing#

Used by ComplexFeatureRemover for hard features:

from OCP.BRepAlgoAPI import BRepAlgoAPI_Defeaturing

defeaturer = BRepAlgoAPI_Defeaturing()
defeaturer.SetShape(shape)
for face in faces_to_remove:
    defeaturer.AddFaceToRemove(face)
defeaturer.Build()
result = defeaturer.Shape()
history = defeaturer.History()

This algorithm:

  1. Extends surrounding surfaces

  2. Intersects them to create new boundary edges

  3. Rebuilds the solid with the new topology

BRepTools_History#

Tracks topological modifications:

history = defeaturer.History()

# Check if a shape was removed
if history.IsRemoved(face):
    print("Face was deleted")

# Get modified images
modified_list = history.Modified(face)
for new_face in modified_list:
    print(f"Face became: {new_face}")

# Get generated shapes
generated_list = history.Generated(face)

Extending the Module#

Adding a New Feature Remover#

Follow this template:

class MyFeatureRemover:
    """Remove my-type features from a shape."""

    def __init__(
        self,
        shape: Union[Solid, Shell],
        aag: Optional[AttributedAdjacencyGraph] = None,
    ):
        self._shape = shape
        self._aag = aag
        self._result: Optional[Union[Solid, Shell]] = None
        self._history: Optional[BRepTools_History] = None

    @property
    def aag(self) -> AttributedAdjacencyGraph:
        """Lazy AAG initialization."""
        if self._aag is None:
            self._aag = AttributedAdjacencyGraph(shape=self._shape)
        return self._aag

    @property
    def result(self) -> Optional[Union[Solid, Shell]]:
        """Get the resulting shape after removal."""
        return self._result

    @property
    def history(self) -> Optional[BRepTools_History]:
        """Get the OCC history for this operation."""
        return self._history

    def remove_features(self, face_indices: list[int]) -> bool:
        """
        Remove features specified by face indices.

        :param face_indices: List of 0-based face indices.
        :return: True if successful.
        """
        # Your implementation here
        # 1. Validate inputs
        # 2. Perform removal using OCC
        # 3. Set self._result and self._history
        # 4. Return success status
        pass

Integrating with Defeaturer#

To add a new operation to Defeaturer:

def remove_my_features(self, **params) -> "Defeaturer":
    """
    Remove my-type features from the current shape.

    :return: Self for method chaining.
    """
    # 1. Optionally extract features using FeatureProcessor
    # 2. Use self._perform_removal() or implement custom logic
    # 3. Return self

    # Example using existing infrastructure:
    processor = FeatureProcessor(shape=self._current_shape)
    processor.extract_my_features()  # Your extraction method
    self._perform_removal("remove_my_features", processor.my_features, **params)
    return self

    # Or custom implementation:
    faces_before = self._ensure_aag().map_faces.Extent()
    shape_before = self._current_shape.wrapped

    remover = MyFeatureRemover(shape=self._current_shape, aag=self._aag)
    if remover.remove_features(face_indices):
        self._current_shape = remover.result
        self._invalidate_aag()
        self._record_step(
            "remove_my_features",
            features_removed=len(face_indices),
            faces_before=faces_before,
            faces_after=self._ensure_aag().map_faces.Extent(),
            shape_before=shape_before,
            history=remover.history,
        )

    return self

Adding History Support#

To track history in your remover:

  1. Store the BRepTools_History from OCC operations

  2. Expose it via a history property

  3. The Defeaturer will automatically concatenate it

def remove_features(self, face_indices):
    # ... perform operation ...

    # Capture history from OCC
    self._history = occ_operation.History()

    return success

Performance Considerations#

  1. AAG caching: Always pass aag parameter when available to avoid recomputation.

  2. Batch operations: Remove multiple features in one call rather than one at a time.

  3. Soft-first strategy: The fallback to hard removal is automatic but slower. If you know features are soft, use IsolatedFeatureRemover directly.

  4. Incremental vs single-pass: For remove_blends(), single-pass (incremental=False) is faster but may fail on complex geometries.

See Also#