mesh 2 pdf changes

This commit is contained in:
Nicolas Predella 2026-08-04 10:49:53 +03:00
parent f1d93bd98c
commit 5aa1345027
10 changed files with 632 additions and 46 deletions

6
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,6 @@
{
"recommendations": [
"ms-python.python",
"ms-python.vscode-pylance"
]
}

45
.vscode/settings.json vendored
View file

@ -1,23 +1,34 @@
{ {
"search.exclude": { "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
"**/.venv": true, "python.analysis.extraPaths": [
"**/release": true, "${workspaceFolder}",
"**/wheels": true, "${workspaceFolder}/.venv/lib/python3.13/site-packages"
"**/__pycache__": true, ],
"**/*.blend": true, "python.analysis.autoSearchPaths": true,
"**/*.blend1": true, "python.analysis.typeCheckingMode": "off",
"**/*.bgcode": true, "python.testing.unittestEnabled": true,
"**/*.whl": true, "python.testing.unittestArgs": [
"**/*.zip": true, "-v",
"profiles/prusaslicer/Link to Configs": true, "-s",
"tests/Link to Configs": true "./tests",
}, "-p",
"test_*.py"
],
"python.testing.pytestEnabled": false,
"files.exclude": { "files.exclude": {
"**/.venv": true, "**/.git": true,
"**/release": true, "**/.svn": true,
"**/wheels": true, "**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/Thumbs.db": true,
"**/__pycache__": true, "**/__pycache__": true,
"**/*.pyc": true "**/*.pyc": true
}, },
"search.followSymlinks": false "files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/.venv/**": true,
"**/release/**": true
}
} }

View file

@ -408,16 +408,24 @@ def _copy_slicing_object(so: SlicingObject) -> SlicingObject:
new_so.mesh = so.mesh.copy() if so.mesh is not None else np.empty((0, 4, 3)) new_so.mesh = so.mesh.copy() if so.mesh is not None else np.empty((0, 4, 3))
new_so.attributes = {} new_so.attributes = {}
for k, v in so.attributes.items(): for k, v in so.attributes.items():
if isinstance(v, dict):
new_so.attributes[k] = { new_so.attributes[k] = {
"domain": v["domain"], "domain": v.get("domain", "face"),
"type": v["type"], "type": v.get("type", "string"),
"values": v["values"].copy() if isinstance(v["values"], np.ndarray) else v["values"] "values": v["values"].copy() if isinstance(v.get("values"), np.ndarray) else v.get("values")
} }
elif isinstance(v, np.ndarray):
new_so.attributes[k] = v.copy()
elif isinstance(v, list):
new_so.attributes[k] = list(v)
else:
new_so.attributes[k] = copy.deepcopy(v)
new_so.object_type = so.object_type new_so.object_type = so.object_type
new_so.modifiers = so.modifiers.copy() if so.modifiers else [] new_so.modifiers = so.modifiers.copy() if so.modifiers else []
new_so.extruder = so.extruder new_so.extruder = so.extruder
new_so.transforms = [dict(t) for t in getattr(so, "transforms", [])] new_so.transforms = [dict(t) for t in getattr(so, "transforms", [])]
new_so.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties() new_so.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties()
new_so.object_properties = list(getattr(so, "object_properties", []))
return new_so return new_so
def _copy_slicing_collection(sc: SlicingCollection) -> SlicingCollection: def _copy_slicing_collection(sc: SlicingCollection) -> SlicingCollection:
@ -566,34 +574,71 @@ class SLICENODE_DropGeometry(SliceNode):
return copied_data return copied_data
def format_name_template(template: str, so: Any, default_old_name: str = "") -> str:
if not template or "{" not in template or "}" not in template:
return template
import re
from ....core.pdf_exporter.metadata import get_first_attribute_value
def replace_match(match: re.Match) -> str:
attr_name = match.group(1).strip()
val = get_first_attribute_value(so, attr_name)
if val is None and attr_name in ("Name", "name"):
val = getattr(so, "name", "") or default_old_name
if val is None:
return ""
if isinstance(val, float):
return f"{val:g}"
return str(val)
return re.sub(r"\{([^}]+)\}", replace_match, template)
def _rename_slicing_item(geom: Any, new_name: str) -> Any: def _rename_slicing_item(geom: Any, new_name: str) -> Any:
if geom is None or not new_name: if geom is None or not new_name:
return geom return geom
elif isinstance(geom, (list, tuple)): elif isinstance(geom, (list, tuple)):
return [_rename_slicing_item(item, f"{new_name}_{i}" if len(geom) > 1 else new_name) for i, item in enumerate(geom)] return [_rename_slicing_item(item, f"{new_name}_{i}" if (len(geom) > 1 and "{" not in new_name) else new_name) for i, item in enumerate(geom)]
elif isinstance(geom, SlicingGroup): elif isinstance(geom, SlicingGroup):
copied = _copy_slicing_group(geom) copied = _copy_slicing_group(geom)
old_colls = dict(copied.collections) old_colls = dict(copied.collections)
new_colls = {} new_colls = {}
for i, (old_key, coll) in enumerate(old_colls.items()): for i, (old_key, coll) in enumerate(old_colls.items()):
coll_name = f"{new_name}_{i}" if len(old_colls) > 1 else new_name if coll.objects:
coll_name = format_name_template(new_name, coll.objects[0], default_old_name=old_key)
else:
coll_name = format_name_template(new_name, None, default_old_name=old_key)
if len(old_colls) > 1 and "{" not in new_name:
coll_name = f"{coll_name}_{i}"
coll.name = coll_name coll.name = coll_name
for obj in coll.objects: for j, obj in enumerate(coll.objects):
obj_name = format_name_template(new_name, obj, default_old_name=obj.name)
if (len(coll.objects) > 1 or len(old_colls) > 1) and "{" not in new_name:
obj_name = f"{obj_name}_{j}"
obj.name = obj_name
obj.parent = coll_name obj.parent = coll_name
obj.name = coll_name
new_colls[coll_name] = coll new_colls[coll_name] = coll
copied.collections = new_colls copied.collections = new_colls
return copied return copied
elif isinstance(geom, SlicingCollection): elif isinstance(geom, SlicingCollection):
copied = _copy_slicing_collection(geom) copied = _copy_slicing_collection(geom)
copied.name = new_name old_coll_name = geom.name
for obj in copied.objects: if copied.objects:
obj.parent = new_name coll_name = format_name_template(new_name, copied.objects[0], default_old_name=old_coll_name)
obj.name = new_name else:
coll_name = format_name_template(new_name, None, default_old_name=old_coll_name)
copied.name = coll_name
for i, obj in enumerate(copied.objects):
obj_name = format_name_template(new_name, obj, default_old_name=obj.name)
if len(copied.objects) > 1 and "{" not in new_name:
obj_name = f"{obj_name}_{i}"
obj.name = obj_name
obj.parent = coll_name
return copied return copied
elif isinstance(geom, SlicingObject): elif isinstance(geom, SlicingObject):
copied = _copy_slicing_object(geom) copied = _copy_slicing_object(geom)
copied.name = new_name copied.name = format_name_template(new_name, copied, default_old_name=geom.name)
return copied return copied
return geom return geom
@ -693,3 +738,65 @@ class SLICENODE_AssignPdfProperties(SliceNode):
return _assign_pdf_properties_to_item(geom, lw, lc, sn) return _assign_pdf_properties_to_item(geom, lw, lc, sn)
def _assign_properties_to_item(geom: Any, props: list[tuple[str, str]]) -> Any:
from ....core.pdf_exporter.metadata import resolve_object_properties
if geom is None:
return None
elif isinstance(geom, (list, tuple)):
return [_assign_properties_to_item(item, props) for item in geom]
elif isinstance(geom, SlicingGroup):
copied = _copy_slicing_group(geom)
for coll in copied.collections.values():
for obj in coll.objects:
obj.object_properties = resolve_object_properties(obj, props)
return copied
elif isinstance(geom, SlicingCollection):
copied = _copy_slicing_collection(geom)
for obj in copied.objects:
obj.object_properties = resolve_object_properties(obj, props)
return copied
elif isinstance(geom, SlicingObject):
copied = _copy_slicing_object(geom)
copied.object_properties = resolve_object_properties(copied, props)
return copied
return geom
@register_class
class SLICENODE_AssignPropertyToObject(SliceNode):
bl_label = "Assign Property to Object"
def init(self, context):
self.add_input("SLICESOCK_Mesh", "Geometry")
self.add_input("SLICESOCK_Settings", "Settings")
self.add_output("SLICESOCK_Mesh", "Geometry")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name != "Geometry":
return None
geom: Any = self.resolve("Geometry")
if not geom:
return None
raw_settings = self.resolve("Settings")
props: list[tuple[str, str]] = []
if raw_settings:
if isinstance(raw_settings, dict):
for k, v in raw_settings.items():
props.append((str(k), str(v)))
elif isinstance(raw_settings, (list, tuple)):
for item in raw_settings:
if isinstance(item, tuple) and len(item) == 2:
props.append((str(item[0]), str(item[1])))
elif isinstance(item, dict):
for k, v in item.items():
props.append((str(k), str(v)))
else:
props.append(("", str(item)))
return _assign_properties_to_item(geom, props)

View file

@ -500,6 +500,7 @@ class SLICENODE_MT_Geometry(Menu):
layout.operator("node.add_node", text="Get Mesh").type = "SLICENODE_GetMesh" layout.operator("node.add_node", text="Get Mesh").type = "SLICENODE_GetMesh"
layout.operator("node.add_node", text="Rename Mesh").type = "SLICENODE_RenameMesh" layout.operator("node.add_node", text="Rename Mesh").type = "SLICENODE_RenameMesh"
layout.operator("node.add_node", text="Assign PDF Properties").type = "SLICENODE_AssignPdfProperties" layout.operator("node.add_node", text="Assign PDF Properties").type = "SLICENODE_AssignPdfProperties"
layout.operator("node.add_node", text="Assign Property to Object").type = "SLICENODE_AssignPropertyToObject"
layout.operator("node.add_node", text="Transform Geometry").type = "SLICENODE_TransformGeometry" layout.operator("node.add_node", text="Transform Geometry").type = "SLICENODE_TransformGeometry"
layout.operator("node.add_node", text="Drop Geometry").type = "SLICENODE_DropGeometry" layout.operator("node.add_node", text="Drop Geometry").type = "SLICENODE_DropGeometry"
layout.operator("node.add_node", text="Separate Mesh").type = "SLICENODE_SeparateMesh" layout.operator("node.add_node", text="Separate Mesh").type = "SLICENODE_SeparateMesh"

View file

@ -34,6 +34,29 @@ def get_first_attribute_value(so_item: Any, attr_name: str) -> Any:
return val return val
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]]: def extract_settings_items(settings: Any) -> list[tuple[str, str]]:
""" """
Parses settings dict or object into key/suffix list. Parses settings dict or object into key/suffix list.

View file

@ -15,6 +15,7 @@ from .metadata import (
extract_object_bounding_boxes, extract_object_bounding_boxes,
extract_settings_items, extract_settings_items,
get_first_attribute_value, get_first_attribute_value,
resolve_object_properties,
) )
from .geometry import ( from .geometry import (
extract_unique_top_view_edges, extract_unique_top_view_edges,
@ -54,9 +55,9 @@ def render_technical_header_table(
h_table_mm = cell_height_mm * 3 h_table_mm = cell_height_mm * 3
x1_pt = page_w_pt - (total_margin_mm * MM_TO_PT) x1_pt = page_w_pt - (total_margin_mm * MM_TO_PT)
y0_pt = total_margin_mm * MM_TO_PT y1_pt = page_h_pt - (total_margin_mm * MM_TO_PT)
x0_pt = x1_pt - (w_table_mm * MM_TO_PT) x0_pt = x1_pt - (w_table_mm * MM_TO_PT)
y1_pt = y0_pt + (h_table_mm * MM_TO_PT) y0_pt = y1_pt - (h_table_mm * MM_TO_PT)
# Fill background box # Fill background box
br, bg, bb = bg_color br, bg, bb = bg_color
@ -146,6 +147,142 @@ def render_technical_header_table(
s.end_text() s.end_text()
def render_object_properties_table(
s: PdfStream,
page_w_pt: float,
page_h_pt: float,
total_margin_mm: float,
slicing_objs: list[Any],
draw_header: bool,
header_info: dict[str, Any] | None,
stroke_color: tuple[float, float, float],
bg_color: tuple[float, float, float],
font_size_pt: float,
line_width_pt: float,
) -> None:
"""
Renders per-object properties boxes under the technical header table on the right side of the drawing.
"""
objs_with_props = [
so for so in slicing_objs
if hasattr(so, "object_properties") and so.object_properties
]
if not objs_with_props:
return
w_table_mm = (font_size_pt / MM_TO_PT) * 25.0
w_box_pt = w_table_mm * MM_TO_PT
cell_height_mm = (font_size_pt / MM_TO_PT) * 1.4
cell_h_pt = cell_height_mm * MM_TO_PT
gap_pt = cell_h_pt * 0.5
x1_pt = page_w_pt - (total_margin_mm * MM_TO_PT)
x0_pt = x1_pt - w_box_pt
top_y_pt = page_h_pt - (total_margin_mm * MM_TO_PT)
if draw_header and header_info is not None:
h_header_table_mm = cell_height_mm * 3.0
header_table_h_pt = h_header_table_mm * MM_TO_PT
curr_y_pt = top_y_pt - header_table_h_pt - gap_pt
else:
curr_y_pt = top_y_pt
table_lw_pt = line_width_pt * 1.2
grid_lw_pt = line_width_pt * 0.8
br, bg, bb = bg_color
sr, sg, sb = stroke_color
eff_font = font_size_pt if font_size_pt is not None and font_size_pt > 0 else 10.0
val_font_pt = eff_font * 0.75
lbl_font_pt = eff_font * 0.65
label_color = (0.35, 0.35, 0.35)
for so in objs_with_props:
raw_props = getattr(so, "object_properties", [])
props = resolve_object_properties(so, raw_props)
if not props:
continue
obj_name = str(getattr(so, "name", "Object"))
num_rows = 1 + len(props)
h_box_pt = num_rows * cell_h_pt
y1_box_pt = curr_y_pt
y0_box_pt = y1_box_pt - h_box_pt
# Fill background
s.opacity("GS100")
s.fill_color(br, bg, bb)
s.fill_rect(x0_pt, y0_box_pt, w_box_pt, h_box_pt)
# Outer border
s.stroke_color(sr, sg, sb).line_width(table_lw_pt).round_caps()
s.rect(x0_pt, y0_box_pt, w_box_pt, h_box_pt).stroke()
# Divider between Subbox 1 (Object Name) and Subbox 2 (Properties)
y_name_div_pt = y1_box_pt - cell_h_pt
s.line_width(grid_lw_pt)
s.line_stroke(x0_pt, y_name_div_pt, x1_pt, y_name_div_pt)
# Subbox 1: Object Name (in bold)
name_vx = x0_pt + (1.5 * MM_TO_PT)
name_vy = y_name_div_pt + (1.5 * MM_TO_PT)
s.begin_text().font("F1", val_font_pt)
s.fill_color(sr, sg, sb)
bold_w = max(0.15, val_font_pt * 0.035)
s.stroke_color(sr, sg, sb).line_width(bold_w).text_rendering_mode(2)
s.text_position(name_vx, name_vy)
s.show(obj_name)
s.text_rendering_mode(0)
s.end_text()
# Subbox 2: Properties
y_prop_curr_pt = y_name_div_pt
x_div_40_pt = x0_pt + (0.40 * w_box_pt)
for prop_idx, (prop_id, prop_val) in enumerate(props):
row_top_pt = y_prop_curr_pt - prop_idx * cell_h_pt
row_bot_pt = row_top_pt - cell_h_pt
if prop_idx > 0:
s.line_width(grid_lw_pt)
s.line_stroke(x0_pt, row_top_pt, x1_pt, row_top_pt)
str_val = str(prop_val)
if not prop_id:
# Single line text (just the value in a cell)
vx = x0_pt + (1.5 * MM_TO_PT)
vy = row_bot_pt + (1.5 * MM_TO_PT)
s.begin_text().font("F1", val_font_pt)
s.fill_color(sr, sg, sb)
s.text_position(vx, vy)
s.show(str_val)
s.end_text()
else:
# Pair ID-value (two cells with divider at 40% of width)
s.line_width(grid_lw_pt)
s.line_stroke(x_div_40_pt, row_bot_pt, x_div_40_pt, row_top_pt)
# Left cell: ID
lx = x0_pt + (1.5 * MM_TO_PT)
ly = row_bot_pt + (1.5 * MM_TO_PT)
s.begin_text().font("F1", lbl_font_pt)
s.fill_color(*label_color)
s.text_position(lx, ly)
s.show(str(prop_id))
s.end_text()
# Right cell: Value
vx = x_div_40_pt + (1.5 * MM_TO_PT)
vy = row_bot_pt + (1.5 * MM_TO_PT)
s.begin_text().font("F1", val_font_pt)
s.fill_color(sr, sg, sb)
s.text_position(vx, vy)
s.show(str_val)
s.end_text()
curr_y_pt = y0_box_pt - gap_pt
def get_all_slicing_objects(mesh_data: Any) -> list[Any]: def get_all_slicing_objects(mesh_data: Any) -> list[Any]:
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
objects = [] objects = []
@ -469,9 +606,21 @@ class PDFObjectLabelsElement(PDFElement):
lines_to_draw.append((f"{key_name}: {val_str}", exp.stroke_color, False)) lines_to_draw.append((f"{key_name}: {val_str}", exp.stroke_color, False))
if lines_to_draw: if lines_to_draw:
contour_w = max(0.5, eff_font_size_pt * 0.12)
for line_idx, (line_str, col, is_bold) in enumerate(lines_to_draw): for line_idx, (line_str, col, is_bold) in enumerate(lines_to_draw):
cr, cg, cb = col cr, cg, cb = col
curr_y_pt = y_text_pt - (line_idx * line_spacing_pt) curr_y_pt = y_text_pt - (line_idx * line_spacing_pt)
# 1. White contour outline pass (behind text for contrast against geometry)
s.begin_text().font("F1", eff_font_size_pt)
s.fill_color(1.0, 1.0, 1.0).stroke_color(1.0, 1.0, 1.0)
s.line_width(contour_w).text_rendering_mode(2)
s.text_position(x_text_pt, curr_y_pt)
s.show(line_str)
s.text_rendering_mode(0)
s.end_text()
# 2. Foreground text pass
s.begin_text().font("F1", eff_font_size_pt) s.begin_text().font("F1", eff_font_size_pt)
s.fill_color(cr, cg, cb) s.fill_color(cr, cg, cb)
if is_bold: if is_bold:
@ -488,7 +637,7 @@ class PDFObjectLabelsElement(PDFElement):
class PDFHeaderTableElement(PDFElement): class PDFHeaderTableElement(PDFElement):
""" """
Renders ISO/DIN technical drawing header table at bottom-right corner. Renders ISO/DIN technical drawing header table at top-right corner.
""" """
def render(self, s: PdfStream) -> None: def render(self, s: PdfStream) -> None:
exp = self.exporter exp = self.exporter
@ -515,6 +664,30 @@ class PDFHeaderTableElement(PDFElement):
) )
class PDFObjectPropertiesTableElement(PDFElement):
"""
Renders object properties table under the technical header table.
"""
def render(self, s: PdfStream) -> None:
exp = self.exporter
if not exp.slicing_objs:
return
render_object_properties_table(
s=s,
page_w_pt=exp.page_w_pt,
page_h_pt=exp.page_h_pt,
total_margin_mm=exp.total_margin_mm,
slicing_objs=exp.slicing_objs,
draw_header=exp.draw_header,
header_info=exp.header_info,
stroke_color=exp.stroke_color,
bg_color=exp.bg_color,
font_size_pt=exp.font_size_pt,
line_width_pt=exp.line_width_pt,
)
class export_mesh_to_pdf: class export_mesh_to_pdf:
""" """
Class responsible for configuring, building, and exporting 2D vector mesh geometry Class responsible for configuring, building, and exporting 2D vector mesh geometry
@ -530,6 +703,7 @@ class export_mesh_to_pdf:
LegendElement = PDFLegendElement LegendElement = PDFLegendElement
ObjectLabelsElement = PDFObjectLabelsElement ObjectLabelsElement = PDFObjectLabelsElement
HeaderTableElement = PDFHeaderTableElement HeaderTableElement = PDFHeaderTableElement
ObjectPropertiesTableElement = PDFObjectPropertiesTableElement
# Convenient aliases for element subclasses # Convenient aliases for element subclasses
Legend = PDFLegendElement Legend = PDFLegendElement
@ -541,6 +715,7 @@ class export_mesh_to_pdf:
SA2Measures = PDFSA2MeasuresElement SA2Measures = PDFSA2MeasuresElement
ObjectLabels = PDFObjectLabelsElement ObjectLabels = PDFObjectLabelsElement
Background = PDFBackgroundElement Background = PDFBackgroundElement
ObjectPropertiesTable = PDFObjectPropertiesTableElement
def __init__( def __init__(
self, self,
@ -576,6 +751,8 @@ class export_mesh_to_pdf:
self.draw_bbox = draw_bbox self.draw_bbox = draw_bbox
self.draw_sa2_holes = draw_sa2_holes self.draw_sa2_holes = draw_sa2_holes
self.draw_header = draw_header self.draw_header = draw_header
if self.draw_header and header_info is None:
header_info = {}
self.header_info = header_info self.header_info = header_info
self.show_name = show_name self.show_name = show_name
self.show_bbox_size = show_bbox_size self.show_bbox_size = show_bbox_size
@ -600,12 +777,15 @@ class export_mesh_to_pdf:
self.LegendElement(self), self.LegendElement(self),
self.ObjectLabelsElement(self), self.ObjectLabelsElement(self),
self.HeaderTableElement(self), self.HeaderTableElement(self),
self.ObjectPropertiesTableElement(self),
] ]
if filepath is not None: if filepath is not None:
self.export(filepath) self.export(filepath)
def _prepare_geometry_and_layout(self) -> None: def _prepare_geometry_and_layout(self) -> None:
self.slicing_objs = get_all_slicing_objects(self.raw_geometry)
edge_list: list[NDArray[np.float64]] = [] edge_list: list[NDArray[np.float64]] = []
if self.draw_wireframe and len(self.edges_2d) > 0: if self.draw_wireframe and len(self.edges_2d) > 0:
@ -668,16 +848,42 @@ class export_mesh_to_pdf:
self.total_margin_mm = self.margin_mm + 2.0 * self.minor_step self.total_margin_mm = self.margin_mm + 2.0 * self.minor_step
w_total_mm = w_grid_mm + 2.0 * self.total_margin_mm
h_total_mm = h_grid_mm + 2.0 * self.total_margin_mm
self.page_w_pt = w_total_mm * MM_TO_PT
self.page_h_pt = h_total_mm * MM_TO_PT
self.line_width_pt, self.font_size_pt = compute_effective_stroke_and_font( self.line_width_pt, self.font_size_pt = compute_effective_stroke_and_font(
self.line_width_mm, self.font_size_mm, self.minor_step self.line_width_mm, self.font_size_mm, self.minor_step
) )
objs_with_props = [
so for so in self.slicing_objs
if hasattr(so, "object_properties") and so.object_properties
]
w_table_mm = 0.0
h_table_col_mm = 0.0
cell_height_mm = (self.font_size_pt / MM_TO_PT) * 1.4
gap_mm = cell_height_mm * 0.5
if self.draw_header and self.header_info is not None:
w_table_mm = (self.font_size_pt / MM_TO_PT) * 25.0
h_header_table_mm = cell_height_mm * 3.0
h_table_col_mm = h_header_table_mm
if objs_with_props:
w_table_mm = (self.font_size_pt / MM_TO_PT) * 25.0
h_props_total_mm = sum(
(1 + len(getattr(so, "object_properties", []))) * cell_height_mm
for so in objs_with_props
) + len(objs_with_props) * gap_mm
if h_table_col_mm > 0.0:
h_table_col_mm += gap_mm + h_props_total_mm
else:
h_table_col_mm = h_props_total_mm
w_total_mm = w_grid_mm + w_table_mm + 2.0 * self.total_margin_mm
h_total_mm = max(h_grid_mm, h_table_col_mm) + 2.0 * self.total_margin_mm
self.page_w_pt = w_total_mm * MM_TO_PT
self.page_h_pt = h_total_mm * MM_TO_PT
max_grid_steps = 2000 max_grid_steps = 2000
# Vectorized vertical grid lines # Vectorized vertical grid lines
@ -821,3 +1027,8 @@ class export_mesh_to_pdf:
f.write(b"startxref\n") f.write(b"startxref\n")
f.write(f"{xref_offset}\n".encode("utf-8")) f.write(f"{xref_offset}\n".encode("utf-8"))
f.write(b"%%EOF\n") f.write(b"%%EOF\n")
# Alias for backward compatibility
export_wireframe_to_pdf = export_mesh_to_pdf

View file

@ -60,13 +60,16 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
### Geometry Processing ### Geometry Processing
- **Get Mesh**: Converts a Blender Object into internal `SlicingObject` format. - **Get Mesh**: Converts a Blender Object into internal `SlicingObject` format.
- **Rename Mesh**: Overrides the internal metadata name of a `SlicingObject`, `SlicingCollection`, or `SlicingGroup`. - **Rename Mesh**: Overrides the internal metadata name of a `SlicingObject`, `SlicingCollection`, or `SlicingGroup`. Supports `{AttributeName}` attribute placeholders (e.g., `Rebar-L{Rebar Length}M`) that evaluate to index zero of the target attribute for each object, as well as `{Name}` to include the item's previous name (e.g., `{Name}_T{Thickness}mm`).
- **Geometry**: Input geometry data. - **Geometry**: Input geometry data.
- **Name**: String input socket or property for the new name. - **Name**: String input socket or property for the new name template.
- **Assign PDF Properties**: Assigns line width (mm) and line color PDF export properties to all `SlicingObject` instances within a `SlicingObject`, `SlicingCollection`, or `SlicingGroup`. - **Assign PDF Properties**: Assigns line width (mm) and line color PDF export properties to all `SlicingObject` instances within a `SlicingObject`, `SlicingCollection`, or `SlicingGroup`.
- **Geometry**: Input geometry socket. - **Geometry**: Input geometry socket.
- **Line Width**: Input socket / float property for line width in mm. - **Line Width**: Input socket / float property for line width in mm.
- **Line Color**: Color property for stroke color. - **Line Color**: Color property for stroke color.
- **Assign Property to Object**: Assigns custom settings/properties to each object in a `SlicingObject`, `SlicingCollection`, or `SlicingGroup` for rendering in PDF properties tables.
- **Geometry**: Input geometry socket.
- **Settings**: Input socket accepting configuration settings or property items.
- **Transform Geometry**: Moves, rotates, and scales a `SlicingCollection` or `SlicingGroup` relative to the origin. - **Transform Geometry**: Moves, rotates, and scales a `SlicingCollection` or `SlicingGroup` relative to the origin.
- **Mesh**: Input mesh data. - **Mesh**: Input mesh data.
- **Translation**: 3D vector specifying the translation offset. - **Translation**: 3D vector specifying the translation offset.

View file

@ -105,6 +105,7 @@ class SlicingObject():
modifiers: list[dict] = [] modifiers: list[dict] = []
extruder: int = 1 extruder: int = 1
pdf_properties: PdfProperties pdf_properties: PdfProperties
object_properties: list[tuple[str, str]]
def __init__(self, obj: Object | None = None, parent: str = "") -> None: def __init__(self, obj: Object | None = None, parent: str = "") -> None:
if not bpy.context.scene: raise Exception('No scene currently open!') if not bpy.context.scene: raise Exception('No scene currently open!')
@ -115,6 +116,7 @@ class SlicingObject():
self.modifiers = [] self.modifiers = []
self.transforms = [] self.transforms = []
self.pdf_properties = PdfProperties() self.pdf_properties = PdfProperties()
self.object_properties = []
assert bpy.context.scene.unit_settings assert bpy.context.scene.unit_settings
scene_scale: float = bpy.context.scene.unit_settings.scale_length scene_scale: float = bpy.context.scene.unit_settings.scale_length
@ -141,6 +143,7 @@ class SlicingObject():
merged.object_type = objects[0].object_type merged.object_type = objects[0].object_type
merged.extruder = objects[0].extruder merged.extruder = objects[0].extruder
merged.pdf_properties = copy.deepcopy(objects[0].pdf_properties) if hasattr(objects[0], "pdf_properties") and objects[0].pdf_properties else PdfProperties() merged.pdf_properties = copy.deepcopy(objects[0].pdf_properties) if hasattr(objects[0], "pdf_properties") and objects[0].pdf_properties else PdfProperties()
merged.object_properties = list(getattr(objects[0], "object_properties", []))
merged.transforms = [] merged.transforms = []
for o in objects: for o in objects:
for t in getattr(o, "transforms", []): for t in getattr(o, "transforms", []):

View file

@ -1059,7 +1059,177 @@ class TestPdfExporter(unittest.TestCase):
self.assertNotIn("(X) Tj", content) self.assertNotIn("(X) Tj", content)
self.assertNotIn("(Y) Tj", content) self.assertNotIn("(Y) Tj", content)
def test_header_table_positioned_to_right_of_drawing(self):
edges_2d = np.array([[[0.0, 0.0], [10.0, 0.0]]], dtype=np.float64)
exp_no_header = export_mesh_to_pdf(edges_2d=edges_2d, draw_wireframe=True, draw_header=False)
exp_with_header = export_mesh_to_pdf(edges_2d=edges_2d, draw_wireframe=True, draw_header=True, header_info={})
w_table_pt = exp_with_header.font_size_pt * 25.0
# Page width with header is wider by the exact width of the table
self.assertAlmostEqual(exp_with_header.page_w_pt, exp_no_header.page_w_pt + w_table_pt, places=4)
# Drawing grid right edge in PDF points
w_grid_mm = exp_with_header.grid_max_x - exp_with_header.grid_min_x
grid_right_pt = (exp_with_header.total_margin_mm + w_grid_mm) * MM_TO_PT
# Table left edge in PDF points
table_left_pt = exp_with_header.page_w_pt - (exp_with_header.total_margin_mm * MM_TO_PT) - w_table_pt
# Table left edge aligns precisely with drawing grid right edge
self.assertAlmostEqual(table_left_pt, grid_right_pt, places=4)
# Drawing grid top edge & table top edge in PDF points
grid_top_pt = exp_with_header.page_h_pt - (exp_with_header.total_margin_mm * MM_TO_PT)
table_top_pt = exp_with_header.page_h_pt - (exp_with_header.total_margin_mm * MM_TO_PT)
self.assertAlmostEqual(table_top_pt, grid_top_pt, places=4)
def test_assign_property_to_object_node_and_helper(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import _assign_properties_to_item, SLICENODE_AssignPropertyToObject
so = SlicingObject()
so.name = "TestBracket"
so.mesh = np.array([[[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 1]]], dtype=np.float64)
sc = SlicingCollection([], "Coll1")
sc.objects = [so]
sg = SlicingGroup([])
sg.collections = {"Coll1": sc}
props = [("", "Text Only Property"), ("Infill", "40%"), ("Layer", "0.2mm")]
updated_sg = _assign_properties_to_item(sg, props)
for coll in updated_sg.collections.values():
for obj in coll.objects:
self.assertEqual(obj.object_properties, props)
def test_export_pdf_object_properties_table(self):
so1 = SlicingObject()
so1.name = "BracketA"
so1.mesh = np.array([[[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 1]]], dtype=np.float64)
so2 = SlicingObject()
so2.name = "BracketB"
so2.mesh = np.array([[[20, 20, 0], [30, 20, 0], [20, 30, 0], [0, 0, 1]]], dtype=np.float64)
sc1 = SlicingCollection([], "Coll1")
sc1.objects = [so1]
sc2 = SlicingCollection([], "Coll2")
sc2.objects = [so2]
sg = SlicingGroup([])
sg.collections = {"Coll1": sc1, "Coll2": sc2}
# Assign properties to so1 only
so1.object_properties = [("", "Material: PETG"), ("Tolerance", "ISO 2768-f")]
edges_2d = extract_unique_top_view_edges(sg)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "obj_props_table.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_wireframe=True,
draw_header=True,
header_info={"company": "Acme Inc"},
raw_geometry=sg,
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
# Check bold object name in table
self.assertIn("(BracketA) Tj", content)
self.assertIn("2 Tr", content)
# Check single line text property
self.assertIn("(Material: PETG) Tj", content)
# Check pair ID-value property
self.assertIn("(Tolerance) Tj", content)
self.assertIn("(ISO 2768-f) Tj", content)
# Object B has no properties, so no box for BracketB in object properties table
# Note: BracketB name is also not rendered in PDF since show_name=False
self.assertNotIn("(BracketB) Tj", content)
def test_assign_property_resolves_attribute_at_index_zero(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import _assign_properties_to_item
so = SlicingObject()
so.name = "AttrObject"
so.mesh = np.array([[[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 1]]], dtype=np.float64)
so.attributes = {
"infill_density": {"domain": "face", "type": "float", "values": np.array([15.5, 15.5])},
"material": ["ABS", "ABS"],
}
sc = SlicingCollection([], "Coll")
sc.objects = [so]
sg = SlicingGroup([])
sg.collections = {"Coll": sc}
# Tuple where 1st position exists as attribute and 2nd position is empty
props = [("infill_density", ""), ("material", ""), ("custom_setting", "fixed_value")]
updated_sg = _assign_properties_to_item(sg, props)
res_obj = updated_sg.collections["Coll"].objects[0]
self.assertEqual(
res_obj.object_properties,
[("infill_density", "15.5"), ("material", "ABS"), ("custom_setting", "fixed_value")]
)
edges_2d = extract_unique_top_view_edges(sg)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "attr_index_zero.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
draw_wireframe=True,
raw_geometry=updated_sg,
)
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn("(AttrObject) Tj", content)
self.assertIn("(infill_density) Tj", content)
self.assertIn("(15.5) Tj", content)
self.assertIn("(material) Tj", content)
self.assertIn("(ABS) Tj", content)
def test_object_name_drawing_white_contour(self):
so = SlicingObject()
so.name = "ContourObj"
so.mesh = np.array([[[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 1]]], dtype=np.float64)
sc = SlicingCollection([], "Coll")
sc.objects = [so]
sg = SlicingGroup([])
sg.collections = {"Coll": sc}
edges_2d = extract_unique_top_view_edges(sg)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "white_contour.pdf")
export_mesh_to_pdf(
edges_2d,
pdf_path,
show_name=True,
draw_wireframe=True,
raw_geometry=sg,
)
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn("(ContourObj) Tj", content)
# Verify white fill/stroke color pass exists for contour
self.assertIn("1.000 1.000 1.000 rg", content)
self.assertIn("1.000 1.000 1.000 RG", content)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View file

@ -7,26 +7,37 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.append(str(PROJECT_ROOT.parent)) sys.path.append(str(PROJECT_ROOT.parent))
# Mock bpy and dependencies for unit testing # Mock bpy and dependencies for unit testing
class DummyNodeSocket: pass
class DummyNode: pass
class DummyObject: class DummyObject:
def __init__(self, name="TestObj"): def __init__(self, name="TestObj"):
self.name = name self.name = name
class DummyMesh: pass
class DummyBase: pass
mock_types = MagicMock() mock_types = MagicMock()
mock_types.NodeSocket = DummyNodeSocket mock_types.NodeSocket = type("NodeSocket", (DummyBase,), {})
mock_types.Node = DummyNode mock_types.Node = type("Node", (DummyBase,), {})
mock_types.NodeCustomGroup = type("NodeCustomGroup", (DummyBase,), {})
mock_types.NodeTree = type("NodeTree", (DummyBase,), {})
mock_types.PropertyGroup = type("PropertyGroup", (DummyBase,), {})
mock_types.Operator = type("Operator", (DummyBase,), {})
mock_types.Panel = type("Panel", (DummyBase,), {})
mock_types.Object = DummyObject mock_types.Object = DummyObject
mock_types.Mesh = DummyMesh
mock_bpy = MagicMock() mock_bpy = MagicMock()
mock_bpy.context.scene = MagicMock() mock_bpy.context.scene = MagicMock()
mock_bpy.context.scene.unit_settings.scale_length = 0.001 mock_bpy.context.scene.unit_settings.scale_length = 0.001
mock_bpy.types = mock_types mock_bpy.types = mock_types
mock_bpy.path.abspath = lambda path: path.replace("//", "/abs/")
sys.modules['bpy'] = mock_bpy sys.modules['bpy'] = mock_bpy
sys.modules['bpy.types'] = mock_types sys.modules['bpy.types'] = mock_types
sys.modules['bpy.props'] = MagicMock() sys.modules['bpy.props'] = MagicMock()
sys.modules['bpy.utils'] = MagicMock() sys.modules['bpy.utils'] = MagicMock()
sys.modules['bpy.utils.previews'] = MagicMock()
sys.modules['bpy.app'] = MagicMock()
sys.modules['bpy.app.handlers'] = MagicMock()
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection, SlicingGroup from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection, SlicingGroup
from NodeSlicer.core.nodeslicer.nodes.mesh import _rename_slicing_item from NodeSlicer.core.nodeslicer.nodes.mesh import _rename_slicing_item
@ -60,6 +71,46 @@ class TestRenameMesh(unittest.TestCase):
self.assertIn("NewGroup", renamed.collections) self.assertIn("NewGroup", renamed.collections)
self.assertEqual(renamed.collections["NewGroup"].name, "NewGroup") self.assertEqual(renamed.collections["NewGroup"].name, "NewGroup")
def test_rename_with_attribute_template(self):
import numpy as np
so = SlicingObject()
so.name = "OriginalBar"
so.attributes = {
"Rebar Length": {"domain": "face", "type": "float", "values": np.array([12.5, 12.5])}
}
renamed = _rename_slicing_item(so, "Rebar-L{Rebar Length}M")
self.assertEqual(renamed.name, "Rebar-L12.5M")
def test_rename_with_multiple_attribute_placeholders(self):
import numpy as np
so = SlicingObject()
so.name = "Piece"
so.attributes = {
"Material": ["Steel", "Steel"],
"Length": {"domain": "face", "type": "float", "values": np.array([500.0, 500.0])}
}
sc = SlicingCollection([], "Coll1")
sc.objects = [so]
renamed_sc = _rename_slicing_item(sc, "Part-{Material}-{Length}mm")
self.assertEqual(renamed_sc.name, "Part-Steel-500mm")
self.assertEqual(renamed_sc.objects[0].name, "Part-Steel-500mm")
def test_rename_with_old_name_placeholder(self):
import numpy as np
so = SlicingObject()
so.name = "OriginalBracket"
so.attributes = {
"Thickness": {"domain": "face", "type": "float", "values": np.array([3.5, 3.5])}
}
renamed = _rename_slicing_item(so, "{Name}_T{Thickness}mm")
self.assertEqual(renamed.name, "OriginalBracket_T3.5mm")
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()