Compare commits
11 commits
bedc33c7d3
...
767fd08060
| Author | SHA1 | Date | |
|---|---|---|---|
| 767fd08060 | |||
| 9137d297e5 | |||
| 8d5fb68aaa | |||
| bcf9334d78 | |||
| 926da63dd8 | |||
| 31d34577e7 | |||
| 562dc221a2 | |||
| c77412bb50 | |||
| 77323d29db | |||
| ec539fa9fe | |||
| 9d282dee01 |
41 changed files with 884 additions and 46062 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,5 +5,3 @@ release/
|
|||
*.blend
|
||||
*.blend1
|
||||
*.bgcode
|
||||
tests/Link to Configs
|
||||
profiles/prusaslicer/Link to Configs
|
||||
|
|
|
|||
18
__init__.py
18
__init__.py
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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="")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
99
doc/NODES.md
99
doc/NODES.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
from .cache import LocalCache
|
||||
from .models import Profile, ConfigWriter
|
||||
|
||||
__all__ = ['LocalCache', 'Profile', 'ConfigWriter']
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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,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
|
|
@ -1 +0,0 @@
|
|||
/home/nicolas/Workspace/Design Projects/3d Print Library/Configs
|
||||
|
|
@ -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]
|
||||
|
|
@ -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]
|
||||
178
tests/test_collection_objects.py
Normal file
178
tests/test_collection_objects.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
32
ui/panels.py
32
ui/panels.py
|
|
@ -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)
|
||||
Loading…
Add table
Reference in a new issue