Section Line Indicator Detection Pipeline#

This document describes the internal detection pipeline used by ViewSectionLineDetector to find section line indicators (renvois de vues) in technical drawings. It is intended for developers working on or extending the detection logic.

Note

For user-facing documentation on working with section line indicators, see Featured Classes Guide.

What is a Section Line Indicator?#

A section line indicator is a symbol on a technical drawing that shows where a section cut or auxiliary view is taken. It consists of:

  • Two collinear arrows pointing in the same direction

  • A letter identifier on each arrow (e.g., “A”, “B”, “K”)

  • Optionally, a cross-reference to a sheet number (e.g., “4/5”)

      A            A
      ↓            ↓
──────┼────────────┼──────
      ↓   4/5      ↓

These symbols indicate where section views (COUPE A-A), detail views, or auxiliary views are cut from.

Pipeline Overview#

ViewSectionLineDetector finds indicators through two independent branches, similar to how SheetTableDetector detects tables from multiple sources:

  1. from_symbolsTypeNote annotations with 2+ collinear FillArrow leaders and duplicated letter text

  2. from_compositesCompositeEntity arrows paired with nearby standalone TypeNote letter labels

ViewSectionLineDetector.detect_all()
│
├── _extract_from_symbols()     → list[SectionLineIndicator]
│   ├── ViewCollector.type.by_types(Symbol/TypeNote)  → all_notes
│   └── for each note:
│       ├── _get_valid_symbol_arrow_pair(note)  → symbol_arrows | None
│       │   ├── _filter_visible_symbol_arrows(note)  → remove ghosts
│       │   ├── _has_exactly_two(symbol_arrows)
│       │   └── _are_collinear(symbol_arrows)
│       ├── get_text_primitives(note)  → text_primitives | None
│       ├── _extract_duplicated_identifier_info(text_primitives)
│       │   ├── match_identifier_texts(text_primitives)
│       │   └── _find_first_duplicate()
│       ├── _complete_identifier_info_with_duplicated_xref()
│       │   └── _extract_duplicated_standalone_xref()
│       └── SectionLineIndicator(symbol_arrows + identifier_info)
│
└── _extract_from_composites()  → list[SectionLineIndicator]
    ├── Step 1: _get_composite_arrow_candidates()
    ├── Step 2: _collect_identifier_notes()
    │   └── match_text_against_pattern()
    ├── Step 3: _match_arrows_to_identifiers()  → list[_IdentifiedArrow]
    │   └── _find_nearest_arrow()
    ├── Step 4: _group_identified_arrows()  → list[list[_IdentifiedArrow]]
    │   ├── _belong_to_same_section_line()
    │   │   ├── same identifier + same cross-reference
    │   │   ├── collinear directions
    │   │   └── _are_tips_on_same_perpendicular_line()
    │   └── nx.connected_components()
    └── Step 5: _build_indicators_from_groups()

Source Annotation Structure#

Understanding the source data is essential for following the detection logic.

Symbol representation (from_symbols)#

In this representation, a single Symbol annotation (entity_type="TypeNote") carries both the arrows and the text labels:

Symbol (entity_type="TypeNote")
├── annotation: Annotation
│   └── leaders: list[Leader]  (2 for a section line indicator)
│       ├── leader[0].arrow_head
│       │   ├── arrow_type: "TerminatorTypeFillArrow"
│       │   ├── location: Point2D  (arrow tip position)
│       │   └── direction: Vector2D  (arrow direction, collinear with leader[1])
│       └── leader[1].arrow_head  (same structure)
│
└── text: CompositeText
    └── get_text_primitives() → list[Text]

Text primitives are duplicated (one set per arrow/leader):

Without cross-reference (2 primitives):
  Text("E"), Text("E")

With standalone cross-reference (4 primitives):
  Text("K"), Text("4/5"), Text("K"), Text("4/5")

With empty texts (6 primitives):
  Text("G"), Text(""), Text("4/5"), Text("G"), Text(""), Text("4/5")

This duplication is why the parsing requires an identifier appearing at least twice.

Composite representation (from_composites)#

In this representation, each arrow is a separate CompositeEntity annotation and the letter labels are standalone TypeNote annotations positioned nearby:

View annotations:
├── CompositeEntity (14 geometries)  ← arrow 1
│   └── geometries: LineSegment2D × 14 (shaft + arrow head lines)
├── Symbol/TypeNote                   ← label "A" near arrow 1
│   └── text: "A"
├── CompositeEntity (14 geometries)  ← arrow 2
│   └── geometries: LineSegment2D × 14
└── Symbol/TypeNote                   ← label "A" near arrow 2
    └── text: "A"

Result Class: SectionLineIndicator#

Each detected indicator is a SectionLineIndicator (subclass of DessiaObject) with the following attributes:

Attribute

Type

Description

identifier

str

Full identifier (e.g., "A", "A2", "F1")

letter

str

Letter part only (e.g., "A", "F")

number

int | None

Numeric suffix (e.g., 2 in "A2"), or None

arrow_locations

list[Point2D]

Positions of the two arrow tips

arrow_direction

Vector2D | None

Shared direction vector of both arrows

cross_reference

str | None

Sheet cross-reference string (e.g., "4/5")

cross_reference_left

int | None

Left part of cross-reference (e.g., 4)

cross_reference_right

int | None

Right part of cross-reference (e.g., 5)

detection_source

str

Detection branch: "symbols" or "composites"

source_entities

list[Entity]

Source dessia_drawing entities. Symbols: [TypeNote]. Composites: [CompositeEntity_i, CompositeEntity_j, TypeNote_i, TypeNote_j].

Text Patterns#

Patterns reuse IDENTIFIER_PATTERN and BARE_CROSS_REFERENCE_PATTERN from default_language_configs.py. Regex group extraction uses IdentifierInfo.from_match() from title_reader.py:

from drawing_tools.config.default_language_configs import (
    BARE_CROSS_REFERENCE_PATTERN, IDENTIFIER_PATTERN,
)

# Full indicator label: "A", "B2", "K 4/5", "A2 1/3"
DEFAULT_INDICATOR_PATTERN = re.compile(
    rf"^{IDENTIFIER_PATTERN}(?:\s+{BARE_CROSS_REFERENCE_PATTERN})?$"
)

# Standalone cross-reference: "4/5", "1/3"
DEFAULT_STANDALONE_XREF_PATTERN = re.compile(
    rf"^{BARE_CROSS_REFERENCE_PATTERN}$"
)

Branch 1: from_symbols#

This branch detects section line indicators encoded as a single TypeNote annotation that carries both arrows and text labels in one entity.

Step 1.1 — Collect all TypeNote annotations#

A ViewCollector is used to find all Symbol annotations of sub-type TypeNote:

all_notes = (
    ViewCollector(view)
    .type.by_types(
        options=ViewCollectorOptions(
            target="annotations",
            include_types=["Symbol"],
            entities_subtypes={"Symbol": ["TypeNote"]},
        )
    )
    .annotations
)

Step 1.2 — Validate arrows: _get_valid_symbol_arrow_pair(note)#

Orchestrates three validation sub-methods in order:

  1. _filter_visible_symbol_arrows: extracts FillArrow leaders (filtered by self.symbol_arrow_type) and removes ghost leaders with no visual primitives (CAD conversion artifacts).

  2. _has_exactly_two: checks that exactly 2 arrows remain.

  3. _are_collinear: verifies the 2 arrow directions are collinear within self.direction_tolerance (default sin(5°) 0.087).

Returns the list of 2 valid symbol arrows, or None.

Step 1.3 — Extract identifier#

Accesses the TypeNote’s CompositeText via get_text_primitives(note) (from identifier_info.py) to get individual Text objects. Then runs a two-step process to produce an IdentifierInfo:

Step 1 — Identifier extraction (_extract_duplicated_identifier_info):

  1. match_identifier_texts(text_primitives, pattern) (from identifier_info.py) matches each stripped text primitive against the indicator pattern.

  2. _find_first_duplicate() finds the first identifier appearing at least twice (e.g., two "A" or two "B2"). The duplication comes from the TypeNote structure which repeats texts once per arrow/leader.

Step 2 — Cross-reference completion (_complete_identifier_info_with_duplicated_xref):

Only called if the IdentifierInfo from Step 1 has no cross-reference. Searches for standalone n/n texts appearing at least twice via _extract_duplicated_standalone_xref.

Example TypeNote text primitives (case with standalone xref):
┌────────┐   ┌────────┐   ┌────────┐   ┌────────┐
│  "K"   │   │ "4/5"  │   │  "K"   │   │ "4/5"  │
└────────┘   └────────┘   └────────┘   └────────┘
    ↓             ↓            ↓            ↓
letter="K"   xref="4/5"  letter="K"   xref="4/5"
    └── duplicated_identifier="K" ──┘
                 └── duplicated xref ──┘

Step 1.4 — Build SectionLineIndicator#

If both symbol_arrows and identifier_info are available, a SectionLineIndicator is created with detection_source="symbols". If _extract_duplicated_identifier_info returns None, a warning is logged and the note is skipped.

Branch 2: from_composites#

This branch detects section line indicators where the arrows are encoded as separate CompositeEntity objects and the letter labels are standalone TypeNote annotations positioned nearby. The detection follows a four-step pipeline orchestrated by _extract_from_composites.

Step 2.1 — _get_composite_arrow_candidates()#

Returns pre-computed composite arrow candidates (from FeaturedView._composite_arrows) or collects them via collect_composite_arrows(). Each candidate is a (CompositeEntity, Point2D, Vector2D) tuple with the arrow tip and direction.

Step 2.2 — _collect_identifier_notes()#

Collects all TypeNote annotations in the view whose text matches self.indicator_pattern via match_text_against_pattern() from identifier_info.py. Returns two parallel lists: (identifier_notes, identifier_infos).

Step 2.3 — _match_arrows_to_identifiers()#

For each identifier note, computes its bounding rectangle center and finds the nearest unassigned arrow candidate via _find_nearest_arrow using BoundingRectangle.distance_to_b_rectangle (max distance: 30 units). Each arrow can only be assigned to one note (tracked via used_arrows). Returns a flat list of _IdentifiedArrow, each grouping a composite arrow entity with its matched label note and IdentifierInfo.

Step 2.4 — _group_identified_arrows()#

Builds a graph (similar to build_balloon_graph in helpers/balloon/grouping.py) where each _IdentifiedArrow is a node and edges connect arrows that satisfy _belong_to_same_section_line. Four conditions are checked:

  1. Same identifier (letter + optional number).

  2. Same cross-reference (e.g., both "2/5" or both None).

  3. Collinear directions (within self.direction_tolerance).

  4. Tips on the same perpendicular line (_are_tips_on_same_perpendicular_line): the vector tip_a→tip_b must be perpendicular to the arrow direction. This separates two indicators with the same identifier at different positions along the arrow axis.

Returns the connected components via nx.connected_components().

Step 2.5 — _build_indicators_from_groups()#

For each group of exactly 2 identified arrows, _build_indicator_from_group creates a SectionLineIndicator. Cross-references are merged from both arrows (info_i.cross_reference or info_j.cross_reference). Groups with more than 2 arrows are logged as warnings and skipped.

The resulting SectionLineIndicator has:

  • arrow_locations = tips of both arrows

  • arrow_direction = direction of the first arrow

  • detection_source = "composites"

  • source_entities = [composite_i, composite_j, label_entity_i, label_entity_j]

Configurable Parameters#

Detection parameters are grouped in SectionLineDetectionConfig (a frozen dataclass), passed to ViewSectionLineDetector.__init__(view, config=...):

SectionLineDetectionConfig field

Default

Purpose

symbol_arrow_type

"TerminatorTypeFillArrow"

Arrow type string to filter relevant leaders

direction_tolerance

0.09

Tolerance for is_colinear_to() (≈ sin(5°))

min_leaders_for_indicator

2

Expected number of arrows per indicator

max_label_arrow_distance

30.0

Maximum bounding-rectangle distance between label and arrow

indicator_pattern

(compiled regex)

Pattern for indicator label matching (letter + optional number + optional xref)

standalone_xref_pattern

(compiled regex)

Pattern for standalone cross-reference matching (n/n)

Additional __init__ parameters (not in the config):

Parameter

Default

Purpose

overlay_colors

8-color palette

Colors for overlay visualization

composite_arrow_candidates

None

Pre-collected arrows from collect_composite_arrows()

Visualization & Overlay#

Each SectionLineIndicator produces overlay primitives via overlay_primitives(color):

  1. Computes a bounding rectangle from arrow locations and source entities

  2. Delegates to build_overlay_primitives() (shared helper) to create:

    • A colored highlight rectangle around the bounding box

    • Text labels showing identifier, cross-reference, detection_source, and direction

ViewSectionLineDetector.collect_overlay_primitives() cycles through a palette of 8 colors to distinguish multiple indicators on the same view.

The overlay is integrated at every level of the hierarchy:

FeaturedDrawing.plot_data_section_lines()
└── for each FeaturedSheet:
    FeaturedSheet.plot_data_section_lines()
    └── FeaturedSheet.collect_section_line_overlay_primitives()
        └── for each FeaturedView:
            FeaturedView.collect_section_line_overlay_primitives()
            └── ViewSectionLineDetector.collect_overlay_primitives()
                └── for each SectionLineIndicator:
                    indicator.overlay_primitives(color)

Usage Example#

from drawing_tools import FeaturedDrawing
from drawing_tools.config.default_language_configs import DEFAULT_FRENCH_CONFIG

featured_drawing = FeaturedDrawing(
    drawing=drawing,
    language_configs=[DEFAULT_FRENCH_CONFIG],
)

# Access indicators on a specific view
for sheet in featured_drawing.sheets:
    for view in sheet.views:
        for indicator in view.section_line_indicators:
            print(f"{indicator.identifier} → "
                  f"cross_ref={indicator.cross_reference}, "
                  f"detection_source={indicator.detection_source}")
# Visualize all section lines across the drawing
featured_drawing.plot_data_section_lines().plot()