diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index 97e1155..2937b13 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -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 diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 1c044d5..421dd6b 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -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 diff --git a/core/nodeslicer/nodes/preview.py b/core/nodeslicer/nodes/preview.py index 158c58d..ef2296e 100644 --- a/core/nodeslicer/nodes/preview.py +++ b/core/nodeslicer/nodes/preview.py @@ -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") diff --git a/core/slice.py b/core/slice.py index d4551be..fe6d6aa 100644 --- a/core/slice.py +++ b/core/slice.py @@ -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", diff --git a/infra/blender/blender_mesh_capture.py b/infra/blender/blender_mesh_capture.py index 9d42b19..538b387 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -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: diff --git a/nodes.blend b/nodes.blend index a653e70..273a199 100644 Binary files a/nodes.blend and b/nodes.blend differ diff --git a/tests/test_transform.py b/tests/test_transform.py index 185a620..bcb1526 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -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()