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

209 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Part geometry assembly from orthographic dimension measurements."""
import logging
from pdf2imos.models import (
DimensionAnnotation,
DimensionDirection,
PartGeometry,
ViewRegion,
ViewType,
)
logger = logging.getLogger(__name__)
def assemble_part_geometry(
views: list[ViewRegion],
dimensions: dict[ViewType, list[DimensionAnnotation]],
part_name: str = "unknown",
tolerance_mm: float = 0.5,
) -> PartGeometry | None:
"""Assemble W×H×D dimensions from orthographic views into PartGeometry.
Args:
views: ViewRegion list from segment_views()
dimensions: Dict mapping ViewType → list of DimensionAnnotations for that view
part_name: Name for the part (from title block)
tolerance_mm: Cross-validation tolerance in mm
Returns:
PartGeometry or None if assembly fails
"""
if not dimensions:
logger.error("No dimensions provided for assembly")
return None
# Extract dimensions by view
front_dims = dimensions.get(ViewType.FRONT, [])
side_dims = dimensions.get(ViewType.SIDE, [])
top_dims = dimensions.get(ViewType.TOP, [])
# Fall back: if no view-specific dims, use all dims combined
all_dims: list[DimensionAnnotation] = []
for dims in dimensions.values():
all_dims.extend(dims)
if not all_dims:
logger.error("No dimension annotations available")
return None
# Extract W, H, D
width_mm = _extract_dimension(
front_dims or all_dims, DimensionDirection.HORIZONTAL, "width"
)
height_mm = _extract_dimension(
front_dims or all_dims, DimensionDirection.VERTICAL, "height"
)
# For depth: prefer side view horizontal, then top view vertical, then smallest dim
depth_mm: float | None = None
if side_dims:
depth_mm = _extract_dimension(
side_dims, DimensionDirection.HORIZONTAL, "depth"
)
if depth_mm is None:
depth_mm = _extract_dimension(
side_dims, DimensionDirection.VERTICAL, "depth"
)
elif top_dims:
depth_mm = _extract_dimension(
top_dims, DimensionDirection.VERTICAL, "depth"
)
# Sanity check: if depth from top view matches height, it's misattributed
if (
depth_mm is not None
and height_mm is not None
and abs(depth_mm - height_mm) < tolerance_mm
):
logger.debug(
"Top view depth (%s) matches height — seeking alternative", depth_mm
)
depth_mm = _extract_smallest_remaining(
top_dims, exclude={width_mm, height_mm}
)
if depth_mm is None:
# No dedicated view or sanity check failed: use smallest remaining
depth_mm = _extract_smallest_remaining(
all_dims, exclude={width_mm, height_mm}
)
if width_mm is None or height_mm is None:
logger.error("Cannot assemble: width=%s, height=%s", width_mm, height_mm)
return None
if depth_mm is None:
logger.warning("Depth not found — defaulting to 18mm")
depth_mm = 18.0
# Cross-validate
_cross_validate(
front_dims, side_dims, top_dims,
width_mm, height_mm, depth_mm, tolerance_mm,
)
logger.info(
"Assembled: %s×%s×%smm (W×H×D)", width_mm, height_mm, depth_mm
)
return PartGeometry(
width_mm=width_mm,
height_mm=height_mm,
depth_mm=depth_mm,
origin=(0.0, 0.0, 0.0),
name=part_name,
)
def _extract_dimension(
dims: list[DimensionAnnotation],
direction: DimensionDirection,
dim_name: str,
) -> float | None:
"""Extract the largest dimension of a given direction (primary/overall dimension).
Returns the largest value of matching direction, or None if none found.
"""
matching = [d for d in dims if d.direction == direction]
if not matching:
# If no exact direction match, try all dims and pick the largest
logger.debug(
"No %s dimension found for %s, using all", direction.name, dim_name
)
matching = dims
if not matching:
return None
# Return the largest dimension (overall/total, not partial)
return max(d.value_mm for d in matching)
def _extract_smallest_remaining(
dims: list[DimensionAnnotation],
exclude: set[float | None],
) -> float | None:
"""Extract the smallest dimension value not in the exclude set."""
values = sorted(d.value_mm for d in dims if d.value_mm not in exclude)
return values[0] if values else None
def _cross_validate(
front_dims: list[DimensionAnnotation],
side_dims: list[DimensionAnnotation],
top_dims: list[DimensionAnnotation],
width: float,
height: float,
depth: float,
tolerance: float,
) -> None:
"""Cross-validate dimensions from different views and log warnings/info."""
# Check front height ≈ side height
if front_dims and side_dims:
front_heights = [
d.value_mm for d in front_dims
if d.direction == DimensionDirection.VERTICAL
]
side_heights = [
d.value_mm for d in side_dims
if d.direction == DimensionDirection.VERTICAL
]
if front_heights and side_heights:
front_h = max(front_heights)
side_h = max(side_heights)
if abs(front_h - side_h) <= tolerance:
logger.info(
"Cross-validation: front H (%smm) ≈ side H (%smm) ✓",
front_h, side_h,
)
else:
logger.warning(
"Cross-validation: front H (%smm) ≠ side H (%smm) — using front",
front_h, side_h,
)
# Check front width ≈ top width
if front_dims and top_dims:
front_widths = [
d.value_mm for d in front_dims
if d.direction == DimensionDirection.HORIZONTAL
]
top_widths = [
d.value_mm for d in top_dims
if d.direction == DimensionDirection.HORIZONTAL
]
if front_widths and top_widths:
front_w = max(front_widths)
top_w = max(top_widths)
if abs(front_w - top_w) <= tolerance:
logger.info(
"Cross-validation: front W (%smm) ≈ top W (%smm) ✓",
front_w, top_w,
)
else:
logger.warning(
"Cross-validation: front W (%smm) ≠ top W (%smm) — using front",
front_w, top_w,
)