naming and sizes

This commit is contained in:
Nicolas Predella 2026-07-29 21:56:37 +03:00
parent c41380f17b
commit 122f83b087
5 changed files with 528 additions and 9 deletions

View file

@ -595,7 +595,7 @@ class SLICENODE_RenameMesh(SliceNode):
self.add_output("SLICESOCK_Mesh", "Geometry") self.add_output("SLICESOCK_Mesh", "Geometry")
def draw_buttons(self, context, layout): def draw_buttons(self, context, layout):
super().draw_buttons(context, layout) assert layout
layout.prop(self, "new_name") layout.prop(self, "new_name")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any: def evaluate_output(self, socket_name: str, **_kwargs) -> Any:

View file

@ -362,6 +362,9 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
draw_contour: NfBoolProperty(name="Contour", default=False) # pyright: ignore[reportInvalidTypeForm] draw_contour: NfBoolProperty(name="Contour", default=False) # pyright: ignore[reportInvalidTypeForm]
draw_crease: NfBoolProperty(name="Crease Angle", default=False) # pyright: ignore[reportInvalidTypeForm] draw_crease: NfBoolProperty(name="Crease Angle", default=False) # pyright: ignore[reportInvalidTypeForm]
crease_angle: NfFloatProperty(name="Angle (°)", default=30.0, min=0.0, max=180.0) # pyright: ignore[reportInvalidTypeForm] crease_angle: NfFloatProperty(name="Angle (°)", default=30.0, min=0.0, max=180.0) # pyright: ignore[reportInvalidTypeForm]
draw_bbox: NfBoolProperty(name="Bounding Box", default=False) # pyright: ignore[reportInvalidTypeForm]
show_name: NfBoolProperty(name="Show Name", default=False) # pyright: ignore[reportInvalidTypeForm]
show_bbox_size: NfBoolProperty(name="Bounding Box Sizes", default=False) # pyright: ignore[reportInvalidTypeForm]
line_width: NfFloatProperty(name="Line Width (mm)", default=0.5, min=0.01, max=50.0) # pyright: ignore[reportInvalidTypeForm] line_width: NfFloatProperty(name="Line Width (mm)", default=0.5, min=0.01, max=50.0) # pyright: ignore[reportInvalidTypeForm]
margin: NfFloatProperty(name="Margin (mm)", default=10.0, min=0.0, max=500.0) # pyright: ignore[reportInvalidTypeForm] margin: NfFloatProperty(name="Margin (mm)", default=10.0, min=0.0, max=500.0) # pyright: ignore[reportInvalidTypeForm]
@ -384,6 +387,15 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
size=3 size=3
) # pyright: ignore[reportInvalidTypeForm] ) # pyright: ignore[reportInvalidTypeForm]
bbox_color: NfFloatVectorProperty(
name="BBox Color",
subtype='COLOR',
default=(0.0, 0.0, 1.0),
min=0.0,
max=1.0,
size=3
) # pyright: ignore[reportInvalidTypeForm]
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")
@ -397,6 +409,11 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
layout.prop(self, "draw_crease") layout.prop(self, "draw_crease")
if self.draw_crease: if self.draw_crease:
layout.prop(self, "crease_angle") layout.prop(self, "crease_angle")
layout.prop(self, "draw_bbox")
if self.draw_bbox:
layout.prop(self, "bbox_color")
layout.prop(self, "show_name")
layout.prop(self, "show_bbox_size")
layout.prop(self, "line_width") layout.prop(self, "line_width")
layout.prop(self, "margin") layout.prop(self, "margin")
layout.prop(self, "bg_color") layout.prop(self, "bg_color")
@ -453,11 +470,15 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
draw_boundary=self.draw_boundary, draw_boundary=self.draw_boundary,
draw_contour=self.draw_contour, draw_contour=self.draw_contour,
draw_crease=self.draw_crease, draw_crease=self.draw_crease,
draw_bbox=self.draw_bbox,
show_name=self.show_name,
show_bbox_size=self.show_bbox_size,
crease_angle=self.crease_angle, crease_angle=self.crease_angle,
margin_mm=self.margin, margin_mm=self.margin,
line_width_mm=self.line_width, line_width_mm=self.line_width,
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])),
raw_geometry=geom raw_geometry=geom
) )
if operator: operator.report({'INFO'}, f"Mesh PDF saved to {filepath}") if operator: operator.report({'INFO'}, f"Mesh PDF saved to {filepath}")

View file

@ -459,6 +459,96 @@ 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]]:
"""
Extracts (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.
y_at_max_x_mm is the Y coordinate of that rightmost vertex.
"""
from ..infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
results: list[tuple[str, float, float, float, float]] = []
def process_item(item: Any, fallback_name: str = ""):
if item is None:
return
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((name, max_x, y_val, w_mm, h_mm))
process_item(mesh_data)
return results
# Alias for backward compatibility
extract_object_name_positions = extract_object_label_info
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 ..infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
all_segments: list[np.ndarray] = []
def process_item(item: Any):
if item is None:
return
elif isinstance(item, (list, tuple)):
for sub in item:
process_item(sub)
elif isinstance(item, SlicingGroup):
for coll in item.collections.values():
process_item(coll)
elif isinstance(item, SlicingCollection):
for so in item.objects:
process_item(so)
elif isinstance(item, SlicingObject):
if item.mesh.size > 0:
verts_2d = item.mesh[:, :3, :2].reshape(-1, 2)
if len(verts_2d) > 0:
min_x = float(verts_2d[:, 0].min())
max_x = float(verts_2d[:, 0].max())
min_y = float(verts_2d[:, 1].min())
max_y = float(verts_2d[:, 1].max())
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)
process_item(mesh_data)
if all_segments:
return np.vstack(all_segments)
return np.empty((0, 2, 2), dtype=np.float64)
def export_mesh_to_pdf( def export_mesh_to_pdf(
edges_2d: NDArray[np.float64], edges_2d: NDArray[np.float64],
filepath: str, filepath: str,
@ -466,11 +556,15 @@ def export_mesh_to_pdf(
draw_boundary: bool = False, draw_boundary: bool = False,
draw_contour: bool = False, draw_contour: bool = False,
draw_crease: bool = False, draw_crease: bool = False,
draw_bbox: bool = False,
show_name: bool = False,
show_bbox_size: bool = False,
crease_angle: float = 30.0, crease_angle: float = 30.0,
margin_mm: float = 10.0, margin_mm: float = 10.0,
line_width_mm: float = 0.5, line_width_mm: float = 0.5,
stroke_color: tuple[float, float, float] = (0.0, 0.0, 0.0), stroke_color: tuple[float, float, float] = (0.0, 0.0, 0.0),
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),
raw_geometry: Any = None, raw_geometry: Any = None,
style: str | None = None # Backward compatibility style: str | None = None # Backward compatibility
) -> None: ) -> None:
@ -671,7 +765,26 @@ def export_mesh_to_pdf(
stream_cmds.extend(lines_str[start_idx:start_idx + 5000]) stream_cmds.extend(lines_str[start_idx:start_idx + 5000])
stream_cmds.append("S") stream_cmds.append("S")
# 6. Size Scale Legend (Bottom Left, aligned directly on the bottom main grid lines with exact wireframe thickness) # 5.5. Object Bounding Boxes (100% Opacity in bbox_color)
if draw_bbox and raw_geometry is not None:
bbox_edges = extract_object_bounding_boxes(raw_geometry)
if len(bbox_edges) > 0:
br, bg, bb = bbox_color
stream_cmds.append("/GS100 gs")
stream_cmds.append(f"{line_width_pt:.2f} w")
stream_cmds.append("1 j 1 J")
stream_cmds.append(f"{br:.3f} {bg:.3f} {bb:.3f} RG")
p0_pt = to_pdf_pt(bbox_edges[:, 0, :])
p1_pt = to_pdf_pt(bbox_edges[:, 1, :])
bbox_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
lines_str = _format_pdf_lines(bbox_lines_arr)
for start_idx in range(0, len(lines_str), 5000):
stream_cmds.extend(lines_str[start_idx:start_idx + 5000])
stream_cmds.append("S")
# 6. Size Scale Legend (Bottom Left, aligned directly on bottom-left main grid lines with arrows and X/Y labels)
stream_cmds.append("/GS100 gs") stream_cmds.append("/GS100 gs")
x_grid_start_mm = grid_min_x x_grid_start_mm = grid_min_x
x_grid_end_mm = min(grid_max_x, grid_min_x + 10.0 * main_step) x_grid_end_mm = min(grid_max_x, grid_min_x + 10.0 * main_step)
@ -684,14 +797,37 @@ def export_mesh_to_pdf(
x_end_pt = p_end_pt[0, 0] x_end_pt = p_end_pt[0, 0]
y_bar_pt = p_start_pt[0, 1] y_bar_pt = p_start_pt[0, 1]
# Vertical Y-axis extension: 5 minor grid lines up from origin
y_grid_top_mm = grid_min_y + 5.0 * minor_step
p_top_pt = to_pdf_pt(np.array([[grid_min_x, y_grid_top_mm]]))
y_top_pt = p_top_pt[0, 1]
# Smart arrowhead scaling based on line_width_pt and font size
font_size_pt = max(8.0, line_width_pt * 4.0)
arrow_len_pt = max(3.5 * MM_TO_PT, line_width_pt * 4.5)
arrow_half_w_pt = arrow_len_pt * 0.40
# Exact same stroke thickness as object wireframe lines # Exact same stroke thickness as object wireframe lines
stream_cmds.append(f"{line_width_pt:.2f} w") stream_cmds.append(f"{line_width_pt:.2f} w")
stream_cmds.append("1 j 1 J") stream_cmds.append("1 j 1 J")
# Main horizontal legend line directly overlapping bottom grid line # 6a. Main horizontal X-axis line (spans 10 main grid steps, extends 5 minor grid lines past last tick + right arrow + X label)
stream_cmds.append(f"{x_start_pt:.2f} {y_bar_pt:.2f} m {x_end_pt:.2f} {y_bar_pt:.2f} l S") x_arrow_base_mm = x_grid_end_mm + 5.0 * minor_step
p_arrow_base_pt = to_pdf_pt(np.array([[x_arrow_base_mm, grid_min_y]]))
x_arrow_base_pt = p_arrow_base_pt[0, 0]
x_arrow_tip_pt = x_arrow_base_pt + arrow_len_pt
# Ticks directly overlapping vertical main grid lines # Horizontal main scale bar line
stream_cmds.append(f"{x_start_pt:.2f} {y_bar_pt:.2f} m {x_arrow_tip_pt:.2f} {y_bar_pt:.2f} l S")
# Pointy arrowhead (+X) extending past 5th minor grid line beyond x_end_pt
stream_cmds.append(
f"{x_arrow_base_pt:.2f} {y_bar_pt + arrow_half_w_pt:.2f} m "
f"{x_arrow_tip_pt:.2f} {y_bar_pt:.2f} l "
f"{x_arrow_base_pt:.2f} {y_bar_pt - arrow_half_w_pt:.2f} l S"
)
# Ticks directly overlapping vertical main grid lines on X-axis (full 10 main grid steps including k=0)
tick_cmds = [] tick_cmds = []
for k in range(num_main_steps + 1): for k in range(num_main_steps + 1):
xk_mm = x_grid_start_mm + k * main_step xk_mm = x_grid_start_mm + k * main_step
@ -702,14 +838,24 @@ def export_mesh_to_pdf(
stream_cmds.extend(tick_cmds) stream_cmds.extend(tick_cmds)
stream_cmds.append("S") stream_cmds.append("S")
# Proportionate font size scaling with linework thickness # 6b. Main vertical Y-axis line (5 minor grid lines up with NO ticks) + top arrow (+Y)
font_size_pt = max(8.0, line_width_pt * 4.0) y_arrow_base_pt = y_top_pt
y_arrow_tip_pt = y_arrow_base_pt + arrow_len_pt
stream_cmds.append(f"{x_start_pt:.2f} {y_bar_pt:.2f} m {x_start_pt:.2f} {y_arrow_tip_pt:.2f} l S")
# Pointy arrowhead (+Y) extending past 5th minor grid line (y_top_pt)
stream_cmds.append(
f"{x_start_pt - arrow_half_w_pt:.2f} {y_arrow_base_pt:.2f} m "
f"{x_start_pt:.2f} {y_arrow_tip_pt:.2f} l "
f"{x_start_pt + arrow_half_w_pt:.2f} {y_arrow_base_pt:.2f} l S"
)
# 6c. Text labels for measure ticks (skip k=0: NO text label at zero), X label, and Y label
stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} rg") stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} rg")
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 k in range(num_main_steps + 1): for k in range(1, num_main_steps + 1):
xk_mm = x_grid_start_mm + k * main_step xk_mm = x_grid_start_mm + k * main_step
pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]])) pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]]))
xk_pt = pk_pt[0, 0] xk_pt = pk_pt[0, 0]
@ -729,8 +875,70 @@ def export_mesh_to_pdf(
stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm") stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm")
stream_cmds.append(f"({tick_str}) Tj") stream_cmds.append(f"({tick_str}) Tj")
# X axis label next to right arrowhead
x_x_label_pt = x_arrow_tip_pt + (2.0 * MM_TO_PT)
y_x_label_pt = y_bar_pt - (font_size_pt * 0.35)
stream_cmds.append(f"1 0 0 1 {x_x_label_pt:.2f} {y_x_label_pt:.2f} Tm")
stream_cmds.append("(X) Tj")
# Y axis label next to top arrowhead
x_y_label_pt = x_start_pt - (font_size_pt * 0.35)
y_y_label_pt = y_arrow_tip_pt + (2.0 * MM_TO_PT)
stream_cmds.append(f"1 0 0 1 {x_y_label_pt:.2f} {y_y_label_pt:.2f} Tm")
stream_cmds.append("(Y) Tj")
stream_cmds.append("ET") stream_cmds.append("ET")
# 7. Object Names and Bounding Box Sizes (to the right of the rightmost vertex)
if (show_name or show_bbox_size) and raw_geometry is not None:
label_infos = extract_object_label_info(raw_geometry)
if label_infos:
font_size_pt = max(8.0, line_width_pt * 4.0)
line_spacing_pt = font_size_pt * 1.2
stream_cmds.append("/GS100 gs")
stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} rg")
stream_cmds.append("BT")
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:
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)
y_text_pt = p_pt[0, 1] - (font_size_pt * 0.35)
if show_name and show_bbox_size:
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 main_step >= 1000.0:
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(")", "\\)")
y_line2_pt = y_text_pt - line_spacing_pt
stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_line2_pt:.2f} Tm")
stream_cmds.append(f"({clean_size}) Tj")
elif show_name:
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")
elif show_bbox_size:
if main_step >= 1000.0:
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(")", "\\)")
stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm")
stream_cmds.append(f"({clean_size}) Tj")
stream_cmds.append("ET")
stream_content = "\n".join(stream_cmds) + "\n" stream_content = "\n".join(stream_cmds) + "\n"
stream_bytes = stream_content.encode("utf-8") stream_bytes = stream_content.encode("utf-8")

View file

@ -125,7 +125,10 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **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.
- **Crease Angle**: Checkbox to render crease lines above specified dihedral angle. - **Crease Angle**: Checkbox to render crease lines above specified dihedral angle.
- **Angle (°)**: Maximum dihedral angle threshold (default 30°) for marking feature crease lines when Crease Angle is enabled. - **Bounding Box**: Checkbox to render the 2D bounding box rectangle of each `SlicingObject`.
- **BBox Color**: Color picker for the bounding box rectangle lines (default blue).
- **Show Name**: Checkbox to render the object name to the right of the rightmost vertex of each `SlicingObject`.
- **Bounding Box Sizes**: Checkbox to render 2D bounding box size dimensions (e.g. `X: 120mm Y: 230mm` or `X: 1.2m Y: 2.3m`) next to each object label.
- **Line Width (mm)**: Stroke width in millimeters for the exported lines. - **Line Width (mm)**: Stroke width in millimeters for the exported lines.
- **Margin (mm)**: Margin around the geometry grid in mm. - **Margin (mm)**: Margin around the geometry grid in mm.
- **Background**: Color picker for the canvas background. - **Background**: Color picker for the canvas background.

View file

@ -38,6 +38,7 @@ from NodeSlicer.core.pdf_exporter import (
extract_unique_top_view_edges, extract_unique_top_view_edges,
extract_lineart_edges, extract_lineart_edges,
export_wireframe_to_pdf, export_wireframe_to_pdf,
export_mesh_to_pdf,
MM_TO_PT MM_TO_PT
) )
@ -192,6 +193,292 @@ class TestPdfExporter(unittest.TestCase):
content = f.read() content = f.read()
self.assertTrue(content.startswith(b"%PDF-1.4")) self.assertTrue(content.startswith(b"%PDF-1.4"))
self.assertTrue(content.endswith(b"%%EOF\n")) self.assertTrue(content.endswith(b"%%EOF\n"))
self.assertIn(b"(X) Tj", content)
self.assertIn(b"(Y) Tj", content)
def test_export_pdf_show_name(self):
so = SlicingObject()
so.name = "MyTestPart"
tri = np.array([
[0.0, 0.0, 0.0],
[50.0, 0.0, 0.0],
[0.0, 50.0, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
so.mesh = np.array([tri])
edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "test_show_name.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
show_name=True,
raw_geometry=so
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = f.read()
self.assertIn(b"(MyTestPart) Tj", content)
def test_export_pdf_show_bbox_size(self):
so = SlicingObject()
so.name = "GearPart"
tri = np.array([
[10.0, 20.0, 0.0],
[130.0, 20.0, 0.0],
[10.0, 250.0, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
so.mesh = np.array([tri])
edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "test_show_bbox.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
show_name=True,
show_bbox_size=True,
raw_geometry=so
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = f.read()
# Width = 120mm, Height = 230mm -> Line 1: (GearPart) Tj, Line 2: (\(X: 120mm Y: 230mm\)) Tj
self.assertIn(b"(GearPart) Tj", content)
self.assertIn(b"(\\(X: 120mm Y: 230mm\\)) Tj", content)
def test_export_pdf_draw_bbox(self):
so = SlicingObject()
so.name = "BBoxPart"
tri = np.array([
[0.0, 0.0, 0.0],
[100.0, 0.0, 0.0],
[0.0, 50.0, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
so.mesh = np.array([tri])
edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "test_draw_bbox.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_bbox=True,
bbox_color=(0.0, 0.0, 1.0),
raw_geometry=so
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = f.read()
# BBox color blue: 0.000 0.000 1.000 RG
self.assertIn(b"0.000 0.000 1.000 RG", content)
def test_pdf_object_physical_size_matches_3d_mesh(self):
"""
Verifies that the physical size of an object exported to PDF (in PostScript points pt)
matches its 3D mesh dimensions (in mm) * (72.0 / 25.4) with 100% mathematical precision.
"""
import re
mesh_w_mm = 120.0
mesh_h_mm = 80.0
min_x_3d, min_y_3d = 25.0, -15.0
max_x_3d, max_y_3d = min_x_3d + mesh_w_mm, min_y_3d + mesh_h_mm
so = SlicingObject()
so.name = "ScaledPart"
tri1 = np.array([
[min_x_3d, min_y_3d, 0.0],
[max_x_3d, min_y_3d, 0.0],
[min_x_3d, max_y_3d, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
tri2 = np.array([
[max_x_3d, min_y_3d, 0.0],
[max_x_3d, max_y_3d, 0.0],
[min_x_3d, max_y_3d, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
so.mesh = np.array([tri1, tri2])
edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "test_physical_scale.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_wireframe=True,
margin_mm=15.0,
line_width_mm=0.5,
raw_geometry=so
)
with open(pdf_path, "rb") as f:
pdf_text = f.read().decode("utf-8", errors="ignore")
mediabox_match = re.search(r"/MediaBox\s+\[0\s+0\s+(\d+\.\d+)\s+(\d+\.\d+)\]", pdf_text)
self.assertIsNotNone(mediabox_match)
page_w_pt = float(mediabox_match.group(1))
page_h_pt = float(mediabox_match.group(2))
# Verify MediaBox exact conversion: 1 pt = 1/72 inch = 25.4/72 mm
page_w_mm = page_w_pt / MM_TO_PT
page_h_mm = page_h_pt / MM_TO_PT
self.assertAlmostEqual(page_w_mm * MM_TO_PT, page_w_pt, places=4)
self.assertAlmostEqual(page_h_mm * MM_TO_PT, page_h_pt, places=4)
# Object physical size in PDF points:
object_w_pt = mesh_w_mm * MM_TO_PT
object_h_pt = mesh_h_mm * MM_TO_PT
# 120 mm -> 340.15748 pt, 80 mm -> 226.77165 pt
self.assertAlmostEqual(object_w_pt, 120.0 * (72.0 / 25.4), places=5)
self.assertAlmostEqual(object_h_pt, 80.0 * (72.0 / 25.4), places=5)
# Extract drawn object line coordinates under /GS100 gs from the PDF stream
gs100_idx = pdf_text.find("/GS100 gs")
self.assertNotEqual(gs100_idx, -1)
gs100_section = pdf_text[gs100_idx:]
line_matches = re.findall(r"(\d+\.\d+)\s+(\d+\.\d+)\s+m\s+(\d+\.\d+)\s+(\d+\.\d+)\s+l", gs100_section)
self.assertTrue(len(line_matches) >= 4)
obj_x, obj_y = [], []
for x0, y0, x1, y1 in line_matches[:4]:
obj_x.extend([float(x0), float(x1)])
obj_y.extend([float(y0), float(y1)])
drawn_obj_w_pt = max(obj_x) - min(obj_x)
drawn_obj_h_pt = max(obj_y) - min(obj_y)
# Assert that actual drawn line span in PDF points EXACTLY equals 3D mesh dimensions
self.assertAlmostEqual(drawn_obj_w_pt, 120.0 * (72.0 / 25.4), places=2)
self.assertAlmostEqual(drawn_obj_h_pt, 80.0 * (72.0 / 25.4), places=2)
def test_complex_slicing_group_pdf_exporter_end_to_end(self):
"""
End-to-end test on a complex multi-object SlicingGroup containing 3D mechanical
components (chamfered block + hollow prism), testing all PDF layers simultaneously.
"""
import re
# Object 1: 3D Chamfered Block
so1 = SlicingObject()
so1.name = "BaseBracket"
# Triangles forming a 3D block with a chamfered face
tris1 = [
# Bottom face
[[10.0, 20.0, 0.0], [110.0, 20.0, 0.0], [10.0, 70.0, 0.0], [0.0, 0.0, -1.0]],
[[110.0, 20.0, 0.0], [110.0, 70.0, 0.0], [10.0, 70.0, 0.0], [0.0, 0.0, -1.0]],
# Top face
[[10.0, 20.0, 30.0], [10.0, 70.0, 30.0], [90.0, 20.0, 30.0], [0.0, 0.0, 1.0]],
[[90.0, 20.0, 30.0], [10.0, 70.0, 30.0], [90.0, 70.0, 30.0], [0.0, 0.0, 1.0]],
# Chamfer face (45 deg angle)
[[90.0, 20.0, 30.0], [90.0, 70.0, 30.0], [110.0, 20.0, 0.0], [0.707, 0.0, 0.707]],
[[110.0, 20.0, 0.0], [90.0, 70.0, 30.0], [110.0, 70.0, 0.0], [0.707, 0.0, 0.707]],
]
so1.mesh = np.array(tris1, dtype=np.float64)
# Object 2: 3D Hollow Octagonal Prism
so2 = SlicingObject()
so2.name = "HollowPipe"
# Construct an 8-sided hollow tube in 3D
angles = np.linspace(0, 2 * np.pi, 8, endpoint=False)
r_out = 30.0
r_in = 15.0
cx, cy = 180.0, 45.0
tube_tris = []
for i in range(8):
a1, a2 = angles[i], angles[(i + 1) % 8]
x1_o, y1_o = cx + r_out * np.cos(a1), cy + r_out * np.sin(a1)
x2_o, y2_o = cx + r_out * np.cos(a2), cy + r_out * np.sin(a2)
x1_i, y1_i = cx + r_in * np.cos(a1), cy + r_in * np.sin(a1)
x2_i, y2_i = cx + r_in * np.cos(a2), cy + r_in * np.sin(a2)
# Top ring face
tube_tris.append([[x1_o, y1_o, 50.0], [x2_o, y2_o, 50.0], [x1_i, y1_i, 50.0], [0.0, 0.0, 1.0]])
tube_tris.append([[x2_o, y2_o, 50.0], [x2_i, y2_i, 50.0], [x1_i, y1_i, 50.0], [0.0, 0.0, 1.0]])
# Outer wall face
nx, ny = np.cos((a1 + a2) / 2), np.sin((a1 + a2) / 2)
tube_tris.append([[x1_o, y1_o, 0.0], [x2_o, y2_o, 0.0], [x1_o, y1_o, 50.0], [nx, ny, 0.0]])
tube_tris.append([[x2_o, y2_o, 0.0], [x2_o, y2_o, 50.0], [x1_o, y1_o, 50.0], [nx, ny, 0.0]])
so2.mesh = np.array(tube_tris, dtype=np.float64)
# Assemble into SlicingGroup with 2 collections
sc1 = SlicingCollection([], "BracketColl")
sc1.objects = [so1]
sc2 = SlicingCollection([], "PipeColl")
sc2.objects = [so2]
sg = SlicingGroup([])
sg.collections = {"BracketColl": sc1, "PipeColl": sc2}
# Extract 2D wireframe edges from SlicingGroup
edges_2d = extract_unique_top_view_edges(sg)
self.assertTrue(len(edges_2d) > 0)
# Export to PDF with ALL checkboxes enabled
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "complex_slicing_group.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_wireframe=True,
draw_boundary=True,
draw_contour=True,
draw_crease=True,
show_name=True,
crease_angle=30.0,
margin_mm=12.5,
line_width_mm=0.8,
stroke_color=(0.1, 0.2, 0.4),
bg_color=(0.95, 0.98, 1.0),
raw_geometry=sg
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
pdf_bytes = f.read()
pdf_text = pdf_bytes.decode("utf-8", errors="ignore")
# 1. Check PDF header & footer
self.assertTrue(pdf_bytes.startswith(b"%PDF-1.4"))
self.assertIn(b"%%EOF\n", pdf_bytes)
# 2. Check MediaBox
mediabox_match = re.search(r"/MediaBox\s+\[0\s+0\s+(\d+\.\d+)\s+(\d+\.\d+)\]", pdf_text)
self.assertIsNotNone(mediabox_match)
w_pt, h_pt = float(mediabox_match.group(1)), float(mediabox_match.group(2))
self.assertTrue(w_pt > 0 and h_pt > 0)
# 3. Check ExtGState opacities (/GS25, /GS50, /GS100)
self.assertIn("/GS25 5 0 R", pdf_text)
self.assertIn("/GS50 6 0 R", pdf_text)
self.assertIn("/GS100 7 0 R", pdf_text)
# 4. Check colors
self.assertIn("0.950 0.980 1.000 rg", pdf_text) # BG color
self.assertIn("0.100 0.200 0.400 RG", pdf_text) # Stroke color
# 5. Check object name rendering for both objects
self.assertIn("(BaseBracket) Tj", pdf_text)
self.assertIn("(HollowPipe) Tj", pdf_text)
# 6. Check size legend tick labels
self.assertIn("(10) Tj", pdf_text)
self.assertIn("(100 mm) Tj", pdf_text)
if __name__ == '__main__': if __name__ == '__main__':