feat: pdf2cad

This commit is contained in:
2026-03-03 21:24:02 +00:00
commit 112213da6e
61 changed files with 7290 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
"""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,
)