1 # SPDX-FileCopyrightText: 2019-2022 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
6 "name": "Offset Edges",
7 "author": "Hidesato Ikeya, Veezen fix 2.8 (temporary)",
8 #i tried edit newest version, but got some errors, works only on 0,2,6
10 "blender": (2, 80, 0),
11 "location": "VIEW3D > Edge menu(CTRL-E) > Offset Edges",
12 "description": "Offset Edges",
14 "doc_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/Scripts/Modeling/offset_edges",
20 from math
import sin
, cos
, pi
, copysign
, radians
22 from bpy_extras
import view3d_utils
24 from mathutils
import Vector
25 from time
import perf_counter
27 X_UP
= Vector((1.0, .0, .0))
28 Y_UP
= Vector((.0, 1.0, .0))
29 Z_UP
= Vector((.0, .0, 1.0))
30 ZERO_VEC
= Vector((.0, .0, .0))
36 def calc_loop_normal(verts
, fallback
=Z_UP
):
37 # Calculate normal from verts using Newell's method.
38 normal
= ZERO_VEC
.copy()
40 if verts
[0] is verts
[-1]:
42 range_verts
= range(1, len(verts
))
45 range_verts
= range(0, len(verts
))
48 v1co
, v2co
= verts
[i
-1].co
, verts
[i
].co
49 normal
.x
+= (v1co
.y
- v2co
.y
) * (v1co
.z
+ v2co
.z
)
50 normal
.y
+= (v1co
.z
- v2co
.z
) * (v1co
.x
+ v2co
.x
)
51 normal
.z
+= (v1co
.x
- v2co
.x
) * (v1co
.y
+ v2co
.y
)
53 if normal
!= ZERO_VEC
:
60 def collect_edges(bm
):
61 set_edges_orig
= set()
65 for f
in e
.link_faces
:
67 co_faces_selected
+= 1
68 if co_faces_selected
== 2:
73 if not set_edges_orig
:
78 def collect_loops(set_edges_orig
):
79 set_edges_copy
= set_edges_orig
.copy()
81 loops
= [] # [v, e, v, e, ... , e, v]
83 edge_start
= set_edges_copy
.pop()
84 v_left
, v_right
= edge_start
.verts
85 lp
= [v_left
, edge_start
, v_right
]
89 for e
in v_right
.link_edges
:
90 if e
in set_edges_copy
:
95 set_edges_copy
.remove(e
)
97 v_right
= edge
.other_vert(v_right
)
98 lp
.extend((edge
, v_right
))
101 if v_right
is v_left
:
105 elif reverse
is False:
106 # Right side of half loop.
107 # Reversing the loop to operate same procedure on the left side.
109 v_right
, v_left
= v_left
, v_right
113 # Half loop, completed.
118 def get_adj_ix(ix_start
, vec_edges
, half_loop
):
119 # Get adjacent edge index, skipping zero length edges
120 len_edges
= len(vec_edges
)
122 range_right
= range(ix_start
, len_edges
)
123 range_left
= range(ix_start
-1, -1, -1)
125 range_right
= range(ix_start
, ix_start
+len_edges
)
126 range_left
= range(ix_start
-1, ix_start
-1-len_edges
, -1)
128 ix_right
= ix_left
= None
129 for i
in range_right
:
132 if vec_edges
[i
] != ZERO_VEC
:
138 if vec_edges
[i
] != ZERO_VEC
:
142 # If index of one side is None, assign another index.
148 return ix_right
, ix_left
150 def get_adj_faces(edges
):
155 for f
in e
.link_faces
:
156 # Search an adjacent face.
157 # Selected face has precedence.
158 if not f
.hide
and f
.normal
!= ZERO_VEC
:
163 adj_faces
.append(adj_f
)
167 adj_faces
.append(adj_f
)
169 adj_faces
.append(None)
173 def get_edge_rail(vert
, set_edges_orig
):
174 co_edges
= co_edges_selected
= 0
176 for e
in vert
.link_edges
:
177 if (e
not in set_edges_orig
and
178 (e
.select
or (co_edges_selected
== 0 and not e
.hide
))):
179 v_other
= e
.other_vert(vert
)
180 vec
= v_other
.co
- vert
.co
184 co_edges_selected
+= 1
185 if co_edges_selected
== 2:
189 if co_edges_selected
== 1:
190 vec_inner
.normalize()
193 # No selected edges, one unselected edge.
194 vec_inner
.normalize()
199 def get_cross_rail(vec_tan
, vec_edge_r
, vec_edge_l
, normal_r
, normal_l
):
200 # Cross rail is a cross vector between normal_r and normal_l.
202 vec_cross
= normal_r
.cross(normal_l
)
203 if vec_cross
.dot(vec_tan
) < .0:
205 cos_min
= min(vec_tan
.dot(vec_edge_r
), vec_tan
.dot(-vec_edge_l
))
206 cos
= vec_tan
.dot(vec_cross
)
208 vec_cross
.normalize()
213 def move_verts(width
, depth
, verts
, directions
, geom_ex
):
215 geom_s
= geom_ex
['side']
218 for e
in v
.link_edges
:
220 verts_ex
.append(e
.other_vert(v
))
222 #assert len(verts) == len(verts_ex)
225 for v
, (vec_width
, vec_depth
) in zip(verts
, directions
):
226 v
.co
+= width
* vec_width
+ depth
* vec_depth
228 def extrude_edges(bm
, edges_orig
):
229 extruded
= bmesh
.ops
.extrude_edge_only(bm
, edges
=edges_orig
)['geom']
230 n_edges
= n_faces
= len(edges_orig
)
231 n_verts
= len(extruded
) - n_edges
- n_faces
234 geom
['verts'] = verts
= set(extruded
[:n_verts
])
235 geom
['edges'] = edges
= set(extruded
[n_verts
:n_verts
+ n_edges
])
236 geom
['faces'] = set(extruded
[n_verts
+ n_edges
:])
237 geom
['side'] = set(e
for v
in verts
for e
in v
.link_edges
if e
not in edges
)
241 def clean(bm
, mode
, edges_orig
, geom_ex
=None):
245 for e
in geom_ex
['edges']:
248 lis_geom
= list(geom_ex
['side']) + list(geom_ex
['faces'])
249 bmesh
.ops
.delete(bm
, geom
=lis_geom
, context
='EDGES')
254 def collect_mirror_planes(edit_object
):
256 eob_mat_inv
= edit_object
.matrix_world
.inverted()
259 for m
in edit_object
.modifiers
:
260 if (m
.type == 'MIRROR' and m
.use_mirror_merge
):
261 merge_limit
= m
.merge_threshold
262 if not m
.mirror_object
:
264 norm_x
, norm_y
, norm_z
= X_UP
, Y_UP
, Z_UP
266 mirror_mat_local
= eob_mat_inv
@ m
.mirror_object
.matrix_world
267 loc
= mirror_mat_local
.to_translation()
268 norm_x
, norm_y
, norm_z
, _
= mirror_mat_local
.adjugated()
269 norm_x
= norm_x
.to_3d().normalized()
270 norm_y
= norm_y
.to_3d().normalized()
271 norm_z
= norm_z
.to_3d().normalized()
273 mirror_planes
.append((loc
, norm_x
, merge_limit
))
275 mirror_planes
.append((loc
, norm_y
, merge_limit
))
277 mirror_planes
.append((loc
, norm_z
, merge_limit
))
280 def get_vert_mirror_pairs(set_edges_orig
, mirror_planes
):
282 set_edges_copy
= set_edges_orig
.copy()
283 vert_mirror_pairs
= dict()
284 for e
in set_edges_orig
:
286 for mp
in mirror_planes
:
287 p_co
, p_norm
, mlimit
= mp
288 v1_dist
= abs(p_norm
.dot(v1
.co
- p_co
))
289 v2_dist
= abs(p_norm
.dot(v2
.co
- p_co
))
290 if v1_dist
<= mlimit
:
291 # v1 is on a mirror plane.
292 vert_mirror_pairs
[v1
] = mp
293 if v2_dist
<= mlimit
:
294 # v2 is on a mirror plane.
295 vert_mirror_pairs
[v2
] = mp
296 if v1_dist
<= mlimit
and v2_dist
<= mlimit
:
297 # This edge is on a mirror_plane, so should not be offsetted.
298 set_edges_copy
.remove(e
)
299 return vert_mirror_pairs
, set_edges_copy
301 return None, set_edges_orig
303 def get_mirror_rail(mirror_plane
, vec_up
):
304 p_norm
= mirror_plane
[1]
305 mirror_rail
= vec_up
.cross(p_norm
)
306 if mirror_rail
!= ZERO_VEC
:
307 mirror_rail
.normalize()
308 # Project vec_up to mirror_plane
309 vec_up
= vec_up
- vec_up
.project(p_norm
)
311 return mirror_rail
, vec_up
315 def reorder_loop(verts
, edges
, lp_normal
, adj_faces
):
316 for i
, adj_f
in enumerate(adj_faces
):
319 v1
, v2
= verts
[i
], verts
[i
+1]
321 fv
= tuple(adj_f
.verts
)
322 if fv
[fv
.index(v1
)-1] is v2
:
323 # Align loop direction
327 if lp_normal
.dot(adj_f
.normal
) < .0:
331 # All elements in adj_faces are None
333 if v
.normal
!= ZERO_VEC
:
334 if lp_normal
.dot(v
.normal
) < .0:
340 return verts
, edges
, lp_normal
, adj_faces
342 def get_directions(lp
, vec_upward
, normal_fallback
, vert_mirror_pairs
, **options
):
343 opt_follow_face
= options
['follow_face']
344 opt_edge_rail
= options
['edge_rail']
345 opt_er_only_end
= options
['edge_rail_only_end']
346 opt_threshold
= options
['threshold']
348 verts
, edges
= lp
[::2], lp
[1::2]
349 set_edges
= set(edges
)
350 lp_normal
= calc_loop_normal(verts
, fallback
=normal_fallback
)
352 ##### Loop order might be changed below.
353 if lp_normal
.dot(vec_upward
) < .0:
354 # Make this loop's normal towards vec_upward.
360 adj_faces
= get_adj_faces(edges
)
361 verts
, edges
, lp_normal
, adj_faces
= \
362 reorder_loop(verts
, edges
, lp_normal
, adj_faces
)
364 adj_faces
= (None, ) * len(edges
)
365 ##### Loop order might be changed above.
367 vec_edges
= tuple((e
.other_vert(v
).co
- v
.co
).normalized()
368 for v
, e
in zip(verts
, edges
))
370 if verts
[0] is verts
[-1]:
371 # Real loop. Popping last vertex.
378 len_verts
= len(verts
)
380 for i
in range(len_verts
):
382 ix_right
, ix_left
= i
, i
-1
390 elif i
== len_verts
- 1:
395 edge_right
, edge_left
= vec_edges
[ix_right
], vec_edges
[ix_left
]
396 face_right
, face_left
= adj_faces
[ix_right
], adj_faces
[ix_left
]
398 norm_right
= face_right
.normal
if face_right
else lp_normal
399 norm_left
= face_left
.normal
if face_left
else lp_normal
400 if norm_right
.angle(norm_left
) > opt_threshold
:
401 # Two faces are not flat.
406 tan_right
= edge_right
.cross(norm_right
).normalized()
407 tan_left
= edge_left
.cross(norm_left
).normalized()
408 tan_avr
= (tan_right
+ tan_left
).normalized()
409 norm_avr
= (norm_right
+ norm_left
).normalized()
412 if two_normals
or opt_edge_rail
:
414 # edge rail is a vector of an inner edge.
415 if two_normals
or (not opt_er_only_end
) or VERT_END
:
416 rail
= get_edge_rail(vert
, set_edges
)
417 if vert_mirror_pairs
and VERT_END
:
418 if vert
in vert_mirror_pairs
:
420 get_mirror_rail(vert_mirror_pairs
[vert
], norm_avr
)
421 if (not rail
) and two_normals
:
423 # Cross rail is a cross vector between norm_right and norm_left.
424 rail
= get_cross_rail(
425 tan_avr
, edge_right
, edge_left
, norm_right
, norm_left
)
427 dot
= tan_avr
.dot(rail
)
433 vec_plane
= norm_avr
.cross(tan_avr
)
434 e_dot_p_r
= edge_right
.dot(vec_plane
)
435 e_dot_p_l
= edge_left
.dot(vec_plane
)
436 if e_dot_p_r
or e_dot_p_l
:
437 if e_dot_p_r
> e_dot_p_l
:
438 vec_edge
, e_dot_p
= edge_right
, e_dot_p_r
440 vec_edge
, e_dot_p
= edge_left
, e_dot_p_l
442 vec_tan
= (tan_avr
- tan_avr
.project(vec_edge
)).normalized()
443 # Make vec_tan perpendicular to vec_edge
444 vec_up
= vec_tan
.cross(vec_edge
)
446 vec_width
= vec_tan
- (vec_tan
.dot(vec_plane
) / e_dot_p
) * vec_edge
447 vec_depth
= vec_up
- (vec_up
.dot(vec_plane
) / e_dot_p
) * vec_edge
452 directions
.append((vec_width
, vec_depth
))
454 return verts
, directions
456 def use_cashes(self
, context
):
457 self
.caches_valid
= True
459 angle_presets
= {'0°': 0,
465 '90°': radians(90),}
466 def assign_angle_presets(self
, context
):
467 use_cashes(self
, context
)
468 self
.angle
= angle_presets
[self
.angle_presets
]
470 class OffsetEdges(bpy
.types
.Operator
):
472 bl_idname
= "mesh.offset_edges"
473 bl_label
= "Offset Edges"
474 bl_options
= {'REGISTER', 'UNDO'}
476 geometry_mode
: bpy
.props
.EnumProperty(
477 items
=[('offset', "Offset", "Offset edges"),
478 ('extrude', "Extrude", "Extrude edges"),
479 ('move', "Move", "Move selected edges")],
480 name
="Geometory mode", default
='offset',
482 width
: bpy
.props
.FloatProperty(
483 name
="Width", default
=.2, precision
=4, step
=1, update
=use_cashes
)
484 flip_width
: bpy
.props
.BoolProperty(
485 name
="Flip Width", default
=False,
486 description
="Flip width direction", update
=use_cashes
)
487 depth
: bpy
.props
.FloatProperty(
488 name
="Depth", default
=.0, precision
=4, step
=1, update
=use_cashes
)
489 flip_depth
: bpy
.props
.BoolProperty(
490 name
="Flip Depth", default
=False,
491 description
="Flip depth direction", update
=use_cashes
)
492 depth_mode
: bpy
.props
.EnumProperty(
493 items
=[('angle', "Angle", "Angle"),
494 ('depth', "Depth", "Depth")],
495 name
="Depth mode", default
='angle', update
=use_cashes
)
496 angle
: bpy
.props
.FloatProperty(
497 name
="Angle", default
=0, precision
=3, step
=.1,
498 min=-2*pi
, max=2*pi
, subtype
='ANGLE',
499 description
="Angle", update
=use_cashes
)
500 flip_angle
: bpy
.props
.BoolProperty(
501 name
="Flip Angle", default
=False,
502 description
="Flip Angle", update
=use_cashes
)
503 follow_face
: bpy
.props
.BoolProperty(
504 name
="Follow Face", default
=False,
505 description
="Offset along faces around")
506 mirror_modifier
: bpy
.props
.BoolProperty(
507 name
="Mirror Modifier", default
=False,
508 description
="Take into account of Mirror modifier")
509 edge_rail
: bpy
.props
.BoolProperty(
510 name
="Edge Rail", default
=False,
511 description
="Align vertices along inner edges")
512 edge_rail_only_end
: bpy
.props
.BoolProperty(
513 name
="Edge Rail Only End", default
=False,
514 description
="Apply edge rail to end verts only")
515 threshold
: bpy
.props
.FloatProperty(
516 name
="Flat Face Threshold", default
=radians(0.05), precision
=5,
517 step
=1.0e-4, subtype
='ANGLE',
518 description
="If difference of angle between two adjacent faces is "
519 "below this value, those faces are regarded as flat",
521 caches_valid
: bpy
.props
.BoolProperty(
522 name
="Caches Valid", default
=False,
524 angle_presets
: bpy
.props
.EnumProperty(
525 items
=[('0°', "0°", "0°"),
526 ('15°', "15°", "15°"),
527 ('30°', "30°", "30°"),
528 ('45°', "45°", "45°"),
529 ('60°', "60°", "60°"),
530 ('75°', "75°", "75°"),
531 ('90°', "90°", "90°"), ],
532 name
="Angle Presets", default
='0°',
533 update
=assign_angle_presets
)
535 _cache_offset_infos
= None
536 _cache_edges_orig_ixs
= None
539 def poll(self
, context
):
540 return context
.mode
== 'EDIT_MESH'
542 def draw(self
, context
):
544 layout
.prop(self
, 'geometry_mode', text
="")
545 #layout.prop(self, 'geometry_mode', expand=True)
547 row
= layout
.row(align
=True)
548 row
.prop(self
, 'width')
549 row
.prop(self
, 'flip_width', icon
='ARROW_LEFTRIGHT', icon_only
=True)
551 layout
.prop(self
, 'depth_mode', expand
=True)
552 if self
.depth_mode
== 'angle':
558 row
= layout
.row(align
=True)
559 row
.prop(self
, d_mode
)
560 row
.prop(self
, flip
, icon
='ARROW_LEFTRIGHT', icon_only
=True)
561 if self
.depth_mode
== 'angle':
562 layout
.prop(self
, 'angle_presets', text
="Presets", expand
=True)
566 layout
.prop(self
, 'follow_face')
569 row
.prop(self
, 'edge_rail')
571 row
.prop(self
, 'edge_rail_only_end', text
="OnlyEnd", toggle
=True)
573 layout
.prop(self
, 'mirror_modifier')
575 #layout.operator('mesh.offset_edges', text='Repeat')
579 layout
.prop(self
, 'threshold', text
='Threshold')
582 def get_offset_infos(self
, bm
, edit_object
):
583 if self
.caches_valid
and self
._cache
_offset
_infos
is not None:
584 # Return None, indicating to use cache.
587 time
= perf_counter()
589 set_edges_orig
= collect_edges(bm
)
590 if set_edges_orig
is None:
591 self
.report({'WARNING'},
592 "No edges selected.")
595 if self
.mirror_modifier
:
596 mirror_planes
= collect_mirror_planes(edit_object
)
597 vert_mirror_pairs
, set_edges
= \
598 get_vert_mirror_pairs(set_edges_orig
, mirror_planes
)
601 set_edges_orig
= set_edges
603 #self.report({'WARNING'},
604 # "All selected edges are on mirror planes.")
605 vert_mirror_pairs
= None
607 vert_mirror_pairs
= None
609 loops
= collect_loops(set_edges_orig
)
611 self
.report({'WARNING'},
612 "Overlap detected. Select non-overlap edge loops")
615 vec_upward
= (X_UP
+ Y_UP
+ Z_UP
).normalized()
616 # vec_upward is used to unify loop normals when follow_face is off.
617 normal_fallback
= Z_UP
618 #normal_fallback = Vector(context.region_data.view_matrix[2][:3])
619 # normal_fallback is used when loop normal cannot be calculated.
621 follow_face
= self
.follow_face
622 edge_rail
= self
.edge_rail
623 er_only_end
= self
.edge_rail_only_end
624 threshold
= self
.threshold
628 verts
, directions
= get_directions(
629 lp
, vec_upward
, normal_fallback
, vert_mirror_pairs
,
630 follow_face
=follow_face
, edge_rail
=edge_rail
,
631 edge_rail_only_end
=er_only_end
,
634 offset_infos
.append((verts
, directions
))
637 self
._cache
_offset
_infos
= _cache_offset_infos
= []
638 for verts
, directions
in offset_infos
:
639 v_ixs
= tuple(v
.index
for v
in verts
)
640 _cache_offset_infos
.append((v_ixs
, directions
))
641 self
._cache
_edges
_orig
_ixs
= tuple(e
.index
for e
in set_edges_orig
)
643 print("Preparing OffsetEdges: ", perf_counter() - time
)
645 return offset_infos
, set_edges_orig
647 def do_offset_and_free(self
, bm
, me
, offset_infos
=None, set_edges_orig
=None):
648 # If offset_infos is None, use caches.
649 # Makes caches invalid after offset.
651 #time = perf_counter()
653 if offset_infos
is None:
655 bmverts
= tuple(bm
.verts
)
656 bmedges
= tuple(bm
.edges
)
657 edges_orig
= [bmedges
[ix
] for ix
in self
._cache
_edges
_orig
_ixs
]
658 verts_directions
= []
659 for ix_vs
, directions
in self
._cache
_offset
_infos
:
660 verts
= tuple(bmverts
[ix
] for ix
in ix_vs
)
661 verts_directions
.append((verts
, directions
))
663 verts_directions
= offset_infos
664 edges_orig
= list(set_edges_orig
)
666 if self
.depth_mode
== 'angle':
667 w
= self
.width
if not self
.flip_width
else -self
.width
668 angle
= self
.angle
if not self
.flip_angle
else -self
.angle
669 width
= w
* cos(angle
)
670 depth
= w
* sin(angle
)
672 width
= self
.width
if not self
.flip_width
else -self
.width
673 depth
= self
.depth
if not self
.flip_depth
else -self
.depth
676 if self
.geometry_mode
== 'move':
679 geom_ex
= extrude_edges(bm
, edges_orig
)
681 for verts
, directions
in verts_directions
:
682 move_verts(width
, depth
, verts
, directions
, geom_ex
)
684 clean(bm
, self
.geometry_mode
, edges_orig
, geom_ex
)
686 bpy
.ops
.object.mode_set(mode
="OBJECT")
688 bpy
.ops
.object.mode_set(mode
="EDIT")
690 self
.caches_valid
= False # Make caches invalid.
692 #print("OffsetEdges offset: ", perf_counter() - time)
694 def execute(self
, context
):
696 edit_object
= context
.edit_object
697 bpy
.ops
.object.mode_set(mode
="OBJECT")
699 me
= edit_object
.data
703 offset_infos
, edges_orig
= self
.get_offset_infos(bm
, edit_object
)
704 if offset_infos
is False:
705 bpy
.ops
.object.mode_set(mode
="EDIT")
708 self
.do_offset_and_free(bm
, me
, offset_infos
, edges_orig
)
712 def restore_original_and_free(self
, context
):
713 self
.caches_valid
= False # Make caches invalid.
714 context
.area
.header_text_set()
716 me
= context
.edit_object
.data
717 bpy
.ops
.object.mode_set(mode
="OBJECT")
718 self
._bm
_orig
.to_mesh(me
)
719 bpy
.ops
.object.mode_set(mode
="EDIT")
722 context
.area
.header_text_set()
724 def invoke(self
, context
, event
):
726 edit_object
= context
.edit_object
727 me
= edit_object
.data
728 bpy
.ops
.object.mode_set(mode
="OBJECT")
729 for p
in me
.polygons
:
731 self
.follow_face
= True
734 self
.caches_valid
= False
735 bpy
.ops
.object.mode_set(mode
="EDIT")
736 return self
.execute(context
)
738 class OffsetEdgesMenu(bpy
.types
.Menu
):
739 bl_idname
= "VIEW3D_MT_edit_mesh_offset_edges"
740 bl_label
= "Offset Edges"
742 def draw(self
, context
):
744 layout
.operator_context
= 'INVOKE_DEFAULT'
746 off
= layout
.operator('mesh.offset_edges', text
='Offset')
747 off
.geometry_mode
= 'offset'
749 ext
= layout
.operator('mesh.offset_edges', text
='Extrude')
750 ext
.geometry_mode
= 'extrude'
752 mov
= layout
.operator('mesh.offset_edges', text
='Move')
753 mov
.geometry_mode
= 'move'
760 def draw_item(self
, context
):
761 self
.layout
.menu("VIEW3D_MT_edit_mesh_offset_edges")
766 bpy
.utils
.register_class(cls
)
767 bpy
.types
.VIEW3D_MT_edit_mesh_edges
.prepend(draw_item
)
771 for cls
in reversed(classes
):
772 bpy
.utils
.unregister_class(cls
)
773 bpy
.types
.VIEW3D_MT_edit_mesh_edges
.remove(draw_item
)
776 if __name__
== '__main__':