mesh 2 pdf

This commit is contained in:
Nicolas Predella 2026-07-30 11:53:20 +03:00
parent 122f83b087
commit 5bb30b08a0
4 changed files with 156 additions and 35 deletions

View file

@ -399,6 +399,7 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
def init(self, context): def init(self, context):
self._init_exec() self._init_exec()
self.add_input("SLICESOCK_DirPath", "Folder") self.add_input("SLICESOCK_DirPath", "Folder")
self.add_input("SLICESOCK_Settings", "Settings")
self.add_multi_socket_group("SLICESOCK_Mesh", name="Geometry") self.add_multi_socket_group("SLICESOCK_Mesh", name="Geometry")
def draw_buttons(self, context, layout): def draw_buttons(self, context, layout):
@ -424,6 +425,7 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
geometries: list[Any] = [g for g in self.resolve_multi_input('Geometry') if g is not None] geometries: list[Any] = [g for g in self.resolve_multi_input('Geometry') if g is not None]
folder: str = self.resolve("Folder") or "" folder: str = self.resolve("Folder") or ""
settings = self.resolve("Settings")
if not geometries: if not geometries:
if operator: operator.report({'ERROR'}, "No Geometry connected") if operator: operator.report({'ERROR'}, "No Geometry connected")
@ -479,7 +481,8 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
stroke_color=(float(self.wire_color[0]), float(self.wire_color[1]), float(self.wire_color[2])), stroke_color=(float(self.wire_color[0]), float(self.wire_color[1]), float(self.wire_color[2])),
bg_color=(float(self.bg_color[0]), float(self.bg_color[1]), float(self.bg_color[2])), bg_color=(float(self.bg_color[0]), float(self.bg_color[1]), float(self.bg_color[2])),
bbox_color=(float(self.bbox_color[0]), float(self.bbox_color[1]), float(self.bbox_color[2])), bbox_color=(float(self.bbox_color[0]), float(self.bbox_color[1]), float(self.bbox_color[2])),
raw_geometry=geom raw_geometry=geom,
settings=settings
) )
if operator: operator.report({'INFO'}, f"Mesh PDF saved to {filepath}") if operator: operator.report({'INFO'}, f"Mesh PDF saved to {filepath}")
except Exception as e: except Exception as e:

View file

@ -459,15 +459,92 @@ def _format_pdf_lines(lines_arr: NDArray[np.float64]) -> list[str]:
return [f"{row[0]:.2f} {row[1]:.2f} m {row[2]:.2f} {row[3]:.2f} l" for row in flat] return [f"{row[0]:.2f} {row[1]:.2f} m {row[2]:.2f} {row[3]:.2f} l" for row in flat]
def extract_object_label_info(mesh_data: Any) -> list[tuple[str, float, float, float, float]]: def extract_settings_items(settings: Any) -> list[tuple[str, str]]:
""" """
Extracts (object_name, max_x_mm, y_at_max_x_mm, width_mm, height_mm) for each SlicingObject in mesh_data. Extracts a list of (setting_name, second_field_suffix) from settings input.
The setting_name is the left field, and second_field_suffix is the right field (e.g. unit).
"""
if not settings:
return []
items: list[tuple[str, str]] = []
if isinstance(settings, dict):
for k, v in settings.items():
key_str = str(k).strip()
if key_str:
suffix_str = str(v) if v is not None else ""
items.append((key_str, suffix_str))
elif isinstance(settings, (list, tuple)):
for item in settings:
if isinstance(item, str) and item.strip():
items.append((item.strip(), ""))
elif isinstance(item, dict):
if "field" in item and item["field"]:
key_str = str(item["field"]).strip()
val_str = str(item.get("value_str", item.get("value", "")))
items.append((key_str, val_str))
else:
for k, v in item.items():
key_str = str(k).strip()
if key_str:
suffix_str = str(v) if v is not None else ""
items.append((key_str, suffix_str))
elif hasattr(item, "field") and getattr(item, "field"):
key_str = str(getattr(item, "field")).strip()
val_str = str(getattr(item, "value_str", getattr(item, "value", "")))
items.append((key_str, val_str))
return items
def extract_settings_keys(settings: Any) -> list[str]:
return [item[0] for item in extract_settings_items(settings)]
def get_first_attribute_value(obj: Any, attr_name: str) -> Any:
"""
Accesses the first element of the attribute with attr_name on obj.
"""
val = None
if hasattr(obj, "attributes") and isinstance(obj.attributes, dict) and attr_name in obj.attributes:
attr = obj.attributes[attr_name]
if isinstance(attr, dict) and "values" in attr:
val = attr["values"]
else:
val = attr
elif hasattr(obj, attr_name):
val = getattr(obj, attr_name)
if val is None:
return None
if isinstance(val, np.ndarray):
if val.size > 0:
first_val = val.flat[0]
if hasattr(first_val, "item"):
return first_val.item()
return first_val
return None
elif isinstance(val, (list, tuple)):
if len(val) > 0:
first_val = val[0]
if hasattr(first_val, "item"):
return first_val.item()
return first_val
return None
else:
if hasattr(val, "item"):
return val.item()
return val
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 in mesh_data.
max_x_mm is the X coordinate of the rightmost vertex of the object. max_x_mm is the X coordinate of the rightmost vertex of the object.
y_at_max_x_mm is the Y coordinate of that rightmost vertex. y_at_max_x_mm is the Y coordinate of that rightmost vertex.
""" """
from ..infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject from ..infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
results: list[tuple[str, float, float, float, float]] = [] results: list[tuple[Any, str, float, float, float, float]] = []
def process_item(item: Any, fallback_name: str = ""): def process_item(item: Any, fallback_name: str = ""):
if item is None: if item is None:
@ -496,7 +573,7 @@ def extract_object_label_info(mesh_data: Any) -> list[tuple[str, float, float, f
w_mm = max_x - min_x w_mm = max_x - min_x
h_mm = max_y - min_y h_mm = max_y - min_y
results.append((name, max_x, y_val, w_mm, h_mm)) results.append((item, name, max_x, y_val, w_mm, h_mm))
process_item(mesh_data) process_item(mesh_data)
return results return results
@ -539,19 +616,19 @@ def extract_object_bounding_boxes(mesh_data: Any) -> NDArray[np.float64]:
[[min_x, min_y], [max_x, min_y]], [[min_x, min_y], [max_x, min_y]],
[[max_x, min_y], [max_x, max_y]], [[max_x, min_y], [max_x, max_y]],
[[max_x, max_y], [min_x, max_y]], [[max_x, max_y], [min_x, max_y]],
[[min_x, max_y], [min_x, min_y]] [[min_x, max_y], [min_x, min_y]],
], dtype=np.float64) ], dtype=np.float64)
all_segments.append(rect) all_segments.append(rect)
process_item(mesh_data) process_item(mesh_data)
if all_segments: if not all_segments:
return np.vstack(all_segments) return np.empty((0, 2, 2), dtype=np.float64)
return np.empty((0, 2, 2), dtype=np.float64) return np.concatenate(all_segments, axis=0)
def export_mesh_to_pdf( def export_mesh_to_pdf(
edges_2d: NDArray[np.float64], edges_2d: NDArray[np.float64],
filepath: str, filepath: str | Path,
draw_wireframe: bool = True, draw_wireframe: bool = True,
draw_boundary: bool = False, draw_boundary: bool = False,
draw_contour: bool = False, draw_contour: bool = False,
@ -566,6 +643,7 @@ def export_mesh_to_pdf(
bg_color: tuple[float, float, float] = (1.0, 1.0, 1.0), bg_color: tuple[float, float, float] = (1.0, 1.0, 1.0),
bbox_color: tuple[float, float, float] = (0.0, 0.0, 1.0), bbox_color: tuple[float, float, float] = (0.0, 0.0, 1.0),
raw_geometry: Any = None, raw_geometry: Any = None,
settings: Any = None,
style: str | None = None # Backward compatibility style: str | None = None # Backward compatibility
) -> None: ) -> None:
""" """
@ -889,8 +967,9 @@ def export_mesh_to_pdf(
stream_cmds.append("ET") stream_cmds.append("ET")
# 7. Object Names and Bounding Box Sizes (to the right of the rightmost vertex) # 7. Object Names, Bounding Box Sizes, and Settings Attributes (to the right of the rightmost vertex)
if (show_name or show_bbox_size) and raw_geometry is not None: settings_items = extract_settings_items(settings)
if (show_name or show_bbox_size or settings_items) and raw_geometry is not None:
label_infos = extract_object_label_info(raw_geometry) label_infos = extract_object_label_info(raw_geometry)
if label_infos: if label_infos:
font_size_pt = max(8.0, line_width_pt * 4.0) font_size_pt = max(8.0, line_width_pt * 4.0)
@ -900,42 +979,41 @@ def export_mesh_to_pdf(
stream_cmds.append("BT") stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf") stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf")
for obj_name, max_x_mm, y_val_mm, w_obj_mm, h_obj_mm in label_infos: for so_item, obj_name, max_x_mm, y_val_mm, w_obj_mm, h_obj_mm in label_infos:
p_pt = to_pdf_pt(np.array([[max_x_mm, y_val_mm]])) p_pt = to_pdf_pt(np.array([[max_x_mm, y_val_mm]]))
x_text_pt = p_pt[0, 0] + (2.5 * MM_TO_PT) x_text_pt = p_pt[0, 0] + (2.5 * MM_TO_PT)
y_text_pt = p_pt[0, 1] - (font_size_pt * 0.35) y_text_pt = p_pt[0, 1] - (font_size_pt * 0.35)
if show_name and show_bbox_size: lines_to_draw: list[str] = []
clean_name = str(obj_name).replace("(", "\\(").replace(")", "\\)")
stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm")
stream_cmds.append(f"({clean_name}) Tj")
if show_name:
lines_to_draw.append(str(obj_name))
if show_bbox_size:
if main_step >= 1000.0: if main_step >= 1000.0:
w_val = w_obj_mm / 1000.0 w_val = w_obj_mm / 1000.0
h_val = h_obj_mm / 1000.0 h_val = h_obj_mm / 1000.0
size_str = f"(X: {w_val:g}m Y: {h_val:g}m)" size_str = f"(X: {w_val:g}m Y: {h_val:g}m)"
else: else:
size_str = f"(X: {w_obj_mm:g}mm Y: {h_obj_mm:g}mm)" size_str = f"(X: {w_obj_mm:g}mm Y: {h_obj_mm:g}mm)"
lines_to_draw.append(size_str)
clean_size = size_str.replace("(", "\\(").replace(")", "\\)") for key_name, suffix in settings_items:
y_line2_pt = y_text_pt - line_spacing_pt attr_val = get_first_attribute_value(so_item, key_name)
stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_line2_pt:.2f} Tm") if attr_val is not None:
stream_cmds.append(f"({clean_size}) Tj") if isinstance(attr_val, float):
elif show_name: val_str = f"{attr_val:g}"
clean_name = str(obj_name).replace("(", "\\(").replace(")", "\\)") else:
stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm") val_str = str(attr_val)
stream_cmds.append(f"({clean_name}) Tj") if suffix:
elif show_bbox_size: val_str = f"{val_str}{suffix}"
if main_step >= 1000.0: lines_to_draw.append(f"{key_name}: {val_str}")
w_val = w_obj_mm / 1000.0
h_val = h_obj_mm / 1000.0
size_str = f"(X: {w_val:g}m Y: {h_val:g}m)"
else:
size_str = f"(X: {w_obj_mm:g}mm Y: {h_obj_mm:g}mm)"
clean_size = size_str.replace("(", "\\(").replace(")", "\\)") for line_idx, line_str in enumerate(lines_to_draw):
stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm") clean_str = line_str.replace("(", "\\(").replace(")", "\\)")
stream_cmds.append(f"({clean_size}) Tj") curr_y_pt = y_text_pt - (line_idx * line_spacing_pt)
stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {curr_y_pt:.2f} Tm")
stream_cmds.append(f"({clean_str}) Tj")
stream_cmds.append("ET") stream_cmds.append("ET")

View file

@ -121,6 +121,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Mesh to PDF**: Generates a 1:1 true-scale 2D top-view (+Z orthographic) vector PDF from a `SlicingGroup` or `SlicingCollection` with a custom grid and background color. - **Mesh to PDF**: Generates a 1:1 true-scale 2D top-view (+Z orthographic) vector PDF from a `SlicingGroup` or `SlicingCollection` with a custom grid and background color.
- **Geometry**: Input socket accepting a `SlicingGroup`, `SlicingCollection`, or `SlicingObject`. - **Geometry**: Input socket accepting a `SlicingGroup`, `SlicingCollection`, or `SlicingObject`.
- **Folder**: Target folder or PDF file path on disk. - **Folder**: Target folder or PDF file path on disk.
- **Settings**: Input socket accepting configuration settings or attribute names to render as `<name>: <value>` next to each object.
- **Wireframe**: Checkbox to render all projected 3D wireframe edges. - **Wireframe**: Checkbox to render all projected 3D wireframe edges.
- **Boundary (1 Face)**: Checkbox to render open mesh boundary edges owned by only 1 face. - **Boundary (1 Face)**: Checkbox to render open mesh boundary edges owned by only 1 face.
- **Contour**: Checkbox to render outer boundary silhouette contour. - **Contour**: Checkbox to render outer boundary silhouette contour.

View file

@ -480,6 +480,45 @@ class TestPdfExporter(unittest.TestCase):
self.assertIn("(10) Tj", pdf_text) self.assertIn("(10) Tj", pdf_text)
self.assertIn("(100 mm) Tj", pdf_text) self.assertIn("(100 mm) Tj", pdf_text)
def test_export_pdf_with_settings_attributes(self):
so = SlicingObject()
so.name = "AttributedPart"
tri = np.array([
[0.0, 0.0, 0.0],
[10.0, 0.0, 0.0],
[0.0, 10.0, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
so.mesh = np.array([tri])
so.attributes = {
"infill_density": {"domain": "face", "type": "float", "values": np.array([20.0, 20.0])},
"layer_height": {"domain": "face", "type": "float", "values": np.array([0.15, 0.15])},
"material": ["PLA", "PLA"]
}
edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "test_settings_attr.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
show_name=True,
show_bbox_size=True,
raw_geometry=so,
settings={"infill_density": "%", "layer_height": " mm", "material": ""}
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = f.read().decode("utf-8", errors="ignore")
self.assertIn("(AttributedPart) Tj", content)
self.assertIn("(infill_density: 20%) Tj", content)
self.assertIn("(layer_height: 0.15 mm) Tj", content)
self.assertIn("(material: PLA) Tj", content)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()