This commit is contained in:
Nicolas Predella 2026-08-03 13:24:59 +03:00
parent 4a858463af
commit dc0849a778
17 changed files with 726 additions and 330 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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)

View file

@ -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])),

View file

@ -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")

View file

@ -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"

View file

@ -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",
]

View file

@ -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")

View file

@ -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

View file

@ -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:
"""

View file

@ -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")

View file

@ -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.

View file

@ -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.

View file

@ -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", []):

Binary file not shown.

View file

@ -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):

View file

@ -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()