diff --git a/core/nodeslicer/bpy_ns_types.py b/core/nodeslicer/bpy_ns_types.py index 2d71c9e..50fc7d2 100644 --- a/core/nodeslicer/bpy_ns_types.py +++ b/core/nodeslicer/bpy_ns_types.py @@ -1,5 +1,5 @@ import bpy -from bpy.props import PointerProperty, StringProperty, FloatProperty, IntProperty, EnumProperty, BoolProperty, CollectionProperty +from bpy.props import PointerProperty, StringProperty, FloatProperty, IntProperty, EnumProperty, BoolProperty, FloatVectorProperty, CollectionProperty from . import tree @@ -65,4 +65,5 @@ NfIntProperty = NfProperty(IntProperty) NfFloatProperty = NfProperty(FloatProperty) NfEnumProperty = NfProperty(EnumProperty) NfBoolProperty = NfProperty(BoolProperty) -NfPointerProperty = NfProperty(PointerProperty) \ No newline at end of file +NfPointerProperty = NfProperty(PointerProperty) +NfFloatVectorProperty = NfProperty(FloatVectorProperty) \ No newline at end of file diff --git a/core/nodeslicer/nodes.py b/core/nodeslicer/nodes.py index 3740ae7..0f1685f 100644 --- a/core/nodeslicer/nodes.py +++ b/core/nodeslicer/nodes.py @@ -345,17 +345,24 @@ class SLICENODE_GetMesh(SliceNode): class SLICENODE_GetPaths(SliceNode): bl_label = "Get Paths" + attr_name: NfStringProperty(name="Extrusion Multiplier", default="e_mult") # pyright: ignore[reportInvalidTypeForm] + def init(self, context): self.add_input("SLICESOCK_Object") self.add_output("SLICESOCK_Paths") + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "attr_name") + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: obj: Any = self.resolve("Object") + attr_name: str = self.attr_name from ...infra.blender.blender_mesh_capture import capture_mesh_edges - so: list[np.ndarray] = capture_mesh_edges(obj) + so, e_rates = capture_mesh_edges(obj, attr_name=attr_name) - return SlicePaths(so) + return SlicePaths(so, extrusion_rate=e_rates) @register_class class SLICENODE_SliceShells(SliceNode): @@ -1569,11 +1576,21 @@ class SLICENODE_GcodeToMesh(ExecSliceNode): obj.data.clear_geometry() return 'empty' - segments = [] + vertices = [] + edges = [] + e_rates = [] + last_x, last_y, last_z = 0.0, 0.0, 0.0 last_e = 0.0 offset_x, offset_y = 0.0, 0.0 + default_extrusion_rate = 0.017 # Default fallback + + last_v_idx = -1 + last_p2 = None + + 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 for line in gcode.splitlines(): # Parse metadata from comments @@ -1585,6 +1602,9 @@ class SLICENODE_GcodeToMesh(ExecSliceNode): elif comment.startswith("OFFSET_Y="): try: offset_y = float(comment.split('=')[1]) except ValueError: pass + elif comment.startswith("DEFAULT_EXTRUSION_RATE="): + try: default_extrusion_rate = float(comment.split('=')[1]) + except ValueError: pass line_no_comment = line.split(';')[0].strip() if not line_no_comment: continue @@ -1615,15 +1635,41 @@ class SLICENODE_GcodeToMesh(ExecSliceNode): if show: # Subtract offsets (scaled to Blender units) to return to original space ox, oy = offset_x * 0.001, offset_y * 0.001 - segments.append(( - (last_x - ox, last_y - oy, last_z), - (new_x - ox, new_y - oy, new_z) - )) + p1 = (last_x - ox, last_y - oy, last_z) + p2 = (new_x - ox, new_y - oy, new_z) + + # Calculate e_rate (proportion between E and default_extrusion_rate) + seg_len_mm = segment_length_3d(p1, p2) * 1000.0 + if seg_len_mm > 0 and default_extrusion_rate > 0: + e_rate = (new_e / seg_len_mm) / default_extrusion_rate + else: + e_rate = 1.0 + + if last_v_idx != -1 and p1 == last_p2: + # Connect to previous segment + v2_idx = len(vertices) + vertices.append(p2) + edges.append((last_v_idx, v2_idx)) + last_v_idx = v2_idx + else: + # Start new path + v1_idx = len(vertices) + v2_idx = v1_idx + 1 + vertices.extend([p1, p2]) + edges.append((v1_idx, v2_idx)) + last_v_idx = v2_idx + + e_rates.append(e_rate) + last_p2 = p2 + else: + # Break path connection on travel or non-shown move + last_v_idx = -1 + last_p2 = None last_x, last_y, last_z = new_x, new_y, new_z last_e = new_e - if not segments: + if not vertices: return 'no segments' # Create or find "Debug" mesh object @@ -1637,13 +1683,15 @@ class SLICENODE_GcodeToMesh(ExecSliceNode): assert bpy.context.collection bpy.context.collection.objects.link(obj) - vertices = [] - edges = [] - for i, (p1, p2) in enumerate(segments): - vertices.extend([p1, p2]) - edges.append((2*i, 2*i + 1)) - mesh.from_pydata(vertices, edges, []) + + # Create e_mult attribute on edges + if "e_mult" not in mesh.attributes: + mesh.attributes.new(name="e_mult", type='FLOAT', domain='EDGE') + + e_mult_attr = mesh.attributes["e_mult"] + e_mult_attr.data.foreach_set("value", e_rates) + mesh.update() # Assign Geometry Nodes nodegroup for preview diff --git a/core/nodeslicer/sockets.py b/core/nodeslicer/sockets.py index c45fc26..40b8b3e 100644 --- a/core/nodeslicer/sockets.py +++ b/core/nodeslicer/sockets.py @@ -3,7 +3,7 @@ from typing import Any import bpy from bpy.props import CollectionProperty from bpy.types import Property, PropertyGroup, bpy_struct -from .bpy_ns_types import NfStringProperty, NfFloatProperty, NfIntProperty, NfEnumProperty, NfPointerProperty +from .bpy_ns_types import NfStringProperty, NfFloatProperty, NfIntProperty, NfEnumProperty, NfPointerProperty, NfFloatVectorProperty from ...registry import register_class from ...infra.blender.blender_slice_nodes_generic import SliceNodeSocket @@ -99,7 +99,7 @@ class SLICESOCK_DirPath(InputNodeSocket): class SLICESOCK_VectorRotation_Common(InputNodeSocket): bl_label = "VectorRotation" - value: bpy.props.FloatVectorProperty(name="Vector", size=3, default=(0.0, 0.0, 0.0), subtype='EULER') # pyright: ignore[reportInvalidTypeForm] + value: NfFloatVectorProperty(name="Vector", size=3, default=(0.0, 0.0, 0.0), subtype='EULER') # pyright: ignore[reportInvalidTypeForm] bl_color = (166/255, 99/255, 199/255, 1.0) @register_class @@ -111,7 +111,7 @@ class SLICESOCK_VectorRotationRo(ReadonlySocket, SLICESOCK_VectorRotation_Common class SLICESOCK_Vector_Common(InputNodeSocket): bl_label = "Vector" - value: bpy.props.FloatVectorProperty(name="Vector", size=3, default=(0.0, 0.0, 0.0)) # pyright: ignore[reportInvalidTypeForm] + value: NfFloatVectorProperty(name="Vector", size=3, default=(0.0, 0.0, 0.0)) # pyright: ignore[reportInvalidTypeForm] bl_color = (0.39, 0.39, 0.78, 1.0) @register_class diff --git a/core/slice.py b/core/slice.py index d1eac4b..1389f98 100644 --- a/core/slice.py +++ b/core/slice.py @@ -10,20 +10,32 @@ SCALE = 1_000_000 class SlicePaths(list[np.ndarray]): groups: list[int] + extrusion_rate: list[np.ndarray] - def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = []): + def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = [], extrusion_rate: list[np.ndarray] | None = None): super().__init__(paths) self.groups = [] if groups: self.groups = list(groups) else: self.groups = list(range(0,len(paths))) + + self.extrusion_rate = [] + if extrusion_rate: + self.extrusion_rate = list(extrusion_rate) + else: + self.extrusion_rate = [np.ones(len(p)) for p in paths] - def copy_with(self, paths: list[np.ndarray], groups: list[int] | None = None) -> SlicePaths: + def copy_with(self, paths: list[np.ndarray], groups: list[int] | None = None, extrusion_rate: list[np.ndarray] | None = None) -> SlicePaths: """Returns a new SlicePaths object with new paths/groups but preserving all other metadata.""" - new_obj = SlicePaths(paths, groups if groups is not None else self.groups) + if extrusion_rate is None: + # Only reuse extrusion_rate if path lengths match exactly + if len(paths) == len(self) and all(len(p) == len(op) for p, op in zip(paths, self)): + extrusion_rate = self.extrusion_rate + + new_obj = SlicePaths(paths, groups if groups is not None else self.groups, extrusion_rate) # Copy any other attributes that might have been added for k, v in self.__dict__.items(): - if k != "groups": + if k not in ["groups", "extrusion_rate"]: setattr(new_obj, k, v) return new_obj @@ -44,12 +56,7 @@ class SlicePaths(list[np.ndarray]): p = self.transform(np.array(mat)) res_paths, res_groups = buffer_slice(p, distance, miter=miter) - new_paths = SlicePaths(res_paths, res_groups) - - # Copy other metadata from self - for k, v in self.__dict__.items(): - if k != "groups": - setattr(new_paths, k, v) + new_paths = self.copy_with(res_paths, res_groups) if rotation and any(rotation): from mathutils import Euler @@ -67,12 +74,7 @@ class SlicePaths(list[np.ndarray]): p = self.transform(np.array(mat)) res_paths, res_groups = infill_slice(p, density, rotation_deg=rotation_deg, infill_type=infill_type) - new_paths = SlicePaths(res_paths, res_groups) - - # Copy other metadata from self - for k, v in self.__dict__.items(): - if k != "groups": - setattr(new_paths, k, v) + new_paths = self.copy_with(res_paths, res_groups) if layer_rotation and any(layer_rotation): from mathutils import Euler @@ -519,6 +521,7 @@ class Pather(object): def __init__(self, paths: list[np.ndarray], **kwargs) -> None: self.paths = paths + self.extrusion_rates = getattr(paths, "extrusion_rate", [np.ones(len(p)) for p in paths]) self.bed_center_x: float = 250. self.bed_center_y: float = 250. self.feedrate: float = 1200. @@ -533,10 +536,10 @@ class Pather(object): min_xy = all_pts[:, :2].min(axis=0) max_xy = all_pts[:, :2].max(axis=0) center_xy = (min_xy + max_xy) / 2.0 - + offset_x = self.bed_center_x - center_xy[0] offset_y = self.bed_center_y - center_xy[1] - + # Apply offset to all paths centered_paths = [] for path in self.paths: @@ -557,8 +560,8 @@ class Pather(object): header: list[str] = [ "; Generated from NodeSlicer", f";TYPE:Custom", - f";FEEDRATE={self.feedrate}", - f";EXTRUSION_RATE={self.extrusion_rate}", + f";DEFAULT_FEEDRATE={self.feedrate}", + f";DEFAULT_EXTRUSION_RATE={self.extrusion_rate}", f";OFFSET_X={offset_x:.6f}", f";OFFSET_Y={offset_y:.6f}", "G21 ; set units to mm", @@ -571,11 +574,13 @@ class Pather(object): last_z = -1.0 max_z = 0 - for line in centered_paths: + for i, line in enumerate(centered_paths): coords = line.tolist() if len(coords) < 2: continue + e_rates = self.extrusion_rates[i] + x0, y0, z0 = coords[0] if abs(z0 - last_z) > 0.001: @@ -589,9 +594,9 @@ class Pather(object): fr = self.feedrate old_fr = 0 - for p1, p2 in zip(coords, coords[1:]): + for j, (p1, p2) in enumerate(zip(coords, coords[1:])): seg_len: float = segment_length_3d(p1, p2) - e_segment = seg_len * self.extrusion_rate + e_segment = seg_len * self.extrusion_rate * e_rates[j] old_x, old_y, old_z = p1 x, y, z = p2 @@ -608,5 +613,5 @@ class Pather(object): max_z = max(max_z, z) old_fr = fr - + return "\n".join(gcode) \ No newline at end of file diff --git a/infra/blender/blender_mesh_capture.py b/infra/blender/blender_mesh_capture.py index 28d8437..edab464 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -409,11 +409,12 @@ def _cached_capture_mesh_edges( obj_name: str, mesh_name: str, matrix_tuple: tuple[float, ...], - scene_scale: float -) -> list[NDArray[float64]]: + scene_scale: float, + attr_name: str = "e_mult" +) -> tuple[list[NDArray[float64]], list[NDArray[float64]]]: # This helper expects that the object and mesh exist and are accessible via bpy.data obj = bpy.data.objects.get(obj_name) - if not obj: return [] + if not obj: return [], [] # We don't use obj_eval here directly as it's not hashable, # but we assume this is called when we know we need to re-capture. @@ -423,10 +424,10 @@ def _cached_capture_mesh_edges( obj_eval = obj.evaluated_get(depsgraph) mesh = obj_eval.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph) except Exception: - return [] + return [], [] if not mesh or not mesh.vertices: - return [] + return [], [] try: world_mat = np.array(matrix_tuple).reshape(4, 4) @@ -434,6 +435,10 @@ def _cached_capture_mesh_edges( verts = np.empty((num_verts, 3), dtype=np.float64) mesh.vertices.foreach_get("co", verts.ravel()) + e_mults = np.ones(num_verts, dtype=np.float64) + if attr_name in mesh.attributes: + mesh.attributes[attr_name].data.foreach_get("value", e_mults) + tx_verts = (verts @ world_mat[:3, :3].T + world_mat[:3, 3]) * scene_scale edges_set = {tuple(sorted(e.vertices)) for e in mesh.edges} @@ -441,25 +446,30 @@ def _cached_capture_mesh_edges( split_at = np.where(~connected)[0] + 1 raw_paths = np.split(tx_verts, split_at) + raw_e_mults = np.split(e_mults, split_at) indices_split = np.split(np.arange(num_verts), split_at) final_paths = [] + final_e_mults = [] for i, path_coords in enumerate(raw_paths): if path_coords.shape[0] == 0: continue idx = indices_split[i] + e_vals = raw_e_mults[i] if len(idx) > 2 and tuple(sorted((idx[0], idx[-1]))) in edges_set: path_coords = np.vstack((path_coords, path_coords[0])) + e_vals = np.concatenate((e_vals, [e_vals[0]])) final_paths.append(path_coords) + final_e_mults.append(e_vals) - return final_paths + return final_paths, final_e_mults finally: if hasattr(obj_eval, "to_mesh_clear"): obj_eval.to_mesh_clear() -def capture_mesh_edges(obj: Object) -> list[NDArray[float64]]: - if not obj or not obj.data: return [] +def capture_mesh_edges(obj: Object, attr_name: str = "e_mult") -> tuple[list[NDArray[float64]], list[NDArray[float64]]]: + if not obj or not obj.data: return [], [] if obj.mode == 'EDIT': obj.update_from_editmode() @@ -474,5 +484,6 @@ def capture_mesh_edges(obj: Object) -> list[NDArray[float64]]: obj.name, obj.data.name, matrix_tuple, - scene_scale + scene_scale, + attr_name ) \ No newline at end of file