up
This commit is contained in:
parent
4a858463af
commit
dc0849a778
17 changed files with 726 additions and 330 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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])),
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
# 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:
|
||||
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
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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", []):
|
||||
|
|
|
|||
BIN
nodes.blend
BIN
nodes.blend
Binary file not shown.
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue