Compare commits

...

11 commits

Author SHA1 Message Date
767fd08060 rm prusaslicer out of scope 2026-09-25 13:33:01 +03:00
9137d297e5 remove prusaslicer (out of scope) 2026-09-25 13:32:51 +03:00
8d5fb68aaa holes 2026-09-25 13:21:10 +03:00
bcf9334d78 exporter 2026-08-31 15:00:07 +03:00
926da63dd8 naming template 2026-08-31 14:42:45 +03:00
31d34577e7 up 2026-08-26 12:54:30 +03:00
562dc221a2 int extruder 2026-08-26 12:46:48 +03:00
c77412bb50 up 2026-08-26 12:39:49 +03:00
77323d29db up 2026-08-26 12:35:32 +03:00
ec539fa9fe xml 2026-08-26 12:34:26 +03:00
9d282dee01 up 2026-08-26 12:04:35 +03:00
41 changed files with 884 additions and 46062 deletions

2
.gitignore vendored
View file

@ -5,5 +5,3 @@ release/
*.blend
*.blend1
*.bgcode
tests/Link to Configs
profiles/prusaslicer/Link to Configs

View file

@ -2,8 +2,6 @@ from __future__ import annotations
import bpy
from . import registry
from .core.nodeslicer import sockets, nodes, tree # sockets and nodes need also to be imported in order for them to be autoregistered
from .ui import panels
from .property_groups.bpy_property_groups import SlicerObjectPropertyGroup
bl_info = {
"name": "NodeSlicer",
@ -15,19 +13,13 @@ bl_info = {
"category": "Node",
}
_registered_nodeslicer = False
_keymaps = []
def register():
global _registered_nodeslicer
registry.blender_register_classes()
bpy.types.NODE_MT_add.append(tree.draw_add_menu)
bpy.app.handlers.depsgraph_update_post.append(tree.depsgraph_update_handler)
if not hasattr(bpy.types.Object, "nodeslicer"):
setattr(bpy.types.Object, "nodeslicer", bpy.props.PointerProperty(type=SlicerObjectPropertyGroup, name="nodeslicer"))
_registered_nodeslicer = True
# Register Keymaps
wm = bpy.context.window_manager
assert wm
@ -44,7 +36,6 @@ def register():
print('Nodeslicer Loaded')
def unregister():
global _registered_nodeslicer
try:
if tree.depsgraph_update_handler in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.remove(tree.depsgraph_update_handler)
@ -67,15 +58,6 @@ def unregister():
registry.blender_unregister_classes()
if _registered_nodeslicer:
try:
if hasattr(bpy.types.Object, "nodeslicer"):
delattr(bpy.types.Object, "nodeslicer")
except Exception as e:
print(f"Error removing property: {e}")
finally:
_registered_nodeslicer = False
print('Nodeslicer Unloaded')
if __name__ == "__main__":

View file

@ -60,7 +60,7 @@ _MATH_OPS: dict[str, Any] = {
'ADD': lambda a, b, t: a + b,
'SUBTRACT': lambda a, b, t: a - b,
'MULTIPLY': lambda a, b, t: a * b,
'DIVIDE': lambda a, b, t: a // b if b != 0 else 0,
'DIVIDE': lambda a, b, t: a / b if b != 0 else 0,
'ABS': lambda a, b, t: abs(a),
'NEGATE': lambda a, b, t: -a,
'POWER': lambda a, b, t: t(a ** b) if not (a == 0 and b < 0) else 0,

View file

@ -67,7 +67,7 @@ class SLICENODE_CollectionObjects(SliceNode):
return
objs = [obj for obj in col.objects if getattr(obj, "type", "") == 'MESH']
expected_names = [obj.name for obj in objs]
expected_names = sorted([obj.name for obj in objs])
# Remove outputs that are no longer in the collection
to_remove = [o for o in self.outputs if o.name not in expected_names]
@ -79,162 +79,23 @@ class SLICENODE_CollectionObjects(SliceNode):
if name not in self.outputs:
self.add_output("NodeSocketObject", name, identifier=name)
# Re-order to match sorted alphabetical order
if hasattr(self.outputs, "find") and hasattr(self.outputs, "move"):
for i, name in enumerate(expected_names):
current_index = self.outputs.find(name)
if current_index != -1 and current_index != i:
self.outputs.move(current_index, i)
def evaluate_output(self, socket_name: str, **kwargs) -> Any:
if self.collection and socket_name in bpy.data.objects:
return bpy.data.objects[socket_name]
return None
@register_class
class SLICENODE_WipeTower(SliceNode):
bl_label = "Wipe Tower"
def init(self, context):
self.add_input("NodeSocketFloat", "X Position").value = 170.0
self.add_input("NodeSocketFloat", "Y Position").value = 140.0
self.add_output("SLICESOCK_SettingsRo", "Settings")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name != "Settings":
return {}
raw_x: Any = self.resolve("X Position")
raw_y: Any = self.resolve("Y Position")
if isinstance(raw_x, (list, tuple, np.ndarray)):
pos_x = float(raw_x[0]) if len(raw_x) >= 1 else 0.0
pos_y = float(raw_x[1]) if len(raw_x) >= 2 else (float(raw_y) if raw_y is not None else 0.0)
else:
pos_x = float(raw_x) if raw_x is not None else 0.0
pos_y = float(raw_y) if raw_y is not None else 0.0
return {
"wipe_tower_position": (pos_x, pos_y)
}
@register_class
class SLICENODE_OffsetObject(SliceNode):
bl_label = "Offset Object"
def init(self, context):
self.add_input("NodeSocketFloat", "X Offset").value = 0.0
self.add_input("NodeSocketFloat", "Y Offset").value = 0.0
self.add_input("NodeSocketFloat", "Z Offset").value = 0.0
self.add_output("SLICESOCK_SettingsRo", "Settings")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name != "Settings":
return {}
raw_x: Any = self.resolve("X Offset")
raw_y: Any = self.resolve("Y Offset")
raw_z: Any = self.resolve("Z Offset")
if isinstance(raw_x, (list, tuple, np.ndarray)):
ox = float(raw_x[0]) if len(raw_x) >= 1 else 0.0
oy = float(raw_x[1]) if len(raw_x) >= 2 else (float(raw_y) if raw_y is not None else 0.0)
oz = float(raw_x[2]) if len(raw_x) >= 3 else (float(raw_z) if raw_z is not None else 0.0)
else:
ox = float(raw_x) if raw_x is not None else 0.0
oy = float(raw_y) if raw_y is not None else 0.0
oz = float(raw_z) if raw_z is not None else 0.0
return {
"offset_object": (ox, oy, oz)
}
@register_class
class SLICENODE_InputSettings(InputNode):
input_type = "Settings"
bl_icon = "OBJECT_DATA"
@register_class
class SLICENODE_PrusaslicerConf(SliceNode):
bl_label = "PrusaSlicer Config"
bl_icon = "PROPERTIES"
_cache = None
@classmethod
def get_cache(cls):
if cls._cache is None:
from ....infra.prusaslicer_profiles import LocalCache
cls._cache = LocalCache()
return cls._cache
def ensure_profiles_loaded(self):
cache = self.get_cache()
if not cache.profiles:
# Use a path relative to the addon directory
# Note: adjust paths to point to the correct addon root
addon_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
profiles_path = os.path.join(addon_dir, "profiles", "prusaslicer")
if os.path.isdir(profiles_path):
cache.load([profiles_path])
if self.printer_id != '':
cache.evaluate_compatibility({self.printer_id}, set(cache.vendors))
def update_profiles(self, context):
cache = self.get_cache()
if self.printer_id != '':
cache.evaluate_compatibility({self.printer_id}, set(cache.vendors))
notify_tree_changed(self, context)
def search_printers(self, _context, edit_text):
self.ensure_profiles_loaded()
cache = self.get_cache()
items: list[str] = [p.id for k, p in cache.printer_profiles.items() if edit_text.lower() in p.id.lower()]
items.sort()
return items
def search_conf(self, edit_text: str, conf_type: str) -> list[str]:
if not (printer_id := self.printer_id): return []
self.ensure_profiles_loaded()
cache = self.get_cache()
if not (printer := cache.printer_profiles.get("printer:" + printer_id)): return []
profiles = printer.compatible_filament_profiles if conf_type == "filaments" else printer.compatible_print_profiles
items: list[str] = [p.id for p in profiles if edit_text.lower() in p.id.lower()]
items.sort()
return items
def search_filaments(self, _context, edit_text) -> list[str]:
return self.search_conf(edit_text, conf_type="filaments")
def search_prints(self, _context, edit_text) -> list[str]:
return self.search_conf(edit_text, conf_type="prints")
printer_id: str = NfStringProperty(name="Printer", default='', update=update_profiles, search=search_printers)
filament_id: str = NfStringProperty(name="Filament", default='', search=search_filaments)
print_id: str = NfStringProperty(name="Print", default='', search=search_prints)
def init(self, context):
self.add_output("SLICESOCK_PrusaslicerConf", "Config")
def draw_buttons(self, context, layout):
assert layout
self.ensure_profiles_loaded()
layout.prop(self, "printer_id")
if self.printer_id != '':
layout.prop(self, "filament_id")
layout.prop(self, "print_id")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
self.ensure_profiles_loaded()
if self.printer_id == '' or self.filament_id == '' or self.print_id == '':
return None
cache = self.get_cache()
return {
'printer': "printer:" + self.printer_id,
'filament': ["filament:" + self.filament_id],
'print': "print:" + self.print_id,
'cache': cache
}
@register_class
class SLICENODE_InputPrinter(SliceNode):
bl_label = "Printer"

View file

@ -135,9 +135,10 @@ class SLICENODE_SeparateMesh(SliceNode):
so_sel = SlicingObject()
so_sel.name = so.name + "_sel"
so_sel.parent = so.parent
so_sel.object_type = so.object_type
so_sel.extruder = so.extruder
so_sel.mesh = so.mesh[mask]
so_sel.transforms = [dict(t) for t in getattr(so, "transforms", [])]
so_sel.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties()
so_sel.pdf_object_properties = list(getattr(so, "pdf_object_properties", []))
for k, v in so.attributes.items():
so_sel.attributes[k] = {
"domain": v["domain"],
@ -149,9 +150,10 @@ class SLICENODE_SeparateMesh(SliceNode):
so_inv = SlicingObject()
so_inv.name = so.name + "_inv"
so_inv.parent = so.parent
so_inv.object_type = so.object_type
so_inv.extruder = so.extruder
so_inv.mesh = so.mesh[~mask]
so_inv.transforms = [dict(t) for t in getattr(so, "transforms", [])]
so_inv.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties()
so_inv.pdf_object_properties = list(getattr(so, "pdf_object_properties", []))
for k, v in so.attributes.items():
so_inv.attributes[k] = {
"domain": v["domain"],
@ -246,13 +248,10 @@ class SLICENODE_SeparateMeshByRange(SliceNode):
base_mesh_name = so.name
so_new.name = f"{base_mesh_name}{i:0{num_digits}d}"
so_new.parent = so.parent
so_new.object_type = so.object_type
so_new.extruder = so.extruder
so_new.mesh = so.mesh[mask]
so_new.transforms = [dict(t) for t in getattr(so, "transforms", [])]
so_new.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties()
so_new.object_properties = list(getattr(so, "object_properties", []))
so_new.modifiers = so.modifiers.copy() if so.modifiers else []
so_new.pdf_object_properties = list(getattr(so, "pdf_object_properties", []))
for k, v in so.attributes.items():
so_new.attributes[k] = {
"domain": v["domain"],
@ -431,12 +430,9 @@ def _copy_slicing_object(so: SlicingObject) -> SlicingObject:
new_so.attributes[k] = list(v)
else:
new_so.attributes[k] = copy.deepcopy(v)
new_so.object_type = so.object_type
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()
new_so.object_properties = list(getattr(so, "object_properties", []))
new_so.pdf_object_properties = list(getattr(so, "pdf_object_properties", []))
return new_so
def _copy_slicing_collection(sc: SlicingCollection) -> SlicingCollection:
@ -448,9 +444,6 @@ def _copy_slicing_collection(sc: SlicingCollection) -> SlicingCollection:
def _copy_slicing_group(sg: SlicingGroup) -> SlicingGroup:
new_sg = SlicingGroup([])
new_sg.collections = {k: _copy_slicing_collection(sc) for k, sc in sg.collections.items()}
new_sg.wipe_tower_xy = sg.wipe_tower_xy.copy()
new_sg.wipe_tower_rotation_deg = sg.wipe_tower_rotation_deg
new_sg.object_offset = sg.object_offset.copy()
new_sg.transforms = [dict(t) for t in getattr(sg, "transforms", [])]
return new_sg
@ -477,17 +470,6 @@ def _transform_geometry_item(geom: Any, t_arr: np.ndarray, r_arr: np.ndarray, s_
col.rotate(r_arr)
if any(t != 0.0 for t in t_arr):
col.offset(t_arr)
if any(t != 0.0 for t in t_arr):
copied_data.wipe_tower_xy = copied_data.wipe_tower_xy + t_arr[0:2]
if any(r != 0.0 for r in r_arr):
rz = r_arr[2]
cz, sz = np.cos(rz), np.sin(rz)
rot_mat_2d = np.array([[cz, -sz], [sz, cz]])
copied_data.wipe_tower_xy = copied_data.wipe_tower_xy @ rot_mat_2d.T
import math
copied_data.wipe_tower_rotation_deg = round(copied_data.wipe_tower_rotation_deg + rz * 180 / math.pi, 5)
if any(s != 1.0 for s in s_arr):
copied_data.wipe_tower_xy *= s_arr[0:2]
if not hasattr(copied_data, "transforms") or copied_data.transforms is None:
copied_data.transforms = []
@ -534,7 +516,7 @@ class SLICENODE_TransformGeometry(SliceNode):
self.add_output("SLICESOCK_Geometry", "Geometry")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name not in ("Geometry"):
if socket_name not in ("Geometry",):
return None
geom_data: Any = self.resolve("Geometry")
@ -778,7 +760,7 @@ def _assign_properties_to_item(geom: Any, props: list[tuple[str, str]]) -> Any:
return None
elif isinstance(geom, SlicePaths):
copied = geom.copy_with(paths=list(geom))
copied.object_properties = resolve_object_properties(copied, props)
copied.pdf_object_properties = resolve_object_properties(copied, props)
return copied
elif isinstance(geom, (list, tuple)):
return [_assign_properties_to_item(item, props) for item in geom]
@ -786,23 +768,23 @@ def _assign_properties_to_item(geom: Any, props: list[tuple[str, str]]) -> Any:
copied = _copy_slicing_group(geom)
for coll in copied.collections.values():
for obj in coll.objects:
obj.object_properties = resolve_object_properties(obj, props)
obj.pdf_object_properties = resolve_object_properties(obj, props)
return copied
elif isinstance(geom, SlicingCollection):
copied = _copy_slicing_collection(geom)
for obj in copied.objects:
obj.object_properties = resolve_object_properties(obj, props)
obj.pdf_object_properties = resolve_object_properties(obj, props)
return copied
elif isinstance(geom, SlicingObject):
copied = _copy_slicing_object(geom)
copied.object_properties = resolve_object_properties(copied, props)
copied.pdf_object_properties = resolve_object_properties(copied, props)
return copied
return geom
@register_class
class SLICENODE_AssignPropertyToObject(SliceNode):
bl_label = "Assign Property to Object"
bl_label = "Assign PDF Object Properties"
def init(self, context):
self.add_input("SLICESOCK_Mesh", "Geometry")
@ -836,5 +818,3 @@ class SLICENODE_AssignPropertyToObject(SliceNode):
return _assign_properties_to_item(geom, props)

View file

@ -328,6 +328,19 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
bl_label = "Mesh to PDF"
button_label = "Generate PDF"
template: str = NfStringProperty(
name="Template",
default="{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf",
)
@property
def filename_template(self) -> str:
return getattr(self, "template", "{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf")
@filename_template.setter
def filename_template(self, value: str) -> None:
self.template = value
draw_wireframe: bool = NfBoolProperty(name="Wireframe", default=True)
draw_boundary: bool = NfBoolProperty(name="Boundary (1 Face)", default=False)
draw_contour: bool = NfBoolProperty(name="Contour", default=False)
@ -392,6 +405,8 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
def draw_buttons(self, context, layout):
super().draw_buttons(context, layout)
layout.prop(self, "template")
# Technical Drawing Header subpanel (collapsible, collapsed by default)
layout.prop(self, "show_header", text="Technical Drawing Header", icon='TRIA_DOWN' if self.show_header else 'TRIA_RIGHT')
if self.show_header:
@ -443,7 +458,12 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
def evaluate(self, operator=None):
import datetime
from ...pdf_exporter import extract_unique_top_view_edges, export_mesh_to_pdf, get_first_object_name
from ...pdf_exporter import (
extract_unique_top_view_edges,
export_mesh_to_pdf,
format_pdf_filename_template,
get_first_object_name,
)
geom = self.resolve("Geometry")
slice_paths = self.resolve("Paths")
@ -485,25 +505,6 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
is_single_pdf_file = len(primary_items) == 1 and folder_path.suffix.lower() == ".pdf"
if is_single_pdf_file:
os.makedirs(folder_path.parent, exist_ok=True)
target_list = [(primary_items[0], str(folder_path))]
else:
out_dir = folder_path if folder_path.suffix == "" else folder_path.parent
os.makedirs(out_dir, exist_ok=True)
target_list = []
for i, target_item in enumerate(primary_items):
if isinstance(target_item, SlicingGroup) and target_item.collections:
raw_name = "_".join(target_item.collections.keys())
elif hasattr(target_item, 'name') and getattr(target_item, 'name'):
raw_name = str(getattr(target_item, 'name'))
else:
raw_name = f"object_{i}"
name_part = raw_name.split('.')[0] if raw_name else f"object_{i}"
filename = f"{name_part}.pdf"
target_list.append((target_item, str(out_dir / filename)))
# Automatic scene scale determination
scale_str = "1:1"
try:
@ -530,7 +531,9 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
"scale": scale_str,
}
for target_item, filepath in target_list:
template = getattr(self, "template", None) or getattr(self, "filename_template", None) or "{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf"
for i, target_item in enumerate(primary_items):
if has_raw_geom:
current_raw_geom = target_item
current_slice_paths = slice_paths
@ -558,13 +561,10 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
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 operator: operator.report({'WARNING'}, f"Geometry for {os.path.basename(filepath)} has no valid 2D top-view wireframe edges")
try:
export_mesh_to_pdf(
exporter = export_mesh_to_pdf(
edges_2d=edges_2d,
filepath=filepath,
filepath=None,
draw_wireframe=self.draw_wireframe,
draw_boundary=self.draw_boundary,
draw_contour=self.draw_contour,
@ -585,6 +585,36 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
draw_grid_xz=getattr(self, "draw_grid_xz", False),
draw_grid_yz=getattr(self, "draw_grid_yz", False),
)
if is_single_pdf_file:
os.makedirs(folder_path.parent, exist_ok=True)
filepath = str(folder_path)
else:
out_dir = folder_path if folder_path.suffix == "" else folder_path.parent
os.makedirs(out_dir, exist_ok=True)
if isinstance(target_item, SlicingGroup) and target_item.collections:
raw_name = "_".join(target_item.collections.keys())
elif hasattr(target_item, 'name') and getattr(target_item, 'name'):
raw_name = str(getattr(target_item, 'name'))
else:
raw_name = f"object_{i}"
object_name = raw_name.split('.')[0] if raw_name else f"object_{i}"
filename = format_pdf_filename_template(
template=template,
object_name=object_name,
size_x_mm=round(exporter.page_w_mm),
size_y_mm=round(exporter.page_h_mm),
today_date_iso=datetime.date.today().isoformat(),
target_item=target_item,
header_info=current_header_info,
)
filepath = str(out_dir / filename)
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")
exporter.export(filepath)
if operator: operator.report({'INFO'}, f"Mesh PDF saved to {filepath}")
except Exception as e:
if operator: operator.report({'ERROR'}, f"PDF Generation Error: {str(e)}")

View file

@ -41,6 +41,12 @@ class SLICENODE_GetPaths(SliceNode):
class SLICENODE_SliceShells(SliceNode):
bl_label = "Slice Shells"
ignore_holes: bool = NfBoolProperty(
name="Ignore Holes",
description="Ignore interior hole boundaries and generate only outer shells",
default=False
)
def init(self, context):
self.add_input("SLICESOCK_Mesh")
self.add_input("NodeSocketFloat", "Layer Height").value = 0.2
@ -48,6 +54,11 @@ class SLICENODE_SliceShells(SliceNode):
self.add_input("NodeSocketRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
if hasattr(self, "ignore_holes"):
layout.prop(self, "ignore_holes")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
@ -69,6 +80,15 @@ class SLICENODE_SliceShells(SliceNode):
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
ignore_holes: bool = bool(getattr(self, "ignore_holes", False))
if hasattr(self, "inputs") and "Ignore Holes" in self.inputs:
try:
val = self.resolve("Ignore Holes")
if val is not None:
ignore_holes = bool(val)
except Exception:
pass
if not coll: return SlicePaths()
# Handle SlicingGroup flattening
@ -104,7 +124,11 @@ class SLICENODE_SliceShells(SliceNode):
else:
z_values = np.array([])
slices_group: list[list[np.ndarray]] | None = slice_at_range([merged_object.mesh], z_values)
slices_group: list[list[np.ndarray]] | None = slice_at_range(
[merged_object.mesh],
z_values,
ignore_holes=ignore_holes
)
assert slices_group
slices = SlicePaths(slices_group[0])
slices.layer_height = layer_height

View file

@ -4,14 +4,10 @@ from pathlib import Path
import os
import bpy
import tempfile
import shlex
import shutil
import subprocess
import numpy as np
from ..bpy_ns_types import NfBoolProperty, NfStringProperty
from ....registry import register_class
from .base import ExecSliceNode, SliceNode, SliceNodeJoiner, SlicingCollection, SlicingGroup
from .base import ExecSliceNode, SliceNodeJoiner
@register_class
class SLICENODE_PrinterList(ExecSliceNode):
@ -153,227 +149,6 @@ class SLICENODE_PrinterList(ExecSliceNode):
return 'ok'
@register_class
class SLICENODE_PrusaslicerSlice(SliceNode):
bl_label = "PrusaSlicer Slice"
prusaslicer_executable: str = NfStringProperty(name="PrusaSlicer Path", subtype='FILE_PATH', default='flatpak run com.prusa3d.PrusaSlicer')
def init(self, context):
self.add_input("SLICESOCK_PrusaslicerConf", "Config")
self.add_input("SLICESOCK_Settings", "Settings")
self.add_multi_socket_group("SLICESOCK_Mesh", "NodeSocketString", name="Slicing Group", in_name="Mesh", out_name="Gcode", in_display_shape='LIST')
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "prusaslicer_executable")
def evaluate_output(self, socket_name: str, identifier: str = "", **kwargs) -> Any:
if identifier.startswith("Slicing Group_ms_out_"):
try:
idx = int(identifier.split("_")[-1])
return self.evaluate_for_index(idx)
except (ValueError, IndexError):
return ""
return super().evaluate_output(socket_name, **kwargs)
def evaluate_for_index(self, index: int, operator=None) -> str:
# Optimization: Only resolve the specific mesh input for this index
target_id = f"Slicing Group_ms_in_{index}"
mesh_data: SlicingCollection | SlicingGroup = self.resolve(target_id)
conf_data = self.resolve("Config")
overrides = self.resolve("Settings")
if not conf_data:
return ""
# Extract data from config dict
printer_id = conf_data['printer']
filament_ids = conf_data['filament']
print_id = conf_data['print']
cache = conf_data['cache']
# Generate the SlicingGroup
if isinstance(mesh_data, SlicingGroup):
slicing_group = mesh_data
else:
slicing_group = SlicingGroup([])
slicing_group.collections = {mesh_data.objects[0].name: mesh_data}
# Offset by the bed center
def parse_bed_center(bed_shape_str: str) -> np.ndarray:
try:
points = []
for p in bed_shape_str.split(','):
x, y = map(float, p.split('x'))
points.append((x, y))
points_np = np.array(points)
return (points_np.min(axis=0) + points_np.max(axis=0)) / 2.0
except:
return np.array([125.0, 105.0])
bed_shape = cache.profiles[printer_id].all_conf_dict.get('bed_shape', '0x0,250x0,250x210,0x210')
bed_center = parse_bed_center(bed_shape)
a_z = -slicing_group.min_z
offset = np.array([bed_center[0], bed_center[1], a_z]) - np.array([slicing_group.center_xy[0], slicing_group.center_xy[1], 0])
slicing_group.offset(offset)
# Check for wipe_tower_position override
if overrides and "wipe_tower_position" in overrides:
wipe_tower_pos = overrides["wipe_tower_position"]
try:
wx = float(wipe_tower_pos[0])
wy = float(wipe_tower_pos[1])
slicing_group.wipe_tower_xy = np.array([wx, wy])
except Exception:
pass
# Check for offset_object override
if overrides and "offset_object" in overrides:
obj_offset = overrides["offset_object"]
try:
ox = float(obj_offset[0])
oy = float(obj_offset[1])
oz = float(obj_offset[2]) if len(obj_offset) > 2 else 0.0
slicing_group.object_offset = np.array([ox, oy, oz])
except Exception:
pass
# Prepare overrides for generate_conf_writer (excluding wipe_tower_position & offset_object)
writer_overrides = {
k: {'value': v} for k, v in (overrides or {}).items() if k not in ("wipe_tower_position", "offset_object")
}
# Generate the ConfigWriter
from ....infra.prusaslicer_profiles import ConfigWriter
conf_writer: ConfigWriter = cache.generate_conf_writer(
printer_id,
filament_ids,
print_id,
overrides=writer_overrides
)
binary_gcode_value = conf_writer.config_dict.get('binary_gcode', '0')
conf_writer.config_dict['binary_gcode'] = '0'
# Prepare 3mf path
temp_dir = Path(tempfile.gettempdir())
output_path = temp_dir / f"tmp_{index}.3mf"
try:
from ....infra._3mf_writer._3mf import prepare_3mf
prepare_3mf(output_path, slicing_group, conf_writer, z_gcodes=[])
exe_str = self.prusaslicer_executable
if not exe_str: return ""
cmd_parts = shlex.split(exe_str)
if not cmd_parts: return ""
if not shutil.which(cmd_parts[0]):
abs_path = bpy.path.abspath(cmd_parts[0])
if os.path.exists(abs_path):
cmd_parts[0] = abs_path
if not shutil.which(cmd_parts[0]):
return ""
gcode_path = output_path.with_suffix(".gcode")
cmd = cmd_parts + ["--export-gcode", str(output_path), "--output", str(gcode_path)]
process = subprocess.run(cmd, capture_output=True, text=True)
if process.returncode != 0:
return ""
if gcode_path.exists():
with open(gcode_path, 'r', encoding='utf-8') as f:
gcode_result = f.read()
gcode_result += f"\n; binary_gcode = {binary_gcode_value}\n"
output_path.unlink()
if gcode_path.exists(): gcode_path.unlink()
return gcode_result
else:
return ""
except Exception as e:
import traceback
traceback.print_exc()
return ""
@register_class
class SLICENODE_PrusaslicerPreview(ExecSliceNode):
bl_label = "PrusaSlicer Preview"
button_label = "Open Preview"
prusaslicer_executable: str = NfStringProperty(
name="PrusaSlicer Path",
subtype='FILE_PATH',
default='flatpak run com.prusa3d.PrusaSlicer'
)
def init(self, context):
self._init_exec()
self.add_multi_socket_group("NodeSocketString", name="Gcode")
def draw_buttons(self, context, layout):
super().draw_buttons(context, layout)
layout.prop(self, "prusaslicer_executable")
def evaluate(self, operator=None):
from ....infra.blender.blender_slice_nodes_generic import resolve_input
gcodes: list[str] = [g for g in self.resolve_multi_input("Gcode") if g]
if not gcodes:
if operator: operator.report({'ERROR'}, "No Gcode to preview")
return 'no gcode'
exe_str = self.prusaslicer_executable
if not exe_str:
if operator: operator.report({'ERROR'}, "PrusaSlicer executable path is empty")
return 'error'
# Parse command (handles multi-word commands like flatpak)
cmd_parts = shlex.split(exe_str)
if not cmd_parts:
if operator: operator.report({'ERROR'}, "Invalid PrusaSlicer command")
return 'error'
# Try to resolve the first part as a path if it's not a command in PATH
if not shutil.which(cmd_parts[0]):
abs_path = bpy.path.abspath(cmd_parts[0])
if os.path.exists(abs_path):
cmd_parts[0] = abs_path
# Final check if command exists
if not shutil.which(cmd_parts[0]):
if operator: operator.report({'ERROR'}, f"Command or executable not found: {cmd_parts[0]}")
return 'error'
# Write to temp files
# We use named temporary files that stay around for a bit.
# PrusaSlicer needs the files to exist when it starts.
try:
for gcode in gcodes:
fd, temp_path = tempfile.mkstemp(suffix='.gcode')
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(gcode)
cmd = cmd_parts + ["--gcodeviewer", temp_path]
# Use Popen to not block Blender
subprocess.Popen(cmd)
if operator:
operator.report({'INFO'}, f"PrusaSlicer Previews launched")
except Exception as e:
if operator: operator.report({'ERROR'}, f"Failed to launch preview: {str(e)}")
return 'error'
return 'ok'
@register_class
class SLICENODE_JoinExecution(SliceNodeJoiner):
bl_label = "Join Execution"

View file

@ -86,12 +86,6 @@ class SLICESOCK_Intrinsics(LabelNodeSocket):
default = None
bl_color = (0.2, 0.2, 0.2, 1.0)
@register_class
class SLICESOCK_PrusaslicerConf(LabelNodeSocket):
bl_label = "PrusaSlicer Config"
default = None
bl_color = (0.95, 0., 0., 1.0) # #F26C21
@register_class
class SettingItem(bpy.types.PropertyGroup):
field: str = NfStringProperty(name="Field", default="")

View file

@ -3,7 +3,6 @@ import bpy
from bpy.types import NodeTree, Operator, Menu
from bpy.app.handlers import persistent
from ...infra.blender.mixins import update_collection
from ...infra.blender.blender_slice_nodes_generic import node_execution_feedback, resolve_input
from ...registry import register_class
@ -114,26 +113,12 @@ def tree_update_handler(scene, depsgraph):
finally:
_is_updating = False
def object_init_handler(scene, depsgraph):
obj = bpy.context.active_object
if not obj or not hasattr(obj, "nodeslicer"):
return
pg = getattr(obj, 'nodeslicer', None)
if not pg:
return
update_collection(pg.modifiers)
@persistent
def depsgraph_update_handler(scene, depsgraph):
global _is_updating, _tree_changed
if _is_updating: return
tree_update_handler(scene, depsgraph)
object_init_handler(scene, depsgraph)
# Sync SLICENODE_Group sockets if interface changed and migrate legacy sockets
for tree in bpy.data.node_groups:
@ -497,9 +482,6 @@ class SLICENODE_MT_DataInput(Menu):
layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject"
layout.operator("node.add_node", text="Collection Objects").type = "SLICENODE_CollectionObjects"
layout.operator("node.add_node", text="Settings").type = "SLICENODE_InputSettings"
layout.operator("node.add_node", text="Wipe Tower").type = "SLICENODE_WipeTower"
layout.operator("node.add_node", text="Offset Object").type = "SLICENODE_OffsetObject"
layout.operator("node.add_node", text="PrusaSlicer Config").type = "SLICENODE_PrusaslicerConf"
layout.separator()
layout.operator("node.add_node", text="Printer").type = "SLICENODE_InputPrinter"
layout.operator("node.add_node", text="Printer List").type = "SLICENODE_PrinterList"
@ -515,8 +497,6 @@ 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="Assign Property to Object").type = "SLICENODE_AssignPropertyToObject"
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"
@ -540,6 +520,19 @@ class SLICENODE_MT_Geometry(Menu):
layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath"
layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath"
@register_class
class SLICENODE_MT_PdfExporter(Menu):
bl_idname = "SLICENODE_MT_PdfExporter"
bl_label = "PDF Exporter"
def draw(self, context):
layout = self.layout
assert layout
layout.operator("node.add_node", text="Assign PDF Properties").type = "SLICENODE_AssignPdfProperties"
layout.operator("node.add_node", text="Assign PDF Object Properties").type = "SLICENODE_AssignPropertyToObject"
layout.separator()
layout.operator("node.add_node", text="Mesh to PDF").type = "SLICENODE_MeshToPdf"
@register_class
class SLICENODE_MT_Gcode(Menu):
bl_idname = "SLICENODE_MT_Gcode"
@ -579,7 +572,6 @@ class SLICENODE_MT_ActionIO(Menu):
layout = self.layout
assert layout
layout.operator("node.add_node", text="Gcode to File").type = "SLICENODE_GcodeToFile"
layout.operator("node.add_node", text="Mesh to PDF").type = "SLICENODE_MeshToPdf"
layout.operator("node.add_node", text="Gcode to Text Block").type = "SLICENODE_GcodeToTextBlock"
layout.operator("node.add_node", text="Start Print").type = "SLICENODE_StartPrint"
layout.separator()
@ -587,8 +579,6 @@ class SLICENODE_MT_ActionIO(Menu):
layout.operator("node.add_node", text="Airtable Submit").type = "SLICENODE_AirtableSubmit"
layout.separator()
layout.operator("node.add_node", text="Gcode to Preview Mesh").type = "SLICENODE_GcodeToMesh"
layout.operator("node.add_node", text="PrusaSlicer Preview").type = "SLICENODE_PrusaslicerPreview"
layout.operator("node.add_node", text="PrusaSlicer Slice").type = "SLICENODE_PrusaslicerSlice"
layout.operator("node.add_node", text="Executor").type = "SLICENODE_Executor"
layout.operator("node.add_node", text="Test").type = "SLICENODE_Test"
@ -619,6 +609,7 @@ class SLICENODE_MT_Menu(Menu):
assert layout
layout.menu("SLICENODE_MT_DataInput")
layout.menu("SLICENODE_MT_Geometry")
layout.menu("SLICENODE_MT_PdfExporter")
layout.menu("SLICENODE_MT_Gcode")
layout.menu("SLICENODE_MT_Joiners")
layout.menu("SLICENODE_MT_ActionIO")

View file

@ -47,6 +47,7 @@ from .sa2_measures import (
from .sizing import MM_TO_PT
from .writer import (
export_mesh_to_pdf,
format_pdf_filename_template,
PDFElement,
PDFBackgroundElement,
PDFGridElement,
@ -61,6 +62,7 @@ from .writer import (
__all__ = [
# Main exporters & element classes
"export_mesh_to_pdf",
"format_pdf_filename_template",
"PDFElement",
"PDFBackgroundElement",
"PDFGridElement",

View file

@ -269,6 +269,10 @@ def generate_legend_slice_paths(
legend_paths: list[NDArray[np.float64]] = []
label_meta: list[dict[str, Any]] = []
p_up = plane.upper()
is_xz = (p_up == "XZ") or (draw_z and not draw_y)
is_yz = (p_up == "YZ") or (draw_z and not draw_x)
# 1. Local X Axis (extends in +X direction from x0)
if draw_x:
x_grid_end_mm = min(max_x_val, x0 + 10.0 * main_step) if max_x_val > x0 else x0 + 10.0 * main_step
@ -278,23 +282,45 @@ def generate_legend_slice_paths(
x_arrow_tip = x_arrow_base + arrow_len_mm
legend_paths.append(np.array([[x0, y0, z0], [x_arrow_tip, y0, z0]], dtype=np.float64))
legend_paths.append(np.array([[x_arrow_base, y0 + arrow_half_w_mm, z0], [x_arrow_tip, y0, z0]], dtype=np.float64))
legend_paths.append(np.array([[x_arrow_tip, y0, z0], [x_arrow_base, y0 - arrow_half_w_mm, z0]], dtype=np.float64))
for k in range(num_main_steps + 1):
xk = x0 + k * main_step
tick_h = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm
legend_paths.append(np.array([[xk, y0, z0], [xk, y0 - tick_h, z0]], dtype=np.float64))
if is_xz:
# On XZ grid plane (constant y=y0, ticks & wings extend in Z direction)
legend_paths.append(np.array([[x_arrow_base, y0, z0 + arrow_half_w_mm], [x_arrow_tip, y0, z0]], dtype=np.float64))
legend_paths.append(np.array([[x_arrow_tip, y0, z0], [x_arrow_base, y0, z0 - arrow_half_w_mm]], dtype=np.float64))
if k > 0:
val_mm = k * main_step
unit = " m" if (main_step >= 1000.0 and k == num_main_steps) else (" mm" if (main_step < 1000.0 and k == num_main_steps) else "")
val_str = f"{val_mm/1000.0:g}{unit}" if main_step >= 1000.0 else f"{val_mm:g}{unit}"
label_meta.append({
"type": "tick",
"text": val_str,
"local_pt": (xk, y0 - tick_h - label_margin_mm, z0),
})
for k in range(num_main_steps + 1):
xk = x0 + k * main_step
tick_h = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm
legend_paths.append(np.array([[xk, y0, z0], [xk, y0, z0 + tick_h]], dtype=np.float64))
if k > 0:
val_mm = k * main_step
unit = " m" if (main_step >= 1000.0 and k == num_main_steps) else (" mm" if (main_step < 1000.0 and k == num_main_steps) else "")
val_str = f"{val_mm/1000.0:g}{unit}" if main_step >= 1000.0 else f"{val_mm:g}{unit}"
label_meta.append({
"type": "tick",
"text": val_str,
"local_pt": (xk, y0, z0 + tick_h + label_margin_mm),
})
else:
# On XY grid plane (constant z=z0, ticks & wings extend in -Y direction)
legend_paths.append(np.array([[x_arrow_base, y0 + arrow_half_w_mm, z0], [x_arrow_tip, y0, z0]], dtype=np.float64))
legend_paths.append(np.array([[x_arrow_tip, y0, z0], [x_arrow_base, y0 - arrow_half_w_mm, z0]], dtype=np.float64))
for k in range(num_main_steps + 1):
xk = x0 + k * main_step
tick_h = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm
legend_paths.append(np.array([[xk, y0, z0], [xk, y0 - tick_h, z0]], dtype=np.float64))
if k > 0:
val_mm = k * main_step
unit = " m" if (main_step >= 1000.0 and k == num_main_steps) else (" mm" if (main_step < 1000.0 and k == num_main_steps) else "")
val_str = f"{val_mm/1000.0:g}{unit}" if main_step >= 1000.0 else f"{val_mm:g}{unit}"
label_meta.append({
"type": "tick",
"text": val_str,
"local_pt": (xk, y0 - tick_h - label_margin_mm, z0),
})
label_meta.append({
"type": "x_axis",
@ -311,14 +337,16 @@ def generate_legend_slice_paths(
y_arrow_tip = y_arrow_base - arrow_len_mm
legend_paths.append(np.array([[x0, y0, z0], [x0, y_arrow_tip, z0]], dtype=np.float64))
legend_paths.append(np.array([[x0 - arrow_half_w_mm, y_arrow_base, z0], [x0, y_arrow_tip, z0]], dtype=np.float64))
legend_paths.append(np.array([[x0, y_arrow_tip, z0], [x0 + arrow_half_w_mm, y_arrow_base, z0]], dtype=np.float64))
if not draw_x:
if is_yz:
# On YZ grid plane (constant x=x0, ticks & wings extend in Z direction)
legend_paths.append(np.array([[x0, y_arrow_base, z0 - arrow_half_w_mm], [x0, y_arrow_tip, z0]], dtype=np.float64))
legend_paths.append(np.array([[x0, y_arrow_tip, z0], [x0, y_arrow_base, z0 + arrow_half_w_mm]], dtype=np.float64))
for k in range(num_main_steps + 1):
yk = y0 - k * main_step
tick_h = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm
legend_paths.append(np.array([[x0, yk, z0], [x0 + tick_h, yk, z0]], dtype=np.float64))
legend_paths.append(np.array([[x0, yk, z0], [x0, yk, z0 + tick_h]], dtype=np.float64))
if k > 0:
val_mm = k * main_step
@ -327,8 +355,28 @@ def generate_legend_slice_paths(
label_meta.append({
"type": "tick",
"text": val_str,
"local_pt": (x0 + tick_h + label_margin_mm, yk, z0),
"local_pt": (x0, yk, z0 + tick_h + label_margin_mm),
})
else:
# On XY grid plane (constant z=z0, ticks & wings extend in X direction)
legend_paths.append(np.array([[x0 - arrow_half_w_mm, y_arrow_base, z0], [x0, y_arrow_tip, z0]], dtype=np.float64))
legend_paths.append(np.array([[x0, y_arrow_tip, z0], [x0 + arrow_half_w_mm, y_arrow_base, z0]], dtype=np.float64))
if not draw_x:
for k in range(num_main_steps + 1):
yk = y0 - k * main_step
tick_h = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm
legend_paths.append(np.array([[x0, yk, z0], [x0 + tick_h, yk, z0]], dtype=np.float64))
if k > 0:
val_mm = k * main_step
unit = " m" if (main_step >= 1000.0 and k == num_main_steps) else (" mm" if (main_step < 1000.0 and k == num_main_steps) else "")
val_str = f"{val_mm/1000.0:g}{unit}" if main_step >= 1000.0 else f"{val_mm:g}{unit}"
label_meta.append({
"type": "tick",
"text": val_str,
"local_pt": (x0 + tick_h + label_margin_mm, yk, z0),
})
label_meta.append({
"type": "y_axis",
@ -345,10 +393,12 @@ def generate_legend_slice_paths(
z_arrow_tip = z_arrow_base + arrow_len_mm
legend_paths.append(np.array([[x0, y0, z0], [x0, y0, z_arrow_tip]], dtype=np.float64))
legend_paths.append(np.array([[x0 - arrow_half_w_mm, y0, z_arrow_base], [x0, y0, z_arrow_tip]], dtype=np.float64))
legend_paths.append(np.array([[x0, y0, z_arrow_tip], [x0 + arrow_half_w_mm, y0, z_arrow_base]], dtype=np.float64))
if not draw_x:
if is_xz or draw_x:
# On XZ grid plane (constant y=y0, ticks & wings extend in X direction)
legend_paths.append(np.array([[x0 - arrow_half_w_mm, y0, z_arrow_base], [x0, y0, z_arrow_tip]], dtype=np.float64))
legend_paths.append(np.array([[x0, y0, z_arrow_tip], [x0 + arrow_half_w_mm, y0, z_arrow_base]], dtype=np.float64))
for k in range(num_main_steps + 1):
zk = z0 + k * main_step
tick_h = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm
@ -363,6 +413,25 @@ def generate_legend_slice_paths(
"text": val_str,
"local_pt": (x0 + tick_h + label_margin_mm, y0, zk),
})
else:
# On YZ grid plane (constant x=x0, ticks & wings extend in Y direction)
legend_paths.append(np.array([[x0, y0 - arrow_half_w_mm, z_arrow_base], [x0, y0, z_arrow_tip]], dtype=np.float64))
legend_paths.append(np.array([[x0, y0, z_arrow_tip], [x0, y0 + arrow_half_w_mm, z_arrow_base]], dtype=np.float64))
for k in range(num_main_steps + 1):
zk = z0 + k * main_step
tick_h = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm
legend_paths.append(np.array([[x0, y0, zk], [x0, y0 - tick_h, zk]], dtype=np.float64))
if k > 0:
val_mm = k * main_step
unit = " m" if (main_step >= 1000.0 and k == num_main_steps) else (" mm" if (main_step < 1000.0 and k == num_main_steps) else "")
val_str = f"{val_mm/1000.0:g}{unit}" if main_step >= 1000.0 else f"{val_mm:g}{unit}"
label_meta.append({
"type": "tick",
"text": val_str,
"local_pt": (x0, y0 - tick_h - label_margin_mm, zk),
})
label_meta.append({
"type": "z_axis",

View file

@ -5,6 +5,7 @@ Formats vector edge arrays and renders scalable PDF files with grid overlays, te
import datetime
import math
import os
import re
from pathlib import Path
from typing import Any
import numpy as np
@ -15,6 +16,7 @@ from .metadata import (
extract_object_bounding_boxes,
extract_settings_items,
get_first_attribute_value,
get_first_object_name,
resolve_object_properties,
)
from .geometry import (
@ -39,6 +41,94 @@ from .sizing import (
from .pdf_utils import PdfStream
def format_pdf_filename_template(
template: str | None = None,
object_name: str = "",
size_x_mm: float = 0.0,
size_y_mm: float = 0.0,
today_date_iso: str | None = None,
target_item: Any = None,
header_info: dict[str, Any] | None = None,
) -> str:
"""
Format a file naming template for PDF export.
Default template:
{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf
Supported placeholders:
- {object_name}, {name}, {obj_name}, {part_name}: Name of the object
- {size_x_mm}, {size_x}, {width_mm}, {width}: Total width in mm of the whole PDF
- {size_y_mm}, {size_y}, {height_mm}, {height}: Total height in mm of the whole PDF
- {today_date_iso}, {date_iso}, {today_date}, {date}, {today}: Current date in ISO format (YYYY-MM-DD)
- Custom object attributes (via get_first_attribute_value) or header_info keys
Args:
template: The template format string.
object_name: The object name.
size_x_mm: Whole PDF width in mm.
size_y_mm: Whole PDF height in mm.
today_date_iso: ISO 8601 date string.
target_item: SlicingObject / SlicingGroup / SlicePaths item for attribute lookups.
header_info: Optional dictionary containing technical header drawing metadata.
Returns:
The formatted PDF filename ending in .pdf.
"""
tmpl = (template or "").strip()
if not tmpl:
tmpl = "{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf"
if today_date_iso is None:
today_date_iso = datetime.date.today().isoformat()
sx_str = f"{round(size_x_mm, 2):g}"
sy_str = f"{round(size_y_mm, 2):g}"
context: dict[str, Any] = {
"object_name": object_name,
"name": object_name,
"obj_name": object_name,
"part_name": object_name,
"size_x_mm": sx_str,
"size_x": sx_str,
"width_mm": sx_str,
"width": sx_str,
"size_y_mm": sy_str,
"size_y": sy_str,
"height_mm": sy_str,
"height": sy_str,
"today_date_iso": today_date_iso,
"date_iso": today_date_iso,
"today_date": today_date_iso,
"date": today_date_iso,
"today": today_date_iso,
}
def replace_match(match: re.Match[str]) -> str:
key = match.group(1).strip()
if key in context:
return str(context[key])
if key.lower() in context:
return str(context[key.lower()])
if target_item is not None:
val = get_first_attribute_value(target_item, key)
if val is not None:
if isinstance(val, float):
return f"{round(val, 2):g}"
return str(val)
if header_info and key in header_info:
return str(header_info[key])
return ""
formatted = re.sub(r"\{([^}]+)\}", replace_match, tmpl).strip()
if not formatted:
formatted = f"{object_name or 'object'}.pdf"
if not formatted.lower().endswith(".pdf"):
formatted = f"{formatted}.pdf"
return formatted
def render_technical_header_table(
s: PdfStream,
page_w_pt: float,
@ -171,7 +261,7 @@ def render_object_properties_table(
"""
objs_with_props = [
so for so in slicing_objs
if getattr(so, "object_properties", None) is not None and len(getattr(so, "object_properties", [])) > 0
if getattr(so, "pdf_object_properties", getattr(so, "object_properties", None)) is not None and len(getattr(so, "pdf_object_properties", getattr(so, "object_properties", []))) > 0
]
if not objs_with_props:
return
@ -203,7 +293,7 @@ def render_object_properties_table(
label_color = (0.35, 0.35, 0.35)
for so in objs_with_props:
raw_props = getattr(so, "object_properties", [])
raw_props = getattr(so, "pdf_object_properties", getattr(so, "object_properties", []))
props = resolve_object_properties(so, raw_props)
if not props:
continue
@ -496,6 +586,7 @@ class PDFLegendElement(PDFElement):
draw_z=getattr(exp, "draw_z_axis", False),
local_origin_3d=getattr(exp, "legend_origin_3d", None),
local_max_3d=getattr(exp, "legend_max_3d", None),
plane=getattr(exp, "projection_plane", "XY"),
)
# Rotate legend SlicePaths with existing transform_slice_paths
@ -539,6 +630,8 @@ class PDFLegendElement(PDFElement):
s.text_position(pt_pdf[0], pt_pdf[1] - (exp.font_size_pt * 0.35)).show(txt)
elif meta["type"] == "y_axis":
s.text_position(pt_pdf[0] - (exp.font_size_pt * 0.35), pt_pdf[1]).show(txt)
elif meta["type"] == "z_axis":
s.text_position(pt_pdf[0] - (exp.font_size_pt * 0.35), pt_pdf[1]).show(txt)
s.end_text()
@ -710,6 +803,7 @@ class export_mesh_to_pdf:
self,
edges_2d: NDArray[np.float64] | None = None,
filepath: str | Path | None = None,
template: str | None = None,
draw_wireframe: bool = True,
draw_boundary: bool = False,
draw_contour: bool = False,
@ -747,6 +841,7 @@ class export_mesh_to_pdf:
self.projection_plane = "XY" if draw_grid_xy else ("XZ" if draw_grid_xz else "YZ")
self.edges_2d = edges_2d if edges_2d is not None else np.empty((0, 2, 2), dtype=np.float64)
self.filepath = filepath
self.template = template
self.draw_wireframe = draw_wireframe
self.draw_boundary = draw_boundary
self.draw_contour = draw_contour
@ -784,7 +879,29 @@ class export_mesh_to_pdf:
]
if filepath is not None:
self.export(filepath)
target_p = Path(filepath)
if target_p.is_dir() or target_p.suffix.lower() != ".pdf":
out_dir = target_p if target_p.suffix == "" else target_p.parent
tmpl = self.template or "{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf"
primary_obj_name = ""
if self.slicing_objs:
for so in self.slicing_objs:
if hasattr(so, "name") and getattr(so, "name"):
primary_obj_name = str(getattr(so, "name")).split('.')[0]
break
if not primary_obj_name:
primary_obj_name = (get_first_object_name(self.raw_geometry) or get_first_object_name(self.slice_paths) or "object").split('.')[0]
fname = format_pdf_filename_template(
template=tmpl,
object_name=primary_obj_name,
size_x_mm=self.page_w_mm,
size_y_mm=self.page_h_mm,
target_item=self.raw_geometry or self.slice_paths,
header_info=self.header_info,
)
self.export(out_dir / fname)
else:
self.export(filepath)
def _prepare_geometry_and_layout(self) -> None:
objs_raw = get_all_slicing_objects(self.raw_geometry)
@ -999,7 +1116,7 @@ class export_mesh_to_pdf:
objs_with_props = [
so for so in self.slicing_objs
if getattr(so, "object_properties", None) is not None and len(getattr(so, "object_properties", [])) > 0
if getattr(so, "pdf_object_properties", getattr(so, "object_properties", None)) is not None and len(getattr(so, "pdf_object_properties", getattr(so, "object_properties", []))) > 0
]
w_table_mm = 0.0
@ -1015,7 +1132,7 @@ class export_mesh_to_pdf:
if objs_with_props:
w_table_mm = (self.font_size_pt / MM_TO_PT) * 25.0
h_props_total_mm = sum(
(1 + len(getattr(so, "object_properties", []))) * cell_height_mm
(1 + len(getattr(so, "pdf_object_properties", getattr(so, "object_properties", [])))) * cell_height_mm
for so in objs_with_props
) + len(objs_with_props) * gap_mm
if h_table_col_mm > 0.0:
@ -1026,6 +1143,10 @@ class export_mesh_to_pdf:
w_total_mm = w_grid_mm + w_table_mm + 2.0 * self.total_margin_mm
h_total_mm = max(h_grid_mm, h_table_col_mm) + 2.0 * self.total_margin_mm
self.w_total_mm = w_total_mm
self.h_total_mm = h_total_mm
self.page_w_mm = w_total_mm
self.page_h_mm = h_total_mm
self.page_w_pt = w_total_mm * MM_TO_PT
self.page_h_pt = h_total_mm * MM_TO_PT

View file

@ -165,7 +165,7 @@ class SlicePaths(list[np.ndarray]):
layer_height: float
first_layer_height: float | None
transforms: list[dict[str, Any]]
object_properties: list[tuple[str, str]]
pdf_object_properties: list[tuple[str, str]]
pdf_properties: PdfProperties
name: str
objects_info: dict[str, Any] | None
@ -610,7 +610,7 @@ class SlicePaths(list[np.ndarray]):
return self.copy_with(new_paths, extrusion_rate=new_ext, feedrate=new_feed, extra_attributes=new_extra)
def slice_at_range(models, z_values) -> list[list[np.ndarray]]:
def slice_at_range(models, z_values, ignore_holes: bool = False) -> list[list[np.ndarray]]:
if not models:
return []
@ -634,7 +634,7 @@ def slice_at_range(models, z_values) -> list[list[np.ndarray]]:
seg3d[..., 2] = zf
# Build geometry for this slice
slice_paths = segments_to_slice(seg3d)
slice_paths = segments_to_slice(seg3d, ignore_holes=ignore_holes)
all_model_paths.extend(slice_paths)
mmps.append(all_model_paths)
@ -746,7 +746,7 @@ def link_segments(segments: np.ndarray, tolerance=1e-6) -> list[np.ndarray]:
return paths
@ndarray_lru_cache(maxsize=128)
def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]:
def segments_to_slice(segments: np.ndarray, ignore_holes: bool = False) -> list[np.ndarray]:
linked = link_segments(segments)
if not linked: return []
@ -771,8 +771,21 @@ def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]:
scaled = (path_pts * SCALE).astype(np.int64).tolist()
pc.AddPath(scaled, pyclipper.PT_SUBJECT, closed = True) # pyright: ignore[reportAttributeAccessIssue]
# Use EVENODD for robust hole detection.
solution = pc.Execute(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
if ignore_holes:
tree = pc.Execute2(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
def collect_outer_contours(node) -> list[list[int]]:
contours = []
if hasattr(node, 'Contour') and node.Contour and not getattr(node, 'IsHole', False):
contours.append(node.Contour)
if hasattr(node, 'Childs') and node.Childs:
for child in node.Childs:
contours.extend(collect_outer_contours(child))
return contours
solution = collect_outer_contours(tree)
else:
# Use EVENODD for robust hole detection.
solution = pc.Execute(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
for path in solution:
if not path: continue

View file

@ -26,12 +26,12 @@ This file provides a mapping of modules to their primary responsibilities and ke
## `/core` (Geometry & Slicing)
- `slice.py`:
- `slice_at_range()`: Top-level slicing function that iterates through Z-heights.
- `slice_at_range()`: Top-level slicing function that iterates through Z-heights (supports `ignore_holes` option).
- `slice_at_z()`: Vectorized plane-mesh intersection using NumPy.
- `align_path_seam_sharpest()`: High-performance vectorized heuristic that positions the start vertex (seam) of closed polygon loops at the sharpest concave angle (inner corner), with fallback to sharpest angle overall.
- `SlicePaths.align_seams()`: Method on `SlicePaths` to align path seams to sharpest concave angles while preserving per-vertex attributes and metadata.
- `Pather`: Class that converts toolpaths into G-code strings. Supports per-segment extrusion rates, feedrates, and custom extra attributes.
- `segments_to_slice()`: Utility to convert raw intersection segments into continuous paths (closed polygons are processed via Pyclipper, open paths remain open).
- `segments_to_slice()`: Utility to convert raw intersection segments into continuous paths (closed polygons are processed via Pyclipper, supports `ignore_holes`, open paths remain open).
- `link_segments()`: Logic for joining loose segments into continuous paths.
- `buffer_slice()`: Offset utility using PyclipperOffset. Supports rotated offsetting.
- `infill_slice()`: Generates rotated grid or rectilinear infill patterns, ensuring layer-to-layer consistency.
@ -47,6 +47,7 @@ This file provides a mapping of modules to their primary responsibilities and ke
- `extract_slicing_object_features()`: Feature detection for circular bores (Ø), arcs (R), chamfers (C), and steps.
- `writer.py`:
- `export_mesh_to_pdf`: Class for building scalable 1:1 vector PDF files. Uses a modular element architecture with element subclasses (`PDFElement`, `PDFLegendElement`, `PDFHeaderTableElement`, `PDFGridElement`, `PDFGeometryElement`, `PDFBoundingBoxElement`, `PDFSA2MeasuresElement`, `PDFObjectLabelsElement`, `PDFBackgroundElement`).
- `format_pdf_filename_template`: Formats file naming templates with placeholders for object names, whole PDF dimensions in mm (`{size_x_mm}`, `{size_y_mm}`), and ISO dates (`{today_date_iso}`).
## `/infra` (Blender Integration)

View file

@ -43,36 +43,22 @@ A standard Python string containing formatted G-code.
All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** category.
### Data & Input
#### Data & Input
- **Object**: Blender object picker.
- **Float**: Single float input.
- **Integer**: Single integer input.
- **String**: Single string/path input.
- **Collection Objects**: Selects a Blender collection and dynamically exposes its mesh objects as output sockets, sorted alphabetically.
- **Settings**: Read-only/write configuration settings dictionary.
- **Wipe Tower**: Configures the Wipe Tower printbed position (`wipe_tower_position`) passed to 3MF metadata (`Prusa_Slicer_wipe_tower_information.xml`) without injecting into profile INI settings.
- **Position X**: X coordinate of the wipe tower on the printbed.
- **Position Y**: Y coordinate of the wipe tower on the printbed.
- **Offset Object**: Configures the object placement transform offset (`offset_object`) applied to `<item transform="...">` inside 3MF model build data (`3D/3dmodel.model`) without injecting into profile INI settings.
- **Offset X**: X translation offset for the build item.
- **Offset Y**: Y translation offset for the build item.
- **Offset Z**: Z translation offset for the build item.
- **Printer**: Printer name picker.
- **Printer List**: List of configured printers.
- **Printer Intrinsics**: Configures bed size, extrusion/travel rates, retraction distance & feedrate, and Z-lift.
- **Gcode Block**: Manual text area for entering raw G-code.
### Geometry Processing
- **Get Mesh**: Converts a Blender Object into internal `SlicingObject` format.
- **Separate Mesh**: Splits a `SlicingCollection` into two collections (`Selection` and `Inverted`) by comparing a specified mesh attribute against a target value with comparison operators (`==`, `!=`, `>`, `<`, `>=`, `<=`).
- **Separate Mesh by Range**: Splits a `SlicingCollection` into multiple output sockets across an integer range (`Start` to `End`), matching a specified integer attribute. Objects in each output socket are renamed with a zero-indexed padded suffix (`f"{base_mesh_name}{i:0{num_digits}d}"` where `num_digits = max(2, len(str(total_parts)))`).
- **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.
- **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 geometry (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or `SlicePaths`).
- **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 Color**: Color property for stroke color.
- **Assign Property to Object**: Assigns custom settings/properties to geometry (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or `SlicePaths`) for rendering in PDF properties tables.
- **Geometry**: Generic geometry input socket (accepts `SLICESOCK_Mesh`, `SLICESOCK_Paths`, or `SLICESOCK_Geometry`).
- **Settings**: Input socket accepting configuration settings or property items.
- **Transform Geometry**: Moves, rotates, and scales geometry (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or `SlicePaths`) relative to the origin.
- **Geometry**: Input geometry data.
- **Translation**: 3D vector specifying the translation offset.
@ -83,16 +69,21 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Z**: Target minimum Z coordinate (defaults to `0.0`).
- **Mesh**: Output dropped mesh data.
- **Translation**: Output translation vector (X, Y, Z) applied to the geometry.
- **Separate Mesh**: Splits a `SlicingCollection` into two collections (`Selection` and `Inverted`) by comparing a specified mesh attribute against a target value with comparison operators (`==`, `!=`, `>`, `<`, `>=`, `<=`).
- **Separate Mesh by Range**: Splits a `SlicingCollection` into multiple output sockets across an integer range (`Start` to `End`), matching a specified integer attribute. Objects in each output socket are renamed with a zero-indexed padded suffix (`f"{base_mesh_name}{i:0{num_digits}d}"` where `num_digits = max(2, len(str(total_parts)))`).
- **Arrange Slicing Group**: Arranges the slicing collections in a slicing group on the build plate using the `libnest2d` bin-packing library. It nests their 2D bounding boxes to prevent collisions.
- **Method**: Switch between **NFP** (No-Fit Polygon placer + First-Fit selection), **Bottom-Left** (Bottom-Left placer + First-Fit selection), and **DJD Heuristic** (No-Fit Polygon placer + Dyckhoff's heuristic).
- **Allow Rotations**: Toggle whether to allow rotating objects (by 90-degree steps) during nesting.
- **Bed X / Y**: The build plate dimensions in mm.
- **Spacing**: The minimum distance between arranged objects in mm.
- **Get Paths**: Extracts existing edges from a mesh as paths.
- **Extrusion Multiplier Attribute**: Mesh attribute to use for per-segment extrusion rates.
- **Feedrate Attribute**: Mesh attribute to use for per-segment feedrates.
- **Extra Attributes**: Dictionary mapping mesh attribute names to custom G-code prefixes (e.g., `opening_size` -> `S`).
- **Path Offset**: Offsets `SlicePaths` by a 3D vector.
- **Rotate Path**: Rotates `SlicePaths` around the Z-axis.
- **Angle**: Rotation angle in degrees.
- **Center**: Optional center of rotation. If not provided, it defaults to the midpoint of the first layer.
- **Slice Shells**: Slices a `SlicingObject` into 2D `SlicePaths` at a given layer height. Automatically aligns path seams to the sharpest concave angle (inner corner) of each layer. Supports rotation for slicing at an angle.
- **Ignore Holes**: Boolean option to omit interior hole perimeters and generate only the outer shell boundaries.
- **Buffer Shells**: Offsets/buffers 2D `SlicePaths`. Supports rotated offsetting and multiple miter types.
- **Buffer Shells Multiple**: Offsets 2D `SlicePaths` with multiple buffer passes.
- **Buffer Curve**: Buffers open/closed curves using Shapely, allowing open path offsetting.
- **Cap Style**: Style of the curve ends (Round, Flat, Square).
- **Join Style**: Style of corner joins (Round, Mitre, Bevel).
@ -103,6 +94,11 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Join Style**: Style of corner joins (Round, Mitre, Bevel).
- **Distance**: The offset distance (positive for left side, negative for right side).
- **Rotation**: Optional Euler rotation for non-planar offsetting.
- **Path Offset**: Offsets `SlicePaths` by a 3D vector.
- **Rotate Path**: Rotates `SlicePaths` around the Z-axis.
- **Angle**: Rotation angle in degrees.
- **Center**: Optional center of rotation. If not provided, it defaults to the midpoint of the first layer.
- **Simplify Paths**: Simplifies `SlicePaths` geometry using the Douglas-Peucker algorithm.
- **Infill**: Generates a cross-hatch grid or parallel lines clipped by boundary paths.
- **Infill Type**: Switch between **Grid** (cross-hatch), **Rectilinear** (alternating parallel lines), and **Lines** (parallel lines in one direction).
- **Density**: Distance between lines in units.
@ -111,26 +107,19 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Get Infill Mask**: Computes the intersection of all closed paths in each group (layer) using Shapely.
- **Overhang Mask**: Computes the accumulated overhang mask for each layer, propagating support requirements from top to bottom.
- **Boolean Paths**: Performs 2D boolean operations (`UNION`, `INTERSECTION`, `DIFFERENCE`, `XOR`) on multiple slice sets.
- **Arrange Slicing Group**: Arranges the slicing collections in a slicing group on the build plate using the `libnest2d` bin-packing library. It nests their 2D bounding boxes to prevent collisions.
- **Method**: Switch between **NFP** (No-Fit Polygon placer + First-Fit selection), **Bottom-Left** (Bottom-Left placer + First-Fit selection), and **DJD Heuristic** (No-Fit Polygon placer + Dyckhoff's heuristic).
- **Allow Rotations**: Toggle whether to allow rotating objects (by 90-degree steps) during nesting.
- **Bed X / Y**: The build plate dimensions in mm.
- **Spacing**: The minimum distance between arranged objects in mm.
### Utility & Joiners
- **Join Mesh**: Merges multiple `SlicingObject` instances into one.
- **Join Paths**: Combines multiple `SlicePaths`. Supports Sequential or Interleaved modes.
- **Join Gcode**: Concatenates multiple G-code strings.
- **Join Settings**: Sequentially merges multiple override dictionaries. Later inputs overwrite earlier ones for matching keys.
- **Math**: Float math operations.
- **Integer Math**: Simple integer operations (Add, Multiply, etc.).
### Action & I/O
- **Paths to Gcode**: Converts `SlicePaths` into G-code blocks.
- **Header**: Toggles the generation of standard NodeSlicer G-code headers and units setup.
- **Load Gcode**: Reads a G-code file from disk.
- **Gcode to File**: Writes G-code string to a file on disk.
- **Automatic Binary Conversion**: If the G-code contains the comment `; binary_gcode = 1`, the node automatically converts the output to Prusa's binary G-code format (`.bgcode`) using MeatPack encoding and Heatshrink compression.
### PDF Exporter
- **Assign PDF Properties**: Assigns line width (mm) and line color PDF export properties to geometry (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or `SlicePaths`).
- **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 Color**: Color property for stroke color.
- **Assign PDF Object Properties**: Assigns custom settings/properties to geometry (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or `SlicePaths`) for rendering in PDF properties tables and object callout labels.
- **Geometry**: Generic geometry input socket (accepts `SLICESOCK_Mesh`, `SLICESOCK_Paths`, or `SLICESOCK_Geometry`).
- **Settings**: Input socket accepting configuration settings or property items.
- **Mesh to PDF**: Generates a 1:1 true-scale 2D top-view (+Z orthographic) vector PDF from a `SlicingGroup` or `SlicingCollection` with a custom grid and background color. Subpanel menus are collapsed by default.
- **Template**: File naming template string for exported PDF files when saving to a folder (default: `{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf`). Supports placeholders `{object_name}`, `{size_x_mm}`, `{size_y_mm}` (the whole PDF page dimensions in mm), `{today_date_iso}` (current ISO date `YYYY-MM-DD`), and custom object attributes.
- **Technical Drawing Header**: Collapsible menu containing string inputs for technical drawing info (Company, Part Name, Drawing No., Author, Revision, Material, Tolerance). If Part Name is left empty, it automatically defaults to the name of the first object being rendered. Automatically computes ISO date and scale length (`1:1`). Renders an ISO/DIN technical drawing header table on the bottom-right of the drawing.
- **Geometry**: Input socket accepting a single geometry (`SlicingGroup`, `SlicingCollection`, or `SlicingObject`). Join multiple geometries using Join Geometry nodes.
- **SlicePaths**: Input socket accepting `SlicePaths` (reserved for path rendering).
@ -151,6 +140,28 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Margin (mm)**: Margin around the geometry grid in mm.
- **Background**: Color picker for the canvas background.
- **Wireframe**: Color picker for the wireframe lines and grid.
### Gcode Processing
- **Paths to Gcode**: Converts `SlicePaths` into G-code blocks.
- **Header**: Toggles the generation of standard NodeSlicer G-code headers and units setup.
- **Gcode Block**: Manual text area for entering raw G-code.
- **Gcode Regex Replace**: Performs regex search and replace on G-code.
- **Assign Metadata**: Injects custom metadata comments into G-code.
- **Gcode Helper**: Utility helper functions for G-code stream modifications.
- **Load Gcode**: Reads a G-code file from disk.
### Utility & Joiners
- **Join Mesh**: Merges multiple `SlicingObject` instances into one.
- **Join Paths**: Combines multiple `SlicePaths`. Supports Sequential or Interleaved modes.
- **Join Gcode**: Concatenates multiple G-code strings.
- **Join Settings**: Sequentially merges multiple override dictionaries. Later inputs overwrite earlier ones for matching keys.
- **Join Execution**: Synchronizes sequential execution flows.
### Action & I/O
- **Gcode to File**: Writes G-code string to a file on disk.
- **Automatic Binary Conversion**: If the G-code contains the comment `; binary_gcode = 1`, the node automatically converts the output to Prusa's binary G-code format (`.bgcode`) using MeatPack encoding and Heatshrink compression.
- **Gcode to Text Block**: Creates a new Blender text block containing the G-code and opens it in any active text editors.
- **Start Print**: Sends G-code directly to a network printer.
- **Baserow Submit**: Sends G-code and metadata to a Baserow database.
- **Host**: Baserow host URL.
- **Token File**: Path to a file containing the Baserow API token.
@ -163,14 +174,6 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Table ID**: The ID or name of the target Airtable table.
- **Record ID**: Optional ID of an existing record to update.
- **Gcode Field**: The name of the attachment field in Airtable.
- **PrusaSlicer Slice**: Exports the current mesh and configuration to a 3MF file and calls PrusaSlicer to generate G-code.
- **Executable**: Path to the PrusaSlicer executable.
- **Object**: The Blender object to slice.
- **Config**: PrusaSlicer configuration (from the **PrusaSlicer Config** node).
- **Gcode**: Output socket providing the generated G-code string.
- **Gcode to Text Block**: Creates a new Blender text block containing the G-code and opens it in any active text editors.
- **Gcode to Preview Mesh**: Decodes G-code and creates a Blender mesh ("Debug" object) for visualization.
- **PrusaSlicer Preview**: Launches PrusaSlicer's built-in G-code viewer to visualize a G-code string.
- **Executable**: Path to the PrusaSlicer executable (default: `flatpak run com.prusa3d.PrusaSlicer`).
- **Gcode**: Input socket for the G-code string to preview.
- **Executor**: Executes sub-tree node chains.
- **Test**: Triggers evaluation for debugging purposes.

View file

@ -1,172 +0,0 @@
from __future__ import annotations
from typing import Any
from numpy.typing import NDArray
from pathlib import Path
import numpy as np
import os, shutil, tempfile
import xml.etree.ElementTree as ET
from datetime import date
from ..blender.blender_mesh_capture import SlicingObject, SlicingGroup, SlicingCollection
script_dir = os.path.dirname(os.path.abspath(__file__))
def write_metadata_xml(group: SlicingGroup, filepath):
# Custom sorting order for object types
object_type_order = {
'ModelPart': 0,
'NegativeVolume': 1,
'ParameterModifier': 2,
'SupportBlocker': 3,
'SupportEnforcer': 4
}
xml_content = ET.Element("config")
valid_collections = {k: c for k, c in group.collections.items() if c.meshes}
for j, (k, collection) in enumerate(valid_collections.items()):
sorted_data: list[tuple[NDArray[Any], NDArray[Any], SlicingObject]] = sorted(zip(collection.mesh_start_ids, collection.mesh_end_ids, collection.objects), key=lambda x: object_type_order.get(x[2].object_type, 5))
object_elem = ET.SubElement(xml_content, "object", id=str(j+1), instances_count="1")
ET.SubElement(object_elem, "metadata", type="object", key="name", value=collection.name)
for i, (start, end, metadata) in enumerate(sorted_data):
if metadata.object_type == "ModelPart":
for mod in metadata.modifiers:
ET.SubElement(object_elem, "metadata", type="object", key=mod['param_id'], value=mod['param_value'])
volume_elem = ET.SubElement(object_elem, "volume", firstid=str(start), lastid=str(end))
ET.SubElement(volume_elem, "metadata", type="volume", key="name", value=metadata.name)
if metadata.object_type == "ParameterModifier":
ET.SubElement(volume_elem, "metadata", type="volume", key="modifier", value="1")
for mod in metadata.modifiers:
ET.SubElement(volume_elem, "metadata", type="volume", key=mod['param_id'], value=mod['param_value'])
ET.SubElement(volume_elem, "metadata", type="volume", key="volume_type", value=metadata.object_type)
ET.SubElement(volume_elem, "metadata", type="volume", key="extruder", value=str(metadata.extruder))
ET.SubElement(volume_elem, "metadata", type="volume", key="source_object_id", value="0")
ET.SubElement(volume_elem, "metadata", type="volume", key="source_volume_id", value=str(i))
ET.SubElement(volume_elem, "metadata", type="volume", key="matrix", value="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1")
ET.SubElement(volume_elem, "mesh", edges_fixed="0", degenerate_facets="0", facets_removed="0", facets_reversed="0", backwards_edges="0")
ET.indent(xml_content)
xml_tree = ET.ElementTree(xml_content)
xml_tree.write(filepath, encoding="UTF-8", xml_declaration=True)
def write_wipe_tower_xml(group: SlicingGroup, filename):
with open(filename, 'w', encoding="UTF-8") as file:
file.write(f'<?xml version="1.0" encoding="utf-8"?>\n')
file.write(f'<wipe_tower_information bed_idx="0" position_x="{group.wipe_tower_xy[0]}" position_y="{group.wipe_tower_xy[1]}" rotation_deg="{group.wipe_tower_rotation_deg}"/>\n')
def write_model_xml(group: SlicingGroup, filename: str):
now = date.today().isoformat()
# Open file for writing
with open(filename, 'w', encoding="UTF-8") as file:
# Write the XML declaration and opening model tag
file.write(f'<?xml version="1.0" encoding="UTF-8"?>\n')
file.write(f'<model xmlns="" unit="millimeter" xml:lang="en-US" xmlns:slic3rpe="">\n')
# Write metadata entries using list comprehension
metadata_entries: list[tuple[str, str]] = [
("slic3rpe:Version3mf", "1"),
("Title", "box"),
("Designer", ""),
("Description", "box"),
("Copyright", ""),
("LicenseTerms", ""),
("Rating", ""),
("CreationDate", now),
("ModificationDate", now),
("Application", "PrusaSlicer-2.9.0")
]
file.writelines([f' <metadata name="{name}">{value}</metadata>\n' for name, value in metadata_entries])
# Write resources element and object using list comprehension
file.write(f' <resources>\n')
verts_template = np.vectorize(lambda x, y, z: '<vertex x="%.6f" y="%.6f" z="%.6f" />\n' % (x, y, z))
idx_template = np.vectorize(lambda a, b, c: '<triangle v1="%d" v2="%d" v3="%d" />\n' % (a, b, c))
valid_collections: dict[str, SlicingCollection] = {k: c for k, c in group.collections.items() if c.meshes}
for i, (k, collection) in enumerate[tuple[str, SlicingCollection]](valid_collections.items()):
if not collection.meshes: continue
uv, t_idx = collection.unique_verts
if uv.size and t_idx.size:
file.write(f' <object id="{str(i+1)}" type="model">\n')
file.write(f' <mesh>\n')
file.write(f' <vertices>\n')
file.writelines(verts_template(uv[:,0], uv[:,1], uv[:,2]))
file.write(f' </vertices>\n')
file.write(f' <triangles>\n')
file.writelines(idx_template(t_idx[:,0], t_idx[:,1], t_idx[:,2]))
file.write(f' </triangles>\n')
file.write(f' </mesh>\n')
file.write(f' </object>\n')
file.write(f' </resources>\n')
# Write build element
file.write(f' <build>\n')
ox, oy, oz = group.object_offset[0], group.object_offset[1], group.object_offset[2]
transform_str = f"1 0 0 0 1 0 0 0 1 {ox} {oy} {oz}"
for i, k in enumerate(valid_collections):
if not valid_collections[k].unique_verts[0].size: continue
file.writelines([f' <item objectid="{str(i+1)}" transform="{transform_str}" printable="1" />\n' ])
file.write(f' </build>\n')
# Close the model tag
file.write(f'</model>\n')
def to_3mf(folder_path, output_base_path):
zip_file_path = shutil.make_archive(os.path.splitext(output_base_path)[0], 'zip', folder_path)
new_file_path = os.path.splitext(zip_file_path)[0] + '.3mf'
os.replace(zip_file_path, new_file_path)
def write_z_gcodes(z_gcodes, filename: str):
root = ET.Element("custom_gcodes_per_print_z", bed_idx="0")
for c in z_gcodes:
ET.SubElement(root, "code", {
"print_z": str(c.z),
"type": str(c.type),
"extruder": str(c.extruder),
"color": c.color,
"extra": c.extra,
"gcode": c.gcode,
})
ET.SubElement(root, "mode", {"value": "SingleExtruder"})
ET.ElementTree(root).write(filename, encoding="utf-8", xml_declaration=True)
def prepare_3mf(filepath: Path, geoms: SlicingGroup, conf, z_gcodes) -> None:
source_folder = os.path.join(script_dir, 'prusaslicer_3mf')
temp_dir = tempfile.mkdtemp()
shutil.copytree(source_folder, temp_dir, dirs_exist_ok=True)
os.makedirs(os.path.join(temp_dir, '3D'), exist_ok=True)
os.makedirs(os.path.join(temp_dir, 'Metadata'), exist_ok=True)
write_model_xml(geoms, os.path.join(temp_dir, '3D', '3dmodel.model'))
write_metadata_xml(geoms, os.path.join(temp_dir, 'Metadata', 'Slic3r_PE_model.config'))
write_wipe_tower_xml(geoms, os.path.join(temp_dir, 'Metadata', 'Prusa_Slicer_wipe_tower_information.xml'))
write_z_gcodes(z_gcodes, os.path.join(temp_dir, 'Metadata', 'Prusa_Slicer_custom_gcode_per_print_z.xml'))
conf.write_ini_3mf(os.path.join(temp_dir, 'Metadata', 'Slic3r_PE.config'))
to_3mf(temp_dir, filepath)
return None

View file

@ -1,6 +0,0 @@
<?xml version='1.0' encoding='UTF-8'?>
<Types xmlns="">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" />
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml" />
<Default Extension="png" ContentType="image/png" />
</Types>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Target="/3D/3dmodel.model" Id="rel-1" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
<Relationship Target="/Metadata/thumbnail.png" Id="rel-2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail"/>
</Relationships>

View file

@ -108,11 +108,8 @@ class SlicingObject():
parent: str
mesh: NDArray[float64]
attributes: dict[str, Any]
object_type: str = "ModelPart"
modifiers: list[dict[str, Any]] = []
extruder: int = 1
pdf_properties: PdfProperties
object_properties: list[tuple[str, str]]
pdf_object_properties: list[tuple[str, str]]
def __init__(self, obj: Object | None = None, parent: str = "") -> None:
if not bpy.context.scene: raise Exception('No scene currently open!')
@ -120,19 +117,13 @@ class SlicingObject():
self.name = str(obj.name) if obj else ""
self.parent = parent
self.attributes = {}
self.modifiers = []
self.transforms = []
self.pdf_properties = PdfProperties()
self.object_properties = []
self.pdf_object_properties = []
assert bpy.context.scene.unit_settings
scene_scale: float = bpy.context.scene.unit_settings.scale_length
if obj and (ns := getattr(obj, 'nodeslicer', None)):
self.object_type = ns.object_type
self.extruder = ns.extruder
self.modifiers = [{'param_id': m.field, 'param_value': m.value_str} for m in ns.modifiers]
if obj:
self.mesh, self.attributes = objects_to_tris([obj], 1000 * scene_scale)
else:
@ -147,10 +138,8 @@ class SlicingObject():
# Take metadata from first object if available
merged.name = objects[0].name + "_merged"
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.object_properties = list(getattr(objects[0], "object_properties", []))
merged.pdf_object_properties = list(getattr(objects[0], "pdf_object_properties", []))
merged.transforms = []
for o in objects:
for t in getattr(o, "transforms", []):
@ -215,12 +204,6 @@ class SlicingObject():
norms_len = np.where(norms_len == 0, 1.0, norms_len)
self.mesh[:, 3, :] = norms / norms_len
def assign_modifiers(self, modifiers: dict[str, str]):
self.modifiers = [
{"param_id": k, "param_value": v}
for k, v in modifiers.items()
]
@property
def checksum(self) -> int:
import json
@ -391,15 +374,9 @@ class SlicingCollection():
class SlicingGroup():
collections: dict[str, SlicingCollection]
# Metadata
wipe_tower_xy: NDArray[Any] = np.array([0, 0])
wipe_tower_rotation_deg: float = 0
object_offset: NDArray[Any] = np.array([0.0, 0.0, 0.0])
transforms: list[dict[str, Any]]
def __init__(self, selected_objs: list[Object]) -> None:
self.object_offset = np.array([0.0, 0.0, 0.0])
self.transforms = []
family = []
@ -407,7 +384,7 @@ class SlicingGroup():
family.append(obj)
family.extend(get_all_children(obj))
filtered_objs = [o for o in family] # if getattr(o, TYPES_NAME).object_type != 'Ignore'
filtered_objs = [o for o in family]
def find_top_parent(o):
current = o
@ -421,33 +398,12 @@ class SlicingGroup():
self.collections = {}
for k, objects in parents.items():
self.collections[k] = SlicingCollection(objects, k)
self._extract_metadata(family)
def _extract_metadata(self, objs) -> None:
import math
assert bpy.context.scene and bpy.context.scene.unit_settings
scale_tx = 1000. * bpy.context.scene.unit_settings.scale_length #type: ignore
if self.center_xy is not None:
self.wipe_tower_xy = self.center_xy[0:2]
for obj in objs:
with get_evaluated_mesh(obj) as (eval_obj, _m):
ns_prop = getattr(eval_obj, 'nodeslicer', None)
if eval_obj is not None and ns_prop and getattr(ns_prop, 'object_type', None) == 'WipeTower':
self.wipe_tower_xy = np.array(eval_obj.location[0:2]) * scale_tx
self.wipe_tower_rotation_deg = round(eval_obj.rotation_euler[2]*180/math.pi,5)
break
return
def offset(self, offset: NDArray[Any]):
for k, so in self.collections.items(): so.offset(offset)
self.wipe_tower_xy = self.wipe_tower_xy + offset[0:2]
def scale(self, factor: float):
for k, so in self.collections.items(): so.scale(factor)
self.wipe_tower_xy *= factor
@property
def checksum(self):
@ -458,10 +414,6 @@ class SlicingGroup():
buf.extend(key_bytes)
buf.extend(struct.pack(">I", col.checksum))
buf.extend(struct.pack(">I", crc32_array(self.wipe_tower_xy)))
buf.extend(struct.pack(">I", crc32_array(np.array(self.wipe_tower_rotation_deg))))
buf.extend(struct.pack(">I", crc32_array(self.object_offset)))
return zlib.crc32(buf) & 0xFFFFFFFF
@property

View file

@ -1,47 +0,0 @@
# PrusaSlicer Profiles Module
This module handles the parsing, management, and evaluation of PrusaSlicer configuration profiles (`.ini` files). It supports profile inheritance, compatibility evaluation using PrusaSlicer's expression language, and generating merged configurations for slicing.
## Project Structure
- **`__init__.py`**: Exports the primary API (`LocalCache`, `Profile`, `ConfigWriter`).
- **`cache.py`**: Contains the `LocalCache` class for managing profile loading and metadata.
- **`models.py`**: Defines core data models like `Profile` (as a dataclass) and `ConfigWriter`.
- **`parser.py`**: Implements a recursive descent parser for PrusaSlicer's expression language.
- **`fields.py`**: Robustly loads and searches PrusaSlicer setting metadata (JSON/CSV).
- **`utils.py`**: Contains canonical implementations of INI parsing and configuration merging (`generate_conf`).
## Core Components
### 1. `LocalCache` (`cache.py`)
- Manages a collection of profiles loaded from disk.
- Scans directories for `.ini` files and tracks changes using file metadata.
- Categorizes profiles into `printer`, `print`, and `filament`.
- `evaluate_compatibility`: Checks which filament and print profiles are compatible with specific printer profiles.
- `generate_conf_writer`: Creates a `ConfigWriter` by merging a printer, print, and one or more filament profiles.
### 2. `Profile` (`models.py`)
- Represents a single PrusaSlicer profile (Printer, Filament, or Print).
- Stores configuration as a dictionary (`conf_dict`).
- Handles inheritance logic via `generate_inherited_confs`.
- Supports compatibility checks via `evaluate_compatibility`.
### 3. `ConfigWriter` (`models.py`)
- Helper to write merged configurations to a file (e.g., for passing to a slicer or embedding in a 3MF).
- Includes checksum generation for configuration integrity.
### 4. `Parser` (`parser.py`)
- Tokenizes and parses expression strings into an AST that can be evaluated against a configuration dictionary.
- Supports variables, indexing, logical operators, and regex matching.
### 5. `fields.py`
- Loads metadata about PrusaSlicer configuration fields from `prusaslicer_fields.json` and `prusaslicer_modifier_fields.csv`.
- Uses robust path resolution relative to the module's directory.
## Typical Workflow
1. Initialize `LocalCache`.
2. Call `cache.load([paths_to_profiles])`.
3. Select a printer, print, and filament profile(s).
4. (Optional) Evaluate compatibility using `cache.evaluate_compatibility`.
5. Generate a merged config using `cache.generate_conf_writer`.
6. Use `ConfigWriter` to output the final configuration for slicing.

View file

@ -1,4 +0,0 @@
from .cache import LocalCache
from .models import Profile, ConfigWriter
__all__ = ['LocalCache', 'Profile', 'ConfigWriter']

View file

@ -1,159 +0,0 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from .models import Profile, ConfigWriter
from .utils import ini_to_dict
class LocalCache:
def __init__(self):
self.profiles: dict[str, Profile] = {}
self.files_metadata: dict[str, float] = {}
@property
def display_profiles(self) -> dict[str, Profile]:
"""Profiles that are not hidden (don't contain '*' in their ID)."""
return {k: p for k, p in self.profiles.items() if '*' not in p.id}
@property
def printer_profiles(self) -> dict[str, Profile]:
return {k: p for k, p in self.display_profiles.items() if p.category == 'printer'}
@property
def print_profiles(self) -> dict[str, Profile]:
return {k: p for k, p in self.display_profiles.items() if p.category == 'print'}
@property
def filament_profiles(self) -> dict[str, Profile]:
return {k: p for k, p in self.display_profiles.items() if p.category == 'filament'}
@property
def vendors(self) -> list[str]:
return sorted({p.vendor for p in self.filament_profiles.values() if p.vendor})
def load(self, dirs: list[Path | str]) -> tuple[set[str], set[str], set[str]]:
"""
Loads profiles from the specified directories.
Returns sets of (changed, added, deleted) file paths.
"""
old_metadata = self.files_metadata.copy()
new_metadata = self._fetch_files_metadata(dirs)
changed = {k for k in old_metadata.keys() & new_metadata.keys() if old_metadata[k] != new_metadata[k]}
added = new_metadata.keys() - old_metadata.keys()
deleted = old_metadata.keys() - new_metadata.keys()
if not (changed | added | deleted):
return set(), set(), set()
# Handle deletions
for deleted_file in deleted:
keys_to_remove = [k for k, v in self.profiles.items() if str(v.path) == deleted_file]
for k in keys_to_remove:
self.profiles.pop(k, None)
# Handle additions and changes
for file_path in (changed | added):
self._process_ini_to_cache(file_path)
self.files_metadata = new_metadata
# Recalculate inheritance for all profiles
for profile in self.display_profiles.values():
profile.generate_inherited_conf(self.profiles)
return changed, added, deleted
def evaluate_compatibility(self, enabled_printers: set[str], enabled_vendors: set[str]):
"""Evaluates compatibility expressions for all active profiles."""
for k in enabled_printers:
printer = self.printer_profiles["printer:" + k]
active_vendors = enabled_vendors | {''}
active_filaments = {k: p for k, p in self.filament_profiles.items() if p.vendor in active_vendors}
printer.evaluate_compatibility(active_filaments | self.print_profiles)
def generate_conf_writer(
self,
printer_id: str,
filament_ids: list[str],
print_id: str,
overrides: dict[str, dict[str, Any]]
) -> ConfigWriter:
"""Merges selected profiles and overrides into a ConfigWriter."""
from .fields import search_db
conf: dict[str, Any] = {}
# Add printer and print profile
conf.update(self.profiles[printer_id].all_conf_dict)
conf.update(self.profiles[print_id].all_conf_dict)
# Merge filament profiles (handling multi-extruder setups)
filament_confs = [self.profiles[fid].all_conf_dict for fid in filament_ids]
all_keys = set().union(*filament_confs)
all_keys.discard('cooling_slowdown_logic')
for key in all_keys:
from .fields import search_db
key_props = search_db.get(key)
if not key_props:
continue
default_val = key_props.get('default', '')
key_type = key_props['type']
# Select separator based on field type
list_types = {
'coPercents', 'coFloats', 'coFloatsOrPercents',
'coInts', 'coIntsNullable', 'coBools', 'coPoints'
}
separator = ',' if key_type in list_types else ';'
values = [d.get(key, default_val) for d in filament_confs]
conf[key] = separator.join(map(str, values))
# Apply overrides
for k, o in overrides.items():
conf[k] = o['value']
# Add profile names for reference
conf.update({
'printer_settings_id': printer_id.split(":")[1],
'filament_settings_id': ";".join(fid.split(":")[1] for fid in filament_ids),
'print_settings_id': print_id.split(":")[1],
})
return ConfigWriter(conf)
def _fetch_files_metadata(self, dirs: list[Path | str]) -> dict[str, float]:
metadata = {}
for d in dirs:
path = Path(os.path.expanduser(d)).resolve()
if not path.is_dir():
continue
for root, _, files in os.walk(path, followlinks=True):
for f in files:
if f.endswith('.ini'):
file_path = Path(root) / f
try:
metadata[str(file_path)] = file_path.stat().st_mtime
except OSError:
continue
return metadata
def _process_ini_to_cache(self, path: str):
has_header, sections = ini_to_dict(path)
for key, conf in sections.items():
if ":" in key:
category = key.split(':')[0]
self.profiles[key] = Profile(
key=key,
category=category,
path=Path(path),
has_header=has_header,
conf_dict=conf
)

View file

@ -1,37 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
import os
from functools import lru_cache
from ..json_handling import dict_from_json
from ..csv_handling import parse_csv_to_dict
# Robust path resolution relative to this file
BASE_DIR = os.path.dirname(__file__)
search_db_path: str = os.path.join(BASE_DIR, 'prusaslicer_fields.json')
search_db: dict[str, dict[str, Any]] = dict_from_json(search_db_path)
search_db_mod_path: str = os.path.join(BASE_DIR, 'prusaslicer_modifier_fields.csv')
search_db_mod: dict[str, list[str]] = parse_csv_to_dict(search_db_mod_path)
@lru_cache(maxsize=128)
def search_in_db(term: str) -> dict[str, dict[str, Any]]:
"""Searches the PrusaSlicer fields database for a given term."""
words = term.lower().split()
return {
k: v for k, v in search_db.items()
if all(word in k + " " + v.get('label', '') for word in words)
}
@lru_cache(maxsize=128)
def search_in_mod_db(term: str) -> dict[str, dict[str, Any]]:
"""Searches the PrusaSlicer modifier fields database for a given term."""
words = term.lower().split()
filtered_db = {k: v for k, v in search_db.items() if k in search_db_mod}
return {
k: v for k, v in filtered_db.items()
if all(word in k + " " + v.get('label', '') for word in words)
}

View file

@ -1,84 +0,0 @@
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from .parser import ExprNode
class Profile:
def __init__(
self,
key: str,
category: str,
path: Path,
has_header: bool,
conf_dict: dict[str, Any]
):
self.key = key
self.category = category
self.path = path
self.has_header = has_header
self.conf_dict = conf_dict
self.id = self.key.split(':')[1] if len(self.key.split(':')) > 1 else self.key
self.vendor = ""
self.all_conf_dict: dict[str, Any] = {}
self.compatibility_expression: ExprNode | None = None
self.compatible_profiles: list[Profile] = []
@property
def compatible_print_profiles(self) -> list[Profile]:
return [k for k in self.compatible_profiles if k.category == 'print']
@property
def compatible_filament_profiles(self) -> list[Profile]:
return [k for k in self.compatible_profiles if k.category == 'filament']
def generate_inherited_conf(self, all_profiles: dict[str, Profile]):
"""Calculates the full configuration dictionary by resolving inheritance."""
from .utils import generate_conf
from .parser import Parser
self.all_conf_dict = generate_conf(all_profiles, self.key)
self.vendor = self.all_conf_dict.get('filament_vendor', '')
if self.category == 'printer':
nozzle_diameters = self.all_conf_dict.get('nozzle_diameter', '0.4')
self.all_conf_dict['num_extruders'] = str(len(nozzle_diameters.split(',')))
if exp := self.all_conf_dict.get('compatible_printers_condition'):
try:
self.compatibility_expression = Parser(exp).parse()
except Exception as e:
print(f'Expression parsing failed for {self.id}: {exp} - {e}')
def evaluate_compatibility(self, profiles: dict[str, Profile]):
"""Checks which of the provided profiles are compatible with this one."""
self.compatible_profiles = []
for key, p in profiles.items():
compat = p.compatibility_expression
if not compat or compat.eval(self.all_conf_dict):
self.compatible_profiles += [p]
class ConfigWriter:
def __init__(self, conf: dict[str, str | list[str]]) -> None:
self.config_dict: dict[str, str | list[str]] = conf
self.temp_dir = tempfile.gettempdir()
def write_ini_3mf(self, config_local_path: Path):
with open(config_local_path, 'w') as file:
for key, val in dict(sorted(self.config_dict.items())).items():
if key in ("wipe_tower_position", "offset_object"):
continue
file.write(f"; {key} = {val}\n")
def get(self, key: str, default: str = "") -> str | list[str]:
return self.config_dict.get(key, default)
@property
def checksum(self) -> int:
import json, zlib
data = json.dumps(self.config_dict, sort_keys=True).encode("utf-8")
return zlib.crc32(data) & 0xFFFFFFFF

View file

@ -1,159 +0,0 @@
from __future__ import annotations
import operator
import re
from typing import Any
TOKENS = [
('OP', r'==|!=|<=|>=|=~|!~|&&|\|\||[()\[\]!><]'),
('REGEX', r'/((?:\\/|[^/])*)/'),
('STRING', r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\''),
('NUMBER', r'\d+(?:\.\d+)?'),
('IDENT', r'[a-zA-Z_]\w*'),
]
TOKEN_RE = re.compile('|'.join(f'(?P<{name}>{pattern})' for name, pattern in TOKENS))
_OPS = {
'==': (str, operator.eq),
'!=': (str, operator.ne),
'<': (float, operator.lt),
'>': (float, operator.gt),
'<=': (float, operator.le),
'>=': (float, operator.ge),
}
def _tokenize(text: str) -> list[tuple[str, Any]]:
tokens: list[tuple[str, Any]] = []
for m in TOKEN_RE.finditer(text):
kind: str = m.lastgroup if m.lastgroup else ""
val = m.group(kind)
if kind == 'OP':
if val == '&&': tokens.append(('OP', 'and'))
elif val == '||': tokens.append(('OP', 'or'))
elif val in ('(', ')', '[', ']'): tokens.append((val, val))
else: tokens.append(('OP', val))
elif kind == 'REGEX':
tokens.append(('REGEX', re.compile(val[1:-1])))
elif kind == 'STRING':
tokens.append(('LIT', val[1:-1]))
elif kind == 'NUMBER':
tokens.append(('LIT', val))
elif kind == 'IDENT':
if val == 'and': tokens.append(('OP', 'and'))
elif val == 'or': tokens.append(('OP', 'or'))
else: tokens.append(('IDENT', val))
return tokens
class ExprNode:
"""Represents an expression node in the AST."""
def __init__(self, node: tuple[Any, ...]) -> None:
self.node = node
def eval(self, context: dict[str, Any]) -> Any:
return self._eval_node(self.node, context)
@staticmethod
def _eval_node(node: tuple[Any, ...], context: dict[str, Any]) -> Any:
ntype = node[0]
if ntype == 'lit':
return node[1]
if ntype == 'var':
return context.get(node[1], '0')
if ntype == 'index':
val = str(context.get(node[1], '0'))
parts = val.split(',')
idx = node[2]
return parts[idx].strip() if idx < len(parts) else '0'
if ntype == 'unary':
cval = ExprNode._eval_node(node[2], context)
try:
return not float(cval)
except (ValueError, TypeError):
return not bool(cval)
if ntype == 'bin':
op, left, right = node[1], node[2], node[3]
if op == 'and':
lv = ExprNode._eval_node(left, context)
if not lv or lv == '0': return False
return ExprNode._eval_node(right, context)
if op == 'or':
lv = ExprNode._eval_node(left, context)
if lv and lv != '0': return True
return ExprNode._eval_node(right, context)
lv = ExprNode._eval_node(left, context)
rv = ExprNode._eval_node(right, context)
if op in _OPS:
caster, func = _OPS[op]
try:
return func(caster(lv), caster(rv))
except (ValueError, TypeError):
return func(str(lv), str(rv))
if op == '=~':
return rv.search(str(lv)) is not None
if op == '!~':
return rv.search(str(lv)) is None
return '0'
class Parser:
"""Recursive descent parser for PrusaSlicer expressions."""
def __init__(self, text: str) -> None:
self.tokens = _tokenize(text)
self.pos = 0
def parse(self) -> ExprNode:
node = self._parse_or()
if self.pos < len(self.tokens):
raise SyntaxError(f'Trailing tokens: {self.tokens[self.pos:]}')
return ExprNode(node)
def _peek(self) -> tuple[str, Any] | None:
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
def _advance(self) -> tuple[str, Any]:
tok = self.tokens[self.pos]
self.pos += 1
return tok
def _parse_or(self) -> tuple[Any, ...]:
left = self._parse_and()
while (tok := self._peek()) and tok == ('OP', 'or'):
self._advance()
left = ('bin', 'or', left, self._parse_and())
return left
def _parse_and(self) -> tuple[Any, ...]:
left = self._parse_not()
while (tok := self._peek()) and tok == ('OP', 'and'):
self._advance()
left = ('bin', 'and', left, self._parse_not())
return left
def _parse_not(self) -> tuple[Any, ...]:
if (tok := self._peek()) and tok == ('OP', '!'):
self._advance()
return ('unary', '!', self._parse_not())
return self._parse_comp()
def _parse_comp(self) -> tuple[Any, ...]:
left = self._parse_atom()
if (tok := self._peek()) and tok[0] == 'OP' and tok[1] in ('==', '!=', '<', '>', '<=', '>=', '=~', '!~'):
op = self._advance()[1]
left = ('bin', op, left, self._parse_atom())
return left
def _parse_atom(self) -> tuple[Any, ...]:
tok = self._advance()
ttype, val = tok
if ttype in ('LIT', 'REGEX'):
return ('lit', val)
if ttype == 'IDENT':
if self._peek() == ('[', '['):
self._advance()
idx_tok = self._advance()
if self._advance() != (']', ']'): raise SyntaxError('Expected ]')
return ('index', val, int(idx_tok[1]))
return ('var', val)
if ttype == '(':
node = self._parse_or()
if self._advance() != (')', ')'): raise SyntaxError('Expected )')
return node
raise SyntaxError(f'Unexpected token: {tok}')

File diff suppressed because it is too large Load diff

View file

@ -1,52 +0,0 @@
bottom_fill_pattern
bottom_solid_layers
bottom_solid_min_thickness
bridge_angle
bridge_flow_ratio
bridge_speed
enable_dynamic_overhang_speeds
external_perimeter_extrusion_width
external_perimeter_speed
external_perimeters_first
extra_perimeters
extra_perimeters_on_overhangs
fill_angle
fill_density
fill_pattern
fuzzy_skin
fuzzy_skin_point_dist
fuzzy_skin_thickness
gap_fill_enabled
gap_fill_speed
infill_anchor
infill_anchor_max
infill_every_layers
infill_extrusion_width
infill_overlap
infill_speed
ironing
ironing_flowrate
ironing_spacing
ironing_speed
ironing_type
only_one_perimeter_first_layer
overhang_speed_0
overhang_speed_1
overhang_speed_2
overhang_speed_3
overhangs
perimeter_extrusion_width
perimeter_speed
perimeters
small_perimeter_speed
solid_infill_below_area
solid_infill_every_layers
solid_infill_extrusion_width
solid_infill_speed
thin_walls
top_fill_pattern
top_infill_extrusion_width
top_one_perimeter_type
top_solid_infill_speed
top_solid_layers
top_solid_min_thickness
1 bottom_fill_pattern
2 bottom_solid_layers
3 bottom_solid_min_thickness
4 bridge_angle
5 bridge_flow_ratio
6 bridge_speed
7 enable_dynamic_overhang_speeds
8 external_perimeter_extrusion_width
9 external_perimeter_speed
10 external_perimeters_first
11 extra_perimeters
12 extra_perimeters_on_overhangs
13 fill_angle
14 fill_density
15 fill_pattern
16 fuzzy_skin
17 fuzzy_skin_point_dist
18 fuzzy_skin_thickness
19 gap_fill_enabled
20 gap_fill_speed
21 infill_anchor
22 infill_anchor_max
23 infill_every_layers
24 infill_extrusion_width
25 infill_overlap
26 infill_speed
27 ironing
28 ironing_flowrate
29 ironing_spacing
30 ironing_speed
31 ironing_type
32 only_one_perimeter_first_layer
33 overhang_speed_0
34 overhang_speed_1
35 overhang_speed_2
36 overhang_speed_3
37 overhangs
38 perimeter_extrusion_width
39 perimeter_speed
40 perimeters
41 small_perimeter_speed
42 solid_infill_below_area
43 solid_infill_every_layers
44 solid_infill_extrusion_width
45 solid_infill_speed
46 thin_walls
47 top_fill_pattern
48 top_infill_extrusion_width
49 top_one_perimeter_type
50 top_solid_infill_speed
51 top_solid_layers
52 top_solid_min_thickness

View file

@ -1,75 +0,0 @@
from __future__ import annotations
import os
import re
from configparser import ConfigParser, MissingSectionHeaderError
from typing import Any
def generate_conf(profiles: dict[str, Any], id: str) -> dict[str, str]:
"""Recursively merges inherited profiles into a single configuration dictionary."""
if not (profile := profiles.get(id)):
return {}
if not profile.conf_dict:
return {}
conf_current = profiles[id].conf_dict.copy()
if conf_current.get('inherits', False):
curr_category = id.split(":")[0]
# Split on semicolon for multiple inheritance
inherited_ids = [
curr_category + ":" + inherit_id.strip()
for inherit_id in conf_current['inherits'].split(';')
]
merged_conf = {}
for inherit_id in inherited_ids:
if inherit_id in profiles:
inherited_conf = generate_conf(profiles, inherit_id)
merged_conf.update(inherited_conf)
merged_conf.update(conf_current)
conf_current = merged_conf
conf_current.pop('inherits', None)
conf_current.pop('renamed_from', None)
return conf_current
def ini_to_dict(path: str) -> tuple[bool, dict[str, dict[str, str]]]:
"""Parses a PrusaSlicer INI file into a dictionary of sections."""
with open(path, 'r') as file:
content = file.read()
config = ConfigParser(interpolation=None)
try:
config.read_string(content)
has_header = True
except MissingSectionHeaderError:
# Determine category based on specific IDs in the content
if re.search(r'^filament_settings_id', content, re.MULTILINE):
cat = 'filament'
elif re.search(r'^print_settings_id', content, re.MULTILINE):
cat = 'print'
elif re.search(r'^printer_settings_id', content, re.MULTILINE):
cat = 'printer'
else:
raise ValueError(f"Unable to determine category for the INI file: {path}")
name = os.path.splitext(os.path.basename(path))[0]
default_section = f"[{cat}:{name}]\n" + content
config.read_string(default_section)
has_header = False
return has_header, {
section: dict(sorted(config.items(section)))
for section in sorted(config.sections())
}
def ini_content_to_dict(path: str) -> dict[str, str]:
"""Parses a PrusaSlicer INI file without sections into a flat dictionary."""
with open(path, 'r') as file:
content = file.read()
config = ConfigParser(interpolation=None)
default_section = f"[default:default]\n" + content
config.read_string(default_section)
return dict(sorted(config.items(config.sections()[0])))

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
/home/nicolas/Workspace/Design Projects/3d Print Library/Configs

View file

@ -1,32 +0,0 @@
import bpy
from ..core.nodeslicer.sockets import SettingItem
from ..registry import register_class
extruder_options: list[tuple[str, str, str]] = [
("0", "Default Extruder", "Default Extruder"),
("1", "Extruder 1", "Extruder 1"),
("2", "Extruder 2", "Extruder 2"),
("3", "Extruder 3", "Extruder 3"),
("4", "Extruder 4", "Extruder 4"),
("5", "Extruder 5", "Extruder 5"),
]
object_type_options: list[tuple[str, str, str]] = [
("ModelPart", "Part", "Model Part"),
("NegativeVolume", "Negative Volume", "Negative Volume"),
("ParameterModifier", "Modifier", "Modifier"),
("SupportBlocker", "Support Blocker", "Support Blocker"),
("SupportEnforcer", "Support Enforcer", "Support Enforcer"),
("WipeTower", "Wipe Tower", "Wipe Tower"),
("Ignore", "Ignore", "Ignore"),
]
from ..core.nodeslicer.bpy_ns_types import NfEnumProperty, NfStringProperty
@register_class
class SlicerObjectPropertyGroup(bpy.types.PropertyGroup):
object_type: NfEnumProperty(name="Part type", default="ModelPart", items=object_type_options) # pyright: ignore[reportInvalidTypeForm]
extruder: NfEnumProperty(name="Extruder", default="0", items=extruder_options) # pyright: ignore[reportInvalidTypeForm]
search_term: NfStringProperty(name="Search") # pyright: ignore[reportInvalidTypeForm]
modifiers: bpy.props.CollectionProperty(type=SettingItem) # pyright: ignore[reportInvalidTypeForm]

View file

@ -1,79 +0,0 @@
import bpy
from bpy.types import bpy_struct
import math
from typing import cast
def _pv(inst: bpy_struct) -> 'PrusaSlicerTypes':
return cast(PrusaSlicerTypes, inst)
def get_prop_bool(inst: bpy_struct) -> bool:
return _pv(inst).param_value == '1'
def set_prop_bool(inst: bpy_struct, value: bool) -> None:
_pv(inst).param_value = '1' if value else '0'
def get_prop_float(inst: bpy_struct) -> float:
return float(_pv(inst).param_value) if _pv(inst).param_value else 0
def set_prop_float(inst: bpy_struct, value: float) -> None:
_pv(inst).param_value = str(round(value, 5))
def get_prop_int(inst: bpy_struct) -> int:
return int(_pv(inst).param_value) if _pv(inst).param_value else 0
def set_prop_int(inst: bpy_struct, value: int) -> None:
_pv(inst).param_value = str(value)
def get_prop_perc(inst: bpy_struct) -> float:
if _pv(inst).param_value:
return float(_pv(inst).param_value.rstrip('%')) if _pv(inst).param_value else 0
return 0
def set_prop_perc(inst: bpy_struct, value: float) -> None:
_pv(inst).param_value = str(value)+'%'
def get_prop_angle(inst: bpy_struct) -> float:
return (float(_pv(inst).param_value) * math.pi) / 180
def set_prop_angle(inst: bpy_struct, value: float) -> None:
_pv(inst).param_value = str(round((value * 180) / math.pi, 5))
class PrusaSlicerTypes():
param_value: bpy.props.StringProperty(name='') # pyright: ignore[reportInvalidTypeForm]
param_bool: bpy.props.BoolProperty(name='',
get=get_prop_bool,
set=set_prop_bool,
default=False,
) # pyright: ignore[reportInvalidTypeForm]
param_float: bpy.props.FloatProperty(name='',
get=get_prop_float,
set=set_prop_float,
soft_min=0,
step = 5,
) # pyright: ignore[reportInvalidTypeForm]
param_int: bpy.props.IntProperty(name='',
get=get_prop_int,
set=set_prop_int,
) # pyright: ignore[reportInvalidTypeForm]
param_perc: bpy.props.FloatProperty(name='',
get=get_prop_perc,
set=set_prop_perc,
subtype='PERCENTAGE',
soft_min=0,
soft_max=100,
step = 5,
) # pyright: ignore[reportInvalidTypeForm]
param_angle: bpy.props.FloatProperty(name='',
get=get_prop_angle,
set=set_prop_angle,
subtype='ANGLE',
soft_min=0,
soft_max=360,
step = 1,
) # pyright: ignore[reportInvalidTypeForm]

View file

View file

@ -0,0 +1,178 @@
import unittest
from unittest.mock import MagicMock
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.append(str(PROJECT_ROOT.parent))
class DummyBase:
def _get_data_dict(self):
if not hasattr(self, "_data") or self._data is None:
self._data = {}
return self._data
def __getitem__(self, key):
return self._get_data_dict()[key]
def __setitem__(self, key, value):
self._get_data_dict()[key] = value
def __contains__(self, key):
return key in self._get_data_dict()
def get(self, key, default=None):
return self._get_data_dict().get(key, default)
class DummyObject:
def __init__(self, name="TestObj", obj_type='MESH'):
self.name = name
self.type = obj_type
mock_types = MagicMock()
mock_types.NodeSocket = type("NodeSocket", (DummyBase,), {})
mock_types.Node = DummyBase
mock_types.NodeCustomGroup = type("NodeCustomGroup", (DummyBase,), {})
mock_types.NodeTree = type("NodeTree", (DummyBase,), {})
mock_types.PropertyGroup = type("PropertyGroup", (DummyBase,), {})
mock_types.Operator = type("Operator", (DummyBase,), {})
mock_types.Panel = type("Panel", (DummyBase,), {})
mock_types.Menu = type("Menu", (DummyBase,), {})
mock_types.Object = DummyObject
mock_types.Collection = type("Collection", (DummyBase,), {})
if 'bpy' not in sys.modules:
mock_bpy = MagicMock()
mock_bpy.types = mock_types
sys.modules['bpy'] = mock_bpy
sys.modules['bpy.types'] = mock_types
sys.modules['bpy.props'] = MagicMock()
sys.modules['bpy.utils'] = MagicMock()
sys.modules['bpy.utils.previews'] = MagicMock()
sys.modules['bpy.app'] = MagicMock()
sys.modules['bpy.app.handlers'] = MagicMock()
import bpy
class TestNodeSocket:
def __init__(self, name="", identifier="", node=None):
self.name = name
self.identifier = identifier or name
self.is_linked = False
self.links = []
self.node = node
def __repr__(self):
return f"<TestNodeSocket name={self.name!r} identifier={self.identifier!r}>"
class TestSocketCollection(list):
def __init__(self, node=None):
super().__init__()
self.node = node
def new(self, type_name, name, identifier=""):
sock = TestNodeSocket(name, identifier or name, node=self.node)
self.append(sock)
return sock
def remove(self, sock):
if sock in self:
super().remove(sock)
def get(self, name, default=None):
for item in self:
if item.name == name or item.identifier == name:
return item
return default
def find(self, name):
for i, item in enumerate(self):
if item.name == name or item.identifier == name:
return i
return -1
def __contains__(self, item):
if isinstance(item, str):
return any(sock.name == item or sock.identifier == item for sock in self)
return super().__contains__(item)
def move(self, from_idx, to_idx):
item = self.pop(from_idx)
self.insert(to_idx, item)
class DummyCollection:
def __init__(self, name="TestCollection", objects=None):
self.name = name
self.objects = objects or []
from NodeSlicer.core.nodeslicer.nodes.inputs import SLICENODE_CollectionObjects
class TestCollectionObjectsSorting(unittest.TestCase):
def setUp(self):
self.node = SLICENODE_CollectionObjects()
self.node.inputs = TestSocketCollection(self.node)
self.node.outputs = TestSocketCollection(self.node)
def test_outputs_sorted_alphabetically(self):
# Create collection with objects in non-alphabetical order
obj_z = DummyObject("Zebra", 'MESH')
obj_a = DummyObject("Apple", 'MESH')
obj_m = DummyObject("Mango", 'MESH')
obj_camera = DummyObject("Camera", 'CAMERA') # Should be ignored
collection = DummyCollection("Fruits", [obj_z, obj_a, obj_camera, obj_m])
self.node.collection = collection
self.node._update_outputs()
output_names = [o.name for o in self.node.outputs]
self.assertEqual(output_names, ["Apple", "Mango", "Zebra"])
def test_dynamic_add_and_reorder(self):
# Initial collection with B and D
obj_b = DummyObject("Banana", 'MESH')
obj_d = DummyObject("Date", 'MESH')
collection = DummyCollection("Fruits", [obj_b, obj_d])
self.node.collection = collection
self.node._update_outputs()
self.assertEqual([o.name for o in self.node.outputs], ["Banana", "Date"])
# Add Apple (which should be placed at the beginning) and Cherry (between Banana and Date)
obj_a = DummyObject("Apple", 'MESH')
obj_c = DummyObject("Cherry", 'MESH')
collection.objects.extend([obj_a, obj_c])
self.node._update_outputs()
self.assertEqual([o.name for o in self.node.outputs], ["Apple", "Banana", "Cherry", "Date"])
def test_remove_object(self):
obj_a = DummyObject("Apple", 'MESH')
obj_b = DummyObject("Banana", 'MESH')
obj_c = DummyObject("Cherry", 'MESH')
collection = DummyCollection("Fruits", [obj_a, obj_b, obj_c])
self.node.collection = collection
self.node._update_outputs()
self.assertEqual([o.name for o in self.node.outputs], ["Apple", "Banana", "Cherry"])
# Remove Banana
collection.objects = [obj_a, obj_c]
self.node._update_outputs()
self.assertEqual([o.name for o in self.node.outputs], ["Apple", "Cherry"])
def test_evaluate_output(self):
obj_a = DummyObject("Apple", 'MESH')
bpy.data.objects = {"Apple": obj_a}
collection = DummyCollection("Fruits", [obj_a])
self.node.collection = collection
self.node._update_outputs()
result = self.node.evaluate_output("Apple")
self.assertEqual(result, obj_a)
result_nonexistent = self.node.evaluate_output("NonExistent")
self.assertIsNone(result_nonexistent)
if __name__ == '__main__':
unittest.main()

View file

@ -1119,13 +1119,13 @@ class TestPdfExporter(unittest.TestCase):
for coll in updated_sg.collections.values():
for obj in coll.objects:
self.assertEqual(obj.object_properties, props)
self.assertEqual(obj.pdf_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)
self.assertEqual(updated_sp.pdf_object_properties, props)
# Verify dynamic socket color logic
from NodeSlicer.core.nodeslicer.sockets import SLICESOCK_Geometry, SLICESOCK_Paths, SLICESOCK_Mesh
@ -1180,7 +1180,7 @@ class TestPdfExporter(unittest.TestCase):
sg.collections = {"Coll1": sc1, "Coll2": sc2}
# Assign properties to so1 only
so1.object_properties = [("", "Material: PETG"), ("Tolerance", "ISO 2768-f")]
so1.pdf_object_properties = [("", "Material: PETG"), ("Tolerance", "ISO 2768-f")]
edges_2d = extract_unique_top_view_edges(sg)
@ -1236,7 +1236,7 @@ class TestPdfExporter(unittest.TestCase):
res_obj = updated_sg.collections["Coll"].objects[0]
self.assertEqual(
res_obj.object_properties,
res_obj.pdf_object_properties,
[("infill_density", "15.5"), ("material", "ABS"), ("custom_setting", "fixed_value")]
)
@ -1742,6 +1742,193 @@ class TestPdfExporter(unittest.TestCase):
self.assertFalse(getattr(node, "draw_grid_xz", False))
self.assertFalse(getattr(node, "draw_grid_yz", False))
def test_format_pdf_filename_template(self):
from NodeSlicer.core.pdf_exporter import format_pdf_filename_template
import datetime
today = datetime.date.today().isoformat()
# 1. Default template
fname = format_pdf_filename_template(
template="{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf",
object_name="PartA",
size_x_mm=140.0,
size_y_mm=95.0,
today_date_iso="2026-08-31",
)
self.assertEqual(fname, "PartA-140x95-2026-08-31.pdf")
# 2. None or empty template -> defaults to {object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf
fname_default = format_pdf_filename_template(
template="",
object_name="PartB",
size_x_mm=200.5,
size_y_mm=100.25,
today_date_iso="2026-08-31",
)
self.assertEqual(fname_default, "PartB-200.5x100.25-2026-08-31.pdf")
# 3. Dynamic today_date_iso when not provided
fname_today = format_pdf_filename_template(
template="{object_name}_{today_date_iso}",
object_name="Plate",
size_x_mm=50.0,
size_y_mm=50.0,
)
self.assertEqual(fname_today, f"Plate_{today}.pdf")
# 4. Aliases: {name}, {width_mm}, {height_mm}, {date}
fname_aliases = format_pdf_filename_template(
template="{name}_W{width_mm}_H{height_mm}_{date}.pdf",
object_name="Bracket",
size_x_mm=120.0,
size_y_mm=80.0,
today_date_iso="2026-01-15",
)
self.assertEqual(fname_aliases, "Bracket_W120_H80_2026-01-15.pdf")
# 5. Missing .pdf extension should be appended
fname_no_ext = format_pdf_filename_template(
template="{object_name}_spec",
object_name="Gear",
size_x_mm=60.0,
size_y_mm=60.0,
)
self.assertEqual(fname_no_ext, "Gear_spec.pdf")
# 6. Target item attributes
so = SlicingObject()
so.attributes = {"material": {"values": np.array(["Aluminum"]), "domain": "OBJECT", "type": "STRING"}}
fname_attr = format_pdf_filename_template(
template="{object_name}_{material}.pdf",
object_name="Rod",
size_x_mm=30.0,
size_y_mm=10.0,
target_item=so,
)
self.assertEqual(fname_attr, "Rod_Aluminum.pdf")
def test_mesh_to_pdf_template_property_and_evaluation_to_directory(self):
from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_MeshToPdf
import datetime
# Check annotations
self.assertIn("template", SLICENODE_MeshToPdf.__annotations__)
node = SLICENODE_MeshToPdf()
self.assertEqual(getattr(node, "template", ""), "{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf")
# Check alias filename_template
self.assertEqual(node.filename_template, "{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf")
node.filename_template = "custom_{object_name}.pdf"
self.assertEqual(node.template, "custom_{object_name}.pdf")
# Test evaluation with folder destination (directory)
node_exec = SLICENODE_MeshToPdf.__new__(SLICENODE_MeshToPdf)
node_exec.template = "{object_name}-{size_x_mm}x{size_y_mm}-{today_date_iso}.pdf"
node_exec.draw_wireframe = True
node_exec.draw_boundary = False
node_exec.draw_contour = False
node_exec.draw_crease = False
node_exec.crease_angle = 30.0
node_exec.draw_bbox = False
node_exec.draw_sa2_holes = False
node_exec.draw_header = False
node_exec.header_company = ""
node_exec.header_part_name = ""
node_exec.header_drawing_number = ""
node_exec.header_author = ""
node_exec.header_revision = "A"
node_exec.header_material = ""
node_exec.header_tolerance = ""
node_exec.font_size = 0.0
node_exec.margin = 10.0
node_exec.wire_color = (0.0, 0.0, 0.0)
node_exec.bg_color = (1.0, 1.0, 1.0)
node_exec.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))
op = MockOperator()
so = SlicingObject()
so.name = "MyComponent"
so.mesh = np.array([[[0.0, 0.0, 0.0], [50.0, 0.0, 0.0], [0.0, 50.0, 0.0]]], dtype=np.float64)
with tempfile.TemporaryDirectory() as tmpdir:
resolved_inputs = {
"Geometry": so,
"Paths": None,
"Folder": tmpdir,
"Settings": None,
}
node_exec.resolve = lambda fieldname: resolved_inputs.get(fieldname)
res = node_exec.evaluate(operator=op)
self.assertEqual(res, "ok")
# Check exported file in directory
files = os.listdir(tmpdir)
self.assertEqual(len(files), 1)
generated_name = files[0]
today_str = datetime.date.today().isoformat()
self.assertTrue(generated_name.startswith("MyComponent-"))
self.assertTrue(generated_name.endswith(f"-{today_str}.pdf"))
self.assertIn("x", generated_name)
def test_generate_legend_slice_paths_xz_plane(self):
from NodeSlicer.core.pdf_exporter.geometry import generate_legend_slice_paths
from NodeSlicer.core.slice import SlicePaths
legend_sp, label_meta = generate_legend_slice_paths(
grid_min_x=0.0,
grid_min_y=0.0,
grid_max_x=50.0,
main_step=10.0,
minor_step=1.0,
line_width_pt=0.5,
font_size_pt=3.0,
plane="XZ",
draw_x=True,
draw_y=False,
draw_z=True,
local_origin_3d=(0.0, 0.0, 0.0),
local_max_3d=(50.0, 0.0, 50.0),
)
self.assertIsInstance(legend_sp, SlicePaths)
self.assertGreater(len(legend_sp), 0)
# Verify that all 3D line endpoints have constant y=0 (i.e. strictly in the XZ plane)
for path in legend_sp:
p_arr = np.asarray(path, dtype=np.float64)
np.testing.assert_allclose(p_arr[:, 1], 0.0, atol=1e-6, err_msg="Legend geometry for XZ plane must have Y=0")
# Verify that ticks on the X axis span in Z
x_ticks_in_z = [
path for path in legend_sp
if len(path) == 2 and abs(path[0, 0] - path[1, 0]) < 1e-6 and abs(path[0, 2] - path[1, 2]) > 0.1
]
self.assertGreater(len(x_ticks_in_z), 0, "X axis ticks must have height along Z on XZ plane")
# Verify that ticks on the Z axis span in X
z_ticks_in_x = [
path for path in legend_sp
if len(path) == 2 and abs(path[0, 2] - path[1, 2]) < 1e-6 and abs(path[0, 0] - path[1, 0]) > 0.1
]
self.assertGreater(len(z_ticks_in_x), 0, "Z axis ticks must have height along X on XZ plane")
# Verify labels
types = [m["type"] for m in label_meta]
self.assertIn("tick", types)
self.assertIn("x_axis", types)
self.assertIn("z_axis", types)
# Verify label positions are in XZ plane (Y=0)
for m in label_meta:
self.assertAlmostEqual(m["local_pt"][1], 0.0, places=5)
if __name__ == '__main__':
unittest.main()

View file

@ -1,106 +0,0 @@
import os
import sys
import unittest
from pathlib import Path
# Add the project root to sys.path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.append(str(PROJECT_ROOT))
from infra.prusaslicer_profiles.cache import LocalCache
from infra.prusaslicer_profiles.fields import search_in_db
class TestPrusaSlicerProfiles(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.cache = LocalCache()
cls.test_dir = PROJECT_ROOT / "profiles" / "prusaslicer"
cls.ini_file = cls.test_dir / "2.4.5.ini"
if not cls.ini_file.exists():
raise FileNotFoundError(f"Test baseline {cls.ini_file} not found.")
cls.cache.load([cls.test_dir])
def test_profile_counts(self):
"""Verify that a large number of profiles are loaded from the baseline INI."""
self.assertGreater(len(self.cache.profiles), 6000)
self.assertGreater(len(self.cache.printer_profiles), 200)
self.assertGreater(len(self.cache.print_profiles), 500)
self.assertGreater(len(self.cache.filament_profiles), 5000)
def test_inheritance_and_vendor(self):
"""Verify that inheritance is resolved and vendor information is extracted."""
# Find a known filament profile
filament_keys = [k for k in self.cache.display_profiles.keys() if k.startswith("filament:")]
self.assertGreater(len(filament_keys), 0)
profile = self.cache.profiles[filament_keys[0]]
self.assertTrue(len(profile.all_conf_dict) > 0)
self.assertTrue(profile.vendor or profile.all_conf_dict.get('filament_vendor') is None)
def test_fields_database(self):
"""Verify that the fields database is accessible and searchable."""
res = search_in_db("bed shape")
self.assertIn("bed_shape", res)
self.assertEqual(res["bed_shape"].get("label"), "Bed shape")
def test_compatibility(self):
"""Verify that compatibility evaluation runs without errors and finds matches."""
printer_keys = {k.removeprefix("printer:") for k in self.cache.printer_profiles.keys()}
vendors = set(self.cache.vendors)
self.cache.evaluate_compatibility(printer_keys, vendors)
has_compatible = any(p.compatible_profiles for p in self.cache.printer_profiles.values())
self.assertTrue(has_compatible, "No compatible profiles found for any printer.")
def test_all_conditions_in_baseline(self):
"""Exhaustively parse and evaluate all unique conditions in 2.4.5.ini across all printer profiles."""
from infra.prusaslicer_profiles.parser import Parser
conditions = [p.all_conf_dict.get('compatible_printers_condition') for p in self.cache.profiles.values()]
unique_conditions = sorted(list(set(c for c in conditions if c)))
self.assertGreater(len(unique_conditions), 300)
printers = [p for p in self.cache.profiles.values() if p.category == 'printer']
self.assertGreater(len(printers), 200)
total_evals = 0
positive_evals = 0
for cond_str in unique_conditions:
expr = Parser(cond_str).parse()
self.assertIsNotNone(expr)
for p in printers:
res = bool(expr.eval(p.all_conf_dict))
if res:
positive_evals += 1
total_evals += 1
self.assertEqual(total_evals, len(unique_conditions) * len(printers))
self.assertEqual(positive_evals, 3722)
def test_parser_operators(self):
"""Test individual parser operator semantics."""
from infra.prusaslicer_profiles.parser import Parser
ctx = {
"printer_model": "MK3S",
"nozzle_diameter": "0.4,0.6",
"single_extruder_multi_material": "0",
"layer_height": "0.2"
}
self.assertTrue(Parser('printer_model == "MK3S"').parse().eval(ctx))
self.assertFalse(Parser('printer_model == "MK2"').parse().eval(ctx))
self.assertTrue(Parser('printer_model =~ /MK(2|3).*/').parse().eval(ctx))
self.assertFalse(Parser('printer_model =~ /MINI/').parse().eval(ctx))
self.assertTrue(Parser('printer_model !~ /MINI/').parse().eval(ctx))
self.assertTrue(Parser('nozzle_diameter[0] == 0.4').parse().eval(ctx))
self.assertTrue(Parser('nozzle_diameter[1] == 0.6').parse().eval(ctx))
self.assertTrue(Parser('nozzle_diameter[0] < 0.5 and nozzle_diameter[1] > 0.5').parse().eval(ctx))
self.assertTrue(Parser('! single_extruder_multi_material').parse().eval(ctx))
self.assertTrue(Parser('! (printer_model == "MK2" and nozzle_diameter[0] == 0.25)').parse().eval(ctx))
if __name__ == "__main__":
unittest.main()

View file

@ -813,6 +813,101 @@ class TestSlice(unittest.TestCase):
self.assertIsNotNone(res_full_2)
self.assertEqual(res_full_2.objects[0].name, "FullMesh00")
def test_segments_to_slice_ignore_holes(self):
# Outer square: [0,0] to [20,20] at z=10
outer_segments = [
[[0.0, 0.0, 10.0], [20.0, 0.0, 10.0]],
[[20.0, 0.0, 10.0], [20.0, 20.0, 10.0]],
[[20.0, 20.0, 10.0], [0.0, 20.0, 10.0]],
[[0.0, 20.0, 10.0], [0.0, 0.0, 10.0]],
]
# Inner hole square: [5,5] to [15,15] at z=10
hole_segments = [
[[5.0, 5.0, 10.0], [15.0, 5.0, 10.0]],
[[15.0, 5.0, 10.0], [15.0, 15.0, 10.0]],
[[15.0, 15.0, 10.0], [5.0, 15.0, 10.0]],
[[5.0, 15.0, 10.0], [5.0, 5.0, 10.0]],
]
all_segs = np.array(outer_segments + hole_segments, dtype=np.float64)
# Default (ignore_holes=False) -> produces 2 paths (outer + hole)
res_with_holes = segments_to_slice(all_segs, ignore_holes=False)
self.assertEqual(len(res_with_holes), 2)
# ignore_holes=True -> produces 1 path (outer shell only)
res_no_holes = segments_to_slice(all_segs, ignore_holes=True)
self.assertEqual(len(res_no_holes), 1)
# Verify outer shell bounds [0, 20]
self.assertAlmostEqual(res_no_holes[0][:, 0].min(), 0.0)
self.assertAlmostEqual(res_no_holes[0][:, 0].max(), 20.0)
def test_slice_shells_node_ignore_holes(self):
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_SliceShells
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection
# Construct a hollow box mesh
# Outer box: [0, 20] x [0, 20] x [0, 1]
# Inner hole box: [5, 15] x [5, 15] x [0, 1]
def make_quad(v0, v1, v2, v3):
return [
[v0, v1, v2],
[v0, v2, v3]
]
# 4 outer vertical walls + 4 inner vertical walls
z0, z1 = 0.0, 1.0
faces = []
# Outer walls
faces += make_quad([0, 0, z0], [20, 0, z0], [20, 0, z1], [0, 0, z1])
faces += make_quad([20, 0, z0], [20, 20, z0], [20, 20, z1], [20, 0, z1])
faces += make_quad([20, 20, z0], [0, 20, z0], [0, 20, z1], [20, 20, z1])
faces += make_quad([0, 20, z0], [0, 0, z0], [0, 0, z1], [0, 20, z1])
# Inner hole walls
faces += make_quad([5, 5, z0], [15, 5, z0], [15, 5, z1], [5, 5, z1])
faces += make_quad([15, 5, z0], [15, 15, z0], [15, 15, z1], [15, 5, z1])
faces += make_quad([15, 15, z0], [5, 15, z0], [5, 15, z1], [15, 15, z1])
faces += make_quad([5, 15, z0], [5, 5, z0], [5, 5, z1], [5, 15, z1])
triangles = np.zeros((len(faces), 4, 3))
for i, tri in enumerate(faces):
triangles[i, 0] = tri[0]
triangles[i, 1] = tri[1]
triangles[i, 2] = tri[2]
sobj = SlicingObject()
sobj.mesh = triangles
scoll = SlicingCollection([], "test_hollow")
scoll.objects = [sobj]
# 1. Normal evaluation (ignore_holes=False)
node = SLICENODE_SliceShells()
node.ignore_holes = False
node.resolve = lambda name: scoll if name == "Mesh" else (0.5 if name == "Layer Height" else None)
slices_normal = node.evaluate_output("Paths")
self.assertEqual(len(slices_normal), 2) # Outer + hole
# 2. Evaluation with ignore_holes=True
node.ignore_holes = True
slices_ignored = node.evaluate_output("Paths")
self.assertEqual(len(slices_ignored), 1) # Only outer
# 3. Evaluation when node lacks ignore_holes property (simulating missing property / legacy tree)
node_missing = SLICENODE_SliceShells.__new__(SLICENODE_SliceShells)
# Only set resolve and standard dictionary without ignore_holes
node_missing.resolve = lambda name: scoll if name == "Mesh" else (0.5 if name == "Layer Height" else None)
slices_missing = node_missing.evaluate_output("Paths")
self.assertIsNotNone(slices_missing)
self.assertEqual(len(slices_missing), 2)
# 4. draw_buttons with and without ignore_holes does not fail
mock_layout = MagicMock()
node.draw_buttons(None, mock_layout)
mock_layout.prop.assert_called_with(node, "ignore_holes")
mock_layout_missing = MagicMock()
node_missing.draw_buttons(None, mock_layout_missing)
if __name__ == '__main__':
unittest.main()

View file

@ -170,7 +170,7 @@ class TestTransform(unittest.TestCase):
# Define mock resolver to return our inputs
def mock_resolve(fieldname):
if fieldname == "Mesh":
if fieldname == "Geometry":
return sc
elif fieldname == "Translation":
return (10.0, 20.0, 30.0)
@ -184,7 +184,7 @@ class TestTransform(unittest.TestCase):
node.resolve = mock_resolve
# Evaluate output
result_sc = node.evaluate_output("Mesh")
result_sc = node.evaluate_output("Geometry")
self.assertIsNotNone(result_sc)
# Ensure it's not the same object (deep copied)
@ -338,13 +338,13 @@ class TestTransform(unittest.TestCase):
# 1. Transform Geometry
t_node = SLICENODE_TransformGeometry()
t_node.resolve = lambda f: {
"Mesh": sc,
"Geometry": sc,
"Translation": (10.0, 20.0, 0.0),
"Rotation": (0.0, 0.0, 0.0),
"Scale": (1.0, 1.0, 1.0)
}.get(f)
sc_transformed = t_node.evaluate_output("Mesh")
sc_transformed = t_node.evaluate_output("Geometry")
self.assertIsNotNone(sc_transformed)
self.assertEqual(len(sc_transformed.transforms), 1)
self.assertEqual(sc_transformed.transforms[0]["type"], "transform_geometry")

View file

@ -1,55 +0,0 @@
import unittest
import numpy as np
from pathlib import Path
import tempfile
import os
import sys
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.append(str(PROJECT_ROOT))
from NodeSlicer.core.nodeslicer.nodes.inputs import SLICENODE_WipeTower, SLICENODE_OffsetObject
from NodeSlicer.infra.prusaslicer_profiles.models import ConfigWriter
from NodeSlicer.infra._3mf_writer._3mf import write_model_xml
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingGroup
class TestWipeTowerNode(unittest.TestCase):
def test_wipe_tower_node_output(self):
node = SLICENODE_WipeTower()
node.resolve = lambda key: 180.5 if key == "X Position" else 135.2
res = node.evaluate_output("Settings")
self.assertIn("wipe_tower_position", res)
self.assertEqual(res["wipe_tower_position"], (180.5, 135.2))
def test_offset_object_node_output(self):
node = SLICENODE_OffsetObject()
node.resolve = lambda key: {"X Offset": 12.5, "Y Offset": -5.0, "Z Offset": 2.0}.get(key, 0.0)
res = node.evaluate_output("Settings")
self.assertIn("offset_object", res)
self.assertEqual(res["offset_object"], (12.5, -5.0, 2.0))
def test_config_writer_excludes_special_keys(self):
conf = {
"layer_height": "0.2",
"wipe_tower_position": "(180.5, 135.2)",
"offset_object": "(12.5, -5.0, 2.0)",
"nozzle_diameter": "0.4"
}
writer = ConfigWriter(conf)
with tempfile.TemporaryDirectory() as temp_dir:
ini_path = Path(temp_dir) / "Slic3r_PE.config"
writer.write_ini_3mf(ini_path)
with open(ini_path, 'r', encoding='utf-8') as f:
content = f.read()
self.assertIn("; layer_height = 0.2", content)
self.assertIn("; nozzle_diameter = 0.4", content)
self.assertNotIn("wipe_tower_position", content)
self.assertNotIn("offset_object", content)
if __name__ == "__main__":
unittest.main()

View file

@ -1,32 +0,0 @@
from bpy.types import Panel
from ..infra.blender.mixins import DrawCollectionMixin
from ..registry import register_class
@register_class
class SlicerObjectPanel(Panel, DrawCollectionMixin):
bl_label = "NodeSlicer Settings"
bl_idname = f"OBJECT_PT_nodeslicer"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "object"
def draw(self, context):
layout = self.layout
if not layout: return
obj = context.object
if not obj: return
pg = getattr(obj, 'nodeslicer', None)
if not pg:
layout.label(text="Property not registered")
return
layout.prop(pg, "object_type", text="Object type")
layout.prop(pg, "extruder", text="Extruder")
layout.label(text="Modifiers:")
if pg.object_type in ['ParameterModifier', 'ModelPart']:
self.draw_collection(pg.modifiers, layout)