""" Object Metadata and Bounding Box Extraction Module for PDF Exporter. Extracts SlicingObject names, 2D bounding boxes, and custom settings attributes. """ from typing import Any import numpy as np from numpy.typing import NDArray def get_first_attribute_value(so_item: Any, attr_name: str) -> Any: """ Extracts scalar attribute value for a SlicingObject or SlicePaths. """ if hasattr(so_item, 'extra_attributes') and isinstance(so_item.extra_attributes, dict): if attr_name in so_item.extra_attributes: vals = so_item.extra_attributes[attr_name] if isinstance(vals, (list, tuple, np.ndarray)) and len(vals) > 0: val = vals[0] if hasattr(val, 'item'): return val.item() return val elif vals is not None: return vals if hasattr(so_item, 'attributes') and isinstance(so_item.attributes, dict): if attr_name in so_item.attributes: attr_info = so_item.attributes[attr_name] if isinstance(attr_info, dict) and 'values' in attr_info: vals = attr_info['values'] elif isinstance(attr_info, (list, tuple, np.ndarray)): vals = attr_info else: return attr_info if len(vals) > 0: val = vals[0] if hasattr(val, 'item'): return val.item() return val attr_key = attr_name.lower().replace(" ", "_") if hasattr(so_item, attr_key): val = getattr(so_item, attr_key) if val is not None and not callable(val): if isinstance(val, (list, tuple, np.ndarray)) and len(val) > 0: val = val[0] if hasattr(val, 'item'): return val.item() return val elif hasattr(so_item, attr_name): val = getattr(so_item, attr_name) if val is not None and not callable(val): if isinstance(val, (list, tuple, np.ndarray)) and len(val) > 0: val = val[0] if hasattr(val, 'item'): return val.item() return val return None def resolve_object_properties(so_item: Any, props: list[tuple[str, str]]) -> list[tuple[str, str]]: """ Resolves property tuples for a SlicingObject. If the first position (p1) exists as an attribute on so_item and the second position (p2) is empty, replaces p2 with the value of that attribute at index zero. """ resolved: list[tuple[str, str]] = [] for p1, p2 in props: if p1 and (not p2 or p2 == ""): attr_val = get_first_attribute_value(so_item, p1) if attr_val is not None: if isinstance(attr_val, float): p2_val = f"{attr_val:g}" else: p2_val = str(attr_val) resolved.append((p1, p2_val)) else: resolved.append((p1, p2)) else: resolved.append((p1, p2)) return resolved def extract_settings_items(settings: Any) -> list[tuple[str, str]]: """ Parses settings dict or object into key/suffix list. """ if settings is None: return [] items: list[tuple[str, str]] = [] if isinstance(settings, dict): for k, v in settings.items(): suffix = str(v) if v is not None else "" items.append((str(k), suffix)) elif hasattr(settings, '__dict__'): for k, v in settings.__dict__.items(): if not k.startswith('_'): suffix = str(v) if v is not None else "" items.append((str(k), suffix)) return items def extract_object_label_info(mesh_data: Any) -> list[tuple[Any, str, float, float, float, float]]: """ Extracts (slicing_object, object_name, max_x_mm, y_at_max_x_mm, width_mm, height_mm) for each SlicingObject or SlicePaths in mesh_data. """ from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject from ..slice import SlicePaths results: list[tuple[Any, str, float, float, float, float]] = [] def process_item(item: Any, fallback_name: str = ""): if item is None: return elif isinstance(item, SlicePaths): all_pts = [p[:, :2] for p in item if p is not None and len(p) > 0] if all_pts: verts_2d = np.concatenate(all_pts, axis=0) if len(verts_2d) > 0: max_idx = np.argmax(verts_2d[:, 0]) max_x = float(verts_2d[max_idx, 0]) min_x = float(verts_2d[:, 0].min()) y_val = float(verts_2d[max_idx, 1]) min_y = float(verts_2d[:, 1].min()) max_y = float(verts_2d[:, 1].max()) w_mm = max_x - min_x h_mm = max_y - min_y name = getattr(item, "name", "") or fallback_name or "SlicePaths" results.append((item, name, max_x, y_val, w_mm, h_mm)) elif isinstance(item, (list, tuple)): for i, sub in enumerate(item): process_item(sub, f"{fallback_name}_{i}" if fallback_name else f"object_{i}") elif isinstance(item, SlicingGroup): for key, coll in item.collections.items(): process_item(coll, key) elif isinstance(item, SlicingCollection): coll_name = item.name or fallback_name for i, so in enumerate(item.objects): process_item(so, so.name or f"{coll_name}_{i}") elif isinstance(item, SlicingObject): if item.mesh.size > 0: name = item.name or fallback_name or "object" verts_2d = item.mesh[:, :3, :2].reshape(-1, 2) if len(verts_2d) > 0: max_idx = np.argmax(verts_2d[:, 0]) max_x = float(verts_2d[max_idx, 0]) min_x = float(verts_2d[:, 0].min()) y_val = float(verts_2d[max_idx, 1]) min_y = float(verts_2d[:, 1].min()) max_y = float(verts_2d[:, 1].max()) w_mm = max_x - min_x h_mm = max_y - min_y results.append((item, name, max_x, y_val, w_mm, h_mm)) process_item(mesh_data) return results def extract_object_bounding_boxes(mesh_data: Any) -> NDArray[np.float64]: """ Extracts 2D top-view bounding box rectangle edge segments (shape N x 2 x 2) for each SlicingObject in mesh_data. """ from .sa2_measures import extract_slicing_objects_bounds sa2_objects = extract_slicing_objects_bounds(mesh_data) if not sa2_objects: return np.empty((0, 2, 2), dtype=np.float64) all_segments: list[np.ndarray] = [] for item, name, min_x, max_x, min_y, max_y in sa2_objects: rect = np.array([ [[min_x, min_y], [max_x, min_y]], [[max_x, min_y], [max_x, max_y]], [[max_x, max_y], [min_x, max_y]], [[min_x, max_y], [min_x, min_y]], ], dtype=np.float64) all_segments.append(rect) return np.concatenate(all_segments, axis=0) # Aliases for backward compatibility extract_object_name_positions = extract_object_label_info