naming and sizes
This commit is contained in:
parent
c41380f17b
commit
122f83b087
5 changed files with 528 additions and 9 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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__':
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue