Files
pdf2cad/src/pdf2imos/parse/dimensions.py
2026-03-03 21:24:02 +00:00

225 lines
6.8 KiB
Python

"""Dimension extractor — find dimensional measurements from orthographic views.
Strategy:
1. Collect all text items in the view that look like numbers (parseable as float/int)
2. Convert text coordinates from PDF coords (y-down) to CAD coords (y-up)
3. For each numeric text, find the nearest horizontal or vertical line segment
4. Determine direction (H/V) from the associated line's orientation
5. Build DimensionAnnotation for each valid (text, line) pair
"""
import logging
import re
from pdf2imos.models import (
ClassifiedLine,
DimensionAnnotation,
DimensionDirection,
LineRole,
ViewRegion,
)
logger = logging.getLogger(__name__)
# Pattern for dimension values: "600", "600.0", "600mm", "18", etc.
_NUMBER_PATTERN = re.compile(r"^(\d+\.?\d*)\s*(?:mm)?$")
def extract_dimensions(
view: ViewRegion,
classified_lines: list[ClassifiedLine],
page_height: float,
) -> list[DimensionAnnotation]:
"""Extract dimension measurements from an orthographic view.
Args:
view: ViewRegion containing paths and texts
classified_lines: ClassifiedLine objects from classify_lines() for this view's paths
page_height: page height for text coordinate conversion (PDF → CAD)
Returns:
List of DimensionAnnotation objects
"""
# Step 1: Get numeric texts (converted to CAD coords)
numeric_texts = _extract_numeric_texts(view, page_height)
if not numeric_texts:
logger.debug("No numeric text found in view")
return []
logger.debug(
"Found %d numeric texts: %s",
len(numeric_texts),
[t[0] for t in numeric_texts],
)
# Filter lines to this view's bounds (expanded slightly for dimension lines
# that sit outside the geometry envelope)
vx0, vy0, vx1, vy1 = view.bounds
view_expanded = (vx0 - 80, vy0 - 80, vx1 + 80, vy1 + 80)
view_lines = [
line
for line in classified_lines
if _line_in_region(line, view_expanded)
]
# Step 2: For each numeric text, find nearest line
dimensions: list[DimensionAnnotation] = []
used_text_centers: set[tuple[float, float]] = set()
for value, text_center, text_bbox_cad in numeric_texts:
# Skip very small values (not dimensions)
if value < 1.0:
continue
# Round center for dedup
center_key = (round(text_center[0], 1), round(text_center[1], 1))
if center_key in used_text_centers:
continue
used_text_centers.add(center_key)
# Find nearest line
nearest = _find_nearest_line(text_center, view_lines)
if nearest is None:
logger.debug("No nearby line for text '%.1f' at %s", value, text_center)
continue
# Determine direction from line orientation
direction = _line_direction(nearest)
dimensions.append(
DimensionAnnotation(
value_mm=value,
direction=direction,
dim_line_start=nearest.start,
dim_line_end=nearest.end,
text_bbox=text_bbox_cad,
)
)
logger.debug("Extracted %d dimensions from view", len(dimensions))
return dimensions
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _extract_numeric_texts(
view: ViewRegion,
page_height: float,
) -> list[tuple[float, tuple[float, float], tuple[float, float, float, float]]]:
"""Extract text items that contain numeric values.
CRITICAL: ViewRegion.texts are in PDF coords (y-down).
We must convert to CAD coords (y-up) before spatial matching.
Returns:
list of (value_mm, text_center_cad, text_bbox_cad)
"""
result: list[
tuple[float, tuple[float, float], tuple[float, float, float, float]]
] = []
for text in view.texts:
text_str = text.text.strip()
match = _NUMBER_PATTERN.match(text_str)
if not match:
continue
try:
value = float(match.group(1))
except ValueError:
continue
# Convert text bbox from PDF coords to CAD coords
tx0, ty0, tx1, ty1 = text.bbox
cad_y0 = page_height - ty1
cad_y1 = page_height - ty0
text_bbox_cad = (tx0, cad_y0, tx1, cad_y1)
text_center = ((tx0 + tx1) / 2, (cad_y0 + cad_y1) / 2)
result.append((value, text_center, text_bbox_cad))
return result
def _find_nearest_line(
text_center: tuple[float, float],
lines: list[ClassifiedLine],
max_distance: float = 60.0,
) -> ClassifiedLine | None:
"""Find the nearest dimension or geometry line to a text center.
Prefers DIMENSION lines over GEOMETRY lines.
Ignores BORDER, HIDDEN, and CENTER lines.
"""
best: ClassifiedLine | None = None
best_dist = max_distance
for line in lines:
if line.role in (LineRole.BORDER, LineRole.HIDDEN, LineRole.CENTER):
continue
# Distance from text center to nearest point on line segment
dist = _point_to_segment_distance(text_center, line.start, line.end)
if dist < best_dist:
# Prefer DIMENSION lines: if current best is DIMENSION and
# candidate is not, only replace if much closer
if (
best is not None
and best.role == LineRole.DIMENSION
and line.role != LineRole.DIMENSION
and dist > best_dist * 0.5
):
continue
best_dist = dist
best = line
return best
def _point_to_segment_distance(
point: tuple[float, float],
seg_start: tuple[float, float],
seg_end: tuple[float, float],
) -> float:
"""Compute distance from point to line segment."""
px, py = point
x1, y1 = seg_start
x2, y2 = seg_end
dx, dy = x2 - x1, y2 - y1
length_sq = dx * dx + dy * dy
if length_sq < 0.0001: # zero-length segment
return ((px - x1) ** 2 + (py - y1) ** 2) ** 0.5
t = max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / length_sq))
proj_x = x1 + t * dx
proj_y = y1 + t * dy
return ((px - proj_x) ** 2 + (py - proj_y) ** 2) ** 0.5
def _line_direction(line: ClassifiedLine) -> DimensionDirection:
"""Determine if a line is horizontal or vertical."""
dx = abs(line.end[0] - line.start[0])
dy = abs(line.end[1] - line.start[1])
if dx > dy:
return DimensionDirection.HORIZONTAL
return DimensionDirection.VERTICAL
def _line_in_region(
line: ClassifiedLine,
region: tuple[float, float, float, float],
) -> bool:
"""Check if a line's midpoint is within a region."""
mx = (line.start[0] + line.end[0]) / 2
my = (line.start[1] + line.end[1]) / 2
x0, y0, x1, y1 = region
return x0 <= mx <= x1 and y0 <= my <= y1