variable extrusion

This commit is contained in:
Nicolas Predella 2026-05-22 19:24:24 +03:00
parent d448fa20cc
commit 3080f65695
5 changed files with 117 additions and 52 deletions

View file

@ -1,5 +1,5 @@
import bpy 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 from . import tree
@ -65,4 +65,5 @@ NfIntProperty = NfProperty(IntProperty)
NfFloatProperty = NfProperty(FloatProperty) NfFloatProperty = NfProperty(FloatProperty)
NfEnumProperty = NfProperty(EnumProperty) NfEnumProperty = NfProperty(EnumProperty)
NfBoolProperty = NfProperty(BoolProperty) NfBoolProperty = NfProperty(BoolProperty)
NfPointerProperty = NfProperty(PointerProperty) NfPointerProperty = NfProperty(PointerProperty)
NfFloatVectorProperty = NfProperty(FloatVectorProperty)

View file

@ -345,17 +345,24 @@ class SLICENODE_GetMesh(SliceNode):
class SLICENODE_GetPaths(SliceNode): class SLICENODE_GetPaths(SliceNode):
bl_label = "Get Paths" bl_label = "Get Paths"
attr_name: NfStringProperty(name="Extrusion Multiplier", default="e_mult") # pyright: ignore[reportInvalidTypeForm]
def init(self, context): def init(self, context):
self.add_input("SLICESOCK_Object") self.add_input("SLICESOCK_Object")
self.add_output("SLICESOCK_Paths") 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: def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
obj: Any = self.resolve("Object") obj: Any = self.resolve("Object")
attr_name: str = self.attr_name
from ...infra.blender.blender_mesh_capture import capture_mesh_edges 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 @register_class
class SLICENODE_SliceShells(SliceNode): class SLICENODE_SliceShells(SliceNode):
@ -1569,11 +1576,21 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
obj.data.clear_geometry() obj.data.clear_geometry()
return 'empty' return 'empty'
segments = [] vertices = []
edges = []
e_rates = []
last_x, last_y, last_z = 0.0, 0.0, 0.0 last_x, last_y, last_z = 0.0, 0.0, 0.0
last_e = 0.0 last_e = 0.0
offset_x, offset_y = 0.0, 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(): for line in gcode.splitlines():
# Parse metadata from comments # Parse metadata from comments
@ -1585,6 +1602,9 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
elif comment.startswith("OFFSET_Y="): elif comment.startswith("OFFSET_Y="):
try: offset_y = float(comment.split('=')[1]) try: offset_y = float(comment.split('=')[1])
except ValueError: pass 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() line_no_comment = line.split(';')[0].strip()
if not line_no_comment: continue if not line_no_comment: continue
@ -1615,15 +1635,41 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
if show: if show:
# Subtract offsets (scaled to Blender units) to return to original space # Subtract offsets (scaled to Blender units) to return to original space
ox, oy = offset_x * 0.001, offset_y * 0.001 ox, oy = offset_x * 0.001, offset_y * 0.001
segments.append(( p1 = (last_x - ox, last_y - oy, last_z)
(last_x - ox, last_y - oy, last_z), p2 = (new_x - ox, new_y - oy, new_z)
(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_x, last_y, last_z = new_x, new_y, new_z
last_e = new_e last_e = new_e
if not segments: if not vertices:
return 'no segments' return 'no segments'
# Create or find "Debug" mesh object # Create or find "Debug" mesh object
@ -1637,13 +1683,15 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
assert bpy.context.collection assert bpy.context.collection
bpy.context.collection.objects.link(obj) 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, []) 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() mesh.update()
# Assign Geometry Nodes nodegroup for preview # Assign Geometry Nodes nodegroup for preview

View file

@ -3,7 +3,7 @@ from typing import Any
import bpy import bpy
from bpy.props import CollectionProperty from bpy.props import CollectionProperty
from bpy.types import Property, PropertyGroup, bpy_struct 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 ...registry import register_class
from ...infra.blender.blender_slice_nodes_generic import SliceNodeSocket from ...infra.blender.blender_slice_nodes_generic import SliceNodeSocket
@ -99,7 +99,7 @@ class SLICESOCK_DirPath(InputNodeSocket):
class SLICESOCK_VectorRotation_Common(InputNodeSocket): class SLICESOCK_VectorRotation_Common(InputNodeSocket):
bl_label = "VectorRotation" 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) bl_color = (166/255, 99/255, 199/255, 1.0)
@register_class @register_class
@ -111,7 +111,7 @@ class SLICESOCK_VectorRotationRo(ReadonlySocket, SLICESOCK_VectorRotation_Common
class SLICESOCK_Vector_Common(InputNodeSocket): class SLICESOCK_Vector_Common(InputNodeSocket):
bl_label = "Vector" 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) bl_color = (0.39, 0.39, 0.78, 1.0)
@register_class @register_class

View file

@ -10,20 +10,32 @@ SCALE = 1_000_000
class SlicePaths(list[np.ndarray]): class SlicePaths(list[np.ndarray]):
groups: list[int] 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) super().__init__(paths)
self.groups = [] self.groups = []
if groups: self.groups = list(groups) if groups: self.groups = list(groups)
else: self.groups = list(range(0,len(paths))) 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.""" """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 # Copy any other attributes that might have been added
for k, v in self.__dict__.items(): for k, v in self.__dict__.items():
if k != "groups": if k not in ["groups", "extrusion_rate"]:
setattr(new_obj, k, v) setattr(new_obj, k, v)
return new_obj return new_obj
@ -44,12 +56,7 @@ class SlicePaths(list[np.ndarray]):
p = self.transform(np.array(mat)) p = self.transform(np.array(mat))
res_paths, res_groups = buffer_slice(p, distance, miter=miter) res_paths, res_groups = buffer_slice(p, distance, miter=miter)
new_paths = SlicePaths(res_paths, res_groups) new_paths = self.copy_with(res_paths, res_groups)
# Copy other metadata from self
for k, v in self.__dict__.items():
if k != "groups":
setattr(new_paths, k, v)
if rotation and any(rotation): if rotation and any(rotation):
from mathutils import Euler from mathutils import Euler
@ -67,12 +74,7 @@ class SlicePaths(list[np.ndarray]):
p = self.transform(np.array(mat)) p = self.transform(np.array(mat))
res_paths, res_groups = infill_slice(p, density, rotation_deg=rotation_deg, infill_type=infill_type) res_paths, res_groups = infill_slice(p, density, rotation_deg=rotation_deg, infill_type=infill_type)
new_paths = SlicePaths(res_paths, res_groups) new_paths = self.copy_with(res_paths, res_groups)
# Copy other metadata from self
for k, v in self.__dict__.items():
if k != "groups":
setattr(new_paths, k, v)
if layer_rotation and any(layer_rotation): if layer_rotation and any(layer_rotation):
from mathutils import Euler from mathutils import Euler
@ -519,6 +521,7 @@ class Pather(object):
def __init__(self, paths: list[np.ndarray], **kwargs) -> None: def __init__(self, paths: list[np.ndarray], **kwargs) -> None:
self.paths = paths 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_x: float = 250.
self.bed_center_y: float = 250. self.bed_center_y: float = 250.
self.feedrate: float = 1200. self.feedrate: float = 1200.
@ -533,10 +536,10 @@ class Pather(object):
min_xy = all_pts[:, :2].min(axis=0) min_xy = all_pts[:, :2].min(axis=0)
max_xy = all_pts[:, :2].max(axis=0) max_xy = all_pts[:, :2].max(axis=0)
center_xy = (min_xy + max_xy) / 2.0 center_xy = (min_xy + max_xy) / 2.0
offset_x = self.bed_center_x - center_xy[0] offset_x = self.bed_center_x - center_xy[0]
offset_y = self.bed_center_y - center_xy[1] offset_y = self.bed_center_y - center_xy[1]
# Apply offset to all paths # Apply offset to all paths
centered_paths = [] centered_paths = []
for path in self.paths: for path in self.paths:
@ -557,8 +560,8 @@ class Pather(object):
header: list[str] = [ header: list[str] = [
"; Generated from NodeSlicer", "; Generated from NodeSlicer",
f";TYPE:Custom", f";TYPE:Custom",
f";FEEDRATE={self.feedrate}", f";DEFAULT_FEEDRATE={self.feedrate}",
f";EXTRUSION_RATE={self.extrusion_rate}", f";DEFAULT_EXTRUSION_RATE={self.extrusion_rate}",
f";OFFSET_X={offset_x:.6f}", f";OFFSET_X={offset_x:.6f}",
f";OFFSET_Y={offset_y:.6f}", f";OFFSET_Y={offset_y:.6f}",
"G21 ; set units to mm", "G21 ; set units to mm",
@ -571,11 +574,13 @@ class Pather(object):
last_z = -1.0 last_z = -1.0
max_z = 0 max_z = 0
for line in centered_paths: for i, line in enumerate(centered_paths):
coords = line.tolist() coords = line.tolist()
if len(coords) < 2: if len(coords) < 2:
continue continue
e_rates = self.extrusion_rates[i]
x0, y0, z0 = coords[0] x0, y0, z0 = coords[0]
if abs(z0 - last_z) > 0.001: if abs(z0 - last_z) > 0.001:
@ -589,9 +594,9 @@ class Pather(object):
fr = self.feedrate fr = self.feedrate
old_fr = 0 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) 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 old_x, old_y, old_z = p1
x, y, z = p2 x, y, z = p2
@ -608,5 +613,5 @@ class Pather(object):
max_z = max(max_z, z) max_z = max(max_z, z)
old_fr = fr old_fr = fr
return "\n".join(gcode) return "\n".join(gcode)

View file

@ -409,11 +409,12 @@ def _cached_capture_mesh_edges(
obj_name: str, obj_name: str,
mesh_name: str, mesh_name: str,
matrix_tuple: tuple[float, ...], matrix_tuple: tuple[float, ...],
scene_scale: float scene_scale: float,
) -> list[NDArray[float64]]: 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 # This helper expects that the object and mesh exist and are accessible via bpy.data
obj = bpy.data.objects.get(obj_name) 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, # 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. # 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) obj_eval = obj.evaluated_get(depsgraph)
mesh = obj_eval.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph) mesh = obj_eval.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
except Exception: except Exception:
return [] return [], []
if not mesh or not mesh.vertices: if not mesh or not mesh.vertices:
return [] return [], []
try: try:
world_mat = np.array(matrix_tuple).reshape(4, 4) 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) verts = np.empty((num_verts, 3), dtype=np.float64)
mesh.vertices.foreach_get("co", verts.ravel()) 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 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} 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 split_at = np.where(~connected)[0] + 1
raw_paths = np.split(tx_verts, split_at) 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) indices_split = np.split(np.arange(num_verts), split_at)
final_paths = [] final_paths = []
final_e_mults = []
for i, path_coords in enumerate(raw_paths): for i, path_coords in enumerate(raw_paths):
if path_coords.shape[0] == 0: if path_coords.shape[0] == 0:
continue continue
idx = indices_split[i] idx = indices_split[i]
e_vals = raw_e_mults[i]
if len(idx) > 2 and tuple(sorted((idx[0], idx[-1]))) in edges_set: if len(idx) > 2 and tuple(sorted((idx[0], idx[-1]))) in edges_set:
path_coords = np.vstack((path_coords, path_coords[0])) path_coords = np.vstack((path_coords, path_coords[0]))
e_vals = np.concatenate((e_vals, [e_vals[0]]))
final_paths.append(path_coords) final_paths.append(path_coords)
final_e_mults.append(e_vals)
return final_paths return final_paths, final_e_mults
finally: finally:
if hasattr(obj_eval, "to_mesh_clear"): if hasattr(obj_eval, "to_mesh_clear"):
obj_eval.to_mesh_clear() obj_eval.to_mesh_clear()
def capture_mesh_edges(obj: Object) -> list[NDArray[float64]]: 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 not obj or not obj.data: return [], []
if obj.mode == 'EDIT': if obj.mode == 'EDIT':
obj.update_from_editmode() obj.update_from_editmode()
@ -474,5 +484,6 @@ def capture_mesh_edges(obj: Object) -> list[NDArray[float64]]:
obj.name, obj.name,
obj.data.name, obj.data.name,
matrix_tuple, matrix_tuple,
scene_scale scene_scale,
attr_name
) )