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) - Build the extension: `./build.sh` (generates a zip file in the `release/` directory)
- Install dependencies: `uv sync` - Install dependencies: `uv sync`
## Testing ## Testing & Token Efficiency
- Tests are located in the `tests/` directory - 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` or `.venv/bin/python`
- Requires the virtual environment: `source .venv/bin/activate` - **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. - **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. - **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. - **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 from typing import Any
import numpy as np 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 ....registry import register_class
from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SlicingGroup, SlicingObject from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SlicingGroup, SlicingObject
from ....infra.blender.blender_mesh_capture import PdfProperties
@register_class @register_class
class SLICENODE_GetMesh(SliceNode): 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.modifiers = so.modifiers.copy() if so.modifiers else []
new_so.extruder = so.extruder new_so.extruder = so.extruder
new_so.transforms = [dict(t) for t in getattr(so, "transforms", [])] new_so.transforms = [dict(t) for t in getattr(so, "transforms", [])]
new_so.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties()
return new_so return new_so
def _copy_slicing_collection(sc: SlicingCollection) -> SlicingCollection: def _copy_slicing_collection(sc: SlicingCollection) -> SlicingCollection:
@ -626,3 +628,68 @@ class SLICENODE_RenameMesh(SliceNode):
return _rename_slicing_item(geom, target_name) 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] draw_sa2_holes: NfBoolProperty(name="Holes (Ø)", default=False) # pyright: ignore[reportInvalidTypeForm]
show_lineart: NfBoolProperty(name="Lineart Visibility", default=True) # 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_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] 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] 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] 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") layout.prop(self, "draw_bbox")
if self.draw_bbox: if self.draw_bbox:
layout.prop(self, "bbox_color") layout.prop(self, "bbox_color")
layout.prop(self, "show_name")
layout.prop(self, "show_bbox_size") layout.prop(self, "show_bbox_size")
# Styling settings # Styling settings
layout.prop(self, "line_width")
layout.prop(self, "font_size") layout.prop(self, "font_size")
layout.prop(self, "margin") layout.prop(self, "margin")
layout.prop(self, "bg_color") layout.prop(self, "bg_color")
@ -494,11 +490,9 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
draw_crease=self.draw_crease, draw_crease=self.draw_crease,
draw_bbox=self.draw_bbox, draw_bbox=self.draw_bbox,
draw_sa2_holes=self.draw_sa2_holes, draw_sa2_holes=self.draw_sa2_holes,
show_name=self.show_name,
show_bbox_size=self.show_bbox_size, show_bbox_size=self.show_bbox_size,
crease_angle=self.crease_angle, crease_angle=self.crease_angle,
margin_mm=self.margin, margin_mm=self.margin,
line_width_mm=self.line_width,
font_size_mm=self.font_size * 2.834645669291339, 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])), stroke_color=(float(self.wire_color[0]), float(self.wire_color[1]), float(self.wire_color[2])),
bg_color=(float(self.bg_color[0]), float(self.bg_color[1]), float(self.bg_color[2])), bg_color=(float(self.bg_color[0]), float(self.bg_color[1]), float(self.bg_color[2])),

View file

@ -198,114 +198,37 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
except Exception as e: except Exception as e:
if operator: operator.report({'WARNING'}, f"Failed to load preview node group: {e}") if operator: operator.report({'WARNING'}, f"Failed to load preview node group: {e}")
# Find or assign Preview modifier ng = bpy.data.node_groups.get(ng_name)
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
if hasattr(obj, "modifiers"): # Get existing 'Preview' modifier or create it
if "Preview" in obj.modifiers: if hasattr(obj.modifiers, "get"):
mod = obj.modifiers["Preview"] mod = obj.modifiers.get("Preview")
else: elif isinstance(obj.modifiers, list):
for m in obj.modifiers: mod = next((m for m in obj.modifiers if (isinstance(m, dict) and m.get("name") == "Preview") or getattr(m, "name", "Preview") == "Preview"), None)
if getattr(m, "type", "") == 'NODES': else:
if ng and getattr(m, "node_group", None) == ng: mod = None
mod = m
break
elif mod is None:
mod = m
if not mod and ng: if (not mod or (hasattr(mod, "type") and mod.type != 'NODES')) and hasattr(obj.modifiers, "new"):
mod = obj.modifiers.new(name="Preview", type='NODES') # type: ignore 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 mod.node_group = ng
if mod: if hasattr(mod, "properties") and hasattr(mod.properties, "inputs"):
ng_ref = getattr(mod, "node_group", ng) mod.properties.inputs.Socket_4.value = default_layer_height * scale_factor
# Helper to find socket identifiers by name, falling back to specified key mod.properties.inputs.Socket_2.value = 10000.0 / scale_length
def find_socket_key(node_group, name: str, fallback: str) -> str: mod.properties.inputs.Socket_8.value = tuple(t * 0.001 for t in prep_translation)
if node_group and hasattr(node_group, "interface") and node_group.interface: mod.properties.inputs.Socket_6.value = tuple(map(float, prep_rotation))
try: mod.properties.inputs.Socket_9.value = tuple(map(float, prep_scale))
items = getattr(node_group.interface, "items", []) elif hasattr(mod, "__setitem__"):
if not isinstance(items, (list, tuple)): mod["Socket_4"] = default_layer_height * scale_factor
items = [] mod["Socket_2"] = 10000.0 / scale_length
for item in items: mod["Socket_8"] = tuple(t * 0.001 for t in prep_translation)
if getattr(item, "item_type", None) == 'INPUT' and getattr(item, "name", None) == name: mod["Socket_6"] = tuple(map(float, prep_rotation))
ident = getattr(item, "identifier", None) mod["Socket_9"] = tuple(map(float, prep_scale))
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)
def evaluate(self, operator=None): def evaluate(self, operator=None):
gcodes = self.resolve_multi_input("Gcode") gcodes = self.resolve_multi_input("Gcode")

View file

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

View file

@ -4,7 +4,7 @@ NodeSlicer PDF Exporter Package.
Modular structure: Modular structure:
- geometry.py: 2D edge extraction (wireframe, lineart, boundary, contour, crease). - geometry.py: 2D edge extraction (wireframe, lineart, boundary, contour, crease).
- metadata.py: SlicingObject labels, attributes, and bounding box extraction. - 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). - sa2_measures.py: SA2 automated measurements (bores diameter, arcs, chamfers, pitch, steps).
- sizing.py: Automatic sizing calculations for PDF elements. - sizing.py: Automatic sizing calculations for PDF elements.
- writer.py: Scalable vector PDF stream assembly and file writing. - writer.py: Scalable vector PDF stream assembly and file writing.
@ -25,6 +25,7 @@ from .metadata import (
extract_object_bounding_boxes, extract_object_bounding_boxes,
) )
from .pdf_utils import ( from .pdf_utils import (
PdfStream,
escape_pdf_text, escape_pdf_text,
pdf_set_graphics_state, pdf_set_graphics_state,
pdf_set_line_width, pdf_set_line_width,
@ -43,7 +44,6 @@ from .sa2_measures import (
) )
from .sizing import MM_TO_PT from .sizing import MM_TO_PT
from .writer import ( from .writer import (
_format_pdf_lines,
export_mesh_to_pdf, export_mesh_to_pdf,
export_wireframe_to_pdf, export_wireframe_to_pdf,
) )
@ -52,6 +52,8 @@ __all__ = [
# Main exporters # Main exporters
"export_mesh_to_pdf", "export_mesh_to_pdf",
"export_wireframe_to_pdf", "export_wireframe_to_pdf",
# PDF stream builder
"PdfStream",
# PDF utilities # PDF utilities
"escape_pdf_text", "escape_pdf_text",
"pdf_set_graphics_state", "pdf_set_graphics_state",
@ -82,5 +84,4 @@ __all__ = [
"render_sa2_measures_pdf", "render_sa2_measures_pdf",
# Constants # Constants
"MM_TO_PT", "MM_TO_PT",
"_format_pdf_lines",
] ]

View file

@ -1,9 +1,19 @@
""" """
PDF Utilities Module. PDF Utilities Module.
Reusable utilities for PDF stream generation, text escaping, and drawing operations. 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 from typing import Callable
import numpy as np
from numpy.typing import NDArray
# =============================================================================
# Text Escaping
# =============================================================================
def escape_pdf_text(text: str) -> str: def escape_pdf_text(text: str) -> str:
""" """
@ -25,6 +35,10 @@ def escape_pdf_text(text: str) -> str:
return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
# =============================================================================
# Standalone PDF Operator Helpers (kept for backward compatibility)
# =============================================================================
def pdf_move_to(x_pt: float, y_pt: float) -> str: def pdf_move_to(x_pt: float, y_pt: float) -> str:
"""Generate PDF move-to operator.""" """Generate PDF move-to operator."""
return f"{x_pt:.2f} {y_pt:.2f} m" 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"{left_x:.2f} {left_y:.2f} l "
f"{right_x:.2f} {right_y:.2f} l S" 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, extract_crease_edges,
) )
from .sizing import SA2Sizing, compute_effective_stroke_and_font, MM_TO_PT from .sizing import SA2Sizing, compute_effective_stroke_and_font, MM_TO_PT
from .pdf_utils import ( from .pdf_utils import PdfStream, escape_pdf_text
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,
)
# ============================================================================= # =============================================================================
@ -207,32 +197,13 @@ def extract_slicing_object_features(
# Drawing Helpers # 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]: def _pt_to_str(pt: NDArray[np.float64]) -> tuple[float, float]:
"""Convert PDF point array to tuple of floats.""" """Convert PDF point array to tuple of floats."""
return (float(pt[0, 0]), float(pt[0, 1])) 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( def _draw_text(
stream_cmds: list[str], s: PdfStream,
text: str, text: str,
x_pt: float, y_pt: float, x_pt: float, y_pt: float,
font_size_pt: float, font_size_pt: float,
@ -247,16 +218,12 @@ def _draw_text(
text_len = len(text) text_len = len(text)
text_x = x_pt - (text_len * font_size_pt * char_spacing) + x_offset text_x = x_pt - (text_len * font_size_pt * char_spacing) + x_offset
text_y = y_pt + y_offset text_y = y_pt + y_offset
stream_cmds.append(pdf_set_rgb_color(*color, fill=True)) s.fill_color(*color)
stream_cmds.append(pdf_text_begin()) s.begin_text().font("F1", font_size_pt).text_position(text_x, text_y).show(text).end_text()
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())
def _draw_horizontal_dimension( def _draw_horizontal_dimension(
stream_cmds: list[str], s: PdfStream,
x1_mm: float, y_mm: float, x2_mm: float, x1_mm: float, y_mm: float, x2_mm: float,
ext_gap_mm: float, ext_overhang_mm: float, ext_gap_mm: float, ext_overhang_mm: float,
to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]], 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_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]]))) 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)) s.stroke_color(*color).line_width(line_width_pt)
stream_cmds.append(pdf_set_line_width(line_width_pt)) s.line_stroke(p_l1[0], p_l1[1], p_l2[0], p_l2[1])
stream_cmds.append(f"{p_l1[0]:.2f} {p_l1[1]:.2f} m {p_l2[0]:.2f} {p_l2[1]:.2f} l S") s.line_stroke(p_r1[0], p_r1[1], p_r2[0], p_r2[1])
stream_cmds.append(f"{p_r1[0]:.2f} {p_r1[1]:.2f} m {p_r2[0]:.2f} {p_r2[1]:.2f} l S")
# Dimension line # Dimension line
p_d1 = _pt_to_str(to_pdf_pt(np.array([[x1_mm, y_mm]]))) 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]]))) 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 # Arrowheads
stream_cmds.append( s.polyline_stroke(
f"{p_d1[0] + arrow_len_pt:.2f} {p_d1[1] + arrow_half_w_pt:.2f} m " (p_d1[0] + arrow_len_pt, p_d1[1] + arrow_half_w_pt),
f"{p_d1[0]:.2f} {p_d1[1]:.2f} l " (p_d1[0], p_d1[1]),
f"{p_d1[0] + arrow_len_pt:.2f} {p_d1[1] - arrow_half_w_pt:.2f} l S" (p_d1[0] + arrow_len_pt, p_d1[1] - arrow_half_w_pt),
) )
stream_cmds.append( s.polyline_stroke(
f"{p_d2[0] - arrow_len_pt:.2f} {p_d2[1] + arrow_half_w_pt:.2f} m " (p_d2[0] - arrow_len_pt, p_d2[1] + arrow_half_w_pt),
f"{p_d2[0]:.2f} {p_d2[1]:.2f} l " (p_d2[0], p_d2[1]),
f"{p_d2[0] - arrow_len_pt:.2f} {p_d2[1] - arrow_half_w_pt:.2f} l S" (p_d2[0] - arrow_len_pt, p_d2[1] - arrow_half_w_pt),
) )
# Text label # Text label
_draw_text( _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 font_size_pt, color, char_spacing=0.28, y_offset=-font_size_pt * 1.1
) )
def _draw_vertical_dimension( def _draw_vertical_dimension(
stream_cmds: list[str], s: PdfStream,
x_mm: float, y1_mm: float, y2_mm: float, x_mm: float, y1_mm: float, y2_mm: float,
ext_gap_mm: float, ext_overhang_mm: float, ext_gap_mm: float, ext_overhang_mm: float,
to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]], 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_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]]))) 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)) s.stroke_color(*color).line_width(line_width_pt)
stream_cmds.append(pdf_set_line_width(line_width_pt)) s.line_stroke(p_b1[0], p_b1[1], p_b2[0], p_b2[1])
stream_cmds.append(f"{p_b1[0]:.2f} {p_b1[1]:.2f} m {p_b2[0]:.2f} {p_b2[1]:.2f} l S") s.line_stroke(p_t1[0], p_t1[1], p_t2[0], p_t2[1])
stream_cmds.append(f"{p_t1[0]:.2f} {p_t1[1]:.2f} m {p_t2[0]:.2f} {p_t2[1]:.2f} l S")
# Dimension line # Dimension line
p_d1 = _pt_to_str(to_pdf_pt(np.array([[x_mm, y1_mm]]))) 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]]))) 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 # Arrowheads
stream_cmds.append( s.polyline_stroke(
f"{p_d1[0] - arrow_half_w_pt:.2f} {p_d1[1] + arrow_len_pt:.2f} m " (p_d1[0] - arrow_half_w_pt, p_d1[1] + arrow_len_pt),
f"{p_d1[0]:.2f} {p_d1[1]:.2f} l " (p_d1[0], p_d1[1]),
f"{p_d1[0] + arrow_half_w_pt:.2f} {p_d1[1] + arrow_len_pt:.2f} l S" (p_d1[0] + arrow_half_w_pt, p_d1[1] + arrow_len_pt),
) )
stream_cmds.append( s.polyline_stroke(
f"{p_d2[0] - arrow_half_w_pt:.2f} {p_d2[1] - arrow_len_pt:.2f} m " (p_d2[0] - arrow_half_w_pt, p_d2[1] - arrow_len_pt),
f"{p_d2[0]:.2f} {p_d2[1]:.2f} l " (p_d2[0], p_d2[1]),
f"{p_d2[0] + arrow_half_w_pt:.2f} {p_d2[1] - arrow_len_pt:.2f} l S" (p_d2[0] + arrow_half_w_pt, p_d2[1] - arrow_len_pt),
) )
# Text label # Text label
_draw_text( _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, font_size_pt, color, char_spacing=0.55,
x_offset=-2.0 * MM_TO_PT, y_offset=-font_size_pt * 0.35 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( def _draw_hole_leader_and_arrow(
stream_cmds: list[str], s: PdfStream,
cx_mm: float, cy_mm: float, dia_mm: float, cx_mm: float, cy_mm: float, dia_mm: float,
to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]], to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]],
sizing: SA2Sizing, sizing: SA2Sizing,
@ -433,22 +398,21 @@ def _draw_hole_leader_and_arrow(
xe, ye = p_edge xe, ye = p_edge
xs1, ys1 = p_sh1 xs1, ys1 = p_sh1
stream_cmds.append(pdf_set_rgb_color(*color, fill=False)) s.stroke_color(*color).line_width(line_width_pt)
stream_cmds.append(pdf_set_line_width(line_width_pt)) s.polyline_stroke((xe, ye), (xs1, ys1), (p_sh2[0], ys1))
stream_cmds.append(f"{xe:.2f} {ye:.2f} m {xs1:.2f} {ys1:.2f} l {p_sh2[0]:.2f} {ys1:.2f} l S")
# Arrowhead at edge (45 degree orientation) # Arrowhead at edge (45 degree orientation)
stream_cmds.append( s.polyline_stroke(
f"{xe + arrow_len_pt * 0.7071:.2f} {ye + arrow_len_pt * 0.7071:.2f} m " (xe + arrow_len_pt * 0.7071, ye + arrow_len_pt * 0.7071),
f"{xe:.2f} {ye:.2f} l " (xe, ye),
f"{xe + arrow_len_pt * 0.3535:.2f} {ye + arrow_len_pt * 0.95:.2f} l S" (xe + arrow_len_pt * 0.3535, ye + arrow_len_pt * 0.95),
) )
return xe, ye, xs1, ys1 return xe, ye, xs1, ys1
def _draw_hole_callout_text_only( def _draw_hole_callout_text_only(
stream_cmds: list[str], s: PdfStream,
dia_mm: float, dia_mm: float,
depth_str: str, depth_str: str,
xs1: float, ys1: float, xs1: float, ys1: float,
@ -458,14 +422,12 @@ def _draw_hole_callout_text_only(
) -> None: ) -> None:
"""Draw the text for a hole callout.""" """Draw the text for a hole callout."""
dia_val = round(dia_mm, 1) 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) y_offset = ys1 + 2.0 - (line_idx * font_size_pt * 1.1)
stream_cmds.append(pdf_text_begin()) s.begin_text().font("F1", font_size_pt * 0.85)
stream_cmds.append(pdf_set_font("F1", font_size_pt * 0.85)) s.text_position(xs1 + 2.0, y_offset).show(dia_str)
stream_cmds.append(f"1 0 0 1 {xs1 + 2.0:.2f} {y_offset:.2f} Tm") s.end_text()
stream_cmds.append(f"({escape_pdf_text(dia_str)}) Tj")
stream_cmds.append(pdf_text_end())
# ============================================================================= # =============================================================================
@ -473,7 +435,7 @@ def _draw_hole_callout_text_only(
# ============================================================================= # =============================================================================
def _draw_pitch_marks( def _draw_pitch_marks(
stream_cmds: list[str], s: PdfStream,
cx1: float, cy1: float, cx2: float, cy2: float, cx1: float, cy1: float, cx2: float, cy2: float,
pitch_mm: float, pitch_y_mm: float, pitch_mm: float, pitch_y_mm: float,
to_pdf_pt: Callable[[NDArray[np.float64]], NDArray[np.float64]], 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_e1 = _pt_to_str(to_pdf_pt(np.array([[cx1, cy1]])))
p_e2 = _pt_to_str(to_pdf_pt(np.array([[cx2, cy2]]))) 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") s.line_stroke(p_e1[0], p_e1[1], p_h1[0], p_h1[1])
stream_cmds.append(f"{p_e2[0]:.2f} {p_e2[1]:.2f} m {p_h2[0]:.2f} {p_h2[1]:.2f} l S") s.line_stroke(p_e2[0], p_e2[1], p_h2[0], p_h2[1])
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_h1[0], p_h1[1], p_h2[0], p_h2[1])
label_x = (p_h1[0] + p_h2[0]) / 2.0 label_x = (p_h1[0] + p_h2[0]) / 2.0
label_y = p_h1[1] - font_size_pt * 0.9 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_e1 = _pt_to_str(to_pdf_pt(np.array([[cx1, cy1]])))
p_e2 = _pt_to_str(to_pdf_pt(np.array([[cx2, cy2]]))) 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") s.line_stroke(p_e1[0], p_e1[1], p_v1[0], p_v1[1])
stream_cmds.append(f"{p_e2[0]:.2f} {p_e2[1]:.2f} m {p_v2[0]:.2f} {p_v2[1]:.2f} l S") s.line_stroke(p_e2[0], p_e2[1], p_v2[0], p_v2[1])
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_v1[0], p_v1[1], p_v2[0], p_v2[1])
label_x = p_v1[0] + 2.0 label_x = p_v1[0] + 2.0
label_y = (p_v1[1] + p_v2[1]) / 2.0 label_y = (p_v1[1] + p_v2[1]) / 2.0
# Draw label # Draw label
stream_cmds.append(pdf_text_begin()) s.begin_text().font("F1", font_size_pt * 0.8)
stream_cmds.append(pdf_set_font("F1", font_size_pt * 0.8)) s.text_position(label_x, label_y).show(label)
stream_cmds.append(f"1 0 0 1 {label_x:.2f} {label_y:.2f} Tm") s.end_text()
stream_cmds.append(f"({escape_pdf_text(label)}) Tj")
stream_cmds.append(pdf_text_end())
# ============================================================================= # =============================================================================
@ -526,7 +486,7 @@ def _draw_pitch_marks(
# ============================================================================= # =============================================================================
def render_sa2_measures_pdf( def render_sa2_measures_pdf(
stream_cmds: list[str], s: PdfStream,
raw_geometry: Any, raw_geometry: Any,
draw_sa2_holes: bool = True, draw_sa2_holes: bool = True,
minor_step_mm: float = 1.0, minor_step_mm: float = 1.0,
@ -561,10 +521,8 @@ def render_sa2_measures_pdf(
sa2_lw_pt = line_width_pt * 0.8 sa2_lw_pt = line_width_pt * 0.8
sa2_color = (0.85, 0.15, 0.15) sa2_color = (0.85, 0.15, 0.15)
stream_cmds.append(pdf_set_graphics_state("GS100")) s.opacity("GS100").line_width(sa2_lw_pt).round_caps()
stream_cmds.append(pdf_set_line_width(sa2_lw_pt)) s.stroke_color(*sa2_color)
stream_cmds.append(pdf_set_line_cap_join(1, 1))
stream_cmds.append(pdf_set_rgb_color(*sa2_color, fill=False))
for so_item, obj_name, min_x, max_x, min_y, max_y in sa2_objects: for so_item, obj_name, min_x, max_x, min_y, max_y in sa2_objects:
w_obj_mm = max_x - min_x w_obj_mm = max_x - min_x
@ -582,7 +540,7 @@ def render_sa2_measures_pdf(
# --- Width Dimension --- # --- Width Dimension ---
w_str = f"{w_obj_mm/1000:g}m" if main_step >= 1000.0 else f"{w_obj_mm:g}mm" w_str = f"{w_obj_mm/1000:g}m" if main_step >= 1000.0 else f"{w_obj_mm:g}mm"
_draw_horizontal_dimension( _draw_horizontal_dimension(
stream_cmds, s,
min_x, min_y - sizing.offset_mm, max_x, min_x, min_y - sizing.offset_mm, max_x,
sizing.ext_gap_mm, sizing.ext_overhang_mm, sizing.ext_gap_mm, sizing.ext_overhang_mm,
to_pdf_pt, sa2_color, sa2_lw_pt, to_pdf_pt, sa2_color, sa2_lw_pt,
@ -593,7 +551,7 @@ def render_sa2_measures_pdf(
# --- Height Dimension --- # --- Height Dimension ---
h_str = f"{h_obj_mm/1000:g}m" if main_step >= 1000.0 else f"{h_obj_mm:g}mm" h_str = f"{h_obj_mm/1000:g}m" if main_step >= 1000.0 else f"{h_obj_mm:g}mm"
_draw_vertical_dimension( _draw_vertical_dimension(
stream_cmds, s,
min_x - sizing.offset_mm, min_y, max_y, min_x - sizing.offset_mm, min_y, max_y,
sizing.ext_gap_mm, sizing.ext_overhang_mm, sizing.ext_gap_mm, sizing.ext_overhang_mm,
to_pdf_pt, sa2_color, sa2_lw_pt, to_pdf_pt, sa2_color, sa2_lw_pt,
@ -615,8 +573,7 @@ def render_sa2_measures_pdf(
# Concentric Bores Grouping & Hole Callouts # Concentric Bores Grouping & Hole Callouts
if draw_sa2_holes and holes: if draw_sa2_holes and holes:
hole_color = (0.10, 0.45, 0.90) hole_color = (0.10, 0.45, 0.90)
stream_cmds.append(pdf_set_rgb_color(*hole_color, fill=False)) s.stroke_color(*hole_color).fill_color(*hole_color)
stream_cmds.append(pdf_set_rgb_color(*hole_color, fill=True))
concentric_groups = _group_concentric_holes(holes) concentric_groups = _group_concentric_holes(holes)
@ -625,7 +582,7 @@ def render_sa2_measures_pdf(
gcx, gcy, max_dia = grp[0] gcx, gcy, max_dia = grp[0]
xe, ye, xs1, ys1 = _draw_hole_leader_and_arrow( 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 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 mesh_verts_3d, total_z_span, hcx, hcy, hdia
) )
_draw_hole_callout_text_only( _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 font_size_pt, hole_color, line_idx
) )
# --- Pitch Dimensions --- # --- Pitch Dimensions ---
if draw_sa2_holes and 'concentric_groups' in locals() and len(concentric_groups) >= 2: if draw_sa2_holes and 'concentric_groups' in locals() and len(concentric_groups) >= 2:
pitch_color = (0.05, 0.58, 0.53) pitch_color = (0.05, 0.58, 0.53)
stream_cmds.append(pdf_set_rgb_color(*pitch_color, fill=False)) s.stroke_color(*pitch_color).fill_color(*pitch_color)
stream_cmds.append(pdf_set_rgb_color(*pitch_color, fill=True))
distinct_centers = [grp[0][:2] for grp in concentric_groups] distinct_centers = [grp[0][:2] for grp in concentric_groups]
distinct_centers.sort(key=lambda c: (c[0], c[1])) distinct_centers.sort(key=lambda c: (c[0], c[1]))
@ -655,7 +611,7 @@ def render_sa2_measures_pdf(
if dx_mm > 0.5: if dx_mm > 0.5:
y_pitch_mm = min(cy1, cy2) - sizing.offset_mm * 0.5 y_pitch_mm = min(cy1, cy2) - sizing.offset_mm * 0.5
_draw_pitch_marks( _draw_pitch_marks(
stream_cmds, cx1, cy1, cx2, cy2, s, cx1, cy1, cx2, cy2,
dx_mm, y_pitch_mm, to_pdf_pt, sizing, dx_mm, y_pitch_mm, to_pdf_pt, sizing,
pitch_color, sa2_lw_pt, f"{dx_mm:.1f} C-C", pitch_color, sa2_lw_pt, f"{dx_mm:.1f} C-C",
font_size_pt, is_horizontal=True font_size_pt, is_horizontal=True
@ -664,7 +620,7 @@ def render_sa2_measures_pdf(
if dy_mm > 0.5: if dy_mm > 0.5:
x_pitch_mm = max(cx1, cx2) + sizing.offset_mm * 0.5 x_pitch_mm = max(cx1, cx2) + sizing.offset_mm * 0.5
_draw_pitch_marks( _draw_pitch_marks(
stream_cmds, cx1, cy1, cx2, cy2, s, cx1, cy1, cx2, cy2,
x_pitch_mm, 0, to_pdf_pt, sizing, x_pitch_mm, 0, to_pdf_pt, sizing,
pitch_color, sa2_lw_pt, f"{dy_mm:.1f} C-C", pitch_color, sa2_lw_pt, f"{dy_mm:.1f} C-C",
font_size_pt, is_horizontal=False 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): 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.main_step = main_step_mm
self.minor_step = minor_step_mm self.minor_step = minor_step_mm
self.line_width_pt = line_width_pt self.line_width_pt = line_width_pt
self.font_size_pt = font_size_pt self.font_size_pt = font_size_pt
# Tick heights in mm proportional to main grid step # Tick heights in mm proportional to main grid step
self.major_tick_mm = main_step_mm * 0.30 self.major_tick_mm = main_step_mm * 0.10
self.minor_tick_mm = main_step_mm * 0.15 self.minor_tick_mm = main_step_mm * 0.05
# Y-axis height in mm (extends comfortably past major tick height) # 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 # X-axis extension past last main step tick in mm
self.x_axis_extension_mm = minor_step_mm * 2.0 self.x_axis_extension_mm = minor_step_mm * 2.0
# Legend Arrowhead dimensions in PT (proportional to line width) # 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.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: class SA2Sizing:
""" """

View file

@ -16,6 +16,7 @@ from .metadata import (
get_first_attribute_value, get_first_attribute_value,
) )
from .geometry import ( from .geometry import (
extract_unique_top_view_edges,
extract_boundary_edges, extract_boundary_edges,
extract_outer_contour_edges, extract_outer_contour_edges,
extract_crease_edges, extract_crease_edges,
@ -28,21 +29,28 @@ from .sizing import (
LegendSizing, LegendSizing,
MM_TO_PT, MM_TO_PT,
) )
from .pdf_utils import PdfStream, escape_pdf_text
from .pdf_utils import escape_pdf_text
def _format_pdf_lines(lines_arr: NDArray[np.float64]) -> list[str]: def get_all_slicing_objects(mesh_data: Any) -> list[Any]:
""" from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
Formats line segment coordinates (N, 2, 2) in PDF points into raw PDF operators. objects = []
""" def process_item(item: Any):
p0 = lines_arr[:, 0, :] if item is None:
p1 = lines_arr[:, 1, :] return
lines_str = [ elif isinstance(item, (list, tuple)):
f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l" for sub in item:
for (x1, y1), (x2, y2) in zip(p0, p1) process_item(sub)
] elif isinstance(item, SlicingGroup):
return lines_str 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( def export_mesh_to_pdf(
@ -58,7 +66,7 @@ def export_mesh_to_pdf(
show_bbox_size: bool = False, show_bbox_size: bool = False,
crease_angle: float = 30.0, crease_angle: float = 30.0,
margin_mm: float = 10.0, margin_mm: float = 10.0,
line_width_mm: float = 0.5, line_width_mm: float = 0.0,
font_size_mm: float = 0.0, font_size_mm: float = 0.0,
stroke_color: tuple[float, float, float] = (0.0, 0.0, 0.0), stroke_color: tuple[float, float, float] = (0.0, 0.0, 0.0),
bg_color: tuple[float, float, float] = (1.0, 1.0, 1.0), bg_color: tuple[float, float, float] = (1.0, 1.0, 1.0),
@ -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)) 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)) 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 # 1. Solid background fill
br, bg, bb = bg_color br, bg, bb = bg_color
sr, sg, sb = stroke_color sr, sg, sb = stroke_color
stream_cmds.append(f"{br:.3f} {bg:.3f} {bb:.3f} rg") s.fill_color(br, bg, bb)
stream_cmds.append(f"0 0 {page_w_pt:.2f} {page_h_pt:.2f} re f") s.fill_rect(0, 0, page_w_pt, page_h_pt)
# 2. Wireframe stroke color # 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) # 3. Minor Grid Lines (25% Opacity)
if len(minor_lines_arr) > 0: if len(minor_lines_arr) > 0:
stream_cmds.append("/GS25 gs") s.opacity("GS25").line_width(line_width_pt)
minor_grid_pt = min(0.3 * MM_TO_PT, line_width_pt) s.draw_line_batch(minor_lines_arr)
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")
# 4. Main Grid Lines (50% Opacity) # 4. Main Grid Lines (50% Opacity)
if len(main_lines_arr) > 0: if len(main_lines_arr) > 0:
stream_cmds.append("/GS50 gs") s.opacity("GS50").line_width(line_width_pt)
main_grid_pt = min(0.4 * MM_TO_PT, line_width_pt) s.draw_line_batch(main_lines_arr)
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")
# 5. Object Lines (100% Opacity) # 5. Object Lines (100% Opacity)
if len(edges_to_draw) > 0: slicing_objs = get_all_slicing_objects(raw_geometry)
stream_cmds.append("/GS100 gs") if slicing_objs:
stream_cmds.append(f"{line_width_pt:.2f} w") for so in slicing_objs:
stream_cmds.append("1 j 1 J") # round join and cap 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, :]) p0_pt = to_pdf_pt(edges_to_draw[:, 0, :])
p1_pt = to_pdf_pt(edges_to_draw[:, 1, :]) p1_pt = to_pdf_pt(edges_to_draw[:, 1, :])
obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
lines_str = _format_pdf_lines(obj_lines_arr) s.draw_line_batch(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")
# 5.5. Object Bounding Boxes (100% Opacity in bbox_color) # 5.5. Object Bounding Boxes (100% Opacity in bbox_color)
if draw_bbox and raw_geometry is not None: if draw_bbox and raw_geometry is not None:
bbox_edges = extract_object_bounding_boxes(raw_geometry) bbox_edges = extract_object_bounding_boxes(raw_geometry)
if len(bbox_edges) > 0: if len(bbox_edges) > 0:
b_r, b_g, b_b = bbox_color b_r, b_g, b_b = bbox_color
stream_cmds.append("/GS100 gs") s.opacity("GS100").line_width(line_width_pt).round_caps()
stream_cmds.append(f"{line_width_pt:.2f} w") s.stroke_color(b_r, b_g, b_b)
stream_cmds.append("1 j 1 J")
stream_cmds.append(f"{b_r:.3f} {b_g:.3f} {b_b:.3f} RG")
p0_pt = to_pdf_pt(bbox_edges[:, 0, :]) p0_pt = to_pdf_pt(bbox_edges[:, 0, :])
p1_pt = to_pdf_pt(bbox_edges[:, 1, :]) p1_pt = to_pdf_pt(bbox_edges[:, 1, :])
bbox_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2) bbox_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
lines_str = _format_pdf_lines(bbox_lines_arr) s.draw_line_batch(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")
# 5.6. SA2 Automated Technical Measures Block (Holes & Concentric Bores) # 5.6. SA2 Automated Technical Measures Block (Holes & Concentric Bores)
if raw_geometry is not None and draw_sa2_holes: if raw_geometry is not None and draw_sa2_holes:
render_sa2_measures_pdf( render_sa2_measures_pdf(
stream_cmds, s,
raw_geometry, raw_geometry,
draw_sa2_holes=draw_sa2_holes, draw_sa2_holes=draw_sa2_holes,
minor_step_mm=minor_step, 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) # 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") s.opacity("GS100").color(0, 0, 0)
stream_cmds.append("0.000 0.000 0.000 RG")
stream_cmds.append("0.000 0.000 0.000 rg")
leg_sizing = LegendSizing(main_step, minor_step, line_width_pt, font_size_pt) leg_sizing = LegendSizing(main_step, minor_step, line_width_pt, font_size_pt)
x_grid_start_mm = grid_min_x 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 arrow_half_w_pt = leg_sizing.arrow_half_w_pt
# Exact same stroke thickness as object wireframe lines # Exact same stroke thickness as object wireframe lines
stream_cmds.append(f"{line_width_pt:.2f} w") s.line_width(line_width_pt).round_caps()
stream_cmds.append("1 j 1 J")
# 6a. Main horizontal X-axis line (spans main grid steps, extends past last tick + right arrow + X label) # 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 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 x_arrow_tip_pt = x_arrow_base_pt + arrow_len_pt
# Horizontal main scale bar line # 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 # Pointy arrowhead (+X) extending past last tick
stream_cmds.append( s.polyline_stroke(
f"{x_arrow_base_pt:.2f} {y_bar_pt + arrow_half_w_pt:.2f} m " (x_arrow_base_pt, y_bar_pt + arrow_half_w_pt),
f"{x_arrow_tip_pt:.2f} {y_bar_pt:.2f} l " (x_arrow_tip_pt, y_bar_pt),
f"{x_arrow_base_pt:.2f} {y_bar_pt - arrow_half_w_pt:.2f} l S" (x_arrow_base_pt, y_bar_pt - arrow_half_w_pt),
) )
# Ticks directly overlapping vertical main grid lines on X-axis # Ticks directly overlapping vertical main grid lines on X-axis
tick_cmds = []
for k in range(num_main_steps + 1): for k in range(num_main_steps + 1):
xk_mm = x_grid_start_mm + k * main_step xk_mm = x_grid_start_mm + k * main_step
pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]])) pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]]))
xk_pt = pk_pt[0, 0] xk_pt = pk_pt[0, 0]
tick_h_mm = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm 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_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") s.line(xk_pt, y_bar_pt, xk_pt, y_bar_pt + tick_h_pt)
stream_cmds.extend(tick_cmds) s.stroke()
stream_cmds.append("S")
# 6b. Main vertical Y-axis line + top arrow (+Y) # 6b. Main vertical Y-axis line + top arrow (+Y)
y_arrow_base_pt = y_top_pt y_arrow_base_pt = y_top_pt
y_arrow_tip_pt = y_arrow_base_pt + arrow_len_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 # Pointy arrowhead (+Y) extending past y_top_pt
stream_cmds.append( s.polyline_stroke(
f"{x_start_pt - arrow_half_w_pt:.2f} {y_arrow_base_pt:.2f} m " (x_start_pt - arrow_half_w_pt, y_arrow_base_pt),
f"{x_start_pt:.2f} {y_arrow_tip_pt:.2f} l " (x_start_pt, y_arrow_tip_pt),
f"{x_start_pt + arrow_half_w_pt:.2f} {y_arrow_base_pt:.2f} l S" (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 # 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") s.fill_color(0, 0, 0)
stream_cmds.append("BT") s.begin_text().font("F1", font_size_pt)
stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf")
for k in range(1, num_main_steps + 1): for k in range(1, num_main_steps + 1):
xk_mm = x_grid_start_mm + k * main_step xk_mm = x_grid_start_mm + k * main_step
@ -401,41 +420,42 @@ def export_mesh_to_pdf(
tw_pt = len(tick_str) * font_size_pt * 0.60 tw_pt = len(tick_str) * font_size_pt * 0.60
x_text_pt = xk_pt - (tw_pt / 2.0) x_text_pt = xk_pt - (tw_pt / 2.0)
y_text_pt = y_bar_pt + tick_h_pt + (2.0 * MM_TO_PT) y_text_pt = y_bar_pt + tick_h_pt + leg_sizing.label_margin_h_pt
stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm") s.text_position(x_text_pt, y_text_pt).show(tick_str)
stream_cmds.append(f"({escape_pdf_text(tick_str)}) Tj")
# X axis label next to right arrowhead # 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) 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") s.text_position(x_x_label_pt, y_x_label_pt).show("X")
stream_cmds.append("(X) Tj")
# Y axis label next to top arrowhead # Y axis label next to top arrowhead
x_y_label_pt = x_start_pt - (font_size_pt * 0.35) 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) y_y_label_pt = y_arrow_tip_pt + leg_sizing.label_margin_h_pt
stream_cmds.append(f"1 0 0 1 {x_y_label_pt:.2f} {y_y_label_pt:.2f} Tm") s.text_position(x_y_label_pt, y_y_label_pt).show("Y")
stream_cmds.append("(Y) Tj")
stream_cmds.append("ET") s.end_text()
# 7. Object Names, Bounding Box Sizes, and Settings Attributes (to the right of the rightmost vertex) # 7. Object Names, Bounding Box Sizes, and Settings Attributes (to the right of the rightmost vertex)
settings_items = extract_settings_items(settings) 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) label_infos = extract_object_label_info(raw_geometry)
if label_infos: 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) 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 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: 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]])) 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) y_text_pt = p_pt[0, 1] - (eff_font_size_pt * 0.35)
lines_to_draw: list[tuple[str, tuple[float, float, float]]] = [] 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)) lines_to_draw.append((str(obj_name), stroke_color))
if show_bbox_size: if show_bbox_size:
@ -459,21 +479,18 @@ def export_mesh_to_pdf(
lines_to_draw.append((f"{key_name}: {val_str}", stroke_color)) lines_to_draw.append((f"{key_name}: {val_str}", stroke_color))
if lines_to_draw: if lines_to_draw:
stream_cmds.append("BT") s.begin_text().font("F1", eff_font_size_pt)
stream_cmds.append(f"/F1 {eff_font_size_pt:.1f} Tf")
for line_idx, (line_str, col) in enumerate(lines_to_draw): for line_idx, (line_str, col) in enumerate(lines_to_draw):
cr, cg, cb = col cr, cg, cb = col
stream_cmds.append(f"{cr:.3f} {cg:.3f} {cb:.3f} rg") s.fill_color(cr, cg, cb)
clean_str = escape_pdf_text(line_str)
curr_y_pt = y_text_pt - (line_idx * line_spacing_pt) 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") s.text_position(x_text_pt, curr_y_pt)
stream_cmds.append(f"({clean_str}) Tj") s.show(line_str)
stream_cmds.append("ET") s.end_text()
stream_content = "\n".join(stream_cmds) + "\n" stream_bytes = s.encode()
stream_bytes = stream_content.encode("cp1252", errors="replace")
objects: list[bytes] = [] objects: list[bytes] = []
objects.append(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n") 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) ## `/infra` (Blender Integration)
- `blender_mesh_capture.py`: - `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. - `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. - `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`. - **Rename Mesh**: Overrides the internal metadata name of a `SlicingObject`, `SlicingCollection`, or `SlicingGroup`.
- **Geometry**: Input geometry data. - **Geometry**: Input geometry data.
- **Name**: String input socket or property for the new name. - **Name**: String input socket or property for the new name.
- **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. - **Transform Geometry**: Moves, rotates, and scales a `SlicingCollection` or `SlicingGroup` relative to the origin.
- **Mesh**: Input mesh data. - **Mesh**: Input mesh data.
- **Translation**: 3D vector specifying the translation offset. - **Translation**: 3D vector specifying the translation offset.
@ -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. - **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. - **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. - **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). - **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. - **Margin (mm)**: Margin around the geometry grid in mm.
- **Background**: Color picker for the canvas background. - **Background**: Color picker for the canvas background.

View file

@ -86,6 +86,16 @@ def get_evaluated_mesh(obj: Object):
except Exception: except Exception:
pass 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(): class SlicingObject():
name: str name: str
parent: str parent: str
@ -94,6 +104,7 @@ class SlicingObject():
object_type: str = "ModelPart" object_type: str = "ModelPart"
modifiers: list[dict] = [] modifiers: list[dict] = []
extruder: int = 1 extruder: int = 1
pdf_properties: PdfProperties
def __init__(self, obj: Object | None = None, parent: str = "") -> None: def __init__(self, obj: Object | None = None, parent: str = "") -> None:
if not bpy.context.scene: raise Exception('No scene currently open!') if not bpy.context.scene: raise Exception('No scene currently open!')
@ -103,6 +114,7 @@ class SlicingObject():
self.attributes = {} self.attributes = {}
self.modifiers = [] self.modifiers = []
self.transforms = [] self.transforms = []
self.pdf_properties = PdfProperties()
assert bpy.context.scene.unit_settings assert bpy.context.scene.unit_settings
scene_scale: float = bpy.context.scene.unit_settings.scale_length scene_scale: float = bpy.context.scene.unit_settings.scale_length
@ -128,6 +140,7 @@ class SlicingObject():
merged.parent = objects[0].parent merged.parent = objects[0].parent
merged.object_type = objects[0].object_type merged.object_type = objects[0].object_type
merged.extruder = objects[0].extruder merged.extruder = objects[0].extruder
merged.pdf_properties = copy.deepcopy(objects[0].pdf_properties) if hasattr(objects[0], "pdf_properties") and objects[0].pdf_properties else PdfProperties()
merged.transforms = [] merged.transforms = []
for o in objects: for o in objects:
for t in getattr(o, "transforms", []): 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() sys.modules['bpy.app.handlers'] = MagicMock()
# Import the actual classes to test # Import the actual classes to test
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_ArrangeMesh from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_ArrangeMesh # pyright: ignore[reportMissingImports]
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject from NodeSlicer.infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject # pyright: ignore[reportMissingImports]
class TestArrangeMesh(unittest.TestCase): class TestArrangeMesh(unittest.TestCase):
def test_arrange_mesh_simple(self): def test_arrange_mesh_simple(self):

View file

@ -729,6 +729,156 @@ class TestPdfExporter(unittest.TestCase):
) )
self.assertIsNotNone(h_w) 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__': if __name__ == '__main__':
unittest.main() unittest.main()