transforms

This commit is contained in:
Nicolas Predella 2026-07-29 16:28:23 +03:00
parent 4fe4f4e75c
commit fda0042ba0
7 changed files with 351 additions and 52 deletions

View file

@ -415,11 +415,13 @@ def _copy_slicing_object(so: SlicingObject) -> SlicingObject:
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", [])]
return new_so
def _copy_slicing_collection(sc: SlicingCollection) -> SlicingCollection:
new_sc = SlicingCollection([], sc.name)
new_sc.objects = [_copy_slicing_object(so) for so in sc.objects]
new_sc.transforms = [dict(t) for t in getattr(sc, "transforms", [])]
return new_sc
def _copy_slicing_group(sg: SlicingGroup) -> SlicingGroup:
@ -428,6 +430,7 @@ def _copy_slicing_group(sg: SlicingGroup) -> SlicingGroup:
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
@ -488,6 +491,16 @@ class SLICENODE_TransformGeometry(SliceNode):
if any(t != 0.0 for t in t_arr):
copied_data.offset(t_arr)
t_rec = {
"type": "transform_geometry",
"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])),
"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
@ -501,8 +514,6 @@ class SLICENODE_DropGeometry(SliceNode):
self.add_output("SLICESOCK_Mesh", "Mesh")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | SlicingGroup | None:
z=self.resolve('Z')
if socket_name != "Mesh":
return None
@ -510,8 +521,31 @@ class SLICENODE_DropGeometry(SliceNode):
if not mesh_data:
return None
assert mesh_data.min_z is not None
min_z = round(mesh_data.min_z , 6)
mesh_data.offset(np.array([0.0, 0.0, z-min_z], dtype=np.float64))
z = self.resolve('Z')
if z is None:
z = 0.0
return mesh_data
assert mesh_data.min_z is not None
min_z = round(mesh_data.min_z, 6)
offset_z = float(z - min_z)
if isinstance(mesh_data, SlicingGroup):
copied_data = _copy_slicing_group(mesh_data)
else:
copied_data = _copy_slicing_collection(mesh_data)
copied_data.offset(np.array([0.0, 0.0, offset_z], dtype=np.float64))
drop_rec = {
"type": "drop_geometry",
"target_z": float(z),
"offset_z": float(offset_z),
"translation": (0.0, 0.0, float(offset_z)),
"rotation": (0.0, 0.0, 0.0),
"scale": (1.0, 1.0, 1.0),
}
if not hasattr(copied_data, "transforms"):
copied_data.transforms = []
copied_data.transforms.append(drop_rec)
return copied_data

View file

@ -29,7 +29,10 @@ class SLICENODE_GetPaths(SliceNode):
from ....infra.blender.blender_mesh_capture import capture_mesh_edges
so, e_rates, f_rates, extra_attrs_data = capture_mesh_edges(obj, e_attr_name, f_attr_name, extra_attrs)
return SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates, extra_attributes=extra_attrs_data)
sp = SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates, extra_attributes=extra_attrs_data)
if hasattr(obj, "transforms"):
sp.transforms = list(getattr(obj, "transforms", []))
return sp
@register_class
class SLICENODE_SliceShells(SliceNode):
@ -104,6 +107,7 @@ class SLICENODE_SliceShells(SliceNode):
slices.layer_height = layer_height
if fl_height is not None:
slices.first_layer_height = fl_height
slices.transforms = list(getattr(coll, "transforms", [])) or list(getattr(merged_object, "transforms", []))
if len(z_values) > 0 and fl_height is not None and layer_height > 0:
mult = fl_height / layer_height

View file

@ -53,6 +53,10 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
def segment_length_3d(p1, p2) -> float:
return ((p2[0]-p1[0])**2 + (p2[1]-p1[1])**2 + (p2[2]-p1[2])**2)**0.5
prep_translation = [0.0, 0.0, 0.0]
prep_rotation = [0.0, 0.0, 0.0]
prep_scale = [1.0, 1.0, 1.0]
for line in gcode.splitlines():
# Parse metadata from comments
if ';' in line:
@ -72,6 +76,21 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
elif comment.startswith("DEFAULT_LAYER_WIDTH="):
try: default_layer_width = float(comment.split('=')[1])
except ValueError: pass
elif comment.startswith("PREPARATION_TOTAL_TRANSLATION="):
try:
vals = [float(v) for v in comment.split('=')[1].split(',')]
if len(vals) >= 3: prep_translation = vals[:3]
except ValueError: pass
elif comment.startswith("PREPARATION_TOTAL_ROTATION="):
try:
vals = [float(v) for v in comment.split('=')[1].split(',')]
if len(vals) >= 3: prep_rotation = vals[:3]
except ValueError: pass
elif comment.startswith("PREPARATION_TOTAL_SCALE="):
try:
vals = [float(v) for v in comment.split('=')[1].split(',')]
if len(vals) >= 3: prep_scale = vals[:3]
except ValueError: pass
line_no_comment = line.split(';')[0].strip()
if not line_no_comment: continue
@ -97,7 +116,7 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
new_e = params.get('E', last_e)
show = False
if self.show_e_0: show = True
if getattr(self, "show_e_0", False): show = True
if cmd == 'G1' and new_e > 0: show = True
if show:
@ -140,16 +159,19 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
if not vertices:
return
import sys
real_bpy = sys.modules.get('bpy', bpy)
# Create or find "Debug" mesh object
if mesh_name in bpy.data.objects:
obj = bpy.data.objects[mesh_name]
if mesh_name in real_bpy.data.objects:
obj = real_bpy.data.objects[mesh_name]
mesh = obj.data
mesh.clear_geometry()
else:
mesh = bpy.data.meshes.new(mesh_name)
obj = bpy.data.objects.new(mesh_name, mesh)
assert bpy.context.collection
bpy.context.collection.objects.link(obj)
mesh = real_bpy.data.meshes.new(mesh_name)
obj = real_bpy.data.objects.new(mesh_name, mesh)
assert real_bpy.context.collection
real_bpy.context.collection.objects.link(obj)
mesh.from_pydata(vertices, edges, [])
@ -164,60 +186,126 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
# Assign Geometry Nodes nodegroup for preview
ng_name = "Nonplanar Slicing Preview"
if ng_name not in bpy.data.node_groups:
if ng_name not in real_bpy.data.node_groups:
# Try to load it from nodes.blend in the addon root
addon_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
blend_path = os.path.join(addon_dir, "nodes.blend")
if os.path.exists(blend_path):
try:
with bpy.data.libraries.load(blend_path) as (data_from, data_to):
with real_bpy.data.libraries.load(blend_path) as (data_from, data_to):
if ng_name in data_from.node_groups:
data_to.node_groups = [ng_name]
except Exception as e:
if operator: operator.report({'WARNING'}, f"Failed to load preview node group: {e}")
if ng_name in bpy.data.node_groups:
ng = bpy.data.node_groups[ng_name]
# Check if already assigned
mod = None
for m in obj.modifiers:
if m.type == 'NODES' and m.node_group == ng: # type: ignore
mod = m
break
if not mod:
mod = obj.modifiers.new(name="Preview", type='NODES') # type: ignore
mod.node_group = ng
# Find or assign Preview modifier
mod = None
ng = real_bpy.data.node_groups.get(ng_name) if hasattr(real_bpy.data, "node_groups") and hasattr(real_bpy.data.node_groups, "get") else None
# Helper to find socket identifiers by name, falling back to specified key
def find_socket_key(node_group, name: str, fallback: str) -> str:
if hasattr(node_group, "interface") and node_group.interface:
if hasattr(obj, "modifiers"):
if "Preview" in obj.modifiers:
mod = obj.modifiers["Preview"]
else:
for m in obj.modifiers:
if getattr(m, "type", "") == 'NODES':
if ng and getattr(m, "node_group", None) == ng:
mod = m
break
elif mod is None:
mod = m
if not mod and ng:
mod = obj.modifiers.new(name="Preview", type='NODES') # type: ignore
mod.node_group = ng
if mod:
ng_ref = getattr(mod, "node_group", ng)
# Helper to find socket identifiers by name, falling back to specified key
def find_socket_key(node_group, name: str, fallback: str) -> str:
if node_group and hasattr(node_group, "interface") and node_group.interface:
try:
items = getattr(node_group.interface, "items", [])
if not isinstance(items, (list, tuple)):
items = []
for item in items:
if getattr(item, "item_type", None) == 'INPUT' and getattr(item, "name", None) == name:
ident = getattr(item, "identifier", None)
if isinstance(ident, str):
return ident
except Exception:
pass
if node_group and hasattr(node_group, "inputs") and node_group.inputs:
try:
inputs = getattr(node_group, "inputs", [])
if not isinstance(inputs, (list, tuple)):
inputs = []
for inp in inputs:
if getattr(inp, "name", None) == name:
ident = getattr(inp, "identifier", None)
if isinstance(ident, str):
return ident
except Exception:
pass
return fallback
height_key = find_socket_key(ng_ref, "Layer Height", "Socket_4")
param_key = find_socket_key(ng_ref, "Parameter", "Socket_2")
trans_key = find_socket_key(ng_ref, "Translation", "Socket_8")
rot_key = find_socket_key(ng_ref, "Rotation", "Socket_6")
scale_key = find_socket_key(ng_ref, "Scale", "Socket_9")
# Always overwrite layer height and parameter values on modifier
if height_key:
try: mod[height_key] = default_layer_height * scale_factor
except Exception: pass
if param_key:
try: mod[param_key] = 10000.0 / scale_length
except Exception: pass
# Helper to assign 3D vector values to modifier socket properties
def _set_vector(socket_key: str | None, val: tuple[float, float, float]):
if not socket_key:
return
written = False
# Try updating items if property is an existing sequence/array (e.g. IDPropertyArray or Vector)
try:
prop = mod.get(socket_key) if hasattr(mod, "get") else None
if prop is not None and hasattr(prop, "__getitem__"):
try:
for item in node_group.interface.items:
if getattr(item, "item_type", None) == 'INPUT' and item.name == name:
return item.identifier
prop[0] = float(val[0])
prop[1] = float(val[1])
prop[2] = float(val[2])
written = True
except Exception:
pass
if hasattr(node_group, "inputs") and node_group.inputs:
except Exception:
pass
# Try direct tuple/sequence assignment
if not written:
try:
mod[socket_key] = val
written = True
except Exception:
try:
for inp in node_group.inputs:
if inp.name == name:
return inp.identifier
mod[socket_key] = [val[0], val[1], val[2]]
written = True
except Exception:
pass
return fallback
height_key = find_socket_key(ng, "Layer Height", "Socket_4")
width_key = find_socket_key(ng, "Layer Width", "Socket_3")
param_key = find_socket_key(ng, "Parameter", "Socket_2")
# Always overwrite Translation (Socket_8), Rotation (Socket_6), Scale (Socket_9)
translation_val = (float(prep_translation[0]) * 0.001, float(prep_translation[1]) * 0.001, float(prep_translation[2]) * 0.001)
rotation_val = (float(prep_rotation[0]), float(prep_rotation[1]), float(prep_rotation[2]))
scale_val = (float(prep_scale[0]), float(prep_scale[1]), float(prep_scale[2]))
# Set the values on the modifier
if height_key:
mod[height_key] = default_layer_height * scale_factor
if width_key:
mod[width_key] = default_layer_width * scale_factor
if param_key:
mod[param_key] = 10000.0 / scale_length
_set_vector(trans_key, translation_val)
_set_vector("Socket_8", translation_val)
_set_vector(rot_key, rotation_val)
_set_vector("Socket_6", rotation_val)
_set_vector(scale_key, scale_val)
_set_vector("Socket_9", scale_val)
def evaluate(self, operator=None):
gcodes = self.resolve_multi_input("Gcode")

View file

@ -159,8 +159,9 @@ class SlicePaths(list[np.ndarray]):
extra_attributes: dict[str, list[np.ndarray]]
layer_height: float
first_layer_height: float | None
transforms: list[dict]
def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = [], extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None, layer_height: float = 0.2, first_layer_height: float | None = None):
def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = [], extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None, layer_height: float = 0.2, first_layer_height: float | None = None, transforms: list[dict] | None = None):
super().__init__(paths)
self.groups = []
@ -182,6 +183,7 @@ class SlicePaths(list[np.ndarray]):
self.extra_attributes = extra_attributes or {}
self.layer_height = layer_height
self.first_layer_height = first_layer_height
self.transforms = list(transforms) if transforms is not None else []
def copy_with(self, paths: list[np.ndarray], groups: list[int] | None = None, extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None) -> SlicePaths:
"""Returns a new SlicePaths object with new paths/groups, preserving all metadata and instance attributes."""
@ -306,8 +308,13 @@ class SlicePaths(list[np.ndarray]):
base = primary_source if primary_source is not None else paths_list[0]
merged = base.copy_with([])
merged_transforms = []
for sp in paths_list:
merged.extend_with_groups(sp)
for t in getattr(sp, "transforms", []):
if t not in merged_transforms:
merged_transforms.append(t)
merged.transforms = merged_transforms
return merged
@ -337,6 +344,13 @@ class SlicePaths(list[np.ndarray]):
for path, ext, feed, extra in items:
merged.append_with_group(path, group=g, extrusion_rate=ext, feedrate=feed, extra_attributes=extra)
merged_transforms = []
for sp in paths_list:
for t in getattr(sp, "transforms", []):
if t not in merged_transforms:
merged_transforms.append(t)
merged.transforms = merged_transforms
return merged
def get_infill_mask(self) -> SlicePaths:
@ -1562,6 +1576,7 @@ class Pather(object):
self.retraction: float = 0.0
self.retraction_feedrate: float = 0.0
self.lift: float = 0.0
self.transforms: list[dict] = list(getattr(paths, "transforms", []))
@property
def layer_width(self) -> float:
@ -1630,6 +1645,47 @@ class Pather(object):
f";DEFAULT_LAYER_WIDTH={self.layer_width:.6f}",
f";OFFSET_X={offset_x:.6f}",
f";OFFSET_Y={offset_y:.6f}",
])
if hasattr(self, "transforms") and self.transforms:
import json
header.append(f";PREPARATION_TRANSFORMS={json.dumps(self.transforms)}")
total_tx, total_ty, total_tz = 0.0, 0.0, 0.0
total_rx, total_ry, total_rz = 0.0, 0.0, 0.0
total_sx, total_sy, total_sz = 1.0, 1.0, 1.0
for i, t in enumerate(self.transforms):
t_type = t.get("type", "transform")
tr = t.get("translation", (0.0, 0.0, 0.0))
rot = t.get("rotation", (0.0, 0.0, 0.0))
sc = t.get("scale", (1.0, 1.0, 1.0))
total_tx += tr[0]
total_ty += tr[1]
total_tz += tr[2]
total_rx += rot[0]
total_ry += rot[1]
total_rz += rot[2]
total_sx *= sc[0]
total_sy *= sc[1]
total_sz *= sc[2]
if t_type == "transform_geometry":
header.append(f";TRANSFORM_GEOMETRY_{i}_TRANSLATION={tr[0]:.6f},{tr[1]:.6f},{tr[2]:.6f}")
header.append(f";TRANSFORM_GEOMETRY_{i}_ROTATION={rot[0]:.6f},{rot[1]:.6f},{rot[2]:.6f}")
header.append(f";TRANSFORM_GEOMETRY_{i}_SCALE={sc[0]:.6f},{sc[1]:.6f},{sc[2]:.6f}")
elif t_type == "drop_geometry":
target_z = t.get("target_z", 0.0)
off_z = t.get("offset_z", 0.0)
header.append(f";DROP_GEOMETRY_{i}_TARGET_Z={target_z:.6f}")
header.append(f";DROP_GEOMETRY_{i}_OFFSET_Z={off_z:.6f}")
header.append(f";PREPARATION_TOTAL_TRANSLATION={total_tx:.6f},{total_ty:.6f},{total_tz:.6f}")
header.append(f";PREPARATION_TOTAL_ROTATION={total_rx:.6f},{total_ry:.6f},{total_rz:.6f}")
header.append(f";PREPARATION_TOTAL_SCALE={total_sx:.6f},{total_sy:.6f},{total_sz:.6f}")
header.extend([
"G21 ; set units to mm",
"G90 ; absolute XYZ positioning",
"M83 ; relative extrusion mode",

View file

@ -102,6 +102,7 @@ class SlicingObject():
self.parent = parent
self.attributes = {}
self.modifiers = []
self.transforms = []
assert bpy.context.scene.unit_settings
scene_scale: float = bpy.context.scene.unit_settings.scale_length
@ -127,6 +128,11 @@ class SlicingObject():
merged.parent = objects[0].parent
merged.object_type = objects[0].object_type
merged.extruder = objects[0].extruder
merged.transforms = []
for o in objects:
for t in getattr(o, "transforms", []):
if t not in merged.transforms:
merged.transforms.append(dict(t))
valid_meshes = [o.mesh for o in objects if o.mesh.size]
merged.mesh = np.concatenate(valid_meshes, axis=0) if valid_meshes else np.empty((0, 4, 3))
@ -257,11 +263,13 @@ class SlicingObject():
class SlicingCollection():
name: str
objects: list[SlicingObject]
transforms: list[dict]
def __init__(self, objs: list[Object], parent: str):
self.objects = [SlicingObject(obj, parent) for obj in objs]
self.objects = [o for o in self.objects if o.mesh.size]
self.name = parent
self.transforms = []
def _clear_cache(self):
for prop in ["all_verts", "unique_verts"]:
@ -386,9 +394,11 @@ class SlicingGroup():
wipe_tower_xy: NDArray = np.array([0, 0])
wipe_tower_rotation_deg: float = 0
object_offset: NDArray = np.array([0.0, 0.0, 0.0])
transforms: list[dict]
def __init__(self, selected_objs: list[Object]) -> None:
self.object_offset = np.array([0.0, 0.0, 0.0])
self.transforms = []
family = []
for obj in selected_objs:

Binary file not shown.

View file

@ -34,12 +34,16 @@ mock_types.Menu = DummyMenu
mock_types.Panel = DummyPanel
mock_types.PropertyGroup = DummyPropertyGroup
mock_bpy = MagicMock()
if 'bpy' in sys.modules and isinstance(sys.modules['bpy'], MagicMock):
mock_bpy = sys.modules['bpy']
else:
mock_bpy = MagicMock()
sys.modules['bpy'] = mock_bpy
mock_bpy.context.scene = MagicMock()
mock_bpy.context.scene.unit_settings.scale_length = 0.001
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()
@ -258,6 +262,109 @@ class TestTransform(unittest.TestCase):
self.assertEqual(result_sc_2.min_z, 10.0)
np.testing.assert_allclose(result_sc_2.objects[0].mesh[0, 0], [1.0, 2.0, 10.0])
def test_gcode_transform_metadata(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_TransformGeometry, SLICENODE_DropGeometry
from NodeSlicer.core.slice import SlicePaths, Pather
so = SlicingObject()
so.mesh = np.array([[[0.0, 0.0, 5.0],
[10.0, 0.0, 5.0],
[5.0, 10.0, 10.0],
[0.0, 0.0, 1.0]]], dtype=np.float64)
sc = SlicingCollection([], "coll")
sc.objects = [so]
# 1. Transform Geometry
t_node = SLICENODE_TransformGeometry()
t_node.resolve = lambda f: {
"Mesh": 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")
self.assertIsNotNone(sc_transformed)
self.assertEqual(len(sc_transformed.transforms), 1)
self.assertEqual(sc_transformed.transforms[0]["type"], "transform_geometry")
# 2. Drop Geometry
d_node = SLICENODE_DropGeometry()
d_node.resolve = lambda f: {
"Mesh": sc_transformed,
"Z": 0.0
}.get(f)
sc_dropped = d_node.evaluate_output("Mesh")
self.assertIsNotNone(sc_dropped)
self.assertEqual(len(sc_dropped.transforms), 2)
self.assertEqual(sc_dropped.transforms[1]["type"], "drop_geometry")
# 3. Create SlicePaths from geometry
path = np.array([[10.0, 20.0, 0.0], [20.0, 20.0, 0.0], [15.0, 30.0, 0.0]])
sp = SlicePaths([path], transforms=sc_dropped.transforms)
# 4. Generate G-code with Pather
pather = Pather(sp)
gcode = pather.to_gcode(include_header=True)
self.assertIn(";PREPARATION_TRANSFORMS=", gcode)
self.assertIn(";TRANSFORM_GEOMETRY_0_TRANSLATION=10.000000,20.000000,0.000000", gcode)
self.assertIn(";DROP_GEOMETRY_1_OFFSET_Z=-5.000000", gcode)
self.assertIn(";PREPARATION_TOTAL_TRANSLATION=10.000000,20.000000,-5.000000", gcode)
# 5. Process G-code with GcodeToMesh preview node
from NodeSlicer.core.nodeslicer.nodes.preview import SLICENODE_GcodeToMesh
preview_node = SLICENODE_GcodeToMesh()
preview_node.show_e_0 = True
mock_mod_dict = {}
class MockModifier:
type = 'NODES'
node_group = MagicMock()
def __getitem__(self, k): return mock_mod_dict.get(k)
def __setitem__(self, k, v): mock_mod_dict[k] = v
def get(self, k, default=None): return mock_mod_dict.get(k, default)
mock_obj = MagicMock()
mock_obj.type = 'MESH'
mock_obj.modifiers = [MockModifier()]
import sys
sys.modules['bpy'].data.objects = {"PreviewObj": mock_obj}
sys.modules['bpy'].data.node_groups = {"Nonplanar Slicing Preview": mock_obj.modifiers[0].node_group}
preview_node._process_gcode(gcode, "PreviewObj")
# Verify modifier sockets Socket_8, Socket_6, Socket_9
self.assertEqual(mock_mod_dict.get("Socket_8"), (0.01, 0.02, -0.005))
self.assertEqual(mock_mod_dict.get("Socket_6"), (0.0, 0.0, 0.0))
# Test overwriting on an existing mesh object with pre-existing modifier values
mock_mod_dict["Socket_8"] = [0.0, 0.0, 0.0]
mock_mod_dict["Socket_6"] = [1.0, 1.0, 1.0]
mock_mod_dict["Socket_9"] = [2.0, 2.0, 2.0]
preview_node._process_gcode(gcode, "PreviewObj")
self.assertEqual(tuple(mock_mod_dict.get("Socket_8")), (0.01, 0.02, -0.005))
self.assertEqual(tuple(mock_mod_dict.get("Socket_6")), (0.0, 0.0, 0.0))
self.assertEqual(tuple(mock_mod_dict.get("Socket_9")), (1.0, 1.0, 1.0))
# Test G-code with non-zero 3D rotation and non-uniform scale
gcode_rot = (
";PREPARATION_TOTAL_TRANSLATION=10.000000,20.000000,30.000000\n"
";PREPARATION_TOTAL_ROTATION=0.150000,0.250000,0.350000\n"
";PREPARATION_TOTAL_SCALE=1.500000,2.000000,2.500000\n"
"G1 X10 Y20 Z30 E1\n"
)
preview_node._process_gcode(gcode_rot, "PreviewObj")
self.assertEqual(tuple(mock_mod_dict.get("Socket_8")), (0.01, 0.02, 0.03))
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))
if __name__ == '__main__':
unittest.main()