Cleanup: tabs -> spaces
[blender-addons.git] / mesh_inset / __init__.py
blob4dbd84721da65ba7701a535bf0c8e5c898196236
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 #####
19 # <pep8 compliant>
21 bl_info = {
22 "name": "Inset Straight Skeleton",
23 "author": "Howard Trickey",
24 "version": (1, 1),
25 "blender": (2, 80, 0),
26 "location": "3DView Operator",
27 "description": "Make an inset inside selection using straight skeleton algorithm.",
28 "warning": "",
29 "doc_url": "https://docs.blender.org/manual/en/dev/addons/"
30 "mesh/inset_straight_skeleton.html",
31 "category": "Mesh",
35 if "bpy" in locals():
36 import importlib
37 else:
38 from . import (
39 geom,
40 model,
41 offset,
42 triquad,
45 import math
46 import bpy
47 import bmesh
48 import mathutils
49 from mathutils import Vector
50 from bpy_extras import view3d_utils
51 import gpu
52 from gpu_extras.batch import batch_for_shader
54 from bpy.props import (
55 BoolProperty,
56 EnumProperty,
57 FloatProperty,
60 SpaceView3D = bpy.types.SpaceView3D
62 INSET_VALUE = 0
63 HEIGHT_VALUE = 1
64 NUM_VALUES = 2
66 # TODO: make a dooted-line shader
67 shader = gpu.shader.from_builtin('2D_UNIFORM_COLOR') if not bpy.app.background else None
69 class MESH_OT_InsetStraightSkeleton(bpy.types.Operator):
70 bl_idname = "mesh.insetstraightskeleton"
71 bl_label = "Inset Straight Skeleton"
72 bl_description = "Make an inset inside selection using straight skeleton algorithm"
73 bl_options = {'UNDO', 'REGISTER', 'GRAB_CURSOR', 'BLOCKING'}
75 inset_amount: FloatProperty(name="Amount",
76 description="Amount to move inset edges",
77 default=0.0,
78 min=0.0,
79 max=1000.0,
80 soft_min=0.0,
81 soft_max=100.0,
82 unit='LENGTH')
83 inset_height: FloatProperty(name="Height",
84 description="Amount to raise inset faces",
85 default=0.0,
86 min=-10000.0,
87 max=10000.0,
88 soft_min=-500.0,
89 soft_max=500.0,
90 unit='LENGTH')
91 region: BoolProperty(name="Region",
92 description="Inset selection as one region?",
93 default=True)
94 quadrangulate: BoolProperty(name="Quadrangulate",
95 description="Quadrangulate after inset?",
96 default=True)
98 @classmethod
99 def poll(cls, context):
100 obj = context.active_object
101 return (obj and obj.type == 'MESH' and context.mode == 'EDIT_MESH')
103 def draw(self, context):
104 layout = self.layout
105 box = layout.box()
106 box.label(text="Inset Options:")
107 box.prop(self, "inset_amount")
108 box.prop(self, "inset_height")
109 box.prop(self, "region")
110 box.prop(self, "quadrangulate")
112 def invoke(self, context, event):
113 self.modal = True
114 # make backup bmesh from current mesh, after flushing editmode to mesh
115 bpy.context.object.update_from_editmode()
116 self.backup = bmesh.new()
117 self.backup.from_mesh(bpy.context.object.data)
118 self.inset_amount = 0.0
119 self.inset_height = 0.0
120 self.center, self.center3d = calc_select_center(context)
121 self.center_pixel_size = calc_pixel_size(context, self.center3d)
122 udpi = context.preferences.system.dpi
123 upixelsize = context.preferences.system.pixel_size
124 self.pixels_per_inch = udpi * upixelsize
125 self.value_mode = INSET_VALUE
126 self.initial_length = [-1.0, -1.0]
127 self.scale = [self.center_pixel_size] * NUM_VALUES
128 self.calc_initial_length(event, True)
129 self.mouse_cur = Vector((event.mouse_region_x, event.mouse_region_y))
130 col = context.preferences.themes["Default"].view_3d.view_overlay
131 self.line_color = (col.r, col.g, col.b, 1.0)
133 self.action(context)
135 context.window_manager.modal_handler_add(self)
136 self.draw_handle = SpaceView3D.draw_handler_add(draw_callback,
137 (self,), 'WINDOW', 'POST_PIXEL')
139 return {'RUNNING_MODAL'}
141 def calc_initial_length(self, event, mode_changed):
142 mdiff = self.center - Vector((event.mouse_region_x, event.mouse_region_y))
143 mlen = mdiff.length;
144 vmode = self.value_mode
145 if mode_changed or self.initial_length[vmode] == -1:
146 if vmode == INSET_VALUE:
147 value = self.inset_amount
148 else:
149 value = self.inset_height
150 sc = self.scale[vmode]
151 if value != 0.0:
152 mlen = mlen - value / sc
153 self.initial_length[vmode] = mlen
155 def modal(self, context, event):
156 if event.type in ['LEFTMOUSE', 'RIGHTMOUSE', 'ESC']:
157 if self.modal:
158 self.backup.free()
159 if self.draw_handle:
160 SpaceView3D.draw_handler_remove(self.draw_handle, 'WINDOW')
161 context.area.tag_redraw()
162 if event.type == 'LEFTMOUSE': # Confirm
163 return {'FINISHED'}
164 else: # Cancel
165 return {'CANCELLED'}
166 else:
167 # restore mesh to original state
168 bpy.ops.object.editmode_toggle()
169 self.backup.to_mesh(bpy.context.object.data)
170 bpy.ops.object.editmode_toggle()
171 if event.type == 'MOUSEMOVE':
172 if self.value_mode == INSET_VALUE and event.ctrl:
173 self.value_mode = HEIGHT_VALUE
174 self.calc_initial_length(event, True)
175 elif self.value_mode == HEIGHT_VALUE and not event.ctrl:
176 self.value_mode = INSET_VALUE
177 self.calc_initial_length(event, True)
178 self.mouse_cur = Vector((event.mouse_region_x, event.mouse_region_y))
179 vmode = self.value_mode
180 mdiff = self.center - self.mouse_cur
181 value = (mdiff.length - self.initial_length[vmode]) * self.scale[vmode]
182 if vmode == INSET_VALUE:
183 self.inset_amount = value
184 else:
185 self.inset_height = value
186 elif event.type == 'R' and event.value == 'PRESS':
187 self.region = not self.region
188 elif event.type == 'Q' and event.value == 'PRESS':
189 self.quadrangulate = not self.quadrangulate
190 self.action(context)
192 return {'RUNNING_MODAL'}
194 def execute(self, context):
195 self.modal = False
196 self.action(context)
197 return {'FINISHED'}
199 def action(self, context):
200 obj = bpy.context.active_object
201 mesh = obj.data
202 do_inset(mesh, self.inset_amount, self.inset_height, self.region,
203 self.quadrangulate)
204 bpy.ops.object.editmode_toggle()
205 bpy.ops.object.editmode_toggle()
208 def draw_callback(op):
209 startpos = op.mouse_cur
210 endpos = op.center
211 coords = [startpos.to_tuple(), endpos.to_tuple()]
212 batch = batch_for_shader(shader, 'LINES', {"pos": coords})
214 try:
215 shader.bind()
216 shader.uniform_float("color", op.line_color)
217 batch.draw(shader)
218 except:
219 pass
221 def calc_pixel_size(context, co):
222 # returns size in blender units of a pixel at 3d coord co
223 # see C code in ED_view3d_pixel_size and ED_view3d_update_viewmat
224 m = context.region_data.perspective_matrix
225 v1 = m[0].to_3d()
226 v2 = m[1].to_3d()
227 ll = min(v1.length_squared, v2.length_squared)
228 len_pz = 2.0 / math.sqrt(ll)
229 len_sz = max(context.region.width, context.region.height)
230 rv3dpixsize = len_pz / len_sz
231 proj = m[3][0] * co[0] + m[3][1] * co[1] + m[3][2] * co[2] + m[3][3]
232 ups = context.preferences.system.pixel_size
233 return proj * rv3dpixsize * ups
235 def calc_select_center(context):
236 # returns region 2d coord and global 3d coord of selection center
237 ob = bpy.context.active_object
238 mesh = ob.data
239 center = Vector((0.0, 0.0, 0.0))
240 n = 0
241 for v in mesh.vertices:
242 if v.select:
243 center = center + Vector(v.co)
244 n += 1
245 if n > 0:
246 center = center / n
247 world_center = ob.matrix_world @ center
248 world_center_2d = view3d_utils.location_3d_to_region_2d( \
249 context.region, context.region_data, world_center)
250 return (world_center_2d, world_center)
252 def do_inset(mesh, amount, height, region, quadrangulate):
253 if amount <= 0.0:
254 return
255 pitch = math.atan(height / amount)
256 selfaces = []
257 selface_indices = []
258 bm = bmesh.from_edit_mesh(mesh)
259 for face in bm.faces:
260 if face.select:
261 selfaces.append(face)
262 selface_indices.append(face.index)
263 m = geom.Model()
264 # if add all mesh.vertices, coord indices will line up
265 # Note: not using Points.AddPoint which does dup elim
266 # because then would have to map vertices in and out
267 m.points.pos = [v.co.to_tuple() for v in bm.verts]
268 for f in selfaces:
269 m.faces.append([loop.vert.index for loop in f.loops])
270 m.face_data.append(f.index)
271 orig_numv = len(m.points.pos)
272 orig_numf = len(m.faces)
273 model.BevelSelectionInModel(m, amount, pitch, quadrangulate, region, False)
274 if len(m.faces) == orig_numf:
275 # something went wrong with Bevel - just treat as no-op
276 return
277 blender_faces = m.faces[orig_numf:len(m.faces)]
278 blender_old_face_index = m.face_data[orig_numf:len(m.faces)]
279 for i in range(orig_numv, len(m.points.pos)):
280 bvertnew = bm.verts.new(m.points.pos[i])
281 bm.verts.index_update()
282 bm.verts.ensure_lookup_table()
283 new_faces = []
284 start_faces = len(bm.faces)
285 for i, newf in enumerate(blender_faces):
286 vs = remove_dups([bm.verts[j] for j in newf])
287 if len(vs) < 3:
288 continue
289 # copy face attributes from old face that it was derived from
290 bfi = blender_old_face_index[i]
291 # sometimes, not sure why, this face already exists
292 # bmesh will give a value error in bm.faces.new() in that case
293 try:
294 if bfi and 0 <= bfi < start_faces:
295 bm.faces.ensure_lookup_table()
296 oldface = bm.faces[bfi]
297 bfacenew = bm.faces.new(vs, oldface)
298 # bfacenew.copy_from_face_interp(oldface)
299 else:
300 bfacenew = bm.faces.new(vs)
301 new_faces.append(bfacenew)
302 except ValueError:
303 # print("dup face with amount", amount)
305 # print([v.index for v in vs])
306 pass
307 # deselect original faces
308 for face in selfaces:
309 face.select_set(False)
310 # remove original faces
311 bmesh.ops.delete(bm, geom=selfaces, context='FACES')
312 # select all new faces (should only select inner faces, but that needs more surgery on rest of code)
313 for face in new_faces:
314 face.select_set(True)
315 bmesh.update_edit_mesh(mesh)
317 def remove_dups(vs):
318 seen = set()
319 return [x for x in vs if not (x in seen or seen.add(x))]
321 def menu(self, context):
322 self.layout.operator("mesh.insetstraightskeleton", text="Inset Straight Skeleton")
324 def register():
325 bpy.utils.register_class(MESH_OT_InsetStraightSkeleton)
326 bpy.types.VIEW3D_MT_edit_mesh_faces.append(menu)
328 def unregister():
329 bpy.utils.unregister_class(MESH_OT_InsetStraightSkeleton)
330 bpy.types.VIEW3D_MT_edit_mesh_faces.remove(menu)