nodeslicer/core/pdf_exporter/metadata.py
2026-08-04 15:44:42 +03:00

187 lines
7.2 KiB
Python

"""
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