209 lines
6.5 KiB
Python
209 lines
6.5 KiB
Python
"""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,
|
||
)
|