This commit is contained in:
Nicolas Predella 2026-08-06 10:50:50 +03:00
parent 02175f379a
commit 30f9d88e4c
9 changed files with 405 additions and 54 deletions

View file

@ -444,35 +444,86 @@ def _copy_slicing_group(sg: SlicingGroup) -> SlicingGroup:
return new_sg return new_sg
@register_class def _transform_slice_paths(sp: SlicePaths, t_arr: np.ndarray, r_arr: np.ndarray, s_arr: np.ndarray) -> SlicePaths:
class SLICENODE_TransformGeometry(SliceNode): new_paths = []
bl_label = "Transform Geometry" has_scale = any(s != 1.0 for s in s_arr)
has_rot = any(r != 0.0 for r in r_arr)
has_trans = any(t != 0.0 for t in t_arr)
def init(self, context): if not (has_scale or has_rot or has_trans):
self.add_input("SLICESOCK_Mesh", "Mesh") return sp.copy_with(paths=list(sp))
self.add_input("SLICESOCK_Vector", "Translation")
self.add_input("SLICESOCK_VectorRotation", "Rotation")
self.add_input("SLICESOCK_Vector", "Scale").value = (1.0, 1.0, 1.0)
self.add_output("SLICESOCK_Mesh", "Mesh")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | SlicingGroup | None: R = None
if socket_name != "Mesh": if has_rot:
rx, ry, rz = r_arr
cx, sx = np.cos(rx), np.sin(rx)
cy, sy = np.cos(ry), np.sin(ry)
cz, sz = np.cos(rz), np.sin(rz)
Rx = np.array([
[1.0, 0.0, 0.0],
[0.0, cx, -sx],
[0.0, sx, cx]
], dtype=np.float64)
Ry = np.array([
[cy, 0.0, sy],
[0.0, 1.0, 0.0],
[-sy, 0.0, cy]
], dtype=np.float64)
Rz = np.array([
[cz, -sz, 0.0],
[sz, cz, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
R = Rz @ (Ry @ Rx)
for p in sp:
if len(p) == 0:
new_paths.append(p.copy())
continue
new_p = p.astype(np.float64, copy=True)
dim = new_p.shape[1] if new_p.ndim > 1 else 0
if has_scale and dim > 0:
if dim >= 3:
new_p[:, :3] *= s_arr
else:
new_p[:, :dim] *= s_arr[:dim]
if has_rot and R is not None and dim > 0:
if dim >= 3:
new_p[:, :3] = new_p[:, :3] @ R.T
elif dim == 2:
rot_2d = R[:2, :2]
new_p[:, :2] = new_p[:, :2] @ rot_2d.T
if has_trans and dim > 0:
if dim >= 3:
new_p[:, :3] += t_arr
else:
new_p[:, :dim] += t_arr[:dim]
new_paths.append(new_p)
return sp.copy_with(paths=new_paths)
def _transform_geometry_item(geom: Any, t_arr: np.ndarray, r_arr: np.ndarray, s_arr: np.ndarray, t_rec: dict) -> Any:
if geom is None:
return None return None
elif isinstance(geom, SlicePaths):
mesh_data: SlicingCollection | SlicingGroup | None = self.resolve("Mesh") copied_data = _transform_slice_paths(geom, t_arr, r_arr, s_arr)
if not mesh_data: if not hasattr(copied_data, "transforms") or copied_data.transforms is None:
return None copied_data.transforms = []
else:
translation = self.resolve("Translation") or (0.0, 0.0, 0.0) copied_data.transforms = [dict(t) for t in copied_data.transforms]
rotation = self.resolve("Rotation") or (0.0, 0.0, 0.0) copied_data.transforms.append(t_rec)
scale = self.resolve("Scale") or (1.0, 1.0, 1.0) return copied_data
elif isinstance(geom, (list, tuple)):
t_arr = np.array(translation, dtype=np.float64) return [_transform_geometry_item(item, t_arr, r_arr, s_arr, t_rec) for item in geom]
r_arr = np.array(rotation, dtype=np.float64) elif isinstance(geom, SlicingGroup):
s_arr = np.array(scale, dtype=np.float64) copied_data = _copy_slicing_group(geom)
if isinstance(mesh_data, SlicingGroup):
copied_data = _copy_slicing_group(mesh_data)
for col in copied_data.collections.values(): for col in copied_data.collections.values():
if any(s != 1.0 for s in s_arr): if any(s != 1.0 for s in s_arr):
col.scale(s_arr) col.scale(s_arr)
@ -480,7 +531,6 @@ class SLICENODE_TransformGeometry(SliceNode):
col.rotate(r_arr) col.rotate(r_arr)
if any(t != 0.0 for t in t_arr): if any(t != 0.0 for t in t_arr):
col.offset(t_arr) col.offset(t_arr)
# Also offset wipe tower location
if any(t != 0.0 for t in 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] copied_data.wipe_tower_xy = copied_data.wipe_tower_xy + t_arr[0:2]
if any(r != 0.0 for r in r_arr): if any(r != 0.0 for r in r_arr):
@ -492,8 +542,13 @@ class SLICENODE_TransformGeometry(SliceNode):
copied_data.wipe_tower_rotation_deg = round(copied_data.wipe_tower_rotation_deg + rz * 180 / math.pi, 5) 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): if any(s != 1.0 for s in s_arr):
copied_data.wipe_tower_xy *= s_arr[0:2] copied_data.wipe_tower_xy *= s_arr[0:2]
else:
copied_data = _copy_slicing_collection(mesh_data) if not hasattr(copied_data, "transforms") or copied_data.transforms is None:
copied_data.transforms = []
copied_data.transforms.append(t_rec)
return copied_data
elif isinstance(geom, SlicingCollection):
copied_data = _copy_slicing_collection(geom)
if any(s != 1.0 for s in s_arr): if any(s != 1.0 for s in s_arr):
copied_data.scale(s_arr) copied_data.scale(s_arr)
if any(r != 0.0 for r in r_arr): if any(r != 0.0 for r in r_arr):
@ -501,17 +556,63 @@ class SLICENODE_TransformGeometry(SliceNode):
if any(t != 0.0 for t in t_arr): if any(t != 0.0 for t in t_arr):
copied_data.offset(t_arr) copied_data.offset(t_arr)
if not hasattr(copied_data, "transforms") or copied_data.transforms is None:
copied_data.transforms = []
copied_data.transforms.append(t_rec)
return copied_data
elif isinstance(geom, SlicingObject):
copied_data = _copy_slicing_object(geom)
if any(s != 1.0 for s in s_arr):
copied_data.scale(s_arr)
if any(r != 0.0 for r in r_arr):
copied_data.rotate(r_arr)
if any(t != 0.0 for t in t_arr):
copied_data.offset(t_arr)
if not hasattr(copied_data, "transforms") or copied_data.transforms is None:
copied_data.transforms = []
copied_data.transforms.append(t_rec)
return copied_data
return geom
@register_class
class SLICENODE_TransformGeometry(SliceNode):
bl_label = "Transform Geometry"
def init(self, context):
self.add_input("SLICESOCK_Geometry", "Geometry")
self.add_input("SLICESOCK_Vector", "Translation")
self.add_input("SLICESOCK_VectorRotation", "Rotation")
self.add_input("SLICESOCK_Vector", "Scale").value = (1.0, 1.0, 1.0)
self.add_output("SLICESOCK_Geometry", "Geometry")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name not in ("Geometry", "Mesh"):
return None
geom_data: Any = self.resolve("Geometry")
if geom_data is None:
geom_data = self.resolve("Mesh")
if geom_data is None:
return None
translation = self.resolve("Translation") or (0.0, 0.0, 0.0)
rotation = self.resolve("Rotation") or (0.0, 0.0, 0.0)
scale = self.resolve("Scale") or (1.0, 1.0, 1.0)
t_arr = np.array(translation, dtype=np.float64)
r_arr = np.array(rotation, dtype=np.float64)
s_arr = np.array(scale, dtype=np.float64)
t_rec = { t_rec = {
"type": "transform_geometry", "type": "transform_geometry",
"translation": (float(t_arr[0]), float(t_arr[1]), float(t_arr[2])), "translation": (float(t_arr[0]), float(t_arr[1]), float(t_arr[2])),
"rotation": (float(r_arr[0]), float(r_arr[1]), float(r_arr[2])), "rotation": (float(r_arr[0]), float(r_arr[1]), float(r_arr[2])),
"scale": (float(s_arr[0]), float(s_arr[1]), float(s_arr[2])), "scale": (float(s_arr[0]), float(s_arr[1]), float(s_arr[2])),
} }
if not hasattr(copied_data, "transforms"):
copied_data.transforms = []
copied_data.transforms.append(t_rec)
return copied_data return _transform_geometry_item(geom_data, t_arr, r_arr, s_arr, t_rec)
@register_class @register_class

View file

@ -461,10 +461,10 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
def evaluate(self, operator=None): def evaluate(self, operator=None):
import datetime import datetime
from ...pdf_exporter import extract_unique_top_view_edges, export_mesh_to_pdf from ...pdf_exporter import extract_unique_top_view_edges, export_mesh_to_pdf, get_first_object_name
geom = self.resolve("Geometry") geom = self.resolve("Geometry")
slice_paths = self.resolve("Paths") or self.resolve("SlicePaths") slice_paths = self.resolve("Paths")
folder: str = self.resolve("Folder") or "" folder: str = self.resolve("Folder") or ""
# Collect raw geometry items # Collect raw geometry items
@ -556,6 +556,16 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
current_raw_geom = None current_raw_geom = None
current_slice_paths = target_item current_slice_paths = target_item
current_header_info = dict(header_info)
part_name_prop = (self.header_part_name or "").strip()
if not part_name_prop:
default_part_name = (
get_first_object_name(target_item)
or get_first_object_name(current_raw_geom)
or get_first_object_name(current_slice_paths)
)
current_header_info["part_name"] = default_part_name
edges_2d = np.empty((0, 2, 2), dtype=np.float64) edges_2d = np.empty((0, 2, 2), dtype=np.float64)
if current_raw_geom is not None: if current_raw_geom is not None:
raw_e = extract_unique_top_view_edges(current_raw_geom) raw_e = extract_unique_top_view_edges(current_raw_geom)
@ -580,7 +590,7 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
draw_bbox=self.draw_bbox, draw_bbox=self.draw_bbox,
draw_sa2_holes=self.draw_sa2_holes, draw_sa2_holes=self.draw_sa2_holes,
draw_header=self.draw_header, draw_header=self.draw_header,
header_info=header_info, header_info=current_header_info,
crease_angle=self.crease_angle, crease_angle=self.crease_angle,
margin_mm=self.margin, margin_mm=self.margin,
font_size_mm=self.font_size * 2.834645669291339, font_size_mm=self.font_size * 2.834645669291339,

View file

@ -231,16 +231,21 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
mod["Socket_9"] = tuple(map(float, prep_scale)) mod["Socket_9"] = tuple(map(float, prep_scale))
def evaluate(self, operator=None): def evaluate(self, operator=None):
gcodes = self.resolve_multi_input("Gcode") gcodes: list[str] = [g for g in (self.resolve_multi_input("Gcode") or []) if g]
base_mesh_name: str = self.resolve("Name") base_mesh_name: str = self.resolve("Name")
if not base_mesh_name: if not base_mesh_name:
base_mesh_name = "GcodePreview" base_mesh_name = "GcodePreview"
for i, gcode in enumerate(gcodes): total_parts = len(gcodes)
if not gcode: continue num_digits = max(2, len(str(total_parts)))
for i, gcode in enumerate(gcodes):
if total_parts > 1:
mesh_name = f"{base_mesh_name}{i+1:0{num_digits}d}"
else:
mesh_name = base_mesh_name
mesh_name = base_mesh_name if i == 0 else f"{base_mesh_name}_{i+1}"
try: try:
self._process_gcode(gcode, mesh_name, operator) self._process_gcode(gcode, mesh_name, operator)
except Exception as e: except Exception as e:

View file

@ -20,6 +20,7 @@ from .geometry import (
) )
from .metadata import ( from .metadata import (
get_first_attribute_value, get_first_attribute_value,
get_first_object_name,
extract_settings_items, extract_settings_items,
extract_object_label_info, extract_object_label_info,
extract_object_name_positions, extract_object_name_positions,

View file

@ -59,6 +59,55 @@ def get_first_attribute_value(so_item: Any, attr_name: str) -> Any:
return None return None
def get_first_object_name(mesh_data: Any) -> str:
"""
Extracts the name of the first SlicingObject, SlicePaths, SlicingCollection,
or Blender Object in mesh_data.
"""
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
from ..slice import SlicePaths
if mesh_data is None:
return ""
if isinstance(mesh_data, (SlicingObject, SlicePaths)):
if getattr(mesh_data, "name", ""):
return str(mesh_data.name)
return ""
if isinstance(mesh_data, (list, tuple)) and not isinstance(mesh_data, SlicePaths):
for sub in mesh_data:
name = get_first_object_name(sub)
if name:
return name
return ""
if isinstance(mesh_data, SlicingGroup) and mesh_data.collections:
for key, coll in mesh_data.collections.items():
name = get_first_object_name(coll)
if name:
return name
if key:
return str(key)
return ""
if isinstance(mesh_data, SlicingCollection):
if mesh_data.objects:
for so in mesh_data.objects:
name = get_first_object_name(so)
if name:
return name
if getattr(mesh_data, "name", ""):
return str(mesh_data.name)
return ""
if hasattr(mesh_data, "name") and getattr(mesh_data, "name"):
return str(getattr(mesh_data, "name"))
return ""
def resolve_object_properties(so_item: Any, props: list[tuple[str, str]]) -> list[tuple[str, str]]: def resolve_object_properties(so_item: Any, props: list[tuple[str, str]]) -> list[tuple[str, str]]:
""" """
Resolves property tuples for a SlicingObject. Resolves property tuples for a SlicingObject.

View file

@ -96,7 +96,8 @@ def render_technical_header_table(
# Extract field values with fallbacks # Extract field values with fallbacks
company_val = str(header_info.get("company", "") or "-") company_val = str(header_info.get("company", "") or "-")
part_name_val = str(header_info.get("part_name", "") or obj_name or "-") raw_part_name = header_info.get("part_name", "")
part_name_val = str((raw_part_name or "").strip() or obj_name or "-")
dwg_val = str(header_info.get("drawing_number", "") or "-") dwg_val = str(header_info.get("drawing_number", "") or "-")
author_val = str(header_info.get("author", "") or "-") author_val = str(header_info.get("author", "") or "-")
rev_val = str(header_info.get("revision", "") or "A") rev_val = str(header_info.get("revision", "") or "A")
@ -647,11 +648,16 @@ class PDFHeaderTableElement(PDFElement):
if not exp.draw_header or not exp.header_info: if not exp.draw_header or not exp.header_info:
return return
from .metadata import get_first_object_name
primary_obj_name = "" primary_obj_name = ""
if exp.slicing_objs: if exp.slicing_objs:
primary_obj_name = str(getattr(exp.slicing_objs[0], "name", "")) for so in exp.slicing_objs:
elif exp.raw_geometry is not None and hasattr(exp.raw_geometry, "name"): if hasattr(so, "name") and getattr(so, "name"):
primary_obj_name = str(getattr(exp.raw_geometry, "name", "")) primary_obj_name = str(getattr(so, "name"))
break
if not primary_obj_name:
primary_obj_name = get_first_object_name(exp.raw_geometry) or get_first_object_name(exp.slice_paths)
render_technical_header_table( render_technical_header_table(
s=s, s=s,

View file

@ -70,8 +70,8 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Assign Property to Object**: Assigns custom settings/properties to geometry (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or `SlicePaths`) for rendering in PDF properties tables. - **Assign Property to Object**: Assigns custom settings/properties to geometry (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or `SlicePaths`) for rendering in PDF properties tables.
- **Geometry**: Generic geometry input socket (accepts `SLICESOCK_Mesh`, `SLICESOCK_Paths`, or `SLICESOCK_Geometry`). - **Geometry**: Generic geometry input socket (accepts `SLICESOCK_Mesh`, `SLICESOCK_Paths`, or `SLICESOCK_Geometry`).
- **Settings**: Input socket accepting configuration settings or property items. - **Settings**: Input socket accepting configuration settings or property items.
- **Transform Geometry**: Moves, rotates, and scales a `SlicingCollection` or `SlicingGroup` relative to the origin. - **Transform Geometry**: Moves, rotates, and scales geometry (`SlicingObject`, `SlicingCollection`, `SlicingGroup`, or `SlicePaths`) relative to the origin.
- **Mesh**: Input mesh data. - **Geometry**: Input geometry data.
- **Translation**: 3D vector specifying the translation offset. - **Translation**: 3D vector specifying the translation offset.
- **Rotation**: 3D vector/Euler rotation values. - **Rotation**: 3D vector/Euler rotation values.
- **Scale**: 3D vector specifying scaling factors along the X, Y, and Z axes (defaults to `1.0, 1.0, 1.0`). - **Scale**: 3D vector specifying scaling factors along the X, Y, and Z axes (defaults to `1.0, 1.0, 1.0`).
@ -128,7 +128,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Gcode to File**: Writes G-code string to a file on 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. - **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.
- **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. - **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.
- **Technical Drawing Header**: Collapsible menu containing string inputs for technical drawing info (Company, Part Name, Drawing No., Author, Revision, Material, Tolerance). Automatically computes ISO date and scale length (`1:1`). Renders an ISO/DIN technical drawing header table on the bottom-right of the drawing. - **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. - **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). - **SlicePaths**: Input socket accepting `SlicePaths` (reserved for path rendering).
- **Folder**: Target folder or PDF file path on disk. - **Folder**: Target folder or PDF file path on disk.

View file

@ -1427,6 +1427,83 @@ class TestPdfExporter(unittest.TestCase):
self.assertIn(" m ", content) self.assertIn(" m ", content)
def test_get_first_object_name(self):
from NodeSlicer.core.pdf_exporter import get_first_object_name
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingGroup, SlicingCollection
from NodeSlicer.core.slice import SlicePaths
so = SlicingObject()
so.name = "FirstObj"
self.assertEqual(get_first_object_name(so), "FirstObj")
sp = SlicePaths()
sp.name = "PathObj"
self.assertEqual(get_first_object_name(sp), "PathObj")
self.assertEqual(get_first_object_name([None, so]), "FirstObj")
coll = SlicingCollection.__new__(SlicingCollection)
coll.objects = [so]
coll.name = "CollName"
group = SlicingGroup.__new__(SlicingGroup)
group.collections = {"GroupKey": coll}
self.assertEqual(get_first_object_name(group), "FirstObj")
def test_mesh_to_pdf_default_part_name_first_object(self):
from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_MeshToPdf
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject
node = SLICENODE_MeshToPdf.__new__(SLICENODE_MeshToPdf)
node.draw_wireframe = True
node.draw_boundary = False
node.draw_contour = False
node.draw_crease = False
node.crease_angle = 30.0
node.draw_bbox = False
node.draw_sa2_holes = False
node.draw_header = True
node.header_company = ""
node.header_part_name = "" # Empty field -> should default to object name
node.header_drawing_number = ""
node.header_author = ""
node.header_revision = "A"
node.header_material = ""
node.header_tolerance = ""
node.font_size = 0.0
node.margin = 10.0
node.wire_color = (0.0, 0.0, 0.0)
node.bg_color = (1.0, 1.0, 1.0)
node.bbox_color = (0.0, 0.0, 1.0)
class MockOperator:
def __init__(self):
self.reports = []
def report(self, type_set, message):
self.reports.append((type_set, message))
op = MockOperator()
so = SlicingObject()
so.name = "GearWheel"
so.mesh = np.array([[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0]]], dtype=np.float64)
with tempfile.TemporaryDirectory() as tmpdir:
pdf_file = os.path.join(tmpdir, "output.pdf")
resolved_inputs = {
"Geometry": so,
"Paths": None,
"Folder": pdf_file,
"Settings": None,
}
node.resolve = lambda fieldname: resolved_inputs.get(fieldname)
res = node.evaluate(operator=op)
self.assertEqual(res, "ok")
self.assertTrue(os.path.exists(pdf_file))
with open(pdf_file, "rb") as f:
content = decompress_pdf_streams(f.read())
self.assertIn("(GearWheel) Tj", content)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View file

@ -201,6 +201,66 @@ class TestTransform(unittest.TestCase):
# v0 becomes [6.0, 22.0, 36.0] # v0 becomes [6.0, 22.0, 36.0]
np.testing.assert_allclose(result_sc.objects[0].mesh[0, 0], [6.0, 22.0, 36.0], atol=1e-7) np.testing.assert_allclose(result_sc.objects[0].mesh[0, 0], [6.0, 22.0, 36.0], atol=1e-7)
def test_transform_node_slice_paths(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry
from NodeSlicer.core.slice import SlicePaths
path = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64)
sp = SlicePaths([path])
node = SLICENODE_TransformGeometry()
node.resolve = lambda f: {
"Geometry": sp,
"Translation": (10.0, 20.0, 30.0),
"Rotation": (0.0, 0.0, np.pi / 2.0),
"Scale": (2.0, 2.0, 2.0)
}.get(f)
result_sp = node.evaluate_output("Geometry")
self.assertIsNotNone(result_sp)
self.assertIsInstance(result_sp, SlicePaths)
self.assertIsNot(result_sp, sp)
# Point 1: [1, 2, 3] -> scale [2, 4, 6] -> rot Z pi/2 [-4, 2, 6] -> trans [6, 22, 36]
np.testing.assert_allclose(result_sp[0][0], [6.0, 22.0, 36.0], atol=1e-7)
# Point 2: [4, 5, 6] -> scale [8, 10, 12] -> rot Z pi/2 [-10, 8, 12] -> trans [0, 28, 42]
np.testing.assert_allclose(result_sp[0][1], [0.0, 28.0, 42.0], atol=1e-7)
# Check transforms metadata
self.assertEqual(len(result_sp.transforms), 1)
self.assertEqual(result_sp.transforms[0]["type"], "transform_geometry")
def test_transform_node_list_input(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry
from NodeSlicer.core.slice import SlicePaths
so = SlicingObject()
so.mesh = np.array([[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0], [0.0, 0.0, 1.0]]], dtype=np.float64)
sc = SlicingCollection([], "coll")
sc.objects = [so]
path = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64)
sp = SlicePaths([path])
geom_list = [sc, sp]
node = SLICENODE_TransformGeometry()
node.resolve = lambda f: {
"Geometry": geom_list,
"Translation": (10.0, 0.0, 0.0),
"Rotation": (0.0, 0.0, 0.0),
"Scale": (1.0, 1.0, 1.0)
}.get(f)
result_list = node.evaluate_output("Geometry")
self.assertIsInstance(result_list, list)
self.assertEqual(len(result_list), 2)
self.assertIsInstance(result_list[0], SlicingCollection)
self.assertIsInstance(result_list[1], SlicePaths)
np.testing.assert_allclose(result_list[0].objects[0].mesh[0, 0], [11.0, 2.0, 3.0])
np.testing.assert_allclose(result_list[1][0][0], [11.0, 2.0, 3.0])
def test_drop_geometry_node(self): def test_drop_geometry_node(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_DropGeometry from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_DropGeometry
@ -364,6 +424,48 @@ class TestTransform(unittest.TestCase):
self.assertEqual(tuple(mock_mod_dict.get("Socket_6")), (0.15, 0.25, 0.35)) self.assertEqual(tuple(mock_mod_dict.get("Socket_6")), (0.15, 0.25, 0.35))
self.assertEqual(tuple(mock_mod_dict.get("Socket_9")), (1.5, 2.0, 2.5)) self.assertEqual(tuple(mock_mod_dict.get("Socket_9")), (1.5, 2.0, 2.5))
def test_gcode_to_mesh_naming_convention(self):
from NodeSlicer.core.nodeslicer.nodes.preview import SLICENODE_GcodeToMesh
node = SLICENODE_GcodeToMesh()
# 1. Single part - no number suffix
node.resolve_multi_input = MagicMock(return_value=["GCODE1"])
node.resolve = MagicMock(return_value="Part")
node._process_gcode = MagicMock()
node.evaluate()
node._process_gcode.assert_called_once_with("GCODE1", "Part", None)
# 2. Three parts - padded to 2 digits (01, 02, 03)
node.resolve_multi_input = MagicMock(return_value=["GC1", "GC2", "GC3"])
node.resolve = MagicMock(return_value="Part")
node._process_gcode = MagicMock()
node.evaluate()
self.assertEqual(node._process_gcode.call_count, 3)
node._process_gcode.assert_any_call("GC1", "Part01", None)
node._process_gcode.assert_any_call("GC2", "Part02", None)
node._process_gcode.assert_any_call("GC3", "Part03", None)
# 3. Fifteen parts - padded to 2 digits (01..15)
node.resolve_multi_input = MagicMock(return_value=[f"GC{i}" for i in range(1, 16)])
node.resolve = MagicMock(return_value="Part")
node._process_gcode = MagicMock()
node.evaluate()
self.assertEqual(node._process_gcode.call_count, 15)
node._process_gcode.assert_any_call("GC1", "Part01", None)
node._process_gcode.assert_any_call("GC15", "Part15", None)
# 4. Default base name when empty name provided
node.resolve_multi_input = MagicMock(return_value=["GC1", "GC2"])
node.resolve = MagicMock(return_value="")
node._process_gcode = MagicMock()
node.evaluate()
node._process_gcode.assert_any_call("GC1", "GcodePreview01", None)
node._process_gcode.assert_any_call("GC2", "GcodePreview02", None)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()