From ae911884ccbde8984ab13e5097a12efdedb566aa Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Mon, 20 Jul 2026 16:53:02 +0300 Subject: [PATCH] up --- core/nodeslicer/nodes/gcode.py | 5 + core/nodeslicer/nodes/path.py | 36 ++++++-- core/nodeslicer/nodes/preview.py | 48 +++++++++- core/slice.py | 31 ++++++- doc/NODES.md | 2 +- infra/blender/blender_slice_nodes_generic.py | 5 +- tests/test_slice.py | 97 ++++++++++++++++++++ 7 files changed, 210 insertions(+), 14 deletions(-) diff --git a/core/nodeslicer/nodes/gcode.py b/core/nodeslicer/nodes/gcode.py index 016f926..78e507c 100644 --- a/core/nodeslicer/nodes/gcode.py +++ b/core/nodeslicer/nodes/gcode.py @@ -28,6 +28,11 @@ class SLICENODE_PathsToGcode(SliceNode): intrinsics: SliceIntrinsics | None = self.resolve("Intrinsics") pather: Pather = Pather(paths) + if hasattr(paths, "layer_height"): + pather.layer_height = getattr(paths, "layer_height") + if hasattr(paths, "first_layer_height"): + pather.first_layer_height = getattr(paths, "first_layer_height") + if intrinsics: # Note: Pather currently uses bed_center_x/y as its internal attributes. # We map the bed size center to these attributes if provided. diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index f0886c7..3f3e3de 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -38,6 +38,7 @@ class SLICENODE_SliceShells(SliceNode): def init(self, context): self.add_input("SLICESOCK_Mesh") self.add_input("SLICESOCK_Float", "Layer Height").value = 0.2 + self.add_input("SLICESOCK_Float", "First Layer Height").value = 0.2 self.add_input("SLICESOCK_VectorRotation", "Rotation") self.add_output("SLICESOCK_Paths") @@ -45,7 +46,21 @@ class SLICENODE_SliceShells(SliceNode): if socket_name != "Paths": return SlicePaths() coll: SlicingCollection | SlicingGroup | None = self.resolve("Mesh") - layer_height: float = self.resolve("Layer Height") + layer_height: float = self.resolve("Layer Height") or 0.2 + fl_height: float | None = None + if hasattr(self, "inputs") and "First Layer Height" in self.inputs: + try: + fl_height = self.resolve("First Layer Height") + except Exception: + fl_height = None + else: + try: + fl_height = self.resolve("First Layer Height") + except Exception: + fl_height = None + if fl_height is not None and fl_height <= 0: + fl_height = None + rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0) if not coll: return SlicePaths() @@ -71,15 +86,24 @@ class SLICENODE_SliceShells(SliceNode): min_z = floor(min_z / layer_height) * layer_height max_z = merged_object.mesh[:,0:3,2:3].max() - z_values = np.arange( - min_z + layer_height/2, - max_z, - layer_height - ) + h_first = fl_height if fl_height is not None else layer_height + z_first = min_z + h_first + z_rest_start = min_z + h_first + layer_height + + if z_rest_start < max_z: + z_rest = np.arange(z_rest_start, max_z, layer_height) + z_values = np.concatenate(([z_first], z_rest)) + elif z_first < max_z: + z_values = np.array([z_first]) + else: + z_values = np.array([]) slices_group: list[list[np.ndarray]] | None = slice_at_range([merged_object.mesh], z_values) assert slices_group slices = SlicePaths(slices_group[0]) + slices.layer_height = layer_height + if fl_height is not None: + slices.first_layer_height = fl_height if rotation and any(rotation): from mathutils import Euler diff --git a/core/nodeslicer/nodes/preview.py b/core/nodeslicer/nodes/preview.py index a82330f..158c58d 100644 --- a/core/nodeslicer/nodes/preview.py +++ b/core/nodeslicer/nodes/preview.py @@ -44,6 +44,8 @@ class SLICENODE_GcodeToMesh(ExecSliceNode): offset_x, offset_y = 0.0, 0.0 custom_default_extrusion_rate = 0.0 + default_layer_height = 0.2 + default_layer_width = 0.4 last_v_idx = -1 last_p2 = None @@ -64,6 +66,12 @@ class SLICENODE_GcodeToMesh(ExecSliceNode): elif comment.startswith("DEFAULT_EXTRUSION_RATE="): try: custom_default_extrusion_rate = float(comment.split('=')[1]) except ValueError: pass + elif comment.startswith("DEFAULT_LAYER_HEIGHT="): + try: default_layer_height = float(comment.split('=')[1]) + except ValueError: pass + elif comment.startswith("DEFAULT_LAYER_WIDTH="): + try: default_layer_width = float(comment.split('=')[1]) + except ValueError: pass line_no_comment = line.split(';')[0].strip() if not line_no_comment: continue @@ -171,16 +179,46 @@ class SLICENODE_GcodeToMesh(ExecSliceNode): if ng_name in bpy.data.node_groups: ng = bpy.data.node_groups[ng_name] # Check if already assigned - has_preview = False - for mod in obj.modifiers: - if mod.type == 'NODES' and mod.node_group == ng: # type: ignore - has_preview = True + mod = None + for m in obj.modifiers: + if m.type == 'NODES' and m.node_group == ng: # type: ignore + mod = m break - if not has_preview: + if not mod: mod = obj.modifiers.new(name="Preview", type='NODES') # type: ignore 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 hasattr(node_group, "interface") and node_group.interface: + try: + for item in node_group.interface.items: + if getattr(item, "item_type", None) == 'INPUT' and item.name == name: + return item.identifier + except Exception: + pass + if hasattr(node_group, "inputs") and node_group.inputs: + try: + for inp in node_group.inputs: + if inp.name == name: + return inp.identifier + 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") + + # 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 + def evaluate(self, operator=None): gcodes = self.resolve_multi_input("Gcode") base_mesh_name: str = self.resolve("Name") diff --git a/core/slice.py b/core/slice.py index ba2e5ec..572a50f 100644 --- a/core/slice.py +++ b/core/slice.py @@ -664,6 +664,12 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI results.groups.extend([group] * len(layer_results)) group += 1 + for attr in ["layer_height", "layer_width"]: + if hasattr(subject, attr): + setattr(results, attr, getattr(subject, attr)) + elif hasattr(object, attr): + setattr(results, attr, getattr(object, attr)) + return results def geometry_to_paths(geom: BaseGeometry, z: float) -> list[np.ndarray]: @@ -1215,9 +1221,26 @@ class Pather(object): self.feedrate: float = 1200. self.travel_feedrate: float = 2400. self.extrusion_rate: float = 0.017 + self.layer_height: float = 0.2 + self.first_layer_height: float | None = None + self._layer_width: float | None = None self.retraction: float = 0.0 self.lift: float = 0.0 + @property + def layer_width(self) -> float: + if self._layer_width is not None: + return self._layer_width + if self.layer_height > 0: + import math + filament_area = math.pi * (1.75 / 2.0) ** 2 + return (self.extrusion_rate * filament_area) / self.layer_height + return 0.4 + + @layer_width.setter + def layer_width(self, val: float | None): + self._layer_width = val + def to_gcode(self, include_header: bool = True) -> str: if not self.paths: return "" @@ -1254,13 +1277,19 @@ class Pather(object): f";DEFAULT_FEEDRATE={self.feedrate}", f";DEFAULT_TRAVEL_FEEDRATE={self.travel_feedrate}", f";DEFAULT_EXTRUSION_RATE={self.extrusion_rate}", + f";DEFAULT_LAYER_HEIGHT={self.layer_height:.6f}", + ] + if self.first_layer_height is not None: + header.append(f";DEFAULT_FIRST_LAYER_HEIGHT={self.first_layer_height:.6f}") + header.extend([ + f";DEFAULT_LAYER_WIDTH={self.layer_width:.6f}", f";OFFSET_X={offset_x:.6f}", f";OFFSET_Y={offset_y:.6f}", "G21 ; set units to mm", "G90 ; absolute XYZ positioning", "M83 ; relative extrusion mode", "G92 E0", - ] + ]) gcode += header # Safely parse retraction and lift fields. diff --git a/doc/NODES.md b/doc/NODES.md index ddad46d..effec97 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -48,7 +48,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Integer**: Single integer input. - **String**: Single string/path input. - **Rotation**: Input for Euler rotation vectors. -- **Printer Intrinsics**: Configures bed size, extrusion rates, retraction, and Z-lift. +- **Printer Intrinsics**: Configures bed size, extrusion/travel rates, retraction, and Z-lift. - **Gcode Block**: Manual text area for entering raw G-code. ### Geometry Processing diff --git a/infra/blender/blender_slice_nodes_generic.py b/infra/blender/blender_slice_nodes_generic.py index 2799a15..d0e448f 100644 --- a/infra/blender/blender_slice_nodes_generic.py +++ b/infra/blender/blender_slice_nodes_generic.py @@ -34,7 +34,10 @@ class SliceNode(Node, AutoBlIdName): def resolve(self, fieldname) -> Any: cache = getattr(self, "_current_cache", None) visited = getattr(self, "_current_visited", None) - return resolve_input(self.inputs.get(fieldname), cache=cache, visited=visited) + inputs = getattr(self, "inputs", None) + if inputs is None: + return None + return resolve_input(inputs.get(fieldname), cache=cache, visited=visited) def add_output(self, bl_idname: str, name: str = "", identifier: str = "", hide: bool = False, display_shape: str = ""): eval_name = name or get_label_by_name(bl_idname) diff --git a/tests/test_slice.py b/tests/test_slice.py index 4a03558..0459cce 100644 --- a/tests/test_slice.py +++ b/tests/test_slice.py @@ -435,5 +435,102 @@ class TestSlice(unittest.TestCase): self.assertNotIn("E-", gcode) self.assertNotIn("Z0.700", gcode) + def test_pather_travel_feedrate(self): + from NodeSlicer.core.slice import Pather + + path1 = np.array([ + [10.0, 10.0, 0.2], + [20.0, 10.0, 0.2] + ]) + path2 = np.array([ + [30.0, 30.0, 0.2], + [40.0, 30.0, 0.2] + ]) + + pather = Pather([path1, path2]) + pather.feedrate = 1200.0 + pather.travel_feedrate = 3000.0 + pather.bed_center_x = 25.0 + pather.bed_center_y = 20.0 + + # Verify travel feedrate is in the header comments + gcode_with_header = pather.to_gcode(include_header=True) + self.assertIn(";DEFAULT_TRAVEL_FEEDRATE=3000.0", gcode_with_header) + self.assertIn(";DEFAULT_FEEDRATE=1200.0", gcode_with_header) + + # Verify G0 travel moves use the travel_feedrate + gcode = pather.to_gcode(include_header=False) + + # The first move should travel to the start of path1 at travel_feedrate + self.assertIn("G0 X10.000 Y10.000 Z0.200 F3000.0", gcode) + + # The transition travel move to the start of path2 should also use travel_feedrate + self.assertIn("G0 X30.000 Y30.000 F3000.0", gcode) + + def test_pather_layer_metadata(self): + import math + from NodeSlicer.core.slice import Pather, SlicePaths + + path1 = np.array([ + [10.0, 10.0, 0.2], + [20.0, 10.0, 0.2] + ]) + + slices = SlicePaths([path1]) + slices.layer_height = 0.25 + + pather = Pather(slices) + pather.layer_height = getattr(slices, "layer_height") + pather.extrusion_rate = 0.035 + + expected_width = (0.035 * math.pi * (1.75 / 2.0) ** 2) / 0.25 + + gcode = pather.to_gcode(include_header=True) + self.assertIn(";DEFAULT_LAYER_HEIGHT=0.250000", gcode) + self.assertIn(f";DEFAULT_LAYER_WIDTH={expected_width:.6f}", gcode) + + def test_first_layer_height(self): + from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_SliceShells + from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection + + # Create a simple box mesh [8, 4, 3] from Z=0 to Z=1 + vertices = np.array([ + [0.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + [10.0, 10.0, 0.0], + [0.0, 10.0, 0.0], + [0.0, 0.0, 1.0], + [10.0, 0.0, 1.0], + [10.0, 10.0, 1.0], + [0.0, 10.0, 1.0], + ]) + faces = [ + [0, 1, 2], [0, 2, 3], + [4, 6, 5], [4, 7, 6], + [0, 4, 5], [0, 5, 1], + [1, 5, 6], [1, 6, 2], + [2, 6, 7], [2, 7, 3], + [3, 7, 4], [3, 4, 0] + ] + triangles = np.zeros((len(faces), 4, 3)) + for i, face in enumerate(faces): + triangles[i, 0] = vertices[face[0]] + triangles[i, 1] = vertices[face[1]] + triangles[i, 2] = vertices[face[2]] + + sobj = SlicingObject() + sobj.mesh = triangles + scoll = SlicingCollection([], "test") + scoll.objects = [sobj] + + node = SLICENODE_SliceShells() + # Test without inputs (simulating missing socket resolution) + node.resolve = lambda name: scoll if name == "Mesh" else (0.2 if name == "Layer Height" else (0.3 if name == "First Layer Height" else None)) + slices = node.evaluate_output("Paths") + self.assertTrue(len(slices) > 0) + # First slice Z should be 0.3 / 2 = 0.15 + self.assertAlmostEqual(slices[0][0, 2], 0.15) + self.assertEqual(getattr(slices, "first_layer_height", None), 0.3) + if __name__ == '__main__': unittest.main()