1 # SPDX-License-Identifier: GPL-2.0-or-later
3 import bpy
, math
, bmesh
4 from bpy_extras
import view3d_utils
5 from mathutils
import Vector
, Matrix
8 class OffsetCurve(bpy
.types
.Operator
):
9 bl_idname
= 'curvetools.add_toolpath_offset_curve'
10 bl_description
= bl_label
= 'Offset Curve'
11 bl_options
= {'REGISTER', 'UNDO'}
13 offset
: bpy
.props
.FloatProperty(name
='Offset', description
='Distace between the original and the first trace', unit
='LENGTH', default
=0.1)
14 pitch
: bpy
.props
.FloatProperty(name
='Pitch', description
='Distace between two parallel traces', unit
='LENGTH', default
=0.1)
15 step_angle
: bpy
.props
.FloatProperty(name
='Resolution', description
='Smaller values make curves smoother by adding more vertices', unit
='ROTATION', min=math
.pi
/128, default
=math
.pi
/16)
16 count
: bpy
.props
.IntProperty(name
='Count', description
='Number of parallel traces', min=1, default
=1)
17 round_line_join
: bpy
.props
.BoolProperty(name
='Round Line Join', description
='Insert circle arcs at convex corners', default
=True)
20 def poll(cls
, context
):
21 return bpy
.context
.object != None and bpy
.context
.object.type == 'CURVE'
23 def execute(self
, context
):
24 if bpy
.context
.object.mode
== 'EDIT':
25 splines
= internal
.getSelectedSplines(True, True)
27 splines
= bpy
.context
.object.data
.splines
30 self
.report({'WARNING'}, 'Nothing selected')
33 if bpy
.context
.object.mode
!= 'EDIT':
34 internal
.addObject('CURVE', 'Offset Toolpath')
35 origin
= bpy
.context
.scene
.cursor
.location
37 origin
= Vector((0.0, 0.0, 0.0))
39 for spline
in splines
:
40 spline_points
= spline
.bezier_points
if spline
.type == 'BEZIER' else spline
.points
41 for spline_point
in spline_points
:
42 if spline_point
.co
.z
!= spline_points
[0].co
.z
:
43 self
.report({'WARNING'}, 'Curves must be planar and in XY plane')
45 for index
in range(0, self
.count
):
46 traces
= internal
.offsetPolygonOfSpline(spline
, self
.offset
+self
.pitch
*index
, self
.step_angle
, self
.round_line_join
)
48 internal
.addPolygonSpline(bpy
.context
.object, spline
.use_cyclic_u
, [vertex
-origin
for vertex
in trace
])
51 class SliceMesh(bpy
.types
.Operator
):
52 bl_idname
= 'curvetools.add_toolpath_slice_mesh'
53 bl_description
= bl_label
= 'Slice Mesh'
54 bl_options
= {'REGISTER', 'UNDO'}
56 pitch
: bpy
.props
.FloatProperty(name
='Pitch', unit
='LENGTH', description
='Distance between two slices', default
=0.1)
57 offset
: bpy
.props
.FloatProperty(name
='Offset', unit
='LENGTH', description
='Position of first slice along the axis', default
=0.0)
58 slice_count
: bpy
.props
.IntProperty(name
='Count', description
='Number of slices', min=1, default
=3)
61 def poll(cls
, context
):
62 return bpy
.context
.object != None and bpy
.context
.object.mode
== 'OBJECT'
64 def perform(self
, context
):
65 axis
= Vector((0.0, 0.0, 1.0))
66 for i
in range(0, self
.slice_count
):
67 aux_mesh
= self
.mesh
.copy()
68 cut_geometry
= bmesh
.ops
.bisect_plane(aux_mesh
, geom
=aux_mesh
.edges
[:]+aux_mesh
.faces
[:], dist
=0, plane_co
=axis
*(i
*self
.pitch
+self
.offset
), plane_no
=axis
, clear_outer
=False, clear_inner
=False)['geom_cut']
69 edge_pool
= set([e
for e
in cut_geometry
if isinstance(e
, bmesh
.types
.BMEdge
)])
70 while len(edge_pool
) > 0:
71 current_edge
= edge_pool
.pop()
72 first_vertex
= current_vertex
= current_edge
.verts
[0]
73 vertices
= [current_vertex
.co
]
74 follow_edge_loop
= len(edge_pool
) > 0
75 while follow_edge_loop
:
76 current_vertex
= current_edge
.other_vert(current_vertex
)
77 vertices
.append(current_vertex
.co
)
78 if current_vertex
== first_vertex
:
80 follow_edge_loop
= False
81 for edge
in current_vertex
.link_edges
:
84 edge_pool
.remove(current_edge
)
85 follow_edge_loop
= True
87 current_vertex
= current_edge
.other_vert(current_vertex
)
88 vertices
.append(current_vertex
.co
)
89 internal
.addPolygonSpline(self
.result
, False, vertices
)
92 def invoke(self
, context
, event
):
93 if bpy
.context
.object.type != 'MESH':
94 self
.report({'WARNING'}, 'Active object must be a mesh')
100 self
.input_obj
= bpy
.context
.object
101 depsgraph
= context
.evaluated_depsgraph_get()
102 self
.mesh
= bmesh
.new()
103 self
.mesh
.from_object(self
.input_obj
, depsgraph
, deform
=True, cage
=False, face_normals
=True)
104 self
.mesh
.transform(bpy
.context
.scene
.cursor
.matrix
.inverted()@self.input_obj
.matrix_world
)
105 self
.result
= internal
.addObject('CURVE', 'Slices')
106 self
.result
.matrix_world
= bpy
.context
.scene
.cursor
.matrix
107 self
.perform(context
)
108 context
.window_manager
.modal_handler_add(self
)
109 return {'RUNNING_MODAL'}
111 def modal(self
, context
, event
):
112 if event
.type == 'MOUSEMOVE':
113 mouse
= (event
.mouse_region_x
, event
.mouse_region_y
)
114 input_value
= internal
.nearestPointOfLines(
115 bpy
.context
.scene
.cursor
.location
,
116 bpy
.context
.scene
.cursor
.matrix
.col
[2].xyz
,
117 view3d_utils
.region_2d_to_origin_3d(context
.region
, context
.region_data
, mouse
),
118 view3d_utils
.region_2d_to_vector_3d(context
.region
, context
.region_data
, mouse
)
120 if self
.mode
== 'PITCH':
121 self
.pitch
= input_value
/(self
.slice_count
-1) if self
.slice_count
> 2 else input_value
122 elif self
.mode
== 'OFFSET':
123 self
.offset
= input_value
-self
.pitch
*0.5*((self
.slice_count
-1) if self
.slice_count
> 2 else 1.0)
124 elif event
.type == 'WHEELUPMOUSE':
125 if self
.slice_count
> 2:
126 self
.pitch
*= (self
.slice_count
-1)
127 self
.slice_count
+= 1
128 if self
.slice_count
> 2:
129 self
.pitch
/= (self
.slice_count
-1)
130 elif event
.type == 'WHEELDOWNMOUSE':
131 if self
.slice_count
> 2:
132 self
.pitch
*= (self
.slice_count
-1)
133 if self
.slice_count
> 1:
134 self
.slice_count
-= 1
135 if self
.slice_count
> 2:
136 self
.pitch
/= (self
.slice_count
-1)
137 elif event
.type == 'LEFTMOUSE' and event
.value
== 'RELEASE':
138 if self
.mode
== 'PITCH':
140 return {'RUNNING_MODAL'}
141 elif self
.mode
== 'OFFSET':
144 elif event
.type in {'RIGHTMOUSE', 'ESC'}:
146 bpy
.context
.scene
.collection
.objects
.unlink(self
.result
)
147 bpy
.context
.view_layer
.objects
.active
= self
.input_obj
150 return {'PASS_THROUGH'}
151 self
.result
.data
.splines
.clear()
152 self
.perform(context
)
153 return {'RUNNING_MODAL'}
155 class DogBone(bpy
.types
.Operator
):
156 bl_idname
= 'curvetools.add_toolpath_dogbone'
157 bl_description
= bl_label
= 'Dog Bone'
158 bl_options
= {'REGISTER', 'UNDO'}
160 radius
: bpy
.props
.FloatProperty(name
='Radius', description
='Tool radius to compensate for', unit
='LENGTH', min=0.0, default
=0.1)
163 def poll(cls
, context
):
164 return bpy
.context
.object != None and bpy
.context
.object.type == 'CURVE'
166 def execute(self
, context
):
167 if bpy
.context
.object.mode
== 'EDIT':
168 splines
= internal
.getSelectedSplines(True, False)
170 splines
= bpy
.context
.object.data
.splines
172 if len(splines
) == 0:
173 self
.report({'WARNING'}, 'Nothing selected')
176 if bpy
.context
.object.mode
!= 'EDIT':
177 internal
.addObject('CURVE', 'Dog Bone')
178 origin
= bpy
.context
.scene
.cursor
.location
180 origin
= Vector((0.0, 0.0, 0.0))
182 for spline
in splines
:
183 if spline
.type != 'BEZIER':
185 result
= internal
.dogBone(spline
, self
.radius
)
186 internal
.addBezierSpline(bpy
.context
.object, spline
.use_cyclic_u
, result
) # [vertex-origin for vertex in result])
189 class DiscretizeCurve(bpy
.types
.Operator
):
190 bl_idname
= 'curvetools.add_toolpath_discretize_curve'
191 bl_description
= bl_label
= 'Discretize Curve'
192 bl_options
= {'REGISTER', 'UNDO'}
194 step_angle
: bpy
.props
.FloatProperty(name
='Resolution', description
='Smaller values make curves smoother by adding more vertices', unit
='ROTATION', min=math
.pi
/512, default
=math
.pi
/16)
195 samples
: bpy
.props
.IntProperty(name
='Sample Count', description
='Number of samples to test per curve segment', min=1, default
=128)
198 def poll(cls
, context
):
199 return bpy
.context
.object != None and bpy
.context
.object.type == 'CURVE'
201 def execute(self
, context
):
202 if bpy
.context
.object.mode
== 'EDIT':
203 splines
= internal
.getSelectedSplines(True, False)
205 splines
= bpy
.context
.object.data
.splines
207 if len(splines
) == 0:
208 self
.report({'WARNING'}, 'Nothing selected')
211 if bpy
.context
.object.mode
!= 'EDIT':
212 internal
.addObject('CURVE', 'Discretized Curve')
213 origin
= bpy
.context
.scene
.cursor
.location
215 origin
= Vector((0.0, 0.0, 0.0))
217 for spline
in splines
:
218 if spline
.type != 'BEZIER':
220 result
= internal
.discretizeCurve(spline
, self
.step_angle
, self
.samples
)
221 internal
.addPolygonSpline(bpy
.context
.object, spline
.use_cyclic_u
, [vertex
-origin
for vertex
in result
])
224 class Truncate(bpy
.types
.Operator
):
225 bl_idname
= 'curvetools.add_toolpath_truncate'
226 bl_description
= bl_label
= 'Truncate'
227 bl_options
= {'REGISTER', 'UNDO'}
229 min_dist
: bpy
.props
.FloatProperty(name
='Min Distance', unit
='LENGTH', description
='Remove vertices which are too close together', min=0.0, default
=0.001)
230 z_hop
: bpy
.props
.BoolProperty(name
='Z Hop', description
='Add movements to the ceiling at trace ends', default
=True)
233 def poll(cls
, context
):
234 return bpy
.context
.object != None and bpy
.context
.object.mode
== 'OBJECT'
236 def execute(self
, context
):
237 if bpy
.context
.object.type != 'EMPTY' or bpy
.context
.object.empty_display_type
!= 'CUBE':
238 self
.report({'WARNING'}, 'Active object must be an empty of display type cube')
240 selection
= bpy
.context
.selected_objects
[:]
241 workspace
= bpy
.context
.object
242 aabb
= internal
.AABB(center
=Vector((0.0, 0.0, 0.0)), dimensions
=Vector((1.0, 1.0, 1.0))*workspace
.empty_display_size
)
243 toolpath
= internal
.addObject('CURVE', 'Truncated Toolpath')
244 for curve
in selection
:
245 if curve
.type == 'CURVE':
246 transform
= workspace
.matrix_world
.inverted()@curve.matrix_world
247 inverse_transform
= Matrix
.Translation(-toolpath
.location
)@workspace.matrix_world
249 for spline
in curve
.data
.splines
:
250 if spline
.type == 'POLY':
251 curve_traces
+= internal
.truncateToFitBox(transform
, spline
, aabb
)
252 for trace
in curve_traces
:
255 if (trace
[0][i
-1]-trace
[0][i
]).length
< self
.min_dist
:
260 begin
= Vector(trace
[0][0])
261 end
= Vector(trace
[0][-1])
262 begin
.z
= end
.z
= workspace
.empty_display_size
263 trace
[0].insert(0, begin
)
264 trace
[1].insert(0, 1.0)
267 internal
.addPolygonSpline(toolpath
, False, [inverse_transform
@vertex for vertex
in trace
[0]], trace
[1])
270 class RectMacro(bpy
.types
.Operator
):
271 bl_idname
= 'curvetools.add_toolpath_rect_macro'
272 bl_description
= bl_label
= 'Rect Macro'
273 bl_options
= {'REGISTER', 'UNDO'}
275 track_count
: bpy
.props
.IntProperty(name
='Number Tracks', description
='How many tracks', min=1, default
=10)
276 stride
: bpy
.props
.FloatProperty(name
='Stride', unit
='LENGTH', description
='Distance to previous track on the way back', min=0.0, default
=0.5)
277 pitch
: bpy
.props
.FloatProperty(name
='Pitch', unit
='LENGTH', description
='Distance between two tracks', default
=-1.0)
278 length
: bpy
.props
.FloatProperty(name
='Length', unit
='LENGTH', description
='Length of one track', default
=10.0)
279 speed
: bpy
.props
.FloatProperty(name
='Speed', description
='Stored in softbody goal weight', min=0.0, max=1.0, default
=0.1)
281 def execute(self
, context
):
282 if not internal
.curveObject():
283 internal
.addObject('CURVE', 'Rect Toolpath')
284 origin
= Vector((0.0, 0.0, 0.0))
286 origin
= bpy
.context
.scene
.cursor
.location
287 stride
= math
.copysign(self
.stride
, self
.pitch
)
288 length
= self
.length
*0.5
291 for i
in range(0, self
.track_count
):
293 flipped
= -1 if (stride
== 0 and i
%2 == 1) else 1
294 vertices
.append(origin
+Vector((shift
, -length
*flipped
, 0.0)))
295 weights
.append(self
.speed
)
296 vertices
.append(origin
+Vector((shift
, length
*flipped
, 0.0)))
297 weights
.append(self
.speed
)
299 vertices
.append(origin
+Vector((shift
-stride
, length
, 0.0)))
300 weights
.append(self
.speed
)
301 vertices
.append(origin
+Vector((shift
-stride
, -length
, 0.0)))
303 internal
.addPolygonSpline(bpy
.context
.object, False, vertices
, weights
)
306 class DrillMacro(bpy
.types
.Operator
):
307 bl_idname
= 'curvetools.add_toolpath_drill_macro'
308 bl_description
= bl_label
= 'Drill Macro'
309 bl_options
= {'REGISTER', 'UNDO'}
311 screw_count
: bpy
.props
.FloatProperty(name
='Screw Turns', description
='How many screw truns', min=1.0, default
=10.0)
312 spiral_count
: bpy
.props
.FloatProperty(name
='Spiral Turns', description
='How many spiral turns', min=0.0, default
=0.0)
313 vertex_count
: bpy
.props
.IntProperty(name
='Number Vertices', description
= 'How many vertices per screw turn', min=3, default
=32)
314 radius
: bpy
.props
.FloatProperty(name
='Radius', unit
='LENGTH', description
='Radius at tool center', min=0.0, default
=5.0)
315 pitch
: bpy
.props
.FloatProperty(name
='Pitch', unit
='LENGTH', description
='Distance between two screw turns', min=0.0, default
=1.0)
316 speed
: bpy
.props
.FloatProperty(name
='Speed', description
='Stored in softbody goal weight', min=0.0, max=1.0, default
=0.1)
318 def execute(self
, context
):
319 if not internal
.curveObject():
320 internal
.addObject('CURVE', 'Drill Toolpath')
321 origin
= Vector((0.0, 0.0, 0.0))
323 origin
= bpy
.context
.scene
.cursor
.location
324 count
= int(self
.vertex_count
*self
.screw_count
)
325 height
= -count
/self
.vertex_count
*self
.pitch
328 def addRadialVertex(param
, radius
, height
):
329 angle
= param
*math
.pi
*2
330 vertices
.append(origin
+Vector((math
.sin(angle
)*radius
, math
.cos(angle
)*radius
, height
)))
331 weights
.append(self
.speed
)
333 if self
.spiral_count
> 0.0:
334 sCount
= math
.ceil(self
.spiral_count
*self
.vertex_count
)
335 for j
in range(1, int(self
.screw_count
)+1):
337 vertices
.append(origin
+Vector((0.0, 0.0, sHeight
)))
338 weights
.append(self
.speed
)
339 sHeight
= max(-j
*self
.pitch
, height
)
340 for i
in range(0, sCount
+1):
341 sParam
= i
/self
.vertex_count
342 addRadialVertex(sParam
, i
/sCount
*self
.radius
, sHeight
)
343 for i
in range(0, self
.vertex_count
+1):
344 addRadialVertex(sParam
+(count
+i
)/self
.vertex_count
, self
.radius
, sHeight
)
346 for i
in range(0, count
):
347 param
= i
/self
.vertex_count
348 addRadialVertex(param
, self
.radius
, -param
*self
.pitch
)
349 for i
in range(0, self
.vertex_count
+1):
350 addRadialVertex((count
+i
)/self
.vertex_count
, self
.radius
, height
)
353 weights
+= [self
.speed
, 1]
354 vertices
+= [origin
+Vector((0.0, 0.0, height
)), origin
]
355 internal
.addPolygonSpline(bpy
.context
.object, False, vertices
, weights
)
360 bpy
.utils
.register_class(operators
)
364 bpy
.utils
.unregister_class(operators
)
366 if __name__
== "__main__":
369 operators
= [OffsetCurve
, SliceMesh
, DogBone
, DiscretizeCurve
, Truncate
, RectMacro
, DrillMacro
]