1 # SPDX-License-Identifier: GPL-2.0-or-later
4 "name": "Offset Edges",
5 "author": "Hidesato Ikeya, Veezen fix 2.8 (temporary)",
6 #i tried edit newest version, but got some errors, works only on 0,2,6
9 "location": "VIEW3D > Edge menu(CTRL-E) > Offset Edges",
10 "description": "Offset Edges",
12 "doc_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/Scripts/Modeling/offset_edges",
18 from math
import sin
, cos
, pi
, copysign
, radians
20 from bpy_extras
import view3d_utils
22 from mathutils
import Vector
23 from time
import perf_counter
25 X_UP
= Vector((1.0, .0, .0))
26 Y_UP
= Vector((.0, 1.0, .0))
27 Z_UP
= Vector((.0, .0, 1.0))
28 ZERO_VEC
= Vector((.0, .0, .0))
34 def calc_loop_normal(verts
, fallback
=Z_UP
):
35 # Calculate normal from verts using Newell's method.
36 normal
= ZERO_VEC
.copy()
38 if verts
[0] is verts
[-1]:
40 range_verts
= range(1, len(verts
))
43 range_verts
= range(0, len(verts
))
46 v1co
, v2co
= verts
[i
-1].co
, verts
[i
].co
47 normal
.x
+= (v1co
.y
- v2co
.y
) * (v1co
.z
+ v2co
.z
)
48 normal
.y
+= (v1co
.z
- v2co
.z
) * (v1co
.x
+ v2co
.x
)
49 normal
.z
+= (v1co
.x
- v2co
.x
) * (v1co
.y
+ v2co
.y
)
51 if normal
!= ZERO_VEC
:
58 def collect_edges(bm
):
59 set_edges_orig
= set()
63 for f
in e
.link_faces
:
65 co_faces_selected
+= 1
66 if co_faces_selected
== 2:
71 if not set_edges_orig
:
76 def collect_loops(set_edges_orig
):
77 set_edges_copy
= set_edges_orig
.copy()
79 loops
= [] # [v, e, v, e, ... , e, v]
81 edge_start
= set_edges_copy
.pop()
82 v_left
, v_right
= edge_start
.verts
83 lp
= [v_left
, edge_start
, v_right
]
87 for e
in v_right
.link_edges
:
88 if e
in set_edges_copy
:
93 set_edges_copy
.remove(e
)
95 v_right
= edge
.other_vert(v_right
)
96 lp
.extend((edge
, v_right
))
103 elif reverse
is False:
104 # Right side of half loop.
105 # Reversing the loop to operate same procedure on the left side.
107 v_right
, v_left
= v_left
, v_right
111 # Half loop, completed.
116 def get_adj_ix(ix_start
, vec_edges
, half_loop
):
117 # Get adjacent edge index, skipping zero length edges
118 len_edges
= len(vec_edges
)
120 range_right
= range(ix_start
, len_edges
)
121 range_left
= range(ix_start
-1, -1, -1)
123 range_right
= range(ix_start
, ix_start
+len_edges
)
124 range_left
= range(ix_start
-1, ix_start
-1-len_edges
, -1)
126 ix_right
= ix_left
= None
127 for i
in range_right
:
130 if vec_edges
[i
] != ZERO_VEC
:
136 if vec_edges
[i
] != ZERO_VEC
:
140 # If index of one side is None, assign another index.
146 return ix_right
, ix_left
148 def get_adj_faces(edges
):
153 for f
in e
.link_faces
:
154 # Search an adjacent face.
155 # Selected face has precedance.
156 if not f
.hide
and f
.normal
!= ZERO_VEC
:
161 adj_faces
.append(adj_f
)
165 adj_faces
.append(adj_f
)
167 adj_faces
.append(None)
171 def get_edge_rail(vert
, set_edges_orig
):
172 co_edges
= co_edges_selected
= 0
174 for e
in vert
.link_edges
:
175 if (e
not in set_edges_orig
and
176 (e
.select
or (co_edges_selected
== 0 and not e
.hide
))):
177 v_other
= e
.other_vert(vert
)
178 vec
= v_other
.co
- vert
.co
182 co_edges_selected
+= 1
183 if co_edges_selected
== 2:
187 if co_edges_selected
== 1:
188 vec_inner
.normalize()
191 # No selected edges, one unselected edge.
192 vec_inner
.normalize()
197 def get_cross_rail(vec_tan
, vec_edge_r
, vec_edge_l
, normal_r
, normal_l
):
198 # Cross rail is a cross vector between normal_r and normal_l.
200 vec_cross
= normal_r
.cross(normal_l
)
201 if vec_cross
.dot(vec_tan
) < .0:
203 cos_min
= min(vec_tan
.dot(vec_edge_r
), vec_tan
.dot(-vec_edge_l
))
204 cos
= vec_tan
.dot(vec_cross
)
206 vec_cross
.normalize()
211 def move_verts(width
, depth
, verts
, directions
, geom_ex
):
213 geom_s
= geom_ex
['side']
216 for e
in v
.link_edges
:
218 verts_ex
.append(e
.other_vert(v
))
220 #assert len(verts) == len(verts_ex)
223 for v
, (vec_width
, vec_depth
) in zip(verts
, directions
):
224 v
.co
+= width
* vec_width
+ depth
* vec_depth
226 def extrude_edges(bm
, edges_orig
):
227 extruded
= bmesh
.ops
.extrude_edge_only(bm
, edges
=edges_orig
)['geom']
228 n_edges
= n_faces
= len(edges_orig
)
229 n_verts
= len(extruded
) - n_edges
- n_faces
232 geom
['verts'] = verts
= set(extruded
[:n_verts
])
233 geom
['edges'] = edges
= set(extruded
[n_verts
:n_verts
+ n_edges
])
234 geom
['faces'] = set(extruded
[n_verts
+ n_edges
:])
235 geom
['side'] = set(e
for v
in verts
for e
in v
.link_edges
if e
not in edges
)
239 def clean(bm
, mode
, edges_orig
, geom_ex
=None):
243 for e
in geom_ex
['edges']:
246 lis_geom
= list(geom_ex
['side']) + list(geom_ex
['faces'])
247 bmesh
.ops
.delete(bm
, geom
=lis_geom
, context
='EDGES')
252 def collect_mirror_planes(edit_object
):
254 eob_mat_inv
= edit_object
.matrix_world
.inverted()
257 for m
in edit_object
.modifiers
:
258 if (m
.type == 'MIRROR' and m
.use_mirror_merge
):
259 merge_limit
= m
.merge_threshold
260 if not m
.mirror_object
:
262 norm_x
, norm_y
, norm_z
= X_UP
, Y_UP
, Z_UP
264 mirror_mat_local
= eob_mat_inv
@ m
.mirror_object
.matrix_world
265 loc
= mirror_mat_local
.to_translation()
266 norm_x
, norm_y
, norm_z
, _
= mirror_mat_local
.adjugated()
267 norm_x
= norm_x
.to_3d().normalized()
268 norm_y
= norm_y
.to_3d().normalized()
269 norm_z
= norm_z
.to_3d().normalized()
271 mirror_planes
.append((loc
, norm_x
, merge_limit
))
273 mirror_planes
.append((loc
, norm_y
, merge_limit
))
275 mirror_planes
.append((loc
, norm_z
, merge_limit
))
278 def get_vert_mirror_pairs(set_edges_orig
, mirror_planes
):
280 set_edges_copy
= set_edges_orig
.copy()
281 vert_mirror_pairs
= dict()
282 for e
in set_edges_orig
:
284 for mp
in mirror_planes
:
285 p_co
, p_norm
, mlimit
= mp
286 v1_dist
= abs(p_norm
.dot(v1
.co
- p_co
))
287 v2_dist
= abs(p_norm
.dot(v2
.co
- p_co
))
288 if v1_dist
<= mlimit
:
289 # v1 is on a mirror plane.
290 vert_mirror_pairs
[v1
] = mp
291 if v2_dist
<= mlimit
:
292 # v2 is on a mirror plane.
293 vert_mirror_pairs
[v2
] = mp
294 if v1_dist
<= mlimit
and v2_dist
<= mlimit
:
295 # This edge is on a mirror_plane, so should not be offsetted.
296 set_edges_copy
.remove(e
)
297 return vert_mirror_pairs
, set_edges_copy
299 return None, set_edges_orig
301 def get_mirror_rail(mirror_plane
, vec_up
):
302 p_norm
= mirror_plane
[1]
303 mirror_rail
= vec_up
.cross(p_norm
)
304 if mirror_rail
!= ZERO_VEC
:
305 mirror_rail
.normalize()
306 # Project vec_up to mirror_plane
307 vec_up
= vec_up
- vec_up
.project(p_norm
)
309 return mirror_rail
, vec_up
313 def reorder_loop(verts
, edges
, lp_normal
, adj_faces
):
314 for i
, adj_f
in enumerate(adj_faces
):
317 v1
, v2
= verts
[i
], verts
[i
+1]
319 fv
= tuple(adj_f
.verts
)
320 if fv
[fv
.index(v1
)-1] is v2
:
321 # Align loop direction
325 if lp_normal
.dot(adj_f
.normal
) < .0:
329 # All elements in adj_faces are None
331 if v
.normal
!= ZERO_VEC
:
332 if lp_normal
.dot(v
.normal
) < .0:
338 return verts
, edges
, lp_normal
, adj_faces
340 def get_directions(lp
, vec_upward
, normal_fallback
, vert_mirror_pairs
, **options
):
341 opt_follow_face
= options
['follow_face']
342 opt_edge_rail
= options
['edge_rail']
343 opt_er_only_end
= options
['edge_rail_only_end']
344 opt_threshold
= options
['threshold']
346 verts
, edges
= lp
[::2], lp
[1::2]
347 set_edges
= set(edges
)
348 lp_normal
= calc_loop_normal(verts
, fallback
=normal_fallback
)
350 ##### Loop order might be changed below.
351 if lp_normal
.dot(vec_upward
) < .0:
352 # Make this loop's normal towards vec_upward.
358 adj_faces
= get_adj_faces(edges
)
359 verts
, edges
, lp_normal
, adj_faces
= \
360 reorder_loop(verts
, edges
, lp_normal
, adj_faces
)
362 adj_faces
= (None, ) * len(edges
)
363 ##### Loop order might be changed above.
365 vec_edges
= tuple((e
.other_vert(v
).co
- v
.co
).normalized()
366 for v
, e
in zip(verts
, edges
))
368 if verts
[0] is verts
[-1]:
369 # Real loop. Popping last vertex.
376 len_verts
= len(verts
)
378 for i
in range(len_verts
):
380 ix_right
, ix_left
= i
, i
-1
388 elif i
== len_verts
- 1:
393 edge_right
, edge_left
= vec_edges
[ix_right
], vec_edges
[ix_left
]
394 face_right
, face_left
= adj_faces
[ix_right
], adj_faces
[ix_left
]
396 norm_right
= face_right
.normal
if face_right
else lp_normal
397 norm_left
= face_left
.normal
if face_left
else lp_normal
398 if norm_right
.angle(norm_left
) > opt_threshold
:
399 # Two faces are not flat.
404 tan_right
= edge_right
.cross(norm_right
).normalized()
405 tan_left
= edge_left
.cross(norm_left
).normalized()
406 tan_avr
= (tan_right
+ tan_left
).normalized()
407 norm_avr
= (norm_right
+ norm_left
).normalized()
410 if two_normals
or opt_edge_rail
:
412 # edge rail is a vector of an inner edge.
413 if two_normals
or (not opt_er_only_end
) or VERT_END
:
414 rail
= get_edge_rail(vert
, set_edges
)
415 if vert_mirror_pairs
and VERT_END
:
416 if vert
in vert_mirror_pairs
:
418 get_mirror_rail(vert_mirror_pairs
[vert
], norm_avr
)
419 if (not rail
) and two_normals
:
421 # Cross rail is a cross vector between norm_right and norm_left.
422 rail
= get_cross_rail(
423 tan_avr
, edge_right
, edge_left
, norm_right
, norm_left
)
425 dot
= tan_avr
.dot(rail
)
431 vec_plane
= norm_avr
.cross(tan_avr
)
432 e_dot_p_r
= edge_right
.dot(vec_plane
)
433 e_dot_p_l
= edge_left
.dot(vec_plane
)
434 if e_dot_p_r
or e_dot_p_l
:
435 if e_dot_p_r
> e_dot_p_l
:
436 vec_edge
, e_dot_p
= edge_right
, e_dot_p_r
438 vec_edge
, e_dot_p
= edge_left
, e_dot_p_l
440 vec_tan
= (tan_avr
- tan_avr
.project(vec_edge
)).normalized()
441 # Make vec_tan perpendicular to vec_edge
442 vec_up
= vec_tan
.cross(vec_edge
)
444 vec_width
= vec_tan
- (vec_tan
.dot(vec_plane
) / e_dot_p
) * vec_edge
445 vec_depth
= vec_up
- (vec_up
.dot(vec_plane
) / e_dot_p
) * vec_edge
450 directions
.append((vec_width
, vec_depth
))
452 return verts
, directions
454 def use_cashes(self
, context
):
455 self
.caches_valid
= True
457 angle_presets
= {'0°': 0,
463 '90°': radians(90),}
464 def assign_angle_presets(self
, context
):
465 use_cashes(self
, context
)
466 self
.angle
= angle_presets
[self
.angle_presets
]
468 class OffsetEdges(bpy
.types
.Operator
):
470 bl_idname
= "mesh.offset_edges"
471 bl_label
= "Offset Edges"
472 bl_options
= {'REGISTER', 'UNDO'}
474 geometry_mode
: bpy
.props
.EnumProperty(
475 items
=[('offset', "Offset", "Offset edges"),
476 ('extrude', "Extrude", "Extrude edges"),
477 ('move', "Move", "Move selected edges")],
478 name
="Geometory mode", default
='offset',
480 width
: bpy
.props
.FloatProperty(
481 name
="Width", default
=.2, precision
=4, step
=1, update
=use_cashes
)
482 flip_width
: bpy
.props
.BoolProperty(
483 name
="Flip Width", default
=False,
484 description
="Flip width direction", update
=use_cashes
)
485 depth
: bpy
.props
.FloatProperty(
486 name
="Depth", default
=.0, precision
=4, step
=1, update
=use_cashes
)
487 flip_depth
: bpy
.props
.BoolProperty(
488 name
="Flip Depth", default
=False,
489 description
="Flip depth direction", update
=use_cashes
)
490 depth_mode
: bpy
.props
.EnumProperty(
491 items
=[('angle', "Angle", "Angle"),
492 ('depth', "Depth", "Depth")],
493 name
="Depth mode", default
='angle', update
=use_cashes
)
494 angle
: bpy
.props
.FloatProperty(
495 name
="Angle", default
=0, precision
=3, step
=.1,
496 min=-2*pi
, max=2*pi
, subtype
='ANGLE',
497 description
="Angle", update
=use_cashes
)
498 flip_angle
: bpy
.props
.BoolProperty(
499 name
="Flip Angle", default
=False,
500 description
="Flip Angle", update
=use_cashes
)
501 follow_face
: bpy
.props
.BoolProperty(
502 name
="Follow Face", default
=False,
503 description
="Offset along faces around")
504 mirror_modifier
: bpy
.props
.BoolProperty(
505 name
="Mirror Modifier", default
=False,
506 description
="Take into account of Mirror modifier")
507 edge_rail
: bpy
.props
.BoolProperty(
508 name
="Edge Rail", default
=False,
509 description
="Align vertices along inner edges")
510 edge_rail_only_end
: bpy
.props
.BoolProperty(
511 name
="Edge Rail Only End", default
=False,
512 description
="Apply edge rail to end verts only")
513 threshold
: bpy
.props
.FloatProperty(
514 name
="Flat Face Threshold", default
=radians(0.05), precision
=5,
515 step
=1.0e-4, subtype
='ANGLE',
516 description
="If difference of angle between two adjacent faces is "
517 "below this value, those faces are regarded as flat",
519 caches_valid
: bpy
.props
.BoolProperty(
520 name
="Caches Valid", default
=False,
522 angle_presets
: bpy
.props
.EnumProperty(
523 items
=[('0°', "0°", "0°"),
524 ('15°', "15°", "15°"),
525 ('30°', "30°", "30°"),
526 ('45°', "45°", "45°"),
527 ('60°', "60°", "60°"),
528 ('75°', "75°", "75°"),
529 ('90°', "90°", "90°"), ],
530 name
="Angle Presets", default
='0°',
531 update
=assign_angle_presets
)
533 _cache_offset_infos
= None
534 _cache_edges_orig_ixs
= None
537 def poll(self
, context
):
538 return context
.mode
== 'EDIT_MESH'
540 def draw(self
, context
):
542 layout
.prop(self
, 'geometry_mode', text
="")
543 #layout.prop(self, 'geometry_mode', expand=True)
545 row
= layout
.row(align
=True)
546 row
.prop(self
, 'width')
547 row
.prop(self
, 'flip_width', icon
='ARROW_LEFTRIGHT', icon_only
=True)
549 layout
.prop(self
, 'depth_mode', expand
=True)
550 if self
.depth_mode
== 'angle':
556 row
= layout
.row(align
=True)
557 row
.prop(self
, d_mode
)
558 row
.prop(self
, flip
, icon
='ARROW_LEFTRIGHT', icon_only
=True)
559 if self
.depth_mode
== 'angle':
560 layout
.prop(self
, 'angle_presets', text
="Presets", expand
=True)
564 layout
.prop(self
, 'follow_face')
567 row
.prop(self
, 'edge_rail')
569 row
.prop(self
, 'edge_rail_only_end', text
="OnlyEnd", toggle
=True)
571 layout
.prop(self
, 'mirror_modifier')
573 #layout.operator('mesh.offset_edges', text='Repeat')
577 layout
.prop(self
, 'threshold', text
='Threshold')
580 def get_offset_infos(self
, bm
, edit_object
):
581 if self
.caches_valid
and self
._cache
_offset
_infos
is not None:
582 # Return None, indicating to use cache.
585 time
= perf_counter()
587 set_edges_orig
= collect_edges(bm
)
588 if set_edges_orig
is None:
589 self
.report({'WARNING'},
590 "No edges selected.")
593 if self
.mirror_modifier
:
594 mirror_planes
= collect_mirror_planes(edit_object
)
595 vert_mirror_pairs
, set_edges
= \
596 get_vert_mirror_pairs(set_edges_orig
, mirror_planes
)
599 set_edges_orig
= set_edges
601 #self.report({'WARNING'},
602 # "All selected edges are on mirror planes.")
603 vert_mirror_pairs
= None
605 vert_mirror_pairs
= None
607 loops
= collect_loops(set_edges_orig
)
609 self
.report({'WARNING'},
610 "Overlap detected. Select non-overlap edge loops")
613 vec_upward
= (X_UP
+ Y_UP
+ Z_UP
).normalized()
614 # vec_upward is used to unify loop normals when follow_face is off.
615 normal_fallback
= Z_UP
616 #normal_fallback = Vector(context.region_data.view_matrix[2][:3])
617 # normal_fallback is used when loop normal cannot be calculated.
619 follow_face
= self
.follow_face
620 edge_rail
= self
.edge_rail
621 er_only_end
= self
.edge_rail_only_end
622 threshold
= self
.threshold
626 verts
, directions
= get_directions(
627 lp
, vec_upward
, normal_fallback
, vert_mirror_pairs
,
628 follow_face
=follow_face
, edge_rail
=edge_rail
,
629 edge_rail_only_end
=er_only_end
,
632 offset_infos
.append((verts
, directions
))
635 self
._cache
_offset
_infos
= _cache_offset_infos
= []
636 for verts
, directions
in offset_infos
:
637 v_ixs
= tuple(v
.index
for v
in verts
)
638 _cache_offset_infos
.append((v_ixs
, directions
))
639 self
._cache
_edges
_orig
_ixs
= tuple(e
.index
for e
in set_edges_orig
)
641 print("Preparing OffsetEdges: ", perf_counter() - time
)
643 return offset_infos
, set_edges_orig
645 def do_offset_and_free(self
, bm
, me
, offset_infos
=None, set_edges_orig
=None):
646 # If offset_infos is None, use caches.
647 # Makes caches invalid after offset.
649 #time = perf_counter()
651 if offset_infos
is None:
653 bmverts
= tuple(bm
.verts
)
654 bmedges
= tuple(bm
.edges
)
655 edges_orig
= [bmedges
[ix
] for ix
in self
._cache
_edges
_orig
_ixs
]
656 verts_directions
= []
657 for ix_vs
, directions
in self
._cache
_offset
_infos
:
658 verts
= tuple(bmverts
[ix
] for ix
in ix_vs
)
659 verts_directions
.append((verts
, directions
))
661 verts_directions
= offset_infos
662 edges_orig
= list(set_edges_orig
)
664 if self
.depth_mode
== 'angle':
665 w
= self
.width
if not self
.flip_width
else -self
.width
666 angle
= self
.angle
if not self
.flip_angle
else -self
.angle
667 width
= w
* cos(angle
)
668 depth
= w
* sin(angle
)
670 width
= self
.width
if not self
.flip_width
else -self
.width
671 depth
= self
.depth
if not self
.flip_depth
else -self
.depth
674 if self
.geometry_mode
== 'move':
677 geom_ex
= extrude_edges(bm
, edges_orig
)
679 for verts
, directions
in verts_directions
:
680 move_verts(width
, depth
, verts
, directions
, geom_ex
)
682 clean(bm
, self
.geometry_mode
, edges_orig
, geom_ex
)
684 bpy
.ops
.object.mode_set(mode
="OBJECT")
686 bpy
.ops
.object.mode_set(mode
="EDIT")
688 self
.caches_valid
= False # Make caches invalid.
690 #print("OffsetEdges offset: ", perf_counter() - time)
692 def execute(self
, context
):
694 edit_object
= context
.edit_object
695 bpy
.ops
.object.mode_set(mode
="OBJECT")
697 me
= edit_object
.data
701 offset_infos
, edges_orig
= self
.get_offset_infos(bm
, edit_object
)
702 if offset_infos
is False:
703 bpy
.ops
.object.mode_set(mode
="EDIT")
706 self
.do_offset_and_free(bm
, me
, offset_infos
, edges_orig
)
710 def restore_original_and_free(self
, context
):
711 self
.caches_valid
= False # Make caches invalid.
712 context
.area
.header_text_set()
714 me
= context
.edit_object
.data
715 bpy
.ops
.object.mode_set(mode
="OBJECT")
716 self
._bm
_orig
.to_mesh(me
)
717 bpy
.ops
.object.mode_set(mode
="EDIT")
720 context
.area
.header_text_set()
722 def invoke(self
, context
, event
):
724 edit_object
= context
.edit_object
725 me
= edit_object
.data
726 bpy
.ops
.object.mode_set(mode
="OBJECT")
727 for p
in me
.polygons
:
729 self
.follow_face
= True
732 self
.caches_valid
= False
733 bpy
.ops
.object.mode_set(mode
="EDIT")
734 return self
.execute(context
)
736 class OffsetEdgesMenu(bpy
.types
.Menu
):
737 bl_idname
= "VIEW3D_MT_edit_mesh_offset_edges"
738 bl_label
= "Offset Edges"
740 def draw(self
, context
):
742 layout
.operator_context
= 'INVOKE_DEFAULT'
744 off
= layout
.operator('mesh.offset_edges', text
='Offset')
745 off
.geometry_mode
= 'offset'
747 ext
= layout
.operator('mesh.offset_edges', text
='Extrude')
748 ext
.geometry_mode
= 'extrude'
750 mov
= layout
.operator('mesh.offset_edges', text
='Move')
751 mov
.geometry_mode
= 'move'
758 def draw_item(self
, context
):
759 self
.layout
.menu("VIEW3D_MT_edit_mesh_offset_edges")
764 bpy
.utils
.register_class(cls
)
765 bpy
.types
.VIEW3D_MT_edit_mesh_edges
.prepend(draw_item
)
769 for cls
in reversed(classes
):
770 bpy
.utils
.unregister_class(cls
)
771 bpy
.types
.VIEW3D_MT_edit_mesh_edges
.remove(draw_item
)
774 if __name__
== '__main__':