From 5aa13450278f7eb37aa541ac7f5297c9906e9fe8 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Tue, 4 Aug 2026 10:49:53 +0300 Subject: [PATCH] mesh 2 pdf changes --- .vscode/extensions.json | 6 + .vscode/settings.json | 45 +++-- core/nodeslicer/nodes/mesh.py | 135 +++++++++++++-- core/nodeslicer/tree.py | 1 + core/pdf_exporter/metadata.py | 23 +++ core/pdf_exporter/writer.py | 229 +++++++++++++++++++++++++- doc/NODES.md | 7 +- infra/blender/blender_mesh_capture.py | 3 + tests/test_pdf_exporter.py | 170 +++++++++++++++++++ tests/test_rename_mesh.py | 59 ++++++- 10 files changed, 632 insertions(+), 46 deletions(-) create mode 100644 .vscode/extensions.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..bb6da09 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 70376b4..cad1d41 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,23 +1,34 @@ { - "search.exclude": { - "**/.venv": true, - "**/release": true, - "**/wheels": true, - "**/__pycache__": true, - "**/*.blend": true, - "**/*.blend1": true, - "**/*.bgcode": true, - "**/*.whl": true, - "**/*.zip": true, - "profiles/prusaslicer/Link to Configs": true, - "tests/Link to Configs": true - }, + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.analysis.extraPaths": [ + "${workspaceFolder}", + "${workspaceFolder}/.venv/lib/python3.13/site-packages" + ], + "python.analysis.autoSearchPaths": true, + "python.analysis.typeCheckingMode": "off", + "python.testing.unittestEnabled": true, + "python.testing.unittestArgs": [ + "-v", + "-s", + "./tests", + "-p", + "test_*.py" + ], + "python.testing.pytestEnabled": false, "files.exclude": { - "**/.venv": true, - "**/release": true, - "**/wheels": true, + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, "**/__pycache__": true, "**/*.pyc": true }, - "search.followSymlinks": false + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true, + "**/.venv/**": true, + "**/release/**": true + } } diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index 5df9b79..5a926cb 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -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.attributes = {} for k, v in so.attributes.items(): - new_so.attributes[k] = { - "domain": v["domain"], - "type": v["type"], - "values": v["values"].copy() if isinstance(v["values"], np.ndarray) else v["values"] - } + if isinstance(v, dict): + new_so.attributes[k] = { + "domain": v.get("domain", "face"), + "type": v.get("type", "string"), + "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.modifiers = so.modifiers.copy() if so.modifiers else [] new_so.extruder = so.extruder 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.object_properties = list(getattr(so, "object_properties", [])) return new_so def _copy_slicing_collection(sc: SlicingCollection) -> SlicingCollection: @@ -566,34 +574,71 @@ class SLICENODE_DropGeometry(SliceNode): 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: if geom is None or not new_name: return geom 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): copied = _copy_slicing_group(geom) old_colls = dict(copied.collections) new_colls = {} 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 - 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.name = coll_name new_colls[coll_name] = coll copied.collections = new_colls return copied elif isinstance(geom, SlicingCollection): copied = _copy_slicing_collection(geom) - copied.name = new_name - for obj in copied.objects: - obj.parent = new_name - obj.name = new_name + old_coll_name = geom.name + if copied.objects: + coll_name = format_name_template(new_name, copied.objects[0], default_old_name=old_coll_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 elif isinstance(geom, SlicingObject): 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 geom @@ -693,3 +738,65 @@ class SLICENODE_AssignPdfProperties(SliceNode): 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) + + + diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 65575bb..63808aa 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -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="Rename Mesh").type = "SLICENODE_RenameMesh" 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="Drop Geometry").type = "SLICENODE_DropGeometry" layout.operator("node.add_node", text="Separate Mesh").type = "SLICENODE_SeparateMesh" diff --git a/core/pdf_exporter/metadata.py b/core/pdf_exporter/metadata.py index 8effdcd..2dd1953 100644 --- a/core/pdf_exporter/metadata.py +++ b/core/pdf_exporter/metadata.py @@ -34,6 +34,29 @@ def get_first_attribute_value(so_item: Any, attr_name: str) -> Any: 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]]: """ Parses settings dict or object into key/suffix list. diff --git a/core/pdf_exporter/writer.py b/core/pdf_exporter/writer.py index f9ffbe6..6a0ae85 100644 --- a/core/pdf_exporter/writer.py +++ b/core/pdf_exporter/writer.py @@ -15,6 +15,7 @@ from .metadata import ( extract_object_bounding_boxes, extract_settings_items, get_first_attribute_value, + resolve_object_properties, ) from .geometry import ( extract_unique_top_view_edges, @@ -54,9 +55,9 @@ def render_technical_header_table( h_table_mm = cell_height_mm * 3 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) - y1_pt = y0_pt + (h_table_mm * MM_TO_PT) + y0_pt = y1_pt - (h_table_mm * MM_TO_PT) # Fill background box br, bg, bb = bg_color @@ -146,6 +147,142 @@ def render_technical_header_table( 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]: from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject objects = [] @@ -469,9 +606,21 @@ class PDFObjectLabelsElement(PDFElement): lines_to_draw.append((f"{key_name}: {val_str}", exp.stroke_color, False)) 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): cr, cg, cb = col 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.fill_color(cr, cg, cb) if is_bold: @@ -488,7 +637,7 @@ class PDFObjectLabelsElement(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: 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 responsible for configuring, building, and exporting 2D vector mesh geometry @@ -530,6 +703,7 @@ class export_mesh_to_pdf: LegendElement = PDFLegendElement ObjectLabelsElement = PDFObjectLabelsElement HeaderTableElement = PDFHeaderTableElement + ObjectPropertiesTableElement = PDFObjectPropertiesTableElement # Convenient aliases for element subclasses Legend = PDFLegendElement @@ -541,6 +715,7 @@ class export_mesh_to_pdf: SA2Measures = PDFSA2MeasuresElement ObjectLabels = PDFObjectLabelsElement Background = PDFBackgroundElement + ObjectPropertiesTable = PDFObjectPropertiesTableElement def __init__( self, @@ -576,6 +751,8 @@ class export_mesh_to_pdf: self.draw_bbox = draw_bbox self.draw_sa2_holes = draw_sa2_holes self.draw_header = draw_header + if self.draw_header and header_info is None: + header_info = {} self.header_info = header_info self.show_name = show_name self.show_bbox_size = show_bbox_size @@ -600,12 +777,15 @@ class export_mesh_to_pdf: self.LegendElement(self), self.ObjectLabelsElement(self), self.HeaderTableElement(self), + self.ObjectPropertiesTableElement(self), ] if filepath is not None: self.export(filepath) def _prepare_geometry_and_layout(self) -> None: + self.slicing_objs = get_all_slicing_objects(self.raw_geometry) + edge_list: list[NDArray[np.float64]] = [] 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 - 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_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 # Vectorized vertical grid lines @@ -821,3 +1027,8 @@ class export_mesh_to_pdf: f.write(b"startxref\n") f.write(f"{xref_offset}\n".encode("utf-8")) f.write(b"%%EOF\n") + + +# Alias for backward compatibility +export_wireframe_to_pdf = export_mesh_to_pdf + diff --git a/doc/NODES.md b/doc/NODES.md index c0e588d..5caaa21 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -60,13 +60,16 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ ### Geometry Processing - **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. - - **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`. - **Geometry**: Input geometry socket. - **Line Width**: Input socket / float property for line width in mm. - **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. - **Mesh**: Input mesh data. - **Translation**: 3D vector specifying the translation offset. diff --git a/infra/blender/blender_mesh_capture.py b/infra/blender/blender_mesh_capture.py index 4d80eb5..18f86ef 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -105,6 +105,7 @@ class SlicingObject(): modifiers: list[dict] = [] extruder: int = 1 pdf_properties: PdfProperties + object_properties: list[tuple[str, str]] def __init__(self, obj: Object | None = None, parent: str = "") -> None: if not bpy.context.scene: raise Exception('No scene currently open!') @@ -115,6 +116,7 @@ class SlicingObject(): self.modifiers = [] self.transforms = [] self.pdf_properties = PdfProperties() + self.object_properties = [] assert bpy.context.scene.unit_settings scene_scale: float = bpy.context.scene.unit_settings.scale_length @@ -141,6 +143,7 @@ class SlicingObject(): merged.object_type = objects[0].object_type 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.object_properties = list(getattr(objects[0], "object_properties", [])) merged.transforms = [] for o in objects: for t in getattr(o, "transforms", []): diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index 8478149..3581bfa 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -1059,7 +1059,177 @@ class TestPdfExporter(unittest.TestCase): self.assertNotIn("(X) 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__': unittest.main() + + + diff --git a/tests/test_rename_mesh.py b/tests/test_rename_mesh.py index 792e905..d3d401f 100644 --- a/tests/test_rename_mesh.py +++ b/tests/test_rename_mesh.py @@ -7,26 +7,37 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent sys.path.append(str(PROJECT_ROOT.parent)) # Mock bpy and dependencies for unit testing -class DummyNodeSocket: pass -class DummyNode: pass class DummyObject: def __init__(self, name="TestObj"): self.name = name +class DummyMesh: pass + +class DummyBase: pass mock_types = MagicMock() -mock_types.NodeSocket = DummyNodeSocket -mock_types.Node = DummyNode +mock_types.NodeSocket = type("NodeSocket", (DummyBase,), {}) +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.Mesh = DummyMesh mock_bpy = MagicMock() mock_bpy.context.scene = MagicMock() mock_bpy.context.scene.unit_settings.scale_length = 0.001 mock_bpy.types = mock_types +mock_bpy.path.abspath = lambda path: path.replace("//", "/abs/") sys.modules['bpy'] = mock_bpy sys.modules['bpy.types'] = mock_types sys.modules['bpy.props'] = 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.core.nodeslicer.nodes.mesh import _rename_slicing_item @@ -60,6 +71,46 @@ class TestRenameMesh(unittest.TestCase): self.assertIn("NewGroup", renamed.collections) 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__': unittest.main() + +