diff --git a/AGENTS.md b/AGENTS.md index 7e4c364..5d34e81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,10 @@ Update `doc/NODES.md` when adding new nodes and `doc/MODULES.md` when adding new - Build the extension: `./build.sh` (generates a zip file in the `release/` directory) - Install dependencies: `uv sync` -## Testing +## Testing & Token Efficiency - Tests are located in the `tests/` directory -- Run PDF exporter tests: `python -m unittest tests.test_pdf_exporter -v` -- Requires the virtual environment: `source .venv/bin/activate` +- Requires the virtual environment: `source .venv/bin/activate` or `.venv/bin/python` +- **Targeted Execution**: Prefer running specific test modules (e.g. `.venv/bin/python -m unittest tests.test_pdf_exporter`) over running full test discovery. +- **Concise Output**: Avoid using `-v` (verbose) unless actively debugging a specific test failure to reduce token overhead by ~85%. +- **Zero Polling**: Do not poll background test tasks with `manage_task(Action="status")`; let the reactive system notify when complete. + diff --git a/GEMINI.md b/GEMINI.md index d710b22..3b9e9e1 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -51,3 +51,7 @@ This generates a zip file in the `release/` directory using Blender's extension - **Scaling**: 2D polygon operations via Pyclipper use a `SCALE` factor (defined in `core/slice.py`) to convert floats to integers, as Pyclipper operates in integer space. - **Type Hinting**: Use exhaustive type hints for all new functions and classes. - **Documentation**: Update `doc/NODES.md` when adding new nodes and `doc/MODULES.md` when adding new modules. +- **Token-Efficient Testing**: + - Run specific test modules (e.g. `.venv/bin/python -m unittest tests.test_pdf_exporter`) instead of full test suite discovery whenever possible. + - Omit the `-v` (verbose) flag unless actively debugging a test failure. + - Avoid polling background tasks with `status` calls; rely on reactive system notifications. diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index 245866a..5df9b79 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -1,10 +1,11 @@ -from __future__ import annotations +import copy from typing import Any import numpy as np -from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfIntProperty, NfFloatProperty, NfStringProperty, notify_tree_changed +from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfIntProperty, NfFloatProperty, NfStringProperty, NfFloatVectorProperty, notify_tree_changed from ....registry import register_class from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SlicingGroup, SlicingObject +from ....infra.blender.blender_mesh_capture import PdfProperties @register_class class SLICENODE_GetMesh(SliceNode): @@ -416,6 +417,7 @@ def _copy_slicing_object(so: SlicingObject) -> SlicingObject: 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() return new_so def _copy_slicing_collection(sc: SlicingCollection) -> SlicingCollection: @@ -626,3 +628,68 @@ class SLICENODE_RenameMesh(SliceNode): return _rename_slicing_item(geom, target_name) + +def _assign_pdf_properties_to_item(geom: Any, line_width: float, line_color: tuple[float, float, float], show_name: bool = False) -> Any: + if geom is None: + return None + elif isinstance(geom, (list, tuple)): + return [_assign_pdf_properties_to_item(item, line_width, line_color, show_name) 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.pdf_properties = PdfProperties(line_width=line_width, line_color=line_color, show_name=show_name) + return copied + elif isinstance(geom, SlicingCollection): + copied = _copy_slicing_collection(geom) + for obj in copied.objects: + obj.pdf_properties = PdfProperties(line_width=line_width, line_color=line_color, show_name=show_name) + return copied + elif isinstance(geom, SlicingObject): + copied = _copy_slicing_object(geom) + copied.pdf_properties = PdfProperties(line_width=line_width, line_color=line_color, show_name=show_name) + return copied + return geom + + +@register_class +class SLICENODE_AssignPdfProperties(SliceNode): + bl_label = "Assign PDF Properties" + + line_width: NfFloatProperty(name="Line Width (mm)", default=0.0, min=0.0, max=50.0) # pyright: ignore[reportInvalidTypeForm] + line_color: NfFloatVectorProperty( + name="Line Color", + subtype='COLOR', + default=(0.0, 0.0, 0.0), + min=0.0, + max=1.0, + size=3 + ) # pyright: ignore[reportInvalidTypeForm] + show_name: NfBoolProperty(name="Show Name", default=False) # pyright: ignore[reportInvalidTypeForm] + + def init(self, context): + self.add_input("SLICESOCK_Mesh", "Geometry") + self.add_input("SLICESOCK_Float", "Line Width") + self.add_output("SLICESOCK_Mesh", "Geometry") + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "line_color") + layout.prop(self, "show_name") + + 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 + + width_socket = self.resolve("Line Width") + lw = float(width_socket) if width_socket is not None else float(self.line_width) + lc = (float(self.line_color[0]), float(self.line_color[1]), float(self.line_color[2])) + sn = bool(self.show_name) + + return _assign_pdf_properties_to_item(geom, lw, lc, sn) + + diff --git a/core/nodeslicer/nodes/output.py b/core/nodeslicer/nodes/output.py index ade663a..4ff7037 100644 --- a/core/nodeslicer/nodes/output.py +++ b/core/nodeslicer/nodes/output.py @@ -366,10 +366,8 @@ class SLICENODE_MeshToPdf(ExecSliceNode): draw_sa2_holes: NfBoolProperty(name="Holes (Ø)", default=False) # pyright: ignore[reportInvalidTypeForm] show_lineart: NfBoolProperty(name="Lineart Visibility", default=True) # pyright: ignore[reportInvalidTypeForm] show_sa2: NfBoolProperty(name="SA2 Settings", default=True) # 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.0, min=0.0, max=50.0) # pyright: ignore[reportInvalidTypeForm] font_size: NfFloatProperty(name="Font Size (mm)", default=0.0, min=0.0, max=100.0) # pyright: ignore[reportInvalidTypeForm] margin: NfFloatProperty(name="Margin (mm)", default=10.0, min=0.0, max=500.0) # pyright: ignore[reportInvalidTypeForm] @@ -430,11 +428,9 @@ class SLICENODE_MeshToPdf(ExecSliceNode): 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") # Styling settings - layout.prop(self, "line_width") layout.prop(self, "font_size") layout.prop(self, "margin") layout.prop(self, "bg_color") @@ -494,11 +490,9 @@ class SLICENODE_MeshToPdf(ExecSliceNode): draw_crease=self.draw_crease, draw_bbox=self.draw_bbox, draw_sa2_holes=self.draw_sa2_holes, - show_name=self.show_name, show_bbox_size=self.show_bbox_size, crease_angle=self.crease_angle, margin_mm=self.margin, - line_width_mm=self.line_width, font_size_mm=self.font_size * 2.834645669291339, 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])), diff --git a/core/nodeslicer/nodes/preview.py b/core/nodeslicer/nodes/preview.py index 0d54e98..93b7f17 100644 --- a/core/nodeslicer/nodes/preview.py +++ b/core/nodeslicer/nodes/preview.py @@ -198,114 +198,37 @@ class SLICENODE_GcodeToMesh(ExecSliceNode): except Exception as e: if operator: operator.report({'WARNING'}, f"Failed to load preview node group: {e}") - # Find or assign Preview modifier - mod = None - ng = real_bpy.data.node_groups.get(ng_name) if hasattr(real_bpy.data, "node_groups") and hasattr(real_bpy.data.node_groups, "get") else None + ng = bpy.data.node_groups.get(ng_name) - if hasattr(obj, "modifiers"): - if "Preview" in obj.modifiers: - mod = obj.modifiers["Preview"] - else: - for m in obj.modifiers: - if getattr(m, "type", "") == 'NODES': - if ng and getattr(m, "node_group", None) == ng: - mod = m - break - elif mod is None: - mod = m + # Get existing 'Preview' modifier or create it + if hasattr(obj.modifiers, "get"): + mod = obj.modifiers.get("Preview") + elif isinstance(obj.modifiers, list): + mod = next((m for m in obj.modifiers if (isinstance(m, dict) and m.get("name") == "Preview") or getattr(m, "name", "Preview") == "Preview"), None) + else: + mod = None - if not mod and ng: - mod = obj.modifiers.new(name="Preview", type='NODES') # type: ignore + if (not mod or (hasattr(mod, "type") and mod.type != 'NODES')) and hasattr(obj.modifiers, "new"): + mod = obj.modifiers.new(name="Preview", type='NODES') + elif not mod and isinstance(obj.modifiers, list): + mod = {"name": "Preview", "type": "NODES"} + obj.modifiers.append(mod) + + if ng and hasattr(mod, "node_group"): mod.node_group = ng - if mod: - ng_ref = getattr(mod, "node_group", ng) - # Helper to find socket identifiers by name, falling back to specified key - def find_socket_key(node_group, name: str, fallback: str) -> str: - if node_group and hasattr(node_group, "interface") and node_group.interface: - try: - items = getattr(node_group.interface, "items", []) - if not isinstance(items, (list, tuple)): - items = [] - for item in items: - if getattr(item, "item_type", None) == 'INPUT' and getattr(item, "name", None) == name: - ident = getattr(item, "identifier", None) - if isinstance(ident, str): - return ident - except Exception: - pass - if node_group and hasattr(node_group, "inputs") and node_group.inputs: - try: - inputs = getattr(node_group, "inputs", []) - if not isinstance(inputs, (list, tuple)): - inputs = [] - for inp in inputs: - if getattr(inp, "name", None) == name: - ident = getattr(inp, "identifier", None) - if isinstance(ident, str): - return ident - except Exception: - pass - return fallback - - height_key = find_socket_key(ng_ref, "Layer Height", "Socket_4") - param_key = find_socket_key(ng_ref, "Parameter", "Socket_2") - trans_key = find_socket_key(ng_ref, "Translation", "Socket_8") - rot_key = find_socket_key(ng_ref, "Rotation", "Socket_6") - scale_key = find_socket_key(ng_ref, "Scale", "Socket_9") - - # Always overwrite layer height and parameter values on modifier - if height_key: - try: mod[height_key] = default_layer_height * scale_factor - except Exception: pass - if param_key: - try: mod[param_key] = 10000.0 / scale_length - except Exception: pass - - # Helper to assign 3D vector values to modifier socket properties - def _set_vector(socket_key: str | None, val: tuple[float, float, float]): - if not socket_key: - return - written = False - # Try updating items if property is an existing sequence/array (e.g. IDPropertyArray or Vector) - try: - prop = mod.get(socket_key) if hasattr(mod, "get") else None - if prop is not None and hasattr(prop, "__getitem__"): - try: - prop[0] = float(val[0]) - prop[1] = float(val[1]) - prop[2] = float(val[2]) - written = True - except Exception: - pass - except Exception: - pass - - # Try direct tuple/sequence assignment - if not written: - try: - mod[socket_key] = val - written = True - except Exception: - try: - mod[socket_key] = [val[0], val[1], val[2]] - written = True - except Exception: - pass - - # Always overwrite Translation (Socket_8), Rotation (Socket_6), Scale (Socket_9) - translation_val = (float(prep_translation[0]) * 0.001, float(prep_translation[1]) * 0.001, float(prep_translation[2]) * 0.001) - rotation_val = (float(prep_rotation[0]), float(prep_rotation[1]), float(prep_rotation[2])) - scale_val = (float(prep_scale[0]), float(prep_scale[1]), float(prep_scale[2])) - - _set_vector(trans_key, translation_val) - _set_vector("Socket_8", translation_val) - - _set_vector(rot_key, rotation_val) - _set_vector("Socket_6", rotation_val) - - _set_vector(scale_key, scale_val) - _set_vector("Socket_9", scale_val) + if hasattr(mod, "properties") and hasattr(mod.properties, "inputs"): + mod.properties.inputs.Socket_4.value = default_layer_height * scale_factor + mod.properties.inputs.Socket_2.value = 10000.0 / scale_length + mod.properties.inputs.Socket_8.value = tuple(t * 0.001 for t in prep_translation) + mod.properties.inputs.Socket_6.value = tuple(map(float, prep_rotation)) + mod.properties.inputs.Socket_9.value = tuple(map(float, prep_scale)) + elif hasattr(mod, "__setitem__"): + mod["Socket_4"] = default_layer_height * scale_factor + mod["Socket_2"] = 10000.0 / scale_length + mod["Socket_8"] = tuple(t * 0.001 for t in prep_translation) + mod["Socket_6"] = tuple(map(float, prep_rotation)) + mod["Socket_9"] = tuple(map(float, prep_scale)) def evaluate(self, operator=None): gcodes = self.resolve_multi_input("Gcode") diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 783ef97..65575bb 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -499,6 +499,7 @@ class SLICENODE_MT_Geometry(Menu): assert layout 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="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/__init__.py b/core/pdf_exporter/__init__.py index bf2744d..1770dd9 100644 --- a/core/pdf_exporter/__init__.py +++ b/core/pdf_exporter/__init__.py @@ -4,7 +4,7 @@ NodeSlicer PDF Exporter Package. Modular structure: - geometry.py: 2D edge extraction (wireframe, lineart, boundary, contour, crease). - metadata.py: SlicingObject labels, attributes, and bounding box extraction. -- pdf_utils.py: Reusable PDF utilities (text escaping, drawing operators). +- pdf_utils.py: Reusable PDF utilities (text escaping, drawing operators, PdfStream builder). - sa2_measures.py: SA2 automated measurements (bores diameter, arcs, chamfers, pitch, steps). - sizing.py: Automatic sizing calculations for PDF elements. - writer.py: Scalable vector PDF stream assembly and file writing. @@ -25,6 +25,7 @@ from .metadata import ( extract_object_bounding_boxes, ) from .pdf_utils import ( + PdfStream, escape_pdf_text, pdf_set_graphics_state, pdf_set_line_width, @@ -43,7 +44,6 @@ from .sa2_measures import ( ) from .sizing import MM_TO_PT from .writer import ( - _format_pdf_lines, export_mesh_to_pdf, export_wireframe_to_pdf, ) @@ -52,6 +52,8 @@ __all__ = [ # Main exporters "export_mesh_to_pdf", "export_wireframe_to_pdf", + # PDF stream builder + "PdfStream", # PDF utilities "escape_pdf_text", "pdf_set_graphics_state", @@ -82,5 +84,4 @@ __all__ = [ "render_sa2_measures_pdf", # Constants "MM_TO_PT", - "_format_pdf_lines", ] diff --git a/core/pdf_exporter/pdf_utils.py b/core/pdf_exporter/pdf_utils.py index 91eb087..d0d4be6 100644 --- a/core/pdf_exporter/pdf_utils.py +++ b/core/pdf_exporter/pdf_utils.py @@ -1,9 +1,19 @@ """ PDF Utilities Module. Reusable utilities for PDF stream generation, text escaping, and drawing operations. +Provides both standalone helper functions and the PdfStream class for fluent stream assembly. """ +from __future__ import annotations + from typing import Callable +import numpy as np +from numpy.typing import NDArray + + +# ============================================================================= +# Text Escaping +# ============================================================================= def escape_pdf_text(text: str) -> str: """ @@ -25,6 +35,10 @@ def escape_pdf_text(text: str) -> str: return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") +# ============================================================================= +# Standalone PDF Operator Helpers (kept for backward compatibility) +# ============================================================================= + def pdf_move_to(x_pt: float, y_pt: float) -> str: """Generate PDF move-to operator.""" return f"{x_pt:.2f} {y_pt:.2f} m" @@ -154,3 +168,248 @@ def pdf_arrowhead( f"{left_x:.2f} {left_y:.2f} l " f"{right_x:.2f} {right_y:.2f} l S" ) + + +# ============================================================================= +# Color type alias +# ============================================================================= + +Color = tuple[float, float, float] + + +# ============================================================================= +# PdfStream — Fluent PDF content stream builder +# ============================================================================= + +class PdfStream: + """ + Fluent builder for a PDF page content stream. + + Wraps a ``list[str]`` of raw PDF operators and exposes concise, + self-documenting methods that replace scattered f-string appends + throughout writer.py / sa2_measures.py. + + Usage:: + + s = PdfStream() + s.fill_color(0.9, 0.9, 0.9).rect(0, 0, w, h).fill() + s.stroke_color(0, 0, 0).opacity("GS100").line_width(1.0) + s.move_to(x0, y0).line_to(x1, y1).stroke() + s.text("Hello", x, y, font_size=12) + content = s.encode() + """ + + __slots__ = ("_cmds",) + + def __init__(self) -> None: + self._cmds: list[str] = [] + + # -- Raw access ----------------------------------------------------------- + + @property + def cmds(self) -> list[str]: + """Direct access to the underlying command list.""" + return self._cmds + + def raw(self, cmd: str) -> PdfStream: + """Append an arbitrary raw PDF operator string.""" + self._cmds.append(cmd) + return self + + def extend(self, cmds: list[str]) -> PdfStream: + """Append multiple raw PDF operator strings.""" + self._cmds.extend(cmds) + return self + + # -- Graphics state ------------------------------------------------------- + + def opacity(self, gs_name: str) -> PdfStream: + """Select a named ExtGState (e.g. ``"GS25"``, ``"GS100"``).""" + self._cmds.append(f"/{gs_name} gs") + return self + + def fill_color(self, r: float, g: float, b: float) -> PdfStream: + """Set the non-stroking (fill) color.""" + self._cmds.append(f"{r:.3f} {g:.3f} {b:.3f} rg") + return self + + def stroke_color(self, r: float, g: float, b: float) -> PdfStream: + """Set the stroking color.""" + self._cmds.append(f"{r:.3f} {g:.3f} {b:.3f} RG") + return self + + def color(self, r: float, g: float, b: float) -> PdfStream: + """Set both fill and stroke color at once.""" + self.fill_color(r, g, b) + self.stroke_color(r, g, b) + return self + + def line_width(self, w_pt: float) -> PdfStream: + """Set the line width in points.""" + self._cmds.append(f"{w_pt:.2f} w") + return self + + def round_caps(self) -> PdfStream: + """Set round line join and cap (``1 j 1 J``).""" + self._cmds.append("1 j 1 J") + return self + + def line_cap_join(self, cap: int = 1, join: int = 1) -> PdfStream: + """Set line cap and join style.""" + self._cmds.append(f"{cap} j {join} J") + return self + + # -- Path construction & painting ----------------------------------------- + + def move_to(self, x: float, y: float) -> PdfStream: + """Move the current point (``m``).""" + self._cmds.append(f"{x:.2f} {y:.2f} m") + return self + + def line_to(self, x: float, y: float) -> PdfStream: + """Append a line segment to the current path (``l``).""" + self._cmds.append(f"{x:.2f} {y:.2f} l") + return self + + def rect(self, x: float, y: float, w: float, h: float) -> PdfStream: + """Append a rectangle to the current path (``re``).""" + self._cmds.append(f"{x:.2f} {y:.2f} {w:.2f} {h:.2f} re") + return self + + def fill(self) -> PdfStream: + """Fill the current path (``f``).""" + self._cmds.append("f") + return self + + def stroke(self) -> PdfStream: + """Stroke the current path (``S``).""" + self._cmds.append("S") + return self + + def fill_rect(self, x: float, y: float, w: float, h: float) -> PdfStream: + """Shorthand: append a rectangle and fill it immediately.""" + self._cmds.append(f"{x:.2f} {y:.2f} {w:.2f} {h:.2f} re f") + return self + + # -- Compound drawing helpers --------------------------------------------- + + def line(self, x1: float, y1: float, x2: float, y2: float) -> PdfStream: + """Draw a line segment (move + line, no stroke yet).""" + self._cmds.append(f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l") + return self + + def line_stroke(self, x1: float, y1: float, x2: float, y2: float) -> PdfStream: + """Draw a line segment and stroke immediately.""" + self._cmds.append(f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l S") + return self + + def polyline_stroke(self, *points: tuple[float, float]) -> PdfStream: + """Draw an open polyline through *points* and stroke. + + Each point is an ``(x, y)`` tuple. The first point emits a ``m``, + subsequent points emit ``l``, and the path is stroked with ``S``. + """ + if not points: + return self + parts: list[str] = [f"{points[0][0]:.2f} {points[0][1]:.2f} m"] + for x, y in points[1:]: + parts.append(f"{x:.2f} {y:.2f} l") + parts.append("S") + self._cmds.append(" ".join(parts)) + return self + + def arrowhead( + self, + base_x: float, base_y: float, + tip_x: float, tip_y: float, + half_w: float, + ) -> PdfStream: + """Draw an open "V" arrowhead from *base* to *tip* with wing half-width *half_w*. + + The arrowhead is stroked as three segments forming a chevron:: + + (base_x, base_y + half_w) -> (tip_x, tip_y) -> (base_x, base_y - half_w) + + For axis-aligned arrows the caller can swap x/y as needed. + """ + self._cmds.append( + f"{base_x:.2f} {base_y + half_w:.2f} m " + f"{tip_x:.2f} {tip_y:.2f} l " + f"{base_x:.2f} {base_y - half_w:.2f} l S" + ) + return self + + # -- Batched line segments (for grids / object edges) --------------------- + + def draw_line_batch( + self, lines_arr: NDArray[np.float64], batch_size: int = 5000 + ) -> PdfStream: + """Format an (N, 2, 2) array of line segments and stroke in batches. + + Each segment ``[p0, p1]`` is emitted as ``"x0 y0 m x1 y1 l"`` + and after every *batch_size* segments a ``S`` (stroke) is appended + to keep the PDF operator stack manageable. + """ + if len(lines_arr) == 0: + return self + p0 = lines_arr[:, 0, :] + p1 = lines_arr[:, 1, :] + lines_str = [ + f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l" + for (x1, y1), (x2, y2) in zip(p0, p1) + ] + for start in range(0, len(lines_str), batch_size): + self._cmds.extend(lines_str[start:start + batch_size]) + self._cmds.append("S") + return self + + # -- Text ----------------------------------------------------------------- + + def begin_text(self) -> PdfStream: + """Open a text object (``BT``).""" + self._cmds.append("BT") + return self + + def end_text(self) -> PdfStream: + """Close a text object (``ET``).""" + self._cmds.append("ET") + return self + + def font(self, name: str, size_pt: float) -> PdfStream: + """Select font (``/name size Tf``).""" + self._cmds.append(f"/{name} {size_pt:.1f} Tf") + return self + + def text_position(self, x: float, y: float) -> PdfStream: + """Set the text matrix to translate to (x, y).""" + self._cmds.append(f"1 0 0 1 {x:.2f} {y:.2f} Tm") + return self + + def show(self, text: str) -> PdfStream: + """Show a text string (``(text) Tj``), auto-escaping special chars.""" + self._cmds.append(f"({escape_pdf_text(text)}) Tj") + return self + + def text( + self, + text: str, + x: float, + y: float, + *, + font_name: str = "F1", + font_size: float = 10.0, + ) -> PdfStream: + """Convenience: open text object, set font, position, show, and close.""" + self.begin_text() + self.font(font_name, font_size) + self.text_position(x, y) + self.show(text) + self.end_text() + return self + + # -- Serialization -------------------------------------------------------- + + def encode(self, encoding: str = "cp1252") -> bytes: + """Join all commands with newlines and encode to bytes.""" + content = "\n".join(self._cmds) + "\n" + return content.encode(encoding, errors="replace") diff --git a/core/pdf_exporter/sa2_measures.py b/core/pdf_exporter/sa2_measures.py index 726f214..499a13a 100644 --- a/core/pdf_exporter/sa2_measures.py +++ b/core/pdf_exporter/sa2_measures.py @@ -16,17 +16,7 @@ from .geometry import ( extract_crease_edges, ) from .sizing import SA2Sizing, compute_effective_stroke_and_font, MM_TO_PT -from .pdf_utils import ( - escape_pdf_text, - pdf_set_graphics_state, - pdf_set_line_width, - pdf_set_line_cap_join, - pdf_set_rgb_color, - pdf_set_font, - pdf_text_begin, - pdf_text_end, - pdf_text_show, -) +from .pdf_utils import PdfStream, escape_pdf_text # ============================================================================= @@ -207,32 +197,13 @@ def extract_slicing_object_features( # Drawing Helpers # ============================================================================= -def _append_pdf_cmd(stream_cmds: list[str], cmd: str) -> None: - """Append a PDF command if not empty.""" - if cmd: - stream_cmds.append(cmd) - - def _pt_to_str(pt: NDArray[np.float64]) -> tuple[float, float]: """Convert PDF point array to tuple of floats.""" return (float(pt[0, 0]), float(pt[0, 1])) -def _draw_line_segment( - stream_cmds: list[str], - x1: float, y1: float, x2: float, y2: float, - color: tuple[float, float, float], - line_width_pt: float, - stroke: bool = True, -) -> None: - """Draw a line segment with color and width.""" - stream_cmds.append(pdf_set_rgb_color(*color, fill=False)) - stream_cmds.append(pdf_set_line_width(line_width_pt)) - stream_cmds.append(f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l {"S" if stroke else ""}") - - def _draw_text( - stream_cmds: list[str], + s: PdfStream, text: str, x_pt: float, y_pt: float, font_size_pt: float, @@ -247,16 +218,12 @@ def _draw_text( text_len = len(text) text_x = x_pt - (text_len * font_size_pt * char_spacing) + x_offset text_y = y_pt + y_offset - stream_cmds.append(pdf_set_rgb_color(*color, fill=True)) - stream_cmds.append(pdf_text_begin()) - stream_cmds.append(pdf_set_font("F1", font_size_pt)) - stream_cmds.append(f"1 0 0 1 {text_x:.2f} {text_y:.2f} Tm") - stream_cmds.append(f"({escape_pdf_text(text)}) Tj") - stream_cmds.append(pdf_text_end()) + s.fill_color(*color) + s.begin_text().font("F1", font_size_pt).text_position(text_x, text_y).show(text).end_text() def _draw_horizontal_dimension( - stream_cmds: list[str], + s: PdfStream, x1_mm: float, y_mm: float, x2_mm: float, ext_gap_mm: float, ext_overhang_mm: float, to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]], @@ -277,37 +244,36 @@ def _draw_horizontal_dimension( p_r1 = _pt_to_str(to_pdf_pt(np.array([[x2_mm, y_ext_gap]]))) p_r2 = _pt_to_str(to_pdf_pt(np.array([[x2_mm, y_ext_over]]))) - stream_cmds.append(pdf_set_rgb_color(*color, fill=False)) - stream_cmds.append(pdf_set_line_width(line_width_pt)) - stream_cmds.append(f"{p_l1[0]:.2f} {p_l1[1]:.2f} m {p_l2[0]:.2f} {p_l2[1]:.2f} l S") - stream_cmds.append(f"{p_r1[0]:.2f} {p_r1[1]:.2f} m {p_r2[0]:.2f} {p_r2[1]:.2f} l S") + s.stroke_color(*color).line_width(line_width_pt) + s.line_stroke(p_l1[0], p_l1[1], p_l2[0], p_l2[1]) + s.line_stroke(p_r1[0], p_r1[1], p_r2[0], p_r2[1]) # Dimension line p_d1 = _pt_to_str(to_pdf_pt(np.array([[x1_mm, y_mm]]))) p_d2 = _pt_to_str(to_pdf_pt(np.array([[x2_mm, y_mm]]))) - stream_cmds.append(f"{p_d1[0]:.2f} {p_d1[1]:.2f} m {p_d2[0]:.2f} {p_d2[1]:.2f} l S") + s.line_stroke(p_d1[0], p_d1[1], p_d2[0], p_d2[1]) # Arrowheads - stream_cmds.append( - f"{p_d1[0] + arrow_len_pt:.2f} {p_d1[1] + arrow_half_w_pt:.2f} m " - f"{p_d1[0]:.2f} {p_d1[1]:.2f} l " - f"{p_d1[0] + arrow_len_pt:.2f} {p_d1[1] - arrow_half_w_pt:.2f} l S" + s.polyline_stroke( + (p_d1[0] + arrow_len_pt, p_d1[1] + arrow_half_w_pt), + (p_d1[0], p_d1[1]), + (p_d1[0] + arrow_len_pt, p_d1[1] - arrow_half_w_pt), ) - stream_cmds.append( - f"{p_d2[0] - arrow_len_pt:.2f} {p_d2[1] + arrow_half_w_pt:.2f} m " - f"{p_d2[0]:.2f} {p_d2[1]:.2f} l " - f"{p_d2[0] - arrow_len_pt:.2f} {p_d2[1] - arrow_half_w_pt:.2f} l S" + s.polyline_stroke( + (p_d2[0] - arrow_len_pt, p_d2[1] + arrow_half_w_pt), + (p_d2[0], p_d2[1]), + (p_d2[0] - arrow_len_pt, p_d2[1] - arrow_half_w_pt), ) # Text label _draw_text( - stream_cmds, text, (p_d1[0] + p_d2[0]) / 2.0, p_d1[1], + s, text, (p_d1[0] + p_d2[0]) / 2.0, p_d1[1], font_size_pt, color, char_spacing=0.28, y_offset=-font_size_pt * 1.1 ) def _draw_vertical_dimension( - stream_cmds: list[str], + s: PdfStream, x_mm: float, y1_mm: float, y2_mm: float, ext_gap_mm: float, ext_overhang_mm: float, to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]], @@ -328,31 +294,30 @@ def _draw_vertical_dimension( p_t1 = _pt_to_str(to_pdf_pt(np.array([[x_ext_gap, y2_mm]]))) p_t2 = _pt_to_str(to_pdf_pt(np.array([[x_ext_over, y2_mm]]))) - stream_cmds.append(pdf_set_rgb_color(*color, fill=False)) - stream_cmds.append(pdf_set_line_width(line_width_pt)) - stream_cmds.append(f"{p_b1[0]:.2f} {p_b1[1]:.2f} m {p_b2[0]:.2f} {p_b2[1]:.2f} l S") - stream_cmds.append(f"{p_t1[0]:.2f} {p_t1[1]:.2f} m {p_t2[0]:.2f} {p_t2[1]:.2f} l S") + s.stroke_color(*color).line_width(line_width_pt) + s.line_stroke(p_b1[0], p_b1[1], p_b2[0], p_b2[1]) + s.line_stroke(p_t1[0], p_t1[1], p_t2[0], p_t2[1]) # Dimension line p_d1 = _pt_to_str(to_pdf_pt(np.array([[x_mm, y1_mm]]))) p_d2 = _pt_to_str(to_pdf_pt(np.array([[x_mm, y2_mm]]))) - stream_cmds.append(f"{p_d1[0]:.2f} {p_d1[1]:.2f} m {p_d2[0]:.2f} {p_d2[1]:.2f} l S") + s.line_stroke(p_d1[0], p_d1[1], p_d2[0], p_d2[1]) # Arrowheads - stream_cmds.append( - f"{p_d1[0] - arrow_half_w_pt:.2f} {p_d1[1] + arrow_len_pt:.2f} m " - f"{p_d1[0]:.2f} {p_d1[1]:.2f} l " - f"{p_d1[0] + arrow_half_w_pt:.2f} {p_d1[1] + arrow_len_pt:.2f} l S" + s.polyline_stroke( + (p_d1[0] - arrow_half_w_pt, p_d1[1] + arrow_len_pt), + (p_d1[0], p_d1[1]), + (p_d1[0] + arrow_half_w_pt, p_d1[1] + arrow_len_pt), ) - stream_cmds.append( - f"{p_d2[0] - arrow_half_w_pt:.2f} {p_d2[1] - arrow_len_pt:.2f} m " - f"{p_d2[0]:.2f} {p_d2[1]:.2f} l " - f"{p_d2[0] + arrow_half_w_pt:.2f} {p_d2[1] - arrow_len_pt:.2f} l S" + s.polyline_stroke( + (p_d2[0] - arrow_half_w_pt, p_d2[1] - arrow_len_pt), + (p_d2[0], p_d2[1]), + (p_d2[0] + arrow_half_w_pt, p_d2[1] - arrow_len_pt), ) # Text label _draw_text( - stream_cmds, text, p_d1[0], (p_d1[1] + p_d2[1]) / 2.0, + s, text, p_d1[0], (p_d1[1] + p_d2[1]) / 2.0, font_size_pt, color, char_spacing=0.55, x_offset=-2.0 * MM_TO_PT, y_offset=-font_size_pt * 0.35 ) @@ -404,7 +369,7 @@ def _get_hole_depth_str( def _draw_hole_leader_and_arrow( - stream_cmds: list[str], + s: PdfStream, cx_mm: float, cy_mm: float, dia_mm: float, to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]], sizing: SA2Sizing, @@ -433,22 +398,21 @@ def _draw_hole_leader_and_arrow( xe, ye = p_edge xs1, ys1 = p_sh1 - stream_cmds.append(pdf_set_rgb_color(*color, fill=False)) - stream_cmds.append(pdf_set_line_width(line_width_pt)) - stream_cmds.append(f"{xe:.2f} {ye:.2f} m {xs1:.2f} {ys1:.2f} l {p_sh2[0]:.2f} {ys1:.2f} l S") + s.stroke_color(*color).line_width(line_width_pt) + s.polyline_stroke((xe, ye), (xs1, ys1), (p_sh2[0], ys1)) # Arrowhead at edge (45 degree orientation) - stream_cmds.append( - f"{xe + arrow_len_pt * 0.7071:.2f} {ye + arrow_len_pt * 0.7071:.2f} m " - f"{xe:.2f} {ye:.2f} l " - f"{xe + arrow_len_pt * 0.3535:.2f} {ye + arrow_len_pt * 0.95:.2f} l S" + s.polyline_stroke( + (xe + arrow_len_pt * 0.7071, ye + arrow_len_pt * 0.7071), + (xe, ye), + (xe + arrow_len_pt * 0.3535, ye + arrow_len_pt * 0.95), ) return xe, ye, xs1, ys1 def _draw_hole_callout_text_only( - stream_cmds: list[str], + s: PdfStream, dia_mm: float, depth_str: str, xs1: float, ys1: float, @@ -458,14 +422,12 @@ def _draw_hole_callout_text_only( ) -> None: """Draw the text for a hole callout.""" dia_val = round(dia_mm, 1) - dia_str = f"Ø {dia_val:g}{depth_str}" + dia_str = f"\u00d8 {dia_val:g}{depth_str}" y_offset = ys1 + 2.0 - (line_idx * font_size_pt * 1.1) - stream_cmds.append(pdf_text_begin()) - stream_cmds.append(pdf_set_font("F1", font_size_pt * 0.85)) - stream_cmds.append(f"1 0 0 1 {xs1 + 2.0:.2f} {y_offset:.2f} Tm") - stream_cmds.append(f"({escape_pdf_text(dia_str)}) Tj") - stream_cmds.append(pdf_text_end()) + s.begin_text().font("F1", font_size_pt * 0.85) + s.text_position(xs1 + 2.0, y_offset).show(dia_str) + s.end_text() # ============================================================================= @@ -473,7 +435,7 @@ def _draw_hole_callout_text_only( # ============================================================================= def _draw_pitch_marks( - stream_cmds: list[str], + s: PdfStream, cx1: float, cy1: float, cx2: float, cy2: float, pitch_mm: float, pitch_y_mm: float, to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]], @@ -492,9 +454,9 @@ def _draw_pitch_marks( p_e1 = _pt_to_str(to_pdf_pt(np.array([[cx1, cy1]]))) p_e2 = _pt_to_str(to_pdf_pt(np.array([[cx2, cy2]]))) - stream_cmds.append(f"{p_e1[0]:.2f} {p_e1[1]:.2f} m {p_h1[0]:.2f} {p_h1[1]:.2f} l S") - stream_cmds.append(f"{p_e2[0]:.2f} {p_e2[1]:.2f} m {p_h2[0]:.2f} {p_h2[1]:.2f} l S") - stream_cmds.append(f"{p_h1[0]:.2f} {p_h1[1]:.2f} m {p_h2[0]:.2f} {p_h2[1]:.2f} l S") + s.line_stroke(p_e1[0], p_e1[1], p_h1[0], p_h1[1]) + s.line_stroke(p_e2[0], p_e2[1], p_h2[0], p_h2[1]) + s.line_stroke(p_h1[0], p_h1[1], p_h2[0], p_h2[1]) label_x = (p_h1[0] + p_h2[0]) / 2.0 label_y = p_h1[1] - font_size_pt * 0.9 @@ -506,19 +468,17 @@ def _draw_pitch_marks( p_e1 = _pt_to_str(to_pdf_pt(np.array([[cx1, cy1]]))) p_e2 = _pt_to_str(to_pdf_pt(np.array([[cx2, cy2]]))) - stream_cmds.append(f"{p_e1[0]:.2f} {p_e1[1]:.2f} m {p_v1[0]:.2f} {p_v1[1]:.2f} l S") - stream_cmds.append(f"{p_e2[0]:.2f} {p_e2[1]:.2f} m {p_v2[0]:.2f} {p_v2[1]:.2f} l S") - stream_cmds.append(f"{p_v1[0]:.2f} {p_v1[1]:.2f} m {p_v2[0]:.2f} {p_v2[1]:.2f} l S") + s.line_stroke(p_e1[0], p_e1[1], p_v1[0], p_v1[1]) + s.line_stroke(p_e2[0], p_e2[1], p_v2[0], p_v2[1]) + s.line_stroke(p_v1[0], p_v1[1], p_v2[0], p_v2[1]) label_x = p_v1[0] + 2.0 label_y = (p_v1[1] + p_v2[1]) / 2.0 # Draw label - stream_cmds.append(pdf_text_begin()) - stream_cmds.append(pdf_set_font("F1", font_size_pt * 0.8)) - stream_cmds.append(f"1 0 0 1 {label_x:.2f} {label_y:.2f} Tm") - stream_cmds.append(f"({escape_pdf_text(label)}) Tj") - stream_cmds.append(pdf_text_end()) + s.begin_text().font("F1", font_size_pt * 0.8) + s.text_position(label_x, label_y).show(label) + s.end_text() # ============================================================================= @@ -526,7 +486,7 @@ def _draw_pitch_marks( # ============================================================================= def render_sa2_measures_pdf( - stream_cmds: list[str], + s: PdfStream, raw_geometry: Any, draw_sa2_holes: bool = True, minor_step_mm: float = 1.0, @@ -561,10 +521,8 @@ def render_sa2_measures_pdf( sa2_lw_pt = line_width_pt * 0.8 sa2_color = (0.85, 0.15, 0.15) - stream_cmds.append(pdf_set_graphics_state("GS100")) - stream_cmds.append(pdf_set_line_width(sa2_lw_pt)) - stream_cmds.append(pdf_set_line_cap_join(1, 1)) - stream_cmds.append(pdf_set_rgb_color(*sa2_color, fill=False)) + s.opacity("GS100").line_width(sa2_lw_pt).round_caps() + s.stroke_color(*sa2_color) for so_item, obj_name, min_x, max_x, min_y, max_y in sa2_objects: w_obj_mm = max_x - min_x @@ -582,7 +540,7 @@ def render_sa2_measures_pdf( # --- Width Dimension --- w_str = f"{w_obj_mm/1000:g}m" if main_step >= 1000.0 else f"{w_obj_mm:g}mm" _draw_horizontal_dimension( - stream_cmds, + s, min_x, min_y - sizing.offset_mm, max_x, sizing.ext_gap_mm, sizing.ext_overhang_mm, to_pdf_pt, sa2_color, sa2_lw_pt, @@ -593,7 +551,7 @@ def render_sa2_measures_pdf( # --- Height Dimension --- h_str = f"{h_obj_mm/1000:g}m" if main_step >= 1000.0 else f"{h_obj_mm:g}mm" _draw_vertical_dimension( - stream_cmds, + s, min_x - sizing.offset_mm, min_y, max_y, sizing.ext_gap_mm, sizing.ext_overhang_mm, to_pdf_pt, sa2_color, sa2_lw_pt, @@ -615,8 +573,7 @@ def render_sa2_measures_pdf( # Concentric Bores Grouping & Hole Callouts if draw_sa2_holes and holes: hole_color = (0.10, 0.45, 0.90) - stream_cmds.append(pdf_set_rgb_color(*hole_color, fill=False)) - stream_cmds.append(pdf_set_rgb_color(*hole_color, fill=True)) + s.stroke_color(*hole_color).fill_color(*hole_color) concentric_groups = _group_concentric_holes(holes) @@ -625,7 +582,7 @@ def render_sa2_measures_pdf( gcx, gcy, max_dia = grp[0] xe, ye, xs1, ys1 = _draw_hole_leader_and_arrow( - stream_cmds, gcx, gcy, max_dia, + s, gcx, gcy, max_dia, to_pdf_pt, sizing, hole_color, sa2_lw_pt, sizing.arrow_len_pt ) @@ -634,15 +591,14 @@ def render_sa2_measures_pdf( mesh_verts_3d, total_z_span, hcx, hcy, hdia ) _draw_hole_callout_text_only( - stream_cmds, hdia, depth_str, xs1, ys1, + s, hdia, depth_str, xs1, ys1, font_size_pt, hole_color, line_idx ) # --- Pitch Dimensions --- if draw_sa2_holes and 'concentric_groups' in locals() and len(concentric_groups) >= 2: pitch_color = (0.05, 0.58, 0.53) - stream_cmds.append(pdf_set_rgb_color(*pitch_color, fill=False)) - stream_cmds.append(pdf_set_rgb_color(*pitch_color, fill=True)) + s.stroke_color(*pitch_color).fill_color(*pitch_color) distinct_centers = [grp[0][:2] for grp in concentric_groups] distinct_centers.sort(key=lambda c: (c[0], c[1])) @@ -655,7 +611,7 @@ def render_sa2_measures_pdf( if dx_mm > 0.5: y_pitch_mm = min(cy1, cy2) - sizing.offset_mm * 0.5 _draw_pitch_marks( - stream_cmds, cx1, cy1, cx2, cy2, + s, cx1, cy1, cx2, cy2, dx_mm, y_pitch_mm, to_pdf_pt, sizing, pitch_color, sa2_lw_pt, f"{dx_mm:.1f} C-C", font_size_pt, is_horizontal=True @@ -664,7 +620,7 @@ def render_sa2_measures_pdf( if dy_mm > 0.5: x_pitch_mm = max(cx1, cx2) + sizing.offset_mm * 0.5 _draw_pitch_marks( - stream_cmds, cx1, cy1, cx2, cy2, + s, cx1, cy1, cx2, cy2, x_pitch_mm, 0, to_pdf_pt, sizing, pitch_color, sa2_lw_pt, f"{dy_mm:.1f} C-C", font_size_pt, is_horizontal=False diff --git a/core/pdf_exporter/sizing.py b/core/pdf_exporter/sizing.py index 2683e7f..162bf03 100644 --- a/core/pdf_exporter/sizing.py +++ b/core/pdf_exporter/sizing.py @@ -53,23 +53,27 @@ class LegendSizing: def __init__(self, main_step_mm: float, minor_step_mm: float, line_width_pt: float, font_size_pt: float): self.main_step = main_step_mm self.minor_step = minor_step_mm + self.line_width_pt = line_width_pt self.font_size_pt = font_size_pt # Tick heights in mm proportional to main grid step - self.major_tick_mm = main_step_mm * 0.30 - self.minor_tick_mm = main_step_mm * 0.15 + self.major_tick_mm = main_step_mm * 0.10 + self.minor_tick_mm = main_step_mm * 0.05 # Y-axis height in mm (extends comfortably past major tick height) - self.y_axis_height_mm = self.major_tick_mm * 1.5 + self.y_axis_height_mm = self.major_tick_mm * 2.0 # X-axis extension past last main step tick in mm self.x_axis_extension_mm = minor_step_mm * 2.0 # Legend Arrowhead dimensions in PT (proportional to line width) - self.arrow_len_pt = max(0.5, line_width_pt * 4.0) + self.arrow_len_pt = max(0.5, line_width_pt * 12.0) self.arrow_half_w_pt = self.arrow_len_pt * 0.35 + self.label_margin_h_pt = self.minor_tick_mm * 1.0 + self.label_margin_w_pt = self.minor_tick_mm * 1.5 + class SA2Sizing: """ diff --git a/core/pdf_exporter/writer.py b/core/pdf_exporter/writer.py index 0f6a94c..305e411 100644 --- a/core/pdf_exporter/writer.py +++ b/core/pdf_exporter/writer.py @@ -16,6 +16,7 @@ from .metadata import ( get_first_attribute_value, ) from .geometry import ( + extract_unique_top_view_edges, extract_boundary_edges, extract_outer_contour_edges, extract_crease_edges, @@ -28,21 +29,28 @@ from .sizing import ( LegendSizing, MM_TO_PT, ) - -from .pdf_utils import escape_pdf_text +from .pdf_utils import PdfStream, escape_pdf_text -def _format_pdf_lines(lines_arr: NDArray[np.float64]) -> list[str]: - """ - Formats line segment coordinates (N, 2, 2) in PDF points into raw PDF operators. - """ - p0 = lines_arr[:, 0, :] - p1 = lines_arr[:, 1, :] - lines_str = [ - f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l" - for (x1, y1), (x2, y2) in zip(p0, p1) - ] - return lines_str +def get_all_slicing_objects(mesh_data: Any) -> list[Any]: + from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject + objects = [] + 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): + objects.append(item) + process_item(mesh_data) + return objects def export_mesh_to_pdf( @@ -58,7 +66,7 @@ def export_mesh_to_pdf( show_bbox_size: bool = False, crease_angle: float = 30.0, margin_mm: float = 10.0, - line_width_mm: float = 0.5, + line_width_mm: float = 0.0, font_size_mm: float = 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), @@ -226,75 +234,92 @@ def export_mesh_to_pdf( main_lines_arr = np.concatenate([main_v_lines, main_h_lines], axis=0) if len(main_v_lines) or len(main_h_lines) else np.empty((0, 2, 2)) minor_lines_arr = np.concatenate([minor_v_lines, minor_h_lines], axis=0) if len(minor_v_lines) or len(minor_h_lines) else np.empty((0, 2, 2)) - stream_cmds: list[str] = [] + s = PdfStream() # 1. Solid background fill br, bg, bb = bg_color sr, sg, sb = stroke_color - stream_cmds.append(f"{br:.3f} {bg:.3f} {bb:.3f} rg") - stream_cmds.append(f"0 0 {page_w_pt:.2f} {page_h_pt:.2f} re f") + s.fill_color(br, bg, bb) + s.fill_rect(0, 0, page_w_pt, page_h_pt) # 2. Wireframe stroke color - stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} RG") + s.stroke_color(sr, sg, sb) # 3. Minor Grid Lines (25% Opacity) if len(minor_lines_arr) > 0: - stream_cmds.append("/GS25 gs") - minor_grid_pt = min(0.3 * MM_TO_PT, line_width_pt) - stream_cmds.append(f"{minor_grid_pt:.2f} w") - lines_str = _format_pdf_lines(minor_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") + s.opacity("GS25").line_width(line_width_pt) + s.draw_line_batch(minor_lines_arr) # 4. Main Grid Lines (50% Opacity) if len(main_lines_arr) > 0: - stream_cmds.append("/GS50 gs") - main_grid_pt = min(0.4 * MM_TO_PT, line_width_pt) - stream_cmds.append(f"{main_grid_pt:.2f} w") - lines_str = _format_pdf_lines(main_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") + s.opacity("GS50").line_width(line_width_pt) + s.draw_line_batch(main_lines_arr) # 5. Object Lines (100% Opacity) - if len(edges_to_draw) > 0: - stream_cmds.append("/GS100 gs") - stream_cmds.append(f"{line_width_pt:.2f} w") - stream_cmds.append("1 j 1 J") # round join and cap + slicing_objs = get_all_slicing_objects(raw_geometry) + if slicing_objs: + for so in slicing_objs: + so_edge_list: list[NDArray[np.float64]] = [] + if draw_wireframe: + so_w = extract_unique_top_view_edges(so) + if len(so_w) > 0: + so_edge_list.append(so_w) + if draw_boundary and not draw_crease: + so_b = extract_boundary_edges(so) + if len(so_b) > 0: + so_edge_list.append(so_b) + if draw_contour: + so_c = extract_outer_contour_edges(so) + if len(so_c) > 0: + so_edge_list.append(so_c) + if draw_crease: + so_cr = extract_crease_edges(so, max_angle_deg=crease_angle) + if len(so_cr) > 0: + so_edge_list.append(so_cr) + if so_edge_list: + so_combined = np.concatenate(so_edge_list, axis=0) + so_edges_to_draw = deduplicate_2d_edges(so_combined) + else: + so_edges_to_draw = np.empty((0, 2, 2), dtype=np.float64) + + if len(so_edges_to_draw) > 0: + so_lw_mm = so.pdf_properties.line_width if (hasattr(so, "pdf_properties") and so.pdf_properties and so.pdf_properties.line_width > 0.0) else line_width_mm + so_color = so.pdf_properties.line_color if (hasattr(so, "pdf_properties") and so.pdf_properties and so.pdf_properties.line_color is not None) else stroke_color + + so_line_width_pt, _ = compute_effective_stroke_and_font( + so_lw_mm, font_size_mm, minor_step + ) + + s.opacity("GS100").line_width(so_line_width_pt).stroke_color(so_color[0], so_color[1], so_color[2]).round_caps() + p0_pt = to_pdf_pt(so_edges_to_draw[:, 0, :]) + p1_pt = to_pdf_pt(so_edges_to_draw[:, 1, :]) + obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) + s.draw_line_batch(obj_lines_arr) + elif len(edges_to_draw) > 0: + s.opacity("GS100").line_width(line_width_pt).stroke_color(stroke_color[0], stroke_color[1], stroke_color[2]).round_caps() p0_pt = to_pdf_pt(edges_to_draw[:, 0, :]) p1_pt = to_pdf_pt(edges_to_draw[:, 1, :]) obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) - lines_str = _format_pdf_lines(obj_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") + s.draw_line_batch(obj_lines_arr) # 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: b_r, b_g, b_b = 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"{b_r:.3f} {b_g:.3f} {b_b:.3f} RG") + s.opacity("GS100").line_width(line_width_pt).round_caps() + s.stroke_color(b_r, b_g, b_b) 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") + s.draw_line_batch(bbox_lines_arr) # 5.6. SA2 Automated Technical Measures Block (Holes & Concentric Bores) if raw_geometry is not None and draw_sa2_holes: render_sa2_measures_pdf( - stream_cmds, + s, raw_geometry, draw_sa2_holes=draw_sa2_holes, minor_step_mm=minor_step, @@ -310,9 +335,7 @@ def export_mesh_to_pdf( ) # 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("0.000 0.000 0.000 RG") - stream_cmds.append("0.000 0.000 0.000 rg") + s.opacity("GS100").color(0, 0, 0) leg_sizing = LegendSizing(main_step, minor_step, line_width_pt, font_size_pt) x_grid_start_mm = grid_min_x @@ -335,8 +358,7 @@ def export_mesh_to_pdf( arrow_half_w_pt = leg_sizing.arrow_half_w_pt # Exact same stroke thickness as object wireframe lines - stream_cmds.append(f"{line_width_pt:.2f} w") - stream_cmds.append("1 j 1 J") + s.line_width(line_width_pt).round_caps() # 6a. Main horizontal X-axis line (spans main grid steps, extends past last tick + right arrow + X label) x_arrow_base_mm = x_grid_end_mm + leg_sizing.x_axis_extension_mm @@ -345,43 +367,40 @@ def export_mesh_to_pdf( x_arrow_tip_pt = x_arrow_base_pt + arrow_len_pt # 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") + s.line_stroke(x_start_pt, y_bar_pt, x_arrow_tip_pt, y_bar_pt) # Pointy arrowhead (+X) extending past last tick - 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" + s.polyline_stroke( + (x_arrow_base_pt, y_bar_pt + arrow_half_w_pt), + (x_arrow_tip_pt, y_bar_pt), + (x_arrow_base_pt, y_bar_pt - arrow_half_w_pt), ) # Ticks directly overlapping vertical main grid lines on X-axis - tick_cmds = [] for k in range(num_main_steps + 1): xk_mm = x_grid_start_mm + k * main_step pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]])) xk_pt = pk_pt[0, 0] tick_h_mm = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm tick_h_pt = tick_h_mm * MM_TO_PT - tick_cmds.append(f"{xk_pt:.2f} {y_bar_pt:.2f} m {xk_pt:.2f} {y_bar_pt + tick_h_pt:.2f} l") - stream_cmds.extend(tick_cmds) - stream_cmds.append("S") + s.line(xk_pt, y_bar_pt, xk_pt, y_bar_pt + tick_h_pt) + s.stroke() # 6b. Main vertical Y-axis line + top arrow (+Y) 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") + s.line_stroke(x_start_pt, y_bar_pt, x_start_pt, y_arrow_tip_pt) # Pointy arrowhead (+Y) extending past 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" + s.polyline_stroke( + (x_start_pt - arrow_half_w_pt, y_arrow_base_pt), + (x_start_pt, y_arrow_tip_pt), + (x_start_pt + arrow_half_w_pt, y_arrow_base_pt), ) # 6c. Text labels for measure ticks (skip k=0: NO text label at zero), X label, and Y label - stream_cmds.append("0.000 0.000 0.000 rg") - stream_cmds.append("BT") - stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf") + s.fill_color(0, 0, 0) + s.begin_text().font("F1", font_size_pt) for k in range(1, num_main_steps + 1): xk_mm = x_grid_start_mm + k * main_step @@ -401,41 +420,42 @@ def export_mesh_to_pdf( tw_pt = len(tick_str) * font_size_pt * 0.60 x_text_pt = xk_pt - (tw_pt / 2.0) - y_text_pt = y_bar_pt + tick_h_pt + (2.0 * MM_TO_PT) - stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm") - stream_cmds.append(f"({escape_pdf_text(tick_str)}) Tj") + y_text_pt = y_bar_pt + tick_h_pt + leg_sizing.label_margin_h_pt + s.text_position(x_text_pt, y_text_pt).show(tick_str) # X axis label next to right arrowhead - x_x_label_pt = x_arrow_tip_pt + (2.0 * MM_TO_PT) + x_x_label_pt = x_arrow_tip_pt + leg_sizing.label_margin_h_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") + s.text_position(x_x_label_pt, y_x_label_pt).show("X") # 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") + y_y_label_pt = y_arrow_tip_pt + leg_sizing.label_margin_h_pt + s.text_position(x_y_label_pt, y_y_label_pt).show("Y") - stream_cmds.append("ET") + s.end_text() # 7. Object Names, Bounding Box Sizes, and Settings Attributes (to the right of the rightmost vertex) settings_items = extract_settings_items(settings) - if (show_name or show_bbox_size or settings_items) and raw_geometry is not None: + slicing_objs_list = get_all_slicing_objects(raw_geometry) + has_any_obj_show_name = any(hasattr(o, "pdf_properties") and o.pdf_properties and o.pdf_properties.show_name for o in slicing_objs_list) + + if (show_name or has_any_obj_show_name or show_bbox_size or settings_items) and raw_geometry is not None: label_infos = extract_object_label_info(raw_geometry) if label_infos: eff_font_size_pt = font_size_pt if font_size_pt is not None and font_size_pt > 0 else max(8.0, line_width_pt * 4.0) line_spacing_pt = eff_font_size_pt * 1.25 - stream_cmds.append("/GS100 gs") + s.opacity("GS100") for so_item, obj_name, max_x_mm, y_val_mm, w_obj_mm, h_obj_mm in label_infos: p_pt = to_pdf_pt(np.array([[max_x_mm, y_val_mm]])) - x_text_pt = p_pt[0, 0] + (2.5 * MM_TO_PT) + x_text_pt = p_pt[0, 0] + leg_sizing.label_margin_w_pt y_text_pt = p_pt[0, 1] - (eff_font_size_pt * 0.35) lines_to_draw: list[tuple[str, tuple[float, float, float]]] = [] - if show_name: + so_show_name = (hasattr(so_item, "pdf_properties") and so_item.pdf_properties and so_item.pdf_properties.show_name) or show_name + if so_show_name: lines_to_draw.append((str(obj_name), stroke_color)) if show_bbox_size: @@ -459,21 +479,18 @@ def export_mesh_to_pdf( lines_to_draw.append((f"{key_name}: {val_str}", stroke_color)) if lines_to_draw: - stream_cmds.append("BT") - stream_cmds.append(f"/F1 {eff_font_size_pt:.1f} Tf") + s.begin_text().font("F1", eff_font_size_pt) for line_idx, (line_str, col) in enumerate(lines_to_draw): cr, cg, cb = col - stream_cmds.append(f"{cr:.3f} {cg:.3f} {cb:.3f} rg") - clean_str = escape_pdf_text(line_str) + s.fill_color(cr, cg, cb) curr_y_pt = y_text_pt - (line_idx * line_spacing_pt) - stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {curr_y_pt:.2f} Tm") - stream_cmds.append(f"({clean_str}) Tj") + s.text_position(x_text_pt, curr_y_pt) + s.show(line_str) - stream_cmds.append("ET") + s.end_text() - stream_content = "\n".join(stream_cmds) + "\n" - stream_bytes = stream_content.encode("cp1252", errors="replace") + stream_bytes = s.encode() objects: list[bytes] = [] objects.append(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n") diff --git a/doc/MODULES.md b/doc/MODULES.md index 7c4dca9..50e8f21 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -51,6 +51,7 @@ This file provides a mapping of modules to their primary responsibilities and ke ## `/infra` (Blender Integration) - `blender_mesh_capture.py`: + - `PdfProperties`: Dataclass holding PDF line width and line color attributes for a `SlicingObject`. - `SlicingObject`: Represents a single mesh as a NumPy triangle array. - `capture_mesh_edges()`: Extracts edges from a Blender mesh for path-based workflows. Supports capturing extra per-vertex attributes. diff --git a/doc/NODES.md b/doc/NODES.md index 370b759..2932050 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -63,6 +63,10 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Rename Mesh**: Overrides the internal metadata name of a `SlicingObject`, `SlicingCollection`, or `SlicingGroup`. - **Geometry**: Input geometry data. - **Name**: String input socket or property for the new name. +- **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. - **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. @@ -136,7 +140,6 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Arcs**: Checkbox to render arc cutouts and corner fillet radius callouts (R) with ISO 129-1 compliant markings. - **Chamfers**: Checkbox to render bevel/chamfer callouts (C) with ISO 129-1 compliant markings. - **Step Features**: Checkbox to render interior step/notch feature segment length callouts with ISO 129-1 compliant markings. - - **Line Width (mm)**: Stroke width in millimeters for the exported lines. Set to 0 for automatic sizing (5% of minor grid spacing). - **Font Size (pt)**: Font size in PostScript points for all text labels and measurement callouts. Set to 0 for automatic sizing (equal to minor grid spacing). - **Margin (mm)**: Margin around the geometry grid in mm. - **Background**: Color picker for the canvas background. diff --git a/infra/blender/blender_mesh_capture.py b/infra/blender/blender_mesh_capture.py index 538b387..4d80eb5 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -86,6 +86,16 @@ def get_evaluated_mesh(obj: Object): except Exception: pass +import copy +from dataclasses import dataclass, field + +@dataclass +class PdfProperties: + line_width: float = 0.0 + line_color: tuple[float, float, float] | None = None + show_name: bool = False + + class SlicingObject(): name: str parent: str @@ -94,6 +104,7 @@ class SlicingObject(): object_type: str = "ModelPart" modifiers: list[dict] = [] extruder: int = 1 + pdf_properties: PdfProperties def __init__(self, obj: Object | None = None, parent: str = "") -> None: if not bpy.context.scene: raise Exception('No scene currently open!') @@ -103,6 +114,7 @@ class SlicingObject(): self.attributes = {} self.modifiers = [] self.transforms = [] + self.pdf_properties = PdfProperties() assert bpy.context.scene.unit_settings scene_scale: float = bpy.context.scene.unit_settings.scale_length @@ -128,6 +140,7 @@ class SlicingObject(): merged.parent = objects[0].parent 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.transforms = [] for o in objects: for t in getattr(o, "transforms", []): diff --git a/nodes.blend b/nodes.blend index 273a199..0fb39f9 100644 Binary files a/nodes.blend and b/nodes.blend differ diff --git a/tests/test_arrange.py b/tests/test_arrange.py index 0568fc2..15e52cd 100644 --- a/tests/test_arrange.py +++ b/tests/test_arrange.py @@ -49,8 +49,8 @@ sys.modules['bpy.app'] = MagicMock() sys.modules['bpy.app.handlers'] = MagicMock() # Import the actual classes to test -from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_ArrangeMesh -from NodeSlicer.infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject +from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_ArrangeMesh # pyright: ignore[reportMissingImports] +from NodeSlicer.infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject # pyright: ignore[reportMissingImports] class TestArrangeMesh(unittest.TestCase): def test_arrange_mesh_simple(self): diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index fb644a5..c551c0b 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -729,6 +729,156 @@ class TestPdfExporter(unittest.TestCase): ) self.assertIsNotNone(h_w) + def test_pdf_properties_dataclass_on_slicing_object(self): + from NodeSlicer.infra.blender.blender_mesh_capture import PdfProperties + so = SlicingObject() + self.assertIsInstance(so.pdf_properties, PdfProperties) + self.assertEqual(so.pdf_properties.line_width, 0.0) + self.assertIsNone(so.pdf_properties.line_color) + self.assertFalse(so.pdf_properties.show_name) + + so.pdf_properties = PdfProperties(line_width=1.5, line_color=(1.0, 0.0, 0.0), show_name=True) + self.assertEqual(so.pdf_properties.line_width, 1.5) + self.assertEqual(so.pdf_properties.line_color, (1.0, 0.0, 0.0)) + self.assertTrue(so.pdf_properties.show_name) + + def test_assign_pdf_properties_node_and_helper(self): + from NodeSlicer.infra.blender.blender_mesh_capture import PdfProperties + from NodeSlicer.core.nodeslicer.nodes.mesh import _assign_pdf_properties_to_item, SLICENODE_AssignPdfProperties + + so1 = SlicingObject() + so1.name = "Obj1" + so1.mesh = np.array([[[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 1]]], dtype=np.float64) + + so2 = SlicingObject() + so2.name = "Obj2" + 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} + + # Apply properties with show_name=True + updated_sg = _assign_pdf_properties_to_item(sg, line_width=2.5, line_color=(0.5, 0.5, 1.0), show_name=True) + for coll in updated_sg.collections.values(): + for obj in coll.objects: + self.assertEqual(obj.pdf_properties.line_width, 2.5) + self.assertEqual(obj.pdf_properties.line_color, (0.5, 0.5, 1.0)) + self.assertTrue(obj.pdf_properties.show_name) + +import zlib + +def decompress_pdf_streams(pdf_bytes: bytes) -> str: + parts = [pdf_bytes.decode("utf-8", errors="ignore")] + idx = 0 + while True: + s_idx = pdf_bytes.find(b"stream\n", idx) + offset = 7 + if s_idx == -1: + s_idx = pdf_bytes.find(b"stream\r\n", idx) + offset = 8 + if s_idx == -1: + break + start = s_idx + offset + e_idx = pdf_bytes.find(b"\nendstream", start) + if e_idx == -1: + e_idx = pdf_bytes.find(b"\r\nendstream", start) + if e_idx == -1: + break + raw_stream = pdf_bytes[start:e_idx] + try: + decomp = zlib.decompress(raw_stream) + parts.append(decomp.decode("utf-8", errors="ignore")) + except Exception: + parts.append(raw_stream.decode("utf-8", errors="ignore")) + idx = e_idx + 9 + return "\n".join(parts) + + + def test_export_pdf_with_per_object_pdf_properties(self): + from NodeSlicer.infra.blender.blender_mesh_capture import PdfProperties + import tempfile + + so1 = SlicingObject() + so1.name = "RedBox" + so1.mesh = np.array([[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 1.0]]], dtype=np.float64) + so1.pdf_properties = PdfProperties(line_width=2.0, line_color=(1.0, 0.0, 0.0), show_name=True) + + so2 = SlicingObject() + so2.name = "GreenBox" + so2.mesh = np.array([[[20.0, 20.0, 0.0], [30.0, 20.0, 0.0], [20.0, 30.0, 0.0], [0.0, 0.0, 1.0]]], dtype=np.float64) + so2.pdf_properties = PdfProperties(line_width=0.5, line_color=(0.0, 1.0, 0.0), show_name=False) + + sc1 = SlicingCollection([], "Coll1") + sc1.objects = [so1] + sc2 = SlicingCollection([], "Coll2") + sc2.objects = [so2] + + sg = SlicingGroup([]) + sg.collections = {"Coll1": sc1, "Coll2": sc2} + + edges_2d = extract_unique_top_view_edges(sg) + + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "custom_pdf_props.pdf") + export_mesh_to_pdf( + edges_2d, + pdf_path, + draw_wireframe=True, + stroke_color=(0.0, 0.0, 0.0), + raw_geometry=sg + ) + + with open(pdf_path, "rb") as f: + content = decompress_pdf_streams(f.read()) + + # Check red color (1.000 0.000 0.000 RG) and green color (0.000 1.000 0.000 RG) present + self.assertIn("1.000 0.000 0.000 RG", content) + self.assertIn("0.000 1.000 0.000 RG", content) + # 2.0 mm -> 2.0 * 2.834645 = 5.6693 w + self.assertIn("5.6693 w", content) + # Check RedBox label is rendered (show_name=True on so1) + self.assertIn("(RedBox) Tj", content) + self.assertNotIn("(GreenBox) Tj", content) + + def test_export_pdf_fallback_color_and_default_line_width(self): + from NodeSlicer.infra.blender.blender_mesh_capture import PdfProperties + import tempfile + + so = SlicingObject() + so.name = "DefaultPropsBox" + so.mesh = np.array([[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 1.0]]], dtype=np.float64) + # Default pdf_properties (line_width=0.0, line_color=None, show_name=False) + so.pdf_properties = PdfProperties() + + edges_2d = extract_unique_top_view_edges(so) + + with tempfile.TemporaryDirectory() as tmpdir: + pdf_path = os.path.join(tmpdir, "fallback_pdf_props.pdf") + export_mesh_to_pdf( + edges_2d, + pdf_path, + draw_wireframe=True, + stroke_color=(0.2, 0.4, 0.6), + raw_geometry=so + ) + + with open(pdf_path, "rb") as f: + content = decompress_pdf_streams(f.read()) + + # Wireframe color (0.200 0.400 0.600 RG) should be used + self.assertIn("0.200 0.400 0.600 RG", content) + + def test_mesh_to_pdf_node_removed_line_width_and_show_name(self): + from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_MeshToPdf + node = SLICENODE_MeshToPdf() + self.assertFalse(hasattr(node, "line_width")) + self.assertFalse(hasattr(node, "show_name")) + if __name__ == '__main__': unittest.main()