diff --git a/core/nodeslicer/nodes.py b/core/nodeslicer/nodes.py index 3edad46..f8c5faa 100644 --- a/core/nodeslicer/nodes.py +++ b/core/nodeslicer/nodes.py @@ -74,8 +74,8 @@ class SLICENODE_InputObject(InputNode): bl_icon = "OBJECT_DATA" @register_class -class SLICENODE_InputOverrides(InputNode): - input_type = "Overrides" +class SLICENODE_InputSettings(InputNode): + input_type = "Settings" bl_icon = "OBJECT_DATA" @register_class @@ -345,27 +345,26 @@ class SLICENODE_GetMesh(SliceNode): class SLICENODE_GetPaths(SliceNode): bl_label = "Get Paths" - attr_name: NfStringProperty(name="Extrusion Attr", default="e_mult") # pyright: ignore[reportInvalidTypeForm] - f_attr_name: NfStringProperty(name="Feedrate Attr", default="f_rate") # pyright: ignore[reportInvalidTypeForm] - def init(self, context): self.add_input("SLICESOCK_Object") + self.add_input("SLICESOCK_String", "Extrusion Multiplier Attribute").value = 'e_mult' + self.add_input("SLICESOCK_String", "Feedrate Attribute").value = 'f_rate' + self.add_input("SLICESOCK_Settings", "Extra Attributes") self.add_output("SLICESOCK_Paths") def draw_buttons(self, context, layout): - assert layout - layout.prop(self, "attr_name") - layout.prop(self, "f_attr_name") + pass def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: obj: Any = self.resolve("Object") - attr_name: str = self.attr_name - f_attr_name: str = self.f_attr_name + attr_name: str = self.resolve("Extrusion Multiplier Attribute") + f_attr_name: str = self.resolve("Feedrate Attribute") + extra_attrs: dict[str, str] = self.resolve("Extra Attributes") from ...infra.blender.blender_mesh_capture import capture_mesh_edges - so, e_rates, f_rates = capture_mesh_edges(obj, attr_name=attr_name, f_attr_name=f_attr_name) + so, e_rates, f_rates, extra_attrs_data = capture_mesh_edges(obj, attr_name=attr_name, f_attr_name=f_attr_name, extra_attrs=extra_attrs) - return SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates) + return SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates, extra_attributes=extra_attrs_data) @register_class class SLICENODE_SliceShells(SliceNode): @@ -643,6 +642,15 @@ class SLICENODE_GcodeRegexReplace(SliceNode): return gcode try: + # Convert $1, $2, ${name} etc to Python backreferences \g<1>, \g<2>, \g + def subst(match): + if match.group(1): return '$' + elif match.group(2): return '\\g<' + match.group(2) + '>' + elif match.group(3): return '\\g<' + match.group(3) + '>' + return match.group(0) + + replace = re.sub(r'(\$\$)|(?:\$\{([^}]+)\})|(?:\$([a-zA-Z0-9_]+))', subst, replace) + return re.sub(find, replace, gcode) except Exception as e: print(f"Regex error in node {self.name}: {e}") @@ -731,10 +739,10 @@ class SLICENODE_JoinGcode(SliceNodeJoiner): @register_class -class SLICENODE_JoinOverrides(SliceNodeJoiner): - bl_label = "Join Overrides" - socket_type = "SLICESOCK_Overrides" - output_socket_type = "SLICESOCK_OverridesRo" +class SLICENODE_JoinSettings(SliceNodeJoiner): + bl_label = "Join Settings" + socket_type = "SLICESOCK_Settings" + output_socket_type = "SLICESOCK_SettingsRo" def evaluate_output(self, socket_name: str, **_kwargs) -> dict[str, str]: merged: dict[str, str] = {} @@ -1341,7 +1349,7 @@ class SLICENODE_PrusaslicerSlice(SliceNode): def init(self, context): self.add_input("SLICESOCK_Mesh", "Mesh") self.add_input("SLICESOCK_PrusaslicerConf", "Config") - self.add_input("SLICESOCK_Overrides", "Overrides") + self.add_input("SLICESOCK_Settings", "Settings") self.add_output("SLICESOCK_GcodeRo") def draw_buttons(self, context, layout): @@ -1359,7 +1367,7 @@ class SLICENODE_PrusaslicerSlice(SliceNode): mesh_data: SlicingCollection | SlicingGroup = self.resolve("Mesh") conf_data = self.resolve("Config") - overrides = self.resolve("Overrides") + overrides = self.resolve("Settings") if not mesh_data: if operator: operator.report({'ERROR'}, "No Mesh data connected") diff --git a/core/nodeslicer/sockets.py b/core/nodeslicer/sockets.py index 40b8b3e..3dc531d 100644 --- a/core/nodeslicer/sockets.py +++ b/core/nodeslicer/sockets.py @@ -153,10 +153,8 @@ class SLICESOCK_PrusaslicerConf(LabelNodeSocket): default = None bl_color = (0.95, 0., 0., 1.0) # #F26C21 - - @register_class -class OverrideItem(bpy.types.PropertyGroup): +class SettingItem(bpy.types.PropertyGroup): field: NfStringProperty(name="Field", default="") # pyright: ignore[reportInvalidTypeForm] value_str: NfStringProperty(name="Value", default="") # pyright: ignore[reportInvalidTypeForm] @@ -170,10 +168,10 @@ class OverrideItem(bpy.types.PropertyGroup): row.prop(self, "value_str", text="") from ...infra.blender.mixins import CollectionMixin -class SLICESOCK_Overrides_Common(CollectionMixin, InputNodeSocket): - bl_label = "Overrides" +class SLICESOCK_Settings_Common(CollectionMixin, InputNodeSocket): + bl_label = "Settings" - items: CollectionProperty(type=OverrideItem) # pyright: ignore[reportInvalidTypeForm] + items: CollectionProperty(type=SettingItem) # pyright: ignore[reportInvalidTypeForm] bl_color = (1.0, 1.0, 0.0, 1.0) # Yellow @@ -186,9 +184,9 @@ class SLICESOCK_Overrides_Common(CollectionMixin, InputNodeSocket): return overrides @register_class -class SLICESOCK_Overrides(SLICESOCK_Overrides_Common): ... +class SLICESOCK_Settings(SLICESOCK_Settings_Common): ... @register_class -class SLICESOCK_OverridesRo(ReadonlySocket, SLICESOCK_Overrides_Common): ... +class SLICESOCK_SettingsRo(ReadonlySocket, SLICESOCK_Settings_Common): ... @register_class class SLICESOCK_ExecutionLink(LabelNodeSocket): diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 808ae3a..399fd2f 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -160,7 +160,7 @@ class SLICENODE_MT_DataInput(Menu): layout.operator("node.add_node", text="Vector").type = "SLICENODE_InputVector" layout.separator() layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject" - layout.operator("node.add_node", text="Overrides").type = "SLICENODE_InputOverrides" + layout.operator("node.add_node", text="Settings").type = "SLICENODE_InputSettings" layout.operator("node.add_node", text="PrusaSlicer Config").type = "SLICENODE_PrusaslicerConf" layout.separator() layout.operator("node.add_node", text="Printer").type = "SLICENODE_InputPrinter" @@ -215,7 +215,7 @@ class SLICENODE_MT_Joiners(Menu): layout.operator("node.add_node", text="Join Mesh Groups").type = "SLICENODE_JoinMeshGroups" layout.operator("node.add_node", text="Join Paths").type = "SLICENODE_JoinPaths" layout.operator("node.add_node", text="Join Gcode").type = "SLICENODE_JoinGcode" - layout.operator("node.add_node", text="Join Overrides").type = "SLICENODE_JoinOverrides" + layout.operator("node.add_node", text="Join Settings").type = "SLICENODE_JoinSettings" layout.operator("node.add_node", text="Join Execution").type = "SLICENODE_JoinExecution" @register_class diff --git a/core/slice.py b/core/slice.py index cd351ff..f9909ba 100644 --- a/core/slice.py +++ b/core/slice.py @@ -12,8 +12,9 @@ class SlicePaths(list[np.ndarray]): groups: list[int] extrusion_rate: list[np.ndarray] feedrate: list[np.ndarray] + extra_attributes: dict[str, list[np.ndarray]] - def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = [], extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | 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): super().__init__(paths) self.groups = [] @@ -31,8 +32,10 @@ class SlicePaths(list[np.ndarray]): self.feedrate = list(feedrate) else: self.feedrate = [np.zeros(len(p)) for p in paths] + + self.extra_attributes = extra_attributes or {} - 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) -> SlicePaths: + 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 but preserving all other metadata.""" if extrusion_rate is None: # Only reuse extrusion_rate if path lengths match exactly @@ -42,11 +45,15 @@ class SlicePaths(list[np.ndarray]): if feedrate is None: if len(paths) == len(self) and all(len(p) == len(op) for p, op in zip(paths, self)): feedrate = self.feedrate + + if extra_attributes is None: + if len(paths) == len(self) and all(len(p) == len(op) for p, op in zip(paths, self)): + extra_attributes = self.extra_attributes - new_obj = SlicePaths(paths, groups if groups is not None else self.groups, extrusion_rate, feedrate) + new_obj = SlicePaths(paths, groups if groups is not None else self.groups, extrusion_rate, feedrate, extra_attributes) # Copy any other attributes that might have been added for k, v in self.__dict__.items(): - if k not in ["groups", "extrusion_rate", "feedrate"]: + if k not in ["groups", "extrusion_rate", "feedrate", "extra_attributes"]: setattr(new_obj, k, v) return new_obj @@ -534,6 +541,7 @@ class Pather(object): self.paths = paths self.extrusion_rates = getattr(paths, "extrusion_rate", [np.ones(len(p)) for p in paths]) self.feedrates = getattr(paths, "feedrate", [np.zeros(len(p)) for p in paths]) + self.extra_attributes = getattr(paths, "extra_attributes", {}) self.bed_center_x: float = 250. self.bed_center_y: float = 250. self.feedrate: float = 1200. @@ -593,6 +601,7 @@ class Pather(object): e_rates = self.extrusion_rates[i] f_rates = self.feedrates[i] + extras = {prefix: vals[i] for prefix, vals in self.extra_attributes.items()} x0, y0, z0 = coords[0] @@ -606,6 +615,7 @@ class Pather(object): old_x, old_y, old_z = x0, y0, z0 old_fr = 0 + old_extras = {prefix: None for prefix in extras} 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_rates[j] @@ -624,6 +634,12 @@ class Pather(object): if e_segment: cur_gcode += f" E{e_segment:.5f}" if not old_fr == fr: cur_gcode += f" F{fr:.3f}" + for prefix, evals in extras.items(): + val = evals[j] + if not old_extras[prefix] == val: + cur_gcode += f" {prefix}{val:.3f}" + old_extras[prefix] = val + gcode += [cur_gcode] max_z = max(max_z, z) diff --git a/doc/MODULES.md b/doc/MODULES.md index e329b22..7857e4f 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -28,7 +28,7 @@ This file provides a mapping of modules to their primary responsibilities and ke - `slice.py`: - `slice_at_range()`: Top-level slicing function that iterates through Z-heights. - `slice_at_z()`: Vectorized plane-mesh intersection using NumPy. - - `Pather`: Class that converts toolpaths into G-code strings. + - `Pather`: Class that converts toolpaths into G-code strings. Supports per-segment extrusion rates, feedrates, and custom extra attributes. - `segments_to_slice()`: Utility to convert raw intersection segments into closed loops using Pyclipper. - `link_segments()`: Logic for joining loose segments into continuous paths. - `buffer_slice()`: Offset utility using PyclipperOffset. Supports rotated offsetting. @@ -42,7 +42,7 @@ This file provides a mapping of modules to their primary responsibilities and ke - `blender_mesh_capture.py`: - `SlicingObject`: Represents a single mesh as a NumPy triangle array. - - `capture_mesh_edges()`: Extracts edges from a Blender mesh for path-based workflows. + - `capture_mesh_edges()`: Extracts edges from a Blender mesh for path-based workflows. Supports capturing extra per-vertex attributes. - `blender_slice_nodes_generic.py`: - Base classes for NodeSlicer nodes (`SliceNode`) and sockets (`SliceNodeSocket`). diff --git a/doc/NODES.md b/doc/NODES.md index fb82fc5..40acade 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -12,8 +12,8 @@ | `SLICESOCK_Mesh` | `SlicingObject` | NumPy NDArray (Triangles) | Internal mesh data (Transformed/Scaled) | | `SLICESOCK_Paths` | `SlicePaths` (list) | List of paths (N, 3) + groups | Generic toolpaths or slice boundaries | | `SLICESOCK_Gcode` | `str` | Multi-line string | Generated G-code text | -| `SLICESOCK_Overrides` | `dict[str, str]` | Python dictionary | Mapping of configuration fields to values | -| `SLICESOCK_OverridesRo` | `dict[str, str]` | Python dictionary | Read-only merged configuration overrides | +| `SLICESOCK_Settings` | `dict[str, str]` | Python dictionary | Mapping of configuration fields to values | +| `SLICESOCK_SettingsRo` | `dict[str, str]` | Python dictionary | Read-only merged configuration overrides | ## Detailed Data Types @@ -28,6 +28,7 @@ The `SlicingObject` class wraps a high-performance NumPy representation of a Ble A custom list subclass `SlicePaths(list[np.ndarray])` containing toolpaths. - **Content**: Each `np.ndarray` has shape `(N, 3)`. - **Groups**: Maintains a `.groups` list of integers mapping each path to a layer or logical group. +- **Extra Attributes**: Maintains an `.extra_attributes` dictionary mapping G-code prefixes (e.g., `S`, `M`) to lists of NumPy arrays, enabling per-segment custom parameters. - **Z-Awareness**: Coordinates are stored as 3D `(X, Y, Z)` to maintain the correct layer height. - **Topology**: Generated and cleaned using `pyclipper`. @@ -53,6 +54,9 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ ### Geometry Processing - **Get Mesh**: Converts a Blender Object into internal `SlicingObject` format. - **Get Paths**: Extracts existing edges from a mesh as paths. + - **Extrusion Multiplier Attribute**: Mesh attribute to use for per-segment extrusion rates. + - **Feedrate Attribute**: Mesh attribute to use for per-segment feedrates. + - **Extra Attributes**: Dictionary mapping mesh attribute names to custom G-code prefixes (e.g., `opening_size` -> `S`). - **Slice Shells**: Slices a `SlicingObject` into 2D `SlicePaths` at a given layer height. - **Buffer Shells**: Offsets/buffers 2D `SlicePaths`. Supports rotated offsetting and multiple miter types. - **Infill**: Generates a cross-hatch grid or parallel lines clipped by boundary paths. @@ -66,7 +70,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Join Mesh**: Merges multiple `SlicingObject` instances into one. - **Join Paths**: Combines multiple `SlicePaths`. Supports Sequential or Interleaved modes. - **Join Gcode**: Concatenates multiple G-code strings. -- **Join Overrides**: Sequentially merges multiple override dictionaries. Later inputs overwrite earlier ones for matching keys. +- **Join Settings**: Sequentially merges multiple override dictionaries. Later inputs overwrite earlier ones for matching keys. - **Integer Math**: Simple integer operations (Add, Multiply, etc.). ### Action & I/O diff --git a/infra/blender/blender_mesh_capture.py b/infra/blender/blender_mesh_capture.py index a9750a1..27b4023 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -411,11 +411,12 @@ def _cached_capture_mesh_edges( matrix_tuple: tuple[float, ...], scene_scale: float, attr_name: str = "e_mult", - f_attr_name: str = "f_rate" -) -> tuple[list[NDArray[float64]], list[NDArray[float64]], list[NDArray[float64]]]: + f_attr_name: str = "f_rate", + extra_attrs: dict[str, str] | None = None +) -> tuple[list[NDArray[float64]], list[NDArray[float64]], list[NDArray[float64]], dict[str, 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. @@ -425,10 +426,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) @@ -443,6 +444,14 @@ def _cached_capture_mesh_edges( f_rates = np.zeros(num_verts, dtype=np.float64) if f_attr_name in mesh.attributes: mesh.attributes[f_attr_name].data.foreach_get("value", f_rates) + + captured_extras = {} + if extra_attrs: + for attr_key, prefix in extra_attrs.items(): + vals = np.zeros(num_verts, dtype=np.float64) + if attr_key in mesh.attributes: + mesh.attributes[attr_key].data.foreach_get("value", vals) + captured_extras[prefix] = vals tx_verts = (verts @ world_mat[:3, :3].T + world_mat[:3, 3]) * scene_scale @@ -453,33 +462,42 @@ def _cached_capture_mesh_edges( raw_paths = np.split(tx_verts, split_at) raw_e_mults = np.split(e_mults, split_at) raw_f_rates = np.split(f_rates, split_at) + raw_extras = {prefix: np.split(vals, split_at) for prefix, vals in captured_extras.items()} indices_split = np.split(np.arange(num_verts), split_at) final_paths = [] final_e_mults = [] final_f_rates = [] + final_extras = {prefix: [] for prefix in captured_extras} 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] f_vals = raw_f_rates[i] + p_extras = {prefix: raw_extras[prefix][i] for prefix in captured_extras} + 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]])) f_vals = np.concatenate((f_vals, [f_vals[0]])) + for prefix in captured_extras: + p_extras[prefix] = np.concatenate((p_extras[prefix], [p_extras[prefix][0]])) + final_paths.append(path_coords) final_e_mults.append(e_vals) final_f_rates.append(f_vals) + for prefix in captured_extras: + final_extras[prefix].append(p_extras[prefix]) - return final_paths, final_e_mults, final_f_rates + return final_paths, final_e_mults, final_f_rates, final_extras finally: if hasattr(obj_eval, "to_mesh_clear"): obj_eval.to_mesh_clear() -def capture_mesh_edges(obj: Object, attr_name: str = "e_mult", f_attr_name: str = "f_rate") -> tuple[list[NDArray[float64]], list[NDArray[float64]], list[NDArray[float64]]]: - if not obj or not obj.data: return [], [], [] +def capture_mesh_edges(obj: Object, attr_name: str = "e_mult", f_attr_name: str = "f_rate", extra_attrs: dict[str, str] | None = None) -> tuple[list[NDArray[float64]], list[NDArray[float64]], list[NDArray[float64]], dict[str, list[NDArray[float64]]]]: + if not obj or not obj.data: return [], [], [], {} if obj.mode == 'EDIT': obj.update_from_editmode() @@ -496,5 +514,6 @@ def capture_mesh_edges(obj: Object, attr_name: str = "e_mult", f_attr_name: str matrix_tuple, scene_scale, attr_name, - f_attr_name + f_attr_name, + extra_attrs ) \ No newline at end of file diff --git a/property_groups/bpy_property_groups.py b/property_groups/bpy_property_groups.py index 178816c..f8ecf6c 100644 --- a/property_groups/bpy_property_groups.py +++ b/property_groups/bpy_property_groups.py @@ -1,6 +1,6 @@ import bpy -from ..core.nodeslicer.sockets import OverrideItem +from ..core.nodeslicer.sockets import SettingItem from ..registry import register_class extruder_options: list[tuple[str, str, str]] = [ @@ -29,4 +29,4 @@ class SlicerObjectPropertyGroup(bpy.types.PropertyGroup): object_type: NfEnumProperty(name="Part type", default="ModelPart", items=object_type_options) # pyright: ignore[reportInvalidTypeForm] extruder: NfEnumProperty(name="Extruder", default="0", items=extruder_options) # pyright: ignore[reportInvalidTypeForm] search_term: NfStringProperty(name="Search") # pyright: ignore[reportInvalidTypeForm] - modifiers: bpy.props.CollectionProperty(type=OverrideItem) # pyright: ignore[reportInvalidTypeForm] \ No newline at end of file + modifiers: bpy.props.CollectionProperty(type=SettingItem) # pyright: ignore[reportInvalidTypeForm] \ No newline at end of file