draw lines

This commit is contained in:
Nicolas Predella 2026-08-04 15:44:42 +03:00
parent 5d52c936be
commit 02175f379a
12 changed files with 448 additions and 76 deletions

View file

@ -4,7 +4,7 @@ import numpy as np
from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfIntProperty, NfFloatProperty, NfStringProperty, NfFloatVectorProperty, 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, SlicePaths
from ....infra.blender.blender_mesh_capture import PdfProperties from ....infra.blender.blender_mesh_capture import PdfProperties
@register_class @register_class
@ -677,6 +677,10 @@ class SLICENODE_RenameMesh(SliceNode):
def _assign_pdf_properties_to_item(geom: Any, line_width: float, line_color: tuple[float, float, float], show_name: bool = False) -> Any: 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: if geom is None:
return None return None
elif isinstance(geom, SlicePaths):
copied = geom.copy_with(paths=list(geom))
copied.pdf_properties = PdfProperties(line_width=line_width, line_color=line_color, show_name=show_name)
return copied
elif isinstance(geom, (list, tuple)): elif isinstance(geom, (list, tuple)):
return [_assign_pdf_properties_to_item(item, line_width, line_color, show_name) for item in geom] return [_assign_pdf_properties_to_item(item, line_width, line_color, show_name) for item in geom]
elif isinstance(geom, SlicingGroup): elif isinstance(geom, SlicingGroup):
@ -742,6 +746,10 @@ def _assign_properties_to_item(geom: Any, props: list[tuple[str, str]]) -> Any:
from ....core.pdf_exporter.metadata import resolve_object_properties from ....core.pdf_exporter.metadata import resolve_object_properties
if geom is None: if geom is None:
return None return None
elif isinstance(geom, SlicePaths):
copied = geom.copy_with(paths=list(geom))
copied.object_properties = resolve_object_properties(copied, props)
return copied
elif isinstance(geom, (list, tuple)): elif isinstance(geom, (list, tuple)):
return [_assign_properties_to_item(item, props) for item in geom] return [_assign_properties_to_item(item, props) for item in geom]
elif isinstance(geom, SlicingGroup): elif isinstance(geom, SlicingGroup):

View file

@ -6,6 +6,7 @@ import bpy
import tempfile import tempfile
import re import re
import json import json
import numpy as np
from ..bpy_ns_types import NfStringProperty, NfFloatProperty, NfEnumProperty, NfFloatVectorProperty, NfBoolProperty from ..bpy_ns_types import NfStringProperty, NfFloatProperty, NfEnumProperty, NfFloatVectorProperty, NfBoolProperty
from ....registry import register_class from ....registry import register_class
@ -463,17 +464,24 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
from ...pdf_exporter import extract_unique_top_view_edges, export_mesh_to_pdf from ...pdf_exporter import extract_unique_top_view_edges, export_mesh_to_pdf
geom = self.resolve("Geometry") geom = self.resolve("Geometry")
slice_paths = self.resolve("Paths") # Currently unused slice_paths = self.resolve("Paths") or self.resolve("SlicePaths")
folder: str = self.resolve("Folder") or "" folder: str = self.resolve("Folder") or ""
settings = self.resolve("Settings")
if geom is None: # Collect raw geometry items
if operator: operator.report({'ERROR'}, "No Geometry connected") raw_geom_items: list[Any] = []
return 'error' if geom is not None:
raw_list = geom if isinstance(geom, list) else [geom]
raw_geom_items = [g for g in raw_list if g is not None]
geometries: list[Any] = [g for g in (geom if isinstance(geom, list) else [geom]) if g is not None] # Collect slice path items
if not geometries: slice_paths_items: list[Any] = []
if operator: operator.report({'ERROR'}, "No Geometry connected") if slice_paths is not None:
is_sp_inst = isinstance(slice_paths, SlicePaths)
raw_list = slice_paths if (isinstance(slice_paths, list) and not is_sp_inst) else [slice_paths]
slice_paths_items = [p for p in raw_list if p is not None]
if not raw_geom_items and not slice_paths_items:
if operator: operator.report({'ERROR'}, "No Geometry or Paths connected")
return 'error' return 'error'
if not folder: if not folder:
@ -490,26 +498,29 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
pass pass
folder_path = Path(abs_folder) folder_path = Path(abs_folder)
is_single_pdf_file = len(geometries) == 1 and folder_path.suffix.lower() == ".pdf" has_raw_geom = len(raw_geom_items) > 0
primary_items = raw_geom_items if has_raw_geom else slice_paths_items
is_single_pdf_file = len(primary_items) == 1 and folder_path.suffix.lower() == ".pdf"
if is_single_pdf_file: if is_single_pdf_file:
os.makedirs(folder_path.parent, exist_ok=True) os.makedirs(folder_path.parent, exist_ok=True)
target_list = [(geometries[0], str(folder_path))] target_list = [(primary_items[0], str(folder_path))]
else: else:
out_dir = folder_path if folder_path.suffix == "" else folder_path.parent out_dir = folder_path if folder_path.suffix == "" else folder_path.parent
os.makedirs(out_dir, exist_ok=True) os.makedirs(out_dir, exist_ok=True)
target_list = [] target_list = []
for i, geom in enumerate(geometries): for i, target_item in enumerate(primary_items):
if isinstance(geom, SlicingGroup) and geom.collections: if isinstance(target_item, SlicingGroup) and target_item.collections:
raw_name = "_".join(geom.collections.keys()) raw_name = "_".join(target_item.collections.keys())
elif hasattr(geom, 'name') and getattr(geom, 'name'): elif hasattr(target_item, 'name') and getattr(target_item, 'name'):
raw_name = str(getattr(geom, 'name')) raw_name = str(getattr(target_item, 'name'))
else: else:
raw_name = f"object_{i}" raw_name = f"object_{i}"
name_part = raw_name.split('.')[0] if raw_name else f"object_{i}" name_part = raw_name.split('.')[0] if raw_name else f"object_{i}"
filename = f"{name_part}.pdf" filename = f"{name_part}.pdf"
target_list.append((geom, str(out_dir / filename))) target_list.append((target_item, str(out_dir / filename)))
# Automatic scene scale determination # Automatic scene scale determination
scale_str = "1:1" scale_str = "1:1"
@ -537,8 +548,24 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
"scale": scale_str, "scale": scale_str,
} }
for geom, filepath in target_list: for target_item, filepath in target_list:
edges_2d = extract_unique_top_view_edges(geom) if has_raw_geom:
current_raw_geom = target_item
current_slice_paths = slice_paths
else:
current_raw_geom = None
current_slice_paths = target_item
edges_2d = np.empty((0, 2, 2), dtype=np.float64)
if current_raw_geom is not None:
raw_e = extract_unique_top_view_edges(current_raw_geom)
if len(raw_e) > 0:
edges_2d = raw_e
if current_slice_paths is not None:
sp_e = extract_unique_top_view_edges(current_slice_paths)
if len(sp_e) > 0:
edges_2d = np.concatenate([edges_2d, sp_e], axis=0) if len(edges_2d) > 0 else sp_e
if len(edges_2d) == 0: if len(edges_2d) == 0:
if operator: operator.report({'WARNING'}, f"Geometry for {os.path.basename(filepath)} has no valid 2D top-view wireframe edges") if operator: operator.report({'WARNING'}, f"Geometry for {os.path.basename(filepath)} has no valid 2D top-view wireframe edges")
@ -560,8 +587,8 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
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])),
bbox_color=(float(self.bbox_color[0]), float(self.bbox_color[1]), float(self.bbox_color[2])), bbox_color=(float(self.bbox_color[0]), float(self.bbox_color[1]), float(self.bbox_color[2])),
raw_geometry=geom, raw_geometry=current_raw_geom,
settings=settings slice_paths=current_slice_paths,
) )
if operator: operator.report({'INFO'}, f"Mesh PDF saved to {filepath}") if operator: operator.report({'INFO'}, f"Mesh PDF saved to {filepath}")
except Exception as e: except Exception as e:

View file

@ -140,6 +140,37 @@ class SLICESOCK_Paths(LabelNodeSocket):
default = SlicePaths() default = SlicePaths()
bl_color = (214/255, 0, 120/255, 1.0) bl_color = (214/255, 0, 120/255, 1.0)
@register_class
class SLICESOCK_Geometry(LabelNodeSocket):
bl_label = "Geometry"
default = None
bl_color = (0.0, 0.75, 0.75, 1.0)
def draw_color(self, context, node) -> tuple[float, float, float, float]:
def _get_upstream_color(sock: Any) -> tuple[float, float, float, float]:
if not sock or not getattr(sock, "is_linked", False) or not getattr(sock, "links", None):
return self.bl_color
from_sock = sock.links[0].from_socket
if hasattr(from_sock, "draw_color"):
try:
from_node = getattr(from_sock, "node", None)
return from_sock.draw_color(context, from_node)
except Exception:
pass
return getattr(from_sock, "bl_color", self.bl_color)
if self.is_output:
if node and hasattr(node, "inputs"):
sock_name = getattr(self, "name", "")
matching_in = (node.inputs.get(sock_name) if sock_name else None) or node.inputs.get("Geometry")
if matching_in:
return _get_upstream_color(matching_in)
return self.bl_color
else:
return _get_upstream_color(self)
class SLICESOCK_Gcode_Common(InputNodeSocket): class SLICESOCK_Gcode_Common(InputNodeSocket):
bl_label = "Gcode" bl_label = "Gcode"
value: NfStringProperty(name="Gcode", default="") # pyright: ignore[reportInvalidTypeForm] value: NfStringProperty(name="Gcode", default="") # pyright: ignore[reportInvalidTypeForm]

View file

@ -155,6 +155,13 @@ class SLICETREE_NodeTree(NodeTree):
bl_group_idname = "SLICENODE_Group" bl_group_idname = "SLICENODE_Group"
bl_use_group_interface = True bl_use_group_interface = True
def valid_link(self, from_socket: Any, to_socket: Any) -> bool:
geom_sockets = {"SLICESOCK_Mesh", "SLICESOCK_Paths", "SLICESOCK_Geometry"}
if from_socket.bl_idname in geom_sockets and to_socket.bl_idname in geom_sockets:
return True
return from_socket.bl_idname == to_socket.bl_idname
@register_class @register_class
class SLICENODE_OT_GroupToggle(Operator): class SLICENODE_OT_GroupToggle(Operator):
bl_idname = "slicenode.group_toggle" bl_idname = "slicenode.group_toggle"

View file

@ -12,6 +12,7 @@ Modular structure:
from .geometry import ( from .geometry import (
deduplicate_2d_edges, deduplicate_2d_edges,
extract_unique_top_view_edges, extract_unique_top_view_edges,
extract_slice_paths_edges,
extract_lineart_edges, extract_lineart_edges,
extract_boundary_edges, extract_boundary_edges,
extract_outer_contour_edges, extract_outer_contour_edges,
@ -85,6 +86,7 @@ __all__ = [
# Geometry # Geometry
"deduplicate_2d_edges", "deduplicate_2d_edges",
"extract_unique_top_view_edges", "extract_unique_top_view_edges",
"extract_slice_paths_edges",
"extract_lineart_edges", "extract_lineart_edges",
"extract_boundary_edges", "extract_boundary_edges",
"extract_outer_contour_edges", "extract_outer_contour_edges",

View file

@ -31,17 +31,54 @@ def deduplicate_2d_edges(edges_2d: NDArray[np.float64]) -> NDArray[np.float64]:
return edges_2d[unique_indices] return edges_2d[unique_indices]
def extract_slice_paths_edges(slice_paths: Any) -> NDArray[np.float64]:
"""
Extracts 2D line segments (shape N x 2 x 2) from SlicePaths or a list/collection of SlicePaths.
"""
from ..slice import SlicePaths
if slice_paths is None:
return np.empty((0, 2, 2), dtype=np.float64)
all_segments: list[np.ndarray] = []
def process_item(item: Any):
if item is None:
return
elif isinstance(item, SlicePaths):
for path in item:
if path is not None and len(path) >= 2:
pts = np.asarray(path, dtype=np.float64)[:, :2]
segs = np.stack([pts[:-1], pts[1:]], axis=1)
all_segments.append(segs)
elif isinstance(item, (list, tuple)):
for sub in item:
process_item(sub)
process_item(slice_paths)
if not all_segments:
return np.empty((0, 2, 2), dtype=np.float64)
return np.concatenate(all_segments, axis=0)
def extract_unique_top_view_edges(mesh_data: Any) -> NDArray[np.float64]: def extract_unique_top_view_edges(mesh_data: Any) -> NDArray[np.float64]:
""" """
Extracts 2D top-view unique edges (X, Y projection) from mesh_data. Extracts 2D top-view unique edges (X, Y projection) from mesh_data (SlicingObjects or SlicePaths).
""" """
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
from ..slice import SlicePaths
all_edge_segments: list[NDArray[np.float64]] = [] all_edge_segments: list[NDArray[np.float64]] = []
def process_item(item: Any): def process_item(item: Any):
if item is None: if item is None:
return return
elif isinstance(item, SlicePaths):
sp_edges = extract_slice_paths_edges(item)
if len(sp_edges) > 0:
all_edge_segments.append(sp_edges)
elif isinstance(item, (list, tuple)): elif isinstance(item, (list, tuple)):
for sub in item: for sub in item:
process_item(sub) process_item(sub)

View file

@ -9,14 +9,21 @@ from numpy.typing import NDArray
def get_first_attribute_value(so_item: Any, attr_name: str) -> Any: def get_first_attribute_value(so_item: Any, attr_name: str) -> Any:
""" """
Extracts scalar attribute value for a SlicingObject. Extracts scalar attribute value for a SlicingObject or SlicePaths.
""" """
if not hasattr(so_item, 'attributes') or not isinstance(so_item.attributes, dict): if hasattr(so_item, 'extra_attributes') and isinstance(so_item.extra_attributes, dict):
return None if attr_name in so_item.extra_attributes:
vals = so_item.extra_attributes[attr_name]
if attr_name not in so_item.attributes: if isinstance(vals, (list, tuple, np.ndarray)) and len(vals) > 0:
return None val = vals[0]
if hasattr(val, 'item'):
return val.item()
return val
elif vals is not None:
return vals
if hasattr(so_item, 'attributes') and isinstance(so_item.attributes, dict):
if attr_name in so_item.attributes:
attr_info = so_item.attributes[attr_name] attr_info = so_item.attributes[attr_name]
if isinstance(attr_info, dict) and 'values' in attr_info: if isinstance(attr_info, dict) and 'values' in attr_info:
vals = attr_info['values'] vals = attr_info['values']
@ -25,14 +32,32 @@ def get_first_attribute_value(so_item: Any, attr_name: str) -> Any:
else: else:
return attr_info return attr_info
if len(vals) == 0: if len(vals) > 0:
return None
val = vals[0] val = vals[0]
if hasattr(val, 'item'): if hasattr(val, 'item'):
return val.item() return val.item()
return val return val
attr_key = attr_name.lower().replace(" ", "_")
if hasattr(so_item, attr_key):
val = getattr(so_item, attr_key)
if val is not None and not callable(val):
if isinstance(val, (list, tuple, np.ndarray)) and len(val) > 0:
val = val[0]
if hasattr(val, 'item'):
return val.item()
return val
elif hasattr(so_item, attr_name):
val = getattr(so_item, attr_name)
if val is not None and not callable(val):
if isinstance(val, (list, tuple, np.ndarray)) and len(val) > 0:
val = val[0]
if hasattr(val, 'item'):
return val.item()
return val
return None
def resolve_object_properties(so_item: Any, props: list[tuple[str, str]]) -> list[tuple[str, str]]: def resolve_object_properties(so_item: Any, props: list[tuple[str, str]]) -> list[tuple[str, str]]:
""" """
@ -79,15 +104,32 @@ def extract_settings_items(settings: Any) -> list[tuple[str, str]]:
def extract_object_label_info(mesh_data: Any) -> list[tuple[Any, str, float, float, float, float]]: def extract_object_label_info(mesh_data: Any) -> list[tuple[Any, str, float, float, float, float]]:
""" """
Extracts (slicing_object, object_name, max_x_mm, y_at_max_x_mm, width_mm, height_mm) for each SlicingObject in mesh_data. Extracts (slicing_object, object_name, max_x_mm, y_at_max_x_mm, width_mm, height_mm) for each SlicingObject or SlicePaths in mesh_data.
""" """
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
from ..slice import SlicePaths
results: list[tuple[Any, str, float, float, float, float]] = [] results: list[tuple[Any, str, float, float, float, float]] = []
def process_item(item: Any, fallback_name: str = ""): def process_item(item: Any, fallback_name: str = ""):
if item is None: if item is None:
return return
elif isinstance(item, SlicePaths):
all_pts = [p[:, :2] for p in item if p is not None and len(p) > 0]
if all_pts:
verts_2d = np.concatenate(all_pts, axis=0)
if len(verts_2d) > 0:
max_idx = np.argmax(verts_2d[:, 0])
max_x = float(verts_2d[max_idx, 0])
min_x = float(verts_2d[:, 0].min())
y_val = float(verts_2d[max_idx, 1])
min_y = float(verts_2d[:, 1].min())
max_y = float(verts_2d[:, 1].max())
w_mm = max_x - min_x
h_mm = max_y - min_y
name = getattr(item, "name", "") or fallback_name or "SlicePaths"
results.append((item, name, max_x, y_val, w_mm, h_mm))
elif isinstance(item, (list, tuple)): elif isinstance(item, (list, tuple)):
for i, sub in enumerate(item): for i, sub in enumerate(item):
process_item(sub, f"{fallback_name}_{i}" if fallback_name else f"object_{i}") process_item(sub, f"{fallback_name}_{i}" if fallback_name else f"object_{i}")

View file

@ -48,15 +48,27 @@ def extract_slicing_objects_bounds(
) -> list[tuple[Any, str, float, float, float, float]]: ) -> list[tuple[Any, str, float, float, float, float]]:
""" """
Extracts (slicing_object, object_name, min_x_mm, max_x_mm, min_y_mm, max_y_mm) Extracts (slicing_object, object_name, min_x_mm, max_x_mm, min_y_mm, max_y_mm)
for each SlicingObject in mesh_data. for each SlicingObject or SlicePaths in mesh_data.
""" """
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
from ..slice import SlicePaths
results: list[tuple[Any, str, float, float, float, float]] = [] results: list[tuple[Any, str, float, float, float, float]] = []
def process_item(item: Any, fallback_name: str = ""): def process_item(item: Any, fallback_name: str = ""):
if item is None: if item is None:
return return
elif isinstance(item, SlicePaths):
all_pts = [p[:, :2] for p in item if p is not None and len(p) > 0]
if all_pts:
verts_2d = np.concatenate(all_pts, axis=0)
if len(verts_2d) > 0:
min_x = float(verts_2d[:, 0].min())
max_x = float(verts_2d[:, 0].max())
min_y = float(verts_2d[:, 1].min())
max_y = float(verts_2d[:, 1].max())
name = getattr(item, "name", "") or fallback_name or "SlicePaths"
results.append((item, name, min_x, max_x, min_y, max_y))
elif isinstance(item, (list, tuple)): elif isinstance(item, (list, tuple)):
for i, sub in enumerate(item): for i, sub in enumerate(item):
process_item(sub, f"{fallback_name}_{i}" if fallback_name else f"object_{i}") process_item(sub, f"{fallback_name}_{i}" if fallback_name else f"object_{i}")

View file

@ -22,6 +22,7 @@ from .geometry import (
extract_boundary_edges, extract_boundary_edges,
extract_outer_contour_edges, extract_outer_contour_edges,
extract_crease_edges, extract_crease_edges,
extract_slice_paths_edges,
deduplicate_2d_edges, deduplicate_2d_edges,
) )
from .sa2_measures import render_sa2_measures_pdf from .sa2_measures import render_sa2_measures_pdf
@ -165,7 +166,7 @@ def render_object_properties_table(
""" """
objs_with_props = [ objs_with_props = [
so for so in slicing_objs so for so in slicing_objs
if hasattr(so, "object_properties") and so.object_properties if getattr(so, "object_properties", None) is not None and len(getattr(so, "object_properties", [])) > 0
] ]
if not objs_with_props: if not objs_with_props:
return return
@ -285,10 +286,15 @@ def render_object_properties_table(
def get_all_slicing_objects(mesh_data: Any) -> list[Any]: def get_all_slicing_objects(mesh_data: Any) -> list[Any]:
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
from ..slice import SlicePaths
objects = [] objects = []
def process_item(item: Any): def process_item(item: Any):
if item is None: if item is None:
return return
elif isinstance(item, (SlicingObject, SlicePaths)):
objects.append(item)
elif isinstance(item, (list, tuple)): elif isinstance(item, (list, tuple)):
for sub in item: for sub in item:
process_item(sub) process_item(sub)
@ -298,8 +304,7 @@ def get_all_slicing_objects(mesh_data: Any) -> list[Any]:
elif isinstance(item, SlicingCollection): elif isinstance(item, SlicingCollection):
for so in item.objects: for so in item.objects:
process_item(so) process_item(so)
elif isinstance(item, SlicingObject):
objects.append(item)
process_item(mesh_data) process_item(mesh_data)
return objects return objects
@ -349,14 +354,20 @@ class PDFGridElement(PDFElement):
class PDFGeometryElement(PDFElement): class PDFGeometryElement(PDFElement):
""" """
Renders 2D vector geometry (wireframe, boundary, contour, crease lines). Renders 2D vector geometry (wireframe, boundary, contour, crease lines, and slice paths).
""" """
def render(self, s: PdfStream) -> None: def render(self, s: PdfStream) -> None:
exp = self.exporter exp = self.exporter
slicing_objs = exp.slicing_objs slicing_objs = exp.slicing_objs
from ..slice import SlicePaths
if slicing_objs: if slicing_objs:
for so in slicing_objs: for so in slicing_objs:
so_edge_list: list[NDArray[np.float64]] = [] so_edge_list: list[NDArray[np.float64]] = []
if isinstance(so, SlicePaths):
sp_edges = extract_slice_paths_edges(so)
if len(sp_edges) > 0:
so_edge_list.append(sp_edges)
else:
if exp.draw_wireframe: if exp.draw_wireframe:
so_w = extract_unique_top_view_edges(so) so_w = extract_unique_top_view_edges(so)
if len(so_w) > 0: if len(so_w) > 0:
@ -561,8 +572,9 @@ class PDFObjectLabelsElement(PDFElement):
slicing_objs_list = exp.slicing_objs slicing_objs_list = exp.slicing_objs
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) 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 (exp.show_name or has_any_obj_show_name or settings_items) and exp.raw_geometry is not None: if (exp.show_name or has_any_obj_show_name or settings_items) and (exp.raw_geometry is not None or exp.slice_paths is not None):
label_infos = extract_object_label_info(exp.raw_geometry) geom_source = [g for g in (exp.raw_geometry, exp.slice_paths) if g is not None]
label_infos = extract_object_label_info(geom_source)
if label_infos: if label_infos:
eff_font_size_pt = exp.font_size_pt if exp.font_size_pt is not None and exp.font_size_pt > 0 else max(8.0, exp.line_width_pt * 4.0) eff_font_size_pt = exp.font_size_pt if exp.font_size_pt is not None and exp.font_size_pt > 0 else max(8.0, exp.line_width_pt * 4.0)
line_spacing_pt = eff_font_size_pt * 1.25 line_spacing_pt = eff_font_size_pt * 1.25
@ -710,7 +722,7 @@ class export_mesh_to_pdf:
def __init__( def __init__(
self, self,
edges_2d: NDArray[np.float64], edges_2d: NDArray[np.float64] | None = None,
filepath: str | Path | None = None, filepath: str | Path | None = None,
draw_wireframe: bool = True, draw_wireframe: bool = True,
draw_boundary: bool = False, draw_boundary: bool = False,
@ -729,10 +741,11 @@ class export_mesh_to_pdf:
bg_color: tuple[float, float, float] = (1.0, 1.0, 1.0), bg_color: tuple[float, float, float] = (1.0, 1.0, 1.0),
bbox_color: tuple[float, float, float] = (0.0, 0.0, 1.0), bbox_color: tuple[float, float, float] = (0.0, 0.0, 1.0),
raw_geometry: Any = None, raw_geometry: Any = None,
slice_paths: Any = None,
settings: Any = None, settings: Any = None,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
self.edges_2d = edges_2d self.edges_2d = edges_2d if edges_2d is not None else np.empty((0, 2, 2), dtype=np.float64)
self.filepath = filepath self.filepath = filepath
self.draw_wireframe = draw_wireframe self.draw_wireframe = draw_wireframe
self.draw_boundary = draw_boundary self.draw_boundary = draw_boundary
@ -753,6 +766,7 @@ class export_mesh_to_pdf:
self.bg_color = bg_color self.bg_color = bg_color
self.bbox_color = bbox_color self.bbox_color = bbox_color
self.raw_geometry = raw_geometry self.raw_geometry = raw_geometry
self.slice_paths = slice_paths
self.settings = settings self.settings = settings
self._prepare_geometry_and_layout() self._prepare_geometry_and_layout()
@ -773,7 +787,12 @@ class export_mesh_to_pdf:
self.export(filepath) self.export(filepath)
def _prepare_geometry_and_layout(self) -> None: def _prepare_geometry_and_layout(self) -> None:
self.slicing_objs = get_all_slicing_objects(self.raw_geometry) objs_raw = get_all_slicing_objects(self.raw_geometry)
objs_paths = get_all_slicing_objects(self.slice_paths)
self.slicing_objs = list(objs_raw)
for po in objs_paths:
if not any(po is existing for existing in self.slicing_objs):
self.slicing_objs.append(po)
edge_list: list[NDArray[np.float64]] = [] edge_list: list[NDArray[np.float64]] = []
@ -795,6 +814,11 @@ class export_mesh_to_pdf:
if len(cr_edges) > 0: if len(cr_edges) > 0:
edge_list.append(cr_edges) edge_list.append(cr_edges)
if self.slice_paths is not None:
sp_edges = extract_slice_paths_edges(self.slice_paths)
if len(sp_edges) > 0:
edge_list.append(sp_edges)
if edge_list: if edge_list:
combined = np.concatenate(edge_list, axis=0) combined = np.concatenate(edge_list, axis=0)
self.edges_to_draw = deduplicate_2d_edges(combined) self.edges_to_draw = deduplicate_2d_edges(combined)
@ -843,7 +867,7 @@ class export_mesh_to_pdf:
objs_with_props = [ objs_with_props = [
so for so in self.slicing_objs so for so in self.slicing_objs
if hasattr(so, "object_properties") and so.object_properties if getattr(so, "object_properties", None) is not None and len(getattr(so, "object_properties", [])) > 0
] ]
w_table_mm = 0.0 w_table_mm = 0.0
@ -918,8 +942,6 @@ class export_mesh_to_pdf:
self.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)) self.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))
self.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)) self.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))
self.slicing_objs = get_all_slicing_objects(self.raw_geometry)
def to_pdf_pt(self, pt_mm: NDArray[np.float64]) -> NDArray[np.float64]: def to_pdf_pt(self, pt_mm: NDArray[np.float64]) -> NDArray[np.float64]:
x_pt = (pt_mm[:, 0] - self.grid_min_x + self.total_margin_mm) * MM_TO_PT x_pt = (pt_mm[:, 0] - self.grid_min_x + self.total_margin_mm) * MM_TO_PT
y_pt = (pt_mm[:, 1] - self.grid_min_y + self.total_margin_mm) * MM_TO_PT y_pt = (pt_mm[:, 1] - self.grid_min_y + self.total_margin_mm) * MM_TO_PT
@ -1020,4 +1042,3 @@ class export_mesh_to_pdf:
# Alias for backward compatibility # Alias for backward compatibility
export_wireframe_to_pdf = export_mesh_to_pdf export_wireframe_to_pdf = export_mesh_to_pdf

View file

@ -5,6 +5,8 @@ import pyclipper
from typing import Any, Literal from typing import Any, Literal
from shapely.geometry.base import BaseGeometry from shapely.geometry.base import BaseGeometry
from ..infra.blender.blender_mesh_capture import PdfProperties
from .geometry import ndarray_lru_cache from .geometry import ndarray_lru_cache
# Scaling factor for pyclipper (it works with integers) # Scaling factor for pyclipper (it works with integers)
@ -160,6 +162,8 @@ class SlicePaths(list[np.ndarray]):
layer_height: float layer_height: float
first_layer_height: float | None first_layer_height: float | None
transforms: list[dict] transforms: list[dict]
object_properties: list[tuple[str, str]]
pdf_properties: PdfProperties
def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = [], extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None, layer_height: float = 0.2, first_layer_height: float | None = None, transforms: list[dict] | None = None): def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = [], extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None, layer_height: float = 0.2, first_layer_height: float | None = None, transforms: list[dict] | None = None):
super().__init__(paths) super().__init__(paths)

View file

@ -63,12 +63,12 @@ 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`. Supports `{AttributeName}` attribute placeholders (e.g., `Rebar-L{Rebar Length}M`) that evaluate to index zero of the target attribute for each object, as well as `{Name}` to include the item's previous name (e.g., `{Name}_T{Thickness}mm`). - **Rename Mesh**: Overrides the internal metadata name of a `SlicingObject`, `SlicingCollection`, or `SlicingGroup`. Supports `{AttributeName}` attribute placeholders (e.g., `Rebar-L{Rebar Length}M`) that evaluate to index zero of the target attribute for each object, as well as `{Name}` to include the item's previous name (e.g., `{Name}_T{Thickness}mm`).
- **Geometry**: Input geometry data. - **Geometry**: Input geometry data.
- **Name**: String input socket or property for the new name template. - **Name**: String input socket or property for the new name template.
- **Assign PDF Properties**: Assigns line width (mm) and line color PDF export properties to all `SlicingObject` instances within a `SlicingObject`, `SlicingCollection`, or `SlicingGroup`. - **Assign PDF Properties**: Assigns line width (mm) and line color PDF export properties to geometry (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or `SlicePaths`).
- **Geometry**: Input geometry socket. - **Geometry**: Generic geometry input socket (accepts `SLICESOCK_Mesh`, `SLICESOCK_Paths`, or `SLICESOCK_Geometry`).
- **Line Width**: Input socket / float property for line width in mm. - **Line Width**: Input socket / float property for line width in mm.
- **Line Color**: Color property for stroke color. - **Line Color**: Color property for stroke color.
- **Assign Property to Object**: Assigns custom settings/properties to each object in a `SlicingObject`, `SlicingCollection`, or `SlicingGroup` for rendering in PDF properties tables. - **Assign Property to Object**: Assigns custom settings/properties to geometry (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or `SlicePaths`) for rendering in PDF properties tables.
- **Geometry**: Input geometry socket. - **Geometry**: Generic geometry input socket (accepts `SLICESOCK_Mesh`, `SLICESOCK_Paths`, or `SLICESOCK_Geometry`).
- **Settings**: Input socket accepting configuration settings or property items. - **Settings**: Input socket accepting configuration settings or property items.
- **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.

View file

@ -951,7 +951,7 @@ class TestPdfExporter(unittest.TestCase):
pdf_file = os.path.join(tmpdir, "output.pdf") pdf_file = os.path.join(tmpdir, "output.pdf")
resolved_inputs = { resolved_inputs = {
"Geometry": so, "Geometry": so,
"SlicePaths": None, "Paths": None,
"Folder": pdf_file, "Folder": pdf_file,
"Settings": None, "Settings": None,
} }
@ -1121,6 +1121,47 @@ class TestPdfExporter(unittest.TestCase):
for obj in coll.objects: for obj in coll.objects:
self.assertEqual(obj.object_properties, props) self.assertEqual(obj.object_properties, props)
# Also verify SlicePaths support
from NodeSlicer.core.slice import SlicePaths
sp = SlicePaths([np.array([[0, 0, 0], [10, 10, 0]], dtype=np.float64)])
updated_sp = _assign_properties_to_item(sp, props)
self.assertEqual(updated_sp.object_properties, props)
# Verify dynamic socket color logic
from NodeSlicer.core.nodeslicer.sockets import SLICESOCK_Geometry, SLICESOCK_Paths, SLICESOCK_Mesh
class DummyLink:
def __init__(self, from_sock):
self.from_socket = from_sock
class DummyNode:
def __init__(self):
self.inputs = {}
dummy_node = DummyNode()
in_geom = SLICESOCK_Geometry()
in_geom.is_output = False
in_geom.is_linked = True
in_geom.name = "Geometry"
paths_out = SLICESOCK_Paths()
in_geom.links = [DummyLink(paths_out)]
dummy_node.inputs["Geometry"] = in_geom
out_geom = SLICESOCK_Geometry()
out_geom.is_output = True
# Input socket draws Paths color (pink)
self.assertEqual(in_geom.draw_color(None, dummy_node), paths_out.bl_color)
# Output socket on same node inherits Paths color (pink) from input socket
self.assertEqual(out_geom.draw_color(None, dummy_node), paths_out.bl_color)
# Switch upstream to Mesh (cyan)
mesh_out = SLICESOCK_Mesh()
in_geom.links = [DummyLink(mesh_out)]
self.assertEqual(in_geom.draw_color(None, dummy_node), mesh_out.bl_color)
self.assertEqual(out_geom.draw_color(None, dummy_node), mesh_out.bl_color)
def test_export_pdf_object_properties_table(self): def test_export_pdf_object_properties_table(self):
so1 = SlicingObject() so1 = SlicingObject()
so1.name = "BracketA" so1.name = "BracketA"
@ -1245,6 +1286,146 @@ class TestPdfExporter(unittest.TestCase):
self.assertIn("1.000 1.000 1.000 rg", content) self.assertIn("1.000 1.000 1.000 rg", content)
self.assertIn("1.000 1.000 1.000 RG", content) self.assertIn("1.000 1.000 1.000 RG", content)
def test_slicepaths_pdf_export_default_style_and_properties(self):
from NodeSlicer.core.slice import SlicePaths
from NodeSlicer.core.nodeslicer.nodes.mesh import _assign_pdf_properties_to_item, _assign_properties_to_item
sp = SlicePaths([np.array([[0.0, 0.0], [50.0, 0.0], [50.0, 50.0], [0.0, 50.0]], dtype=np.float64)])
sp.name = "TestSlicePath"
# 1. Export with default style
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "slicepath_default.pdf")
export_mesh_to_pdf(
slice_paths=sp,
filepath=pdf_path,
draw_wireframe=True,
)
self.assertTrue(os.path.exists(pdf_path))
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertTrue(len(content) > 0)
# 2. Assign PDF properties (custom width, color, show_name)
sp_pdf_props = _assign_pdf_properties_to_item(sp, line_width=3.0, line_color=(1.0, 0.0, 0.0), show_name=True)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "slicepath_pdf_props.pdf")
export_mesh_to_pdf(
slice_paths=sp_pdf_props,
filepath=pdf_path,
draw_wireframe=True,
)
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn("1.000 0.000 0.000 RG", content)
self.assertIn("(TestSlicePath) Tj", content)
# 3. Assign Property to Object (table properties)
props = [("Layer Height", "0.2mm"), ("Speed", "60mm/s")]
sp_props = _assign_properties_to_item(sp, props)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_path = os.path.join(tmpdir, "slicepath_object_props.pdf")
export_mesh_to_pdf(
slice_paths=sp_props,
filepath=pdf_path,
draw_wireframe=True,
)
with open(pdf_path, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn("(Layer Height) Tj", content)
self.assertIn("(0.2mm) Tj", content)
self.assertIn("(Speed) Tj", content)
self.assertIn("(60mm/s) Tj", content)
def test_mesh_to_pdf_paths_input_only_and_combined(self):
from NodeSlicer.core.slice import SlicePaths
from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_MeshToPdf
node = SLICENODE_MeshToPdf.__new__(SLICENODE_MeshToPdf)
node.draw_wireframe = True
node.draw_boundary = False
node.draw_contour = False
node.draw_crease = False
node.crease_angle = 30.0
node.draw_bbox = False
node.draw_sa2_holes = False
node.draw_header = False
node.header_company = ""
node.header_part_name = ""
node.header_drawing_number = ""
node.header_author = ""
node.header_revision = ""
node.header_material = ""
node.header_tolerance = ""
node.font_size = 0.0
node.margin = 10.0
node.wire_color = (0.0, 0.0, 0.0)
node.bg_color = (1.0, 1.0, 1.0)
node.bbox_color = (0.0, 0.0, 1.0)
class MockOperator:
def __init__(self):
self.reports = []
def report(self, type_set, message):
self.reports.append((type_set, message))
sp = SlicePaths([np.array([[5.0, 5.0], [25.0, 25.0]], dtype=np.float64)])
sp.name = "PathOnly"
# Case A: Only Paths connected (Geometry is None)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_file = os.path.join(tmpdir, "paths_only.pdf")
op = MockOperator()
resolved = {
"Geometry": None,
"Paths": sp,
"Folder": pdf_file,
}
node.resolve = lambda k: resolved.get(k)
res = node.evaluate(operator=op)
self.assertEqual(res, "ok", msg=f"Reports: {op.reports}")
self.assertTrue(os.path.exists(pdf_file))
with open(pdf_file, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn(" m ", content)
# Case B: Both Geometry and Paths connected
so = SlicingObject()
so.name = "MeshObj"
so.mesh = np.array([[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]]], dtype=np.float64)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_file = os.path.join(tmpdir, "combined.pdf")
op = MockOperator()
resolved = {
"Geometry": so,
"Paths": sp,
"Folder": pdf_file,
}
node.resolve = lambda k: resolved.get(k)
res = node.evaluate(operator=op)
self.assertEqual(res, "ok", msg=f"Reports: {op.reports}")
self.assertTrue(os.path.exists(pdf_file))
with open(pdf_file, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn(" m ", content)
# Case C: Geometry is an empty list [] and Paths is connected
with tempfile.TemporaryDirectory() as tmpdir:
pdf_file = os.path.join(tmpdir, "empty_geom_list.pdf")
op = MockOperator()
resolved = {
"Geometry": [],
"Paths": sp,
"Folder": pdf_file,
}
node.resolve = lambda k: resolved.get(k)
res = node.evaluate(operator=op)
self.assertEqual(res, "ok", msg=f"Reports: {op.reports}")
self.assertTrue(os.path.exists(pdf_file))
with open(pdf_file, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn(" m ", content)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()