1 # ##### BEGIN GPL LICENSE BLOCK #####
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; either version 2
6 # of the License, or (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software Foundation,
15 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 # ##### END GPL LICENSE BLOCK #####
20 "name": "Offset Edges",
21 "author": "Hidesato Ikeya",
23 "blender": (2, 70, 0),
24 "location": "VIEW3D > Edge menu(CTRL-E) > Offset Edges",
25 "description": "Offset Edges",
27 "wiki_url": "https://wiki.blender.org/index.php/Extensions:2.6/"
28 "Py/Scripts/Modeling/offset_edges",
33 from bpy
.types
import Operator
34 from math
import sin
, cos
, pi
, radians
35 from mathutils
import Vector
36 from time
import perf_counter
38 from bpy
.props
import (
45 X_UP
= Vector((1.0, .0, .0))
46 Y_UP
= Vector((.0, 1.0, .0))
47 Z_UP
= Vector((.0, .0, 1.0))
48 ZERO_VEC
= Vector((.0, .0, .0))
53 # switch performance logging
57 def calc_loop_normal(verts
, fallback
=Z_UP
):
58 # Calculate normal from verts using Newell's method
59 normal
= ZERO_VEC
.copy()
61 if verts
[0] is verts
[-1]:
63 range_verts
= range(1, len(verts
))
66 range_verts
= range(0, len(verts
))
69 v1co
, v2co
= verts
[i
- 1].co
, verts
[i
].co
70 normal
.x
+= (v1co
.y
- v2co
.y
) * (v1co
.z
+ v2co
.z
)
71 normal
.y
+= (v1co
.z
- v2co
.z
) * (v1co
.x
+ v2co
.x
)
72 normal
.z
+= (v1co
.x
- v2co
.x
) * (v1co
.y
+ v2co
.y
)
74 if normal
!= ZERO_VEC
:
82 def collect_edges(bm
):
83 set_edges_orig
= set()
87 for f
in e
.link_faces
:
89 co_faces_selected
+= 1
90 if co_faces_selected
== 2:
95 if not set_edges_orig
:
101 def collect_loops(set_edges_orig
):
102 set_edges_copy
= set_edges_orig
.copy()
104 loops
= [] # [v, e, v, e, ... , e, v]
105 while set_edges_copy
:
106 edge_start
= set_edges_copy
.pop()
107 v_left
, v_right
= edge_start
.verts
108 lp
= [v_left
, edge_start
, v_right
]
112 for e
in v_right
.link_edges
:
113 if e
in set_edges_copy
:
118 set_edges_copy
.remove(e
)
120 v_right
= edge
.other_vert(v_right
)
121 lp
.extend((edge
, v_right
))
124 if v_right
is v_left
:
128 elif reverse
is False:
129 # Right side of half loop
130 # Reversing the loop to operate same procedure on the left side
132 v_right
, v_left
= v_left
, v_right
136 # Half loop, completed
142 def get_adj_ix(ix_start
, vec_edges
, half_loop
):
143 # Get adjacent edge index, skipping zero length edges
144 len_edges
= len(vec_edges
)
146 range_right
= range(ix_start
, len_edges
)
147 range_left
= range(ix_start
- 1, -1, -1)
149 range_right
= range(ix_start
, ix_start
+ len_edges
)
150 range_left
= range(ix_start
- 1, ix_start
- 1 - len_edges
, -1)
152 ix_right
= ix_left
= None
153 for i
in range_right
:
156 if vec_edges
[i
] != ZERO_VEC
:
162 if vec_edges
[i
] != ZERO_VEC
:
166 # If index of one side is None, assign another index
172 return ix_right
, ix_left
175 def get_adj_faces(edges
):
180 for f
in e
.link_faces
:
181 # Search an adjacent face
182 # Selected face has precedence
183 if not f
.hide
and f
.normal
!= ZERO_VEC
:
187 adj_faces
.append(adj_f
)
191 adj_faces
.append(adj_f
)
193 adj_faces
.append(None)
197 def get_edge_rail(vert
, set_edges_orig
):
198 co_edges
= co_edges_selected
= 0
200 for e
in vert
.link_edges
:
201 if (e
not in set_edges_orig
and
202 (e
.select
or (co_edges_selected
== 0 and not e
.hide
))):
203 v_other
= e
.other_vert(vert
)
204 vec
= v_other
.co
- vert
.co
208 co_edges_selected
+= 1
209 if co_edges_selected
== 2:
213 if co_edges_selected
== 1:
214 vec_inner
.normalize()
217 # No selected edges, one unselected edge
218 vec_inner
.normalize()
224 def get_cross_rail(vec_tan
, vec_edge_r
, vec_edge_l
, normal_r
, normal_l
):
225 # Cross rail is a cross vector between normal_r and normal_l
226 vec_cross
= normal_r
.cross(normal_l
)
227 if vec_cross
.dot(vec_tan
) < .0:
229 cos_min
= min(vec_tan
.dot(vec_edge_r
), vec_tan
.dot(-vec_edge_l
))
230 cos
= vec_tan
.dot(vec_cross
)
232 vec_cross
.normalize()
238 def move_verts(width
, depth
, verts
, directions
, geom_ex
):
240 geom_s
= geom_ex
['side']
243 for e
in v
.link_edges
:
245 verts_ex
.append(e
.other_vert(v
))
249 for v
, (vec_width
, vec_depth
) in zip(verts
, directions
):
250 v
.co
+= width
* vec_width
+ depth
* vec_depth
253 def extrude_edges(bm
, edges_orig
):
254 extruded
= bmesh
.ops
.extrude_edge_only(bm
, edges
=edges_orig
)['geom']
255 n_edges
= n_faces
= len(edges_orig
)
256 n_verts
= len(extruded
) - n_edges
- n_faces
259 geom
['verts'] = verts
= set(extruded
[:n_verts
])
260 geom
['edges'] = edges
= set(extruded
[n_verts
:n_verts
+ n_edges
])
261 geom
['faces'] = set(extruded
[n_verts
+ n_edges
:])
262 geom
['side'] = set(e
for v
in verts
for e
in v
.link_edges
if e
not in edges
)
267 def clean(bm
, mode
, edges_orig
, geom_ex
=None):
271 for e
in geom_ex
['edges']:
274 lis_geom
= list(geom_ex
['side']) + list(geom_ex
['faces'])
275 bmesh
.ops
.delete(bm
, geom
=lis_geom
, context
=2)
281 def collect_mirror_planes(edit_object
):
283 eob_mat_inv
= edit_object
.matrix_world
.inverted()
284 for m
in edit_object
.modifiers
:
285 if (m
.type == 'MIRROR' and m
.use_mirror_merge
):
286 merge_limit
= m
.merge_threshold
287 if not m
.mirror_object
:
289 norm_x
, norm_y
, norm_z
= X_UP
, Y_UP
, Z_UP
291 mirror_mat_local
= eob_mat_inv
* m
.mirror_object
.matrix_world
292 loc
= mirror_mat_local
.to_translation()
293 norm_x
, norm_y
, norm_z
, _
= mirror_mat_local
.adjugated()
294 norm_x
= norm_x
.to_3d().normalized()
295 norm_y
= norm_y
.to_3d().normalized()
296 norm_z
= norm_z
.to_3d().normalized()
298 mirror_planes
.append((loc
, norm_x
, merge_limit
))
300 mirror_planes
.append((loc
, norm_y
, merge_limit
))
302 mirror_planes
.append((loc
, norm_z
, merge_limit
))
306 def get_vert_mirror_pairs(set_edges_orig
, mirror_planes
):
308 set_edges_copy
= set_edges_orig
.copy()
309 vert_mirror_pairs
= dict()
310 for e
in set_edges_orig
:
312 for mp
in mirror_planes
:
313 p_co
, p_norm
, mlimit
= mp
314 v1_dist
= abs(p_norm
.dot(v1
.co
- p_co
))
315 v2_dist
= abs(p_norm
.dot(v2
.co
- p_co
))
316 if v1_dist
<= mlimit
:
317 # v1 is on a mirror plane
318 vert_mirror_pairs
[v1
] = mp
319 if v2_dist
<= mlimit
:
320 # v2 is on a mirror plane
321 vert_mirror_pairs
[v2
] = mp
322 if v1_dist
<= mlimit
and v2_dist
<= mlimit
:
323 # This edge is on a mirror_plane, so should not be offsetted
324 set_edges_copy
.remove(e
)
325 return vert_mirror_pairs
, set_edges_copy
327 return None, set_edges_orig
330 def get_mirror_rail(mirror_plane
, vec_up
):
331 p_norm
= mirror_plane
[1]
332 mirror_rail
= vec_up
.cross(p_norm
)
333 if mirror_rail
!= ZERO_VEC
:
334 mirror_rail
.normalize()
335 # Project vec_up to mirror_plane
336 vec_up
= vec_up
- vec_up
.project(p_norm
)
338 return mirror_rail
, vec_up
343 def reorder_loop(verts
, edges
, lp_normal
, adj_faces
):
344 for i
, adj_f
in enumerate(adj_faces
):
348 v1
, v2
= verts
[i
], verts
[i
+ 1]
349 fv
= tuple(adj_f
.verts
)
350 if fv
[fv
.index(v1
) - 1] is v2
:
351 # Align loop direction
356 if lp_normal
.dot(adj_f
.normal
) < .0:
360 # All elements in adj_faces are None
362 if v
.normal
!= ZERO_VEC
:
363 if lp_normal
.dot(v
.normal
) < .0:
369 return verts
, edges
, lp_normal
, adj_faces
372 def get_directions(lp
, vec_upward
, normal_fallback
, vert_mirror_pairs
, **options
):
373 opt_follow_face
= options
['follow_face']
374 opt_edge_rail
= options
['edge_rail']
375 opt_er_only_end
= options
['edge_rail_only_end']
376 opt_threshold
= options
['threshold']
378 verts
, edges
= lp
[::2], lp
[1::2]
379 set_edges
= set(edges
)
380 lp_normal
= calc_loop_normal(verts
, fallback
=normal_fallback
)
382 # Loop order might be changed below
383 if lp_normal
.dot(vec_upward
) < .0:
384 # Make this loop's normal towards vec_upward
390 adj_faces
= get_adj_faces(edges
)
391 verts
, edges
, lp_normal
, adj_faces
= \
392 reorder_loop(verts
, edges
, lp_normal
, adj_faces
)
394 adj_faces
= (None, ) * len(edges
)
395 # Loop order might be changed above
397 vec_edges
= tuple((e
.other_vert(v
).co
- v
.co
).normalized()
398 for v
, e
in zip(verts
, edges
))
400 if verts
[0] is verts
[-1]:
401 # Real loop. Popping last vertex
408 len_verts
= len(verts
)
410 for i
in range(len_verts
):
412 ix_right
, ix_left
= i
, i
- 1
420 elif i
== len_verts
- 1:
425 edge_right
, edge_left
= vec_edges
[ix_right
], vec_edges
[ix_left
]
426 face_right
, face_left
= adj_faces
[ix_right
], adj_faces
[ix_left
]
428 norm_right
= face_right
.normal
if face_right
else lp_normal
429 norm_left
= face_left
.normal
if face_left
else lp_normal
430 if norm_right
.angle(norm_left
) > opt_threshold
:
431 # Two faces are not flat
436 tan_right
= edge_right
.cross(norm_right
).normalized()
437 tan_left
= edge_left
.cross(norm_left
).normalized()
438 tan_avr
= (tan_right
+ tan_left
).normalized()
439 norm_avr
= (norm_right
+ norm_left
).normalized()
442 if two_normals
or opt_edge_rail
:
444 # edge rail is a vector of an inner edge
445 if two_normals
or (not opt_er_only_end
) or VERT_END
:
446 rail
= get_edge_rail(vert
, set_edges
)
447 if vert_mirror_pairs
and VERT_END
:
448 if vert
in vert_mirror_pairs
:
449 rail
, norm_avr
= get_mirror_rail(vert_mirror_pairs
[vert
], norm_avr
)
450 if (not rail
) and two_normals
:
452 # Cross rail is a cross vector between norm_right and norm_left
453 rail
= get_cross_rail(
454 tan_avr
, edge_right
, edge_left
, norm_right
, norm_left
)
456 dot
= tan_avr
.dot(rail
)
462 vec_plane
= norm_avr
.cross(tan_avr
)
463 e_dot_p_r
= edge_right
.dot(vec_plane
)
464 e_dot_p_l
= edge_left
.dot(vec_plane
)
465 if e_dot_p_r
or e_dot_p_l
:
466 if e_dot_p_r
> e_dot_p_l
:
467 vec_edge
, e_dot_p
= edge_right
, e_dot_p_r
469 vec_edge
, e_dot_p
= edge_left
, e_dot_p_l
471 vec_tan
= (tan_avr
- tan_avr
.project(vec_edge
)).normalized()
472 # Make vec_tan perpendicular to vec_edge
473 vec_up
= vec_tan
.cross(vec_edge
)
475 vec_width
= vec_tan
- (vec_tan
.dot(vec_plane
) / e_dot_p
) * vec_edge
476 vec_depth
= vec_up
- (vec_up
.dot(vec_plane
) / e_dot_p
) * vec_edge
481 directions
.append((vec_width
, vec_depth
))
483 return verts
, directions
486 angle_presets
= {'0°': 0,
496 def use_cashes(self
, context
):
497 self
.caches_valid
= True
500 def assign_angle_presets(self
, context
):
501 use_cashes(self
, context
)
502 self
.angle
= angle_presets
[self
.angle_presets
]
505 class OffsetEdges(Operator
):
506 bl_idname
= "mesh.offset_edges"
507 bl_label
= "Offset Edges"
508 bl_description
= ("Extrude, Move or Offset the selected Edges\n"
509 "Operates only on separate Edge loops selections")
510 bl_options
= {'REGISTER', 'UNDO'}
512 geometry_mode
= EnumProperty(
513 items
=[('offset', "Offset", "Offset edges"),
514 ('extrude', "Extrude", "Extrude edges"),
515 ('move', "Move", "Move selected edges")],
516 name
="Geometry mode",
520 width
= FloatProperty(
526 flip_width
= BoolProperty(
529 description
="Flip width direction",
532 depth
= FloatProperty(
538 flip_depth
= BoolProperty(
541 description
="Flip depth direction",
544 depth_mode
= EnumProperty(
545 items
=[('angle', "Angle", "Angle"),
546 ('depth', "Depth", "Depth")],
551 angle
= FloatProperty(
552 name
="Angle", default
=0,
553 precision
=3, step
=.1,
554 min=-2 * pi
, max=2 * pi
,
559 flip_angle
= BoolProperty(
562 description
="Flip Angle",
565 follow_face
= BoolProperty(
568 description
="Offset along faces around"
570 mirror_modifier
= BoolProperty(
571 name
="Mirror Modifier",
573 description
="Take into account of Mirror modifier"
575 edge_rail
= BoolProperty(
578 description
="Align vertices along inner edges"
580 edge_rail_only_end
= BoolProperty(
581 name
="Edge Rail Only End",
583 description
="Apply edge rail to end verts only"
585 threshold
= FloatProperty(
586 name
="Flat Face Threshold",
587 default
=radians(0.05), precision
=5,
588 step
=1.0e-4, subtype
='ANGLE',
589 description
="If difference of angle between two adjacent faces is "
590 "below this value, those faces are regarded as flat",
593 caches_valid
= BoolProperty(
598 angle_presets
= EnumProperty(
599 items
=[('0°', "0°", "0°"),
600 ('15°', "15°", "15°"),
601 ('30°', "30°", "30°"),
602 ('45°', "45°", "45°"),
603 ('60°', "60°", "60°"),
604 ('75°', "75°", "75°"),
605 ('90°', "90°", "90°"), ],
606 name
="Angle Presets",
608 update
=assign_angle_presets
611 _cache_offset_infos
= None
612 _cache_edges_orig_ixs
= None
615 def poll(self
, context
):
616 return context
.mode
== 'EDIT_MESH'
618 def draw(self
, context
):
620 layout
.prop(self
, 'geometry_mode', text
="")
622 row
= layout
.row(align
=True)
623 row
.prop(self
, 'width')
624 row
.prop(self
, 'flip_width', icon
='ARROW_LEFTRIGHT', icon_only
=True)
625 layout
.prop(self
, 'depth_mode', expand
=True)
627 if self
.depth_mode
== 'angle':
633 row
= layout
.row(align
=True)
634 row
.prop(self
, d_mode
)
635 row
.prop(self
, flip
, icon
='ARROW_LEFTRIGHT', icon_only
=True)
636 if self
.depth_mode
== 'angle':
637 layout
.prop(self
, 'angle_presets', text
="Presets", expand
=True)
641 layout
.prop(self
, 'follow_face')
644 row
.prop(self
, 'edge_rail')
646 row
.prop(self
, 'edge_rail_only_end', text
="OnlyEnd", toggle
=True)
648 layout
.prop(self
, 'mirror_modifier')
649 layout
.operator('mesh.offset_edges', text
="Repeat")
653 layout
.prop(self
, 'threshold', text
="Threshold")
655 def get_offset_infos(self
, bm
, edit_object
):
656 if self
.caches_valid
and self
._cache
_offset
_infos
is not None:
657 # Return None, indicating to use cache
661 time
= perf_counter()
663 set_edges_orig
= collect_edges(bm
)
664 if set_edges_orig
is None:
665 self
.report({'WARNING'},
666 "No edges selected or edge loops could not be determined")
669 if self
.mirror_modifier
:
670 mirror_planes
= collect_mirror_planes(edit_object
)
671 vert_mirror_pairs
, set_edges
= \
672 get_vert_mirror_pairs(set_edges_orig
, mirror_planes
)
675 set_edges_orig
= set_edges
677 vert_mirror_pairs
= None
679 vert_mirror_pairs
= None
681 loops
= collect_loops(set_edges_orig
)
683 self
.report({'WARNING'},
684 "Overlap detected. Select non-overlapping edge loops")
687 vec_upward
= (X_UP
+ Y_UP
+ Z_UP
).normalized()
688 # vec_upward is used to unify loop normals when follow_face is off
689 normal_fallback
= Z_UP
690 # normal_fallback = Vector(context.region_data.view_matrix[2][:3])
691 # normal_fallback is used when loop normal cannot be calculated
693 follow_face
= self
.follow_face
694 edge_rail
= self
.edge_rail
695 er_only_end
= self
.edge_rail_only_end
696 threshold
= self
.threshold
700 verts
, directions
= get_directions(
701 lp
, vec_upward
, normal_fallback
, vert_mirror_pairs
,
702 follow_face
=follow_face
, edge_rail
=edge_rail
,
703 edge_rail_only_end
=er_only_end
,
706 offset_infos
.append((verts
, directions
))
709 self
._cache
_offset
_infos
= _cache_offset_infos
= []
710 for verts
, directions
in offset_infos
:
711 v_ixs
= tuple(v
.index
for v
in verts
)
712 _cache_offset_infos
.append((v_ixs
, directions
))
713 self
._cache
_edges
_orig
_ixs
= tuple(e
.index
for e
in set_edges_orig
)
716 print("Preparing OffsetEdges: ", perf_counter() - time
)
718 return offset_infos
, set_edges_orig
720 def do_offset_and_free(self
, bm
, me
, offset_infos
=None, set_edges_orig
=None):
721 # If offset_infos is None, use caches
722 # Makes caches invalid after offset
725 time
= perf_counter()
727 if offset_infos
is None:
729 bmverts
= tuple(bm
.verts
)
730 bmedges
= tuple(bm
.edges
)
731 edges_orig
= [bmedges
[ix
] for ix
in self
._cache
_edges
_orig
_ixs
]
732 verts_directions
= []
733 for ix_vs
, directions
in self
._cache
_offset
_infos
:
734 verts
= tuple(bmverts
[ix
] for ix
in ix_vs
)
735 verts_directions
.append((verts
, directions
))
737 verts_directions
= offset_infos
738 edges_orig
= list(set_edges_orig
)
740 if self
.depth_mode
== 'angle':
741 w
= self
.width
if not self
.flip_width
else -self
.width
742 angle
= self
.angle
if not self
.flip_angle
else -self
.angle
743 width
= w
* cos(angle
)
744 depth
= w
* sin(angle
)
746 width
= self
.width
if not self
.flip_width
else -self
.width
747 depth
= self
.depth
if not self
.flip_depth
else -self
.depth
750 if self
.geometry_mode
== 'move':
753 geom_ex
= extrude_edges(bm
, edges_orig
)
755 for verts
, directions
in verts_directions
:
756 move_verts(width
, depth
, verts
, directions
, geom_ex
)
758 clean(bm
, self
.geometry_mode
, edges_orig
, geom_ex
)
760 bpy
.ops
.object.mode_set(mode
="OBJECT")
762 bpy
.ops
.object.mode_set(mode
="EDIT")
764 self
.caches_valid
= False # Make caches invalid
767 print("OffsetEdges offset: ", perf_counter() - time
)
769 def execute(self
, context
):
771 edit_object
= context
.edit_object
772 bpy
.ops
.object.mode_set(mode
="OBJECT")
774 me
= edit_object
.data
778 offset_infos
, edges_orig
= self
.get_offset_infos(bm
, edit_object
)
779 if offset_infos
is False:
780 bpy
.ops
.object.mode_set(mode
="EDIT")
783 self
.do_offset_and_free(bm
, me
, offset_infos
, edges_orig
)
787 def restore_original_and_free(self
, context
):
788 self
.caches_valid
= False # Make caches invalid
789 context
.area
.header_text_set(None)
791 me
= context
.edit_object
.data
792 bpy
.ops
.object.mode_set(mode
="OBJECT")
793 self
._bm
_orig
.to_mesh(me
)
794 bpy
.ops
.object.mode_set(mode
="EDIT")
797 context
.area
.header_text_set(None)
799 def invoke(self
, context
, event
):
801 edit_object
= context
.edit_object
802 me
= edit_object
.data
803 bpy
.ops
.object.mode_set(mode
="OBJECT")
804 for p
in me
.polygons
:
806 self
.follow_face
= True
809 self
.caches_valid
= False
810 bpy
.ops
.object.mode_set(mode
="EDIT")
811 return self
.execute(context
)
815 bpy
.utils
.register_module(__name__
)
819 bpy
.utils
.unregister_module(__name__
)
822 if __name__
== '__main__':