2025-07-01

This commit is contained in:
2026-03-17 14:30:01 -06:00
parent f9a22056dd
commit 62b5978595
4579 changed files with 1257472 additions and 0 deletions
@@ -0,0 +1,278 @@
'''
Copyright (C) 2023 CG Cookie
http://cgcookie.com
hello@cgcookie.com
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
'''
import random
from mathutils.geometry import intersect_line_line_2d as intersect2d_segment_segment
from ..rftool import RFTool
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
from ..rfwidgets.rfwidget_hidden import RFWidget_Hidden_Factory
from ..rfmesh.rfmesh_wrapper import RFVert, RFEdge, RFFace
from ...addon_common.common import gpustate
from ...addon_common.common.drawing import (
CC_DRAW,
CC_2D_POINTS,
CC_2D_LINES, CC_2D_LINE_LOOP,
CC_2D_TRIANGLES, CC_2D_TRIANGLE_FAN,
)
from ...addon_common.common.profiler import profiler
from ...addon_common.common.maths import Point, Point2D, Vec2D, Vec, Direction2D, intersection2d_line_line, closest2d_point_segment
from ...addon_common.common.fsm import FSM
from ...addon_common.common.globals import Globals
from ...addon_common.common.utils import iter_pairs
from ...addon_common.common.blender import tag_redraw_all
from ...addon_common.common.drawing import DrawCallbacks
from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat, BoundString
from ...addon_common.common.timerhandler import CallGovernor
from ...addon_common.common.debug import dprint
from .polypen_insert import PolyPen_Insert
from ...config.options import options, themes
class PolyPen(RFTool, PolyPen_Insert):
name = 'PolyPen'
description = 'Create complex topology on vertex-by-vertex basis'
icon = 'polypen-icon.png'
help = 'polypen.md'
shortcut = 'polypen tool'
statusbar = '{{insert}} Insert'
ui_config = 'polypen_options.html'
RFWidget_Default = RFWidget_Default_Factory.create()
RFWidget_Crosshair = RFWidget_Default_Factory.create(cursor='CROSSHAIR')
RFWidget_Move = RFWidget_Default_Factory.create(cursor='HAND')
RFWidget_Knife = RFWidget_Default_Factory.create(cursor='KNIFE')
RFWidget_Hidden = RFWidget_Hidden_Factory.create()
@RFTool.on_init
def init(self):
self.rfwidgets = {
'default': self.RFWidget_Default(self),
'insert': self.RFWidget_Crosshair(self),
'hover': self.RFWidget_Move(self),
'knife': self.RFWidget_Knife(self),
'hidden': self.RFWidget_Hidden(self),
}
self.rfwidget = None
self.next_state = 'unset'
self.nearest_vert, self.nearest_edge, self.nearest_face, self.nearest_geom = None, None, None, None
self.vis_verts, self.vis_edges, self.vis_faces = [], [], []
self.update_selection()
self._var_merge_dist = BoundFloat( '''options['polypen merge dist'] ''')
self._var_automerge = BoundBool( '''options['polypen automerge'] ''')
self._var_insert_mode = BoundString('''options['polypen insert mode']''')
def _fsm_in_main(self):
# needed so main actions using Ctrl (ex: undo, redo, save) can still work
return self._fsm.state in {'main', 'previs insert'}
def update_insert_mode(self):
mode = options['polypen insert mode']
self.ui_options_label.innerText = f'PolyPen: {mode}'
self.ui_insert_modes.dirty(cause='insert mode change', children=True)
@RFTool.on_ui_setup
def ui(self):
ui_options = self.document.body.getElementById('polypen-options')
self.ui_options_label = ui_options.getElementById('polypen-summary-label')
self.ui_insert_modes = ui_options.getElementById('polypen-insert-modes')
self.update_insert_mode()
@RFTool.on_reset
@RFTool.on_target_change
@FSM.onlyinstate('main')
def update_selection(self):
self.sel_verts, self.sel_edges, self.sel_faces = self.rfcontext.get_selected_geom()
@RFTool.on_events('reset', 'target change', 'view change', 'mouse move')
@RFTool.not_while_navigating
@FSM.onlyinstate('main')
def update_nearest(self):
self.nearest_vert,_ = self.rfcontext.accel_nearest2D_vert(max_dist=options['polypen merge dist'], selected_only=True)
self.nearest_edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['polypen merge dist'], selected_only=True)
self.nearest_face,_ = self.rfcontext.accel_nearest2D_face(max_dist=options['polypen merge dist'], selected_only=True)
self.nearest_geom = self.nearest_vert or self.nearest_edge or self.nearest_face
@FSM.on_state('main', 'enter')
def main_enter(self):
self.update_selection()
self.update_nearest()
@FSM.on_state('main')
def main(self):
if self.actions.using_onlymods('insert'):
return 'previs insert'
if self.nearest_geom and self.nearest_geom.select:
self.set_widget('hover')
else:
self.set_widget('default')
if self.handle_inactive_passthrough(): return
if self.actions.pressed('pie menu alt0'):
def callback(option):
if not option: return
options['polypen insert mode'] = option
self.update_insert_mode()
self.rfcontext.show_pie_menu([
'Tri/Quad',
'Quad-Only',
'Tri-Only',
'Edge-Only',
], callback, highlighted=options['polypen insert mode'])
return
if self.nearest_geom and self.nearest_geom.select and self.actions.pressed('action'):
self.rfcontext.undo_push('grab')
self.prep_move(
action_confirm=lambda: self.actions.released('action', ignoremods=True),
)
return 'move after select'
if self.actions.pressed('grab'):
self.rfcontext.undo_push('move grabbed')
self.prep_move(
action_confirm=lambda: self.actions.pressed({'confirm', 'confirm drag'}),
)
return 'move'
if self.actions.pressed({'select path add'}):
return self.rfcontext.select_path(
{'edge', 'face'},
kwargs_select={'supparts': False},
)
if self.actions.pressed({'select paint', 'select paint add'}, unpress=False):
sel_only = self.actions.pressed('select paint')
self.actions.unpress()
return self.rfcontext.setup_smart_selection_painting(
{'vert','edge','face'},
use_select_tool=True,
selecting=not sel_only,
deselect_all=sel_only,
kwargs_select={'supparts': False},
kwargs_deselect={'subparts': False},
)
if self.actions.pressed({'select single', 'select single add'}, unpress=False):
sel_only = self.actions.pressed('select single')
self.actions.unpress()
bmv,_ = self.rfcontext.accel_nearest2D_vert(max_dist=options['select dist'])
bme,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['select dist'])
bmf,_ = self.rfcontext.accel_nearest2D_face(max_dist=options['select dist'])
sel = bmv or bme or bmf
if not sel_only and not sel: return
self.rfcontext.undo_push('select')
if sel_only: self.rfcontext.deselect_all()
if not sel: return
if sel.select: self.rfcontext.deselect(sel, subparts=False)
else: self.rfcontext.select(sel, supparts=False, only=sel_only)
return
@FSM.on_state('move after select')
def modal_move_after_select(self):
if self.actions.released('action'):
return 'main'
if (self.actions.mouse - self.mousedown).length >= self.rfcontext.drawing.scale(options['move dist']):
self.rfcontext.undo_push('move after select')
return 'move'
def prep_move(self, *, bmverts=None, action_confirm=None, action_cancel=None, defer_recomputing=True):
Point_to_Point2D = self.rfcontext.Point_to_Point2D
self.bmverts = [bmv for bmv in bmverts if bmv and bmv.is_valid] if bmverts is not None else self.rfcontext.get_selected_verts()
self.bmverts_xys = [
(bmv, xy)
for bmv in self.bmverts
if bmv and bmv.is_valid and (xy := Point_to_Point2D(bmv.co)) is not None
]
self.move_actions = {
'confirm': action_confirm or (lambda: self.actions.pressed('confirm')),
'cancel': action_cancel or (lambda: self.actions.pressed('cancel')),
}
self.mousedown = self.actions.mouse
self.last_delta = None
self.defer_recomputing = defer_recomputing
@FSM.on_state('move', 'enter')
def move_enter(self):
self.move_vis_accel = self.rfcontext.get_accel_visible(selected_only=False)
# if not self.move_done_released and options['hide cursor on tweak']: self.set_widget('hidden')
if options['hide cursor on tweak']: self.set_widget('hidden')
self.rfcontext.split_target_visualization_selected()
self.rfcontext.fast_update_timer.start()
self.rfcontext.set_accel_defer(True)
self.last_delta = None
@FSM.on_state('move')
def modal_move(self):
if self.move_actions['confirm']():
self.defer_recomputing = False
if options['polypen automerge']:
self.rfcontext.merge_verts_by_dist(self.bmverts, options['polypen merge dist'])
return 'main'
if self.move_actions['cancel']():
self.defer_recomputing = False
self.rfcontext.undo_cancel()
return 'main'
@RFTool.on_mouse_move
@RFTool.once_per_frame
@FSM.onlyinstate('move')
def modal_move_update(self):
delta = Vec2D(self.actions.mouse - self.mousedown)
if delta == self.last_delta: return
self.last_delta = delta
set2D_vert = self.rfcontext.set2D_vert
for bmv,xy in self.bmverts_xys:
if not xy: continue
xy_updated = xy + delta
# check if xy_updated is "close" to any visible verts (in image plane)
# if so, snap xy_updated to vert position (in image plane)
if options['polypen automerge']:
bmv1,_ = self.rfcontext.accel_nearest2D_vert(point=xy_updated, vis_accel=self.move_vis_accel, max_dist=options['polypen merge dist'])
if bmv1:
xy_updated = self.rfcontext.Point_to_Point2D(bmv1.co)
set2D_vert(bmv, xy_updated)
self.rfcontext.update_verts_faces(self.bmverts)
self.rfcontext.dirty()
tag_redraw_all('polypen mouse move')
@FSM.on_state('move', 'exit')
def move_exit(self):
self.rfcontext.fast_update_timer.stop()
self.rfcontext.set_accel_defer(False)
self.rfcontext.clear_split_target_visualization()
@@ -0,0 +1,711 @@
'''
Copyright (C) 2023 CG Cookie
http://cgcookie.com
hello@cgcookie.com
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
'''
import random
from mathutils.geometry import intersect_line_line_2d as intersect2d_segment_segment
from ..rftool import RFTool
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
from ..rfwidgets.rfwidget_hidden import RFWidget_Hidden_Factory
from ..rfmesh.rfmesh_wrapper import RFVert, RFEdge, RFFace
from ...addon_common.common import gpustate
from ...addon_common.common.drawing import (
CC_DRAW,
CC_2D_POINTS,
CC_2D_LINES, CC_2D_LINE_LOOP,
CC_2D_TRIANGLES, CC_2D_TRIANGLE_FAN,
)
from ...addon_common.common.profiler import profiler
from ...addon_common.common.maths import Point, Point2D, Vec2D, Vec, Direction2D, intersection2d_line_line, closest2d_point_segment
from ...addon_common.common.fsm import FSM
from ...addon_common.common.globals import Globals
from ...addon_common.common.utils import iter_pairs
from ...addon_common.common.blender import tag_redraw_all
from ...addon_common.common.drawing import DrawCallbacks
from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat, BoundString
from ...addon_common.common.timerhandler import CallGovernor
from ...addon_common.common.debug import dprint
from ...config.options import options, themes
class PolyPen_Insert():
@RFTool.on_events('target change')
@FSM.onlyinstate('previs insert')
@RFTool.not_while_navigating
def gather_selection(self):
self.sel_verts, self.sel_edges, self.sel_faces = self.rfcontext.get_selected_geom()
self.num_sel_verts, self.num_sel_edges, self.num_sel_faces = len(self.sel_verts), len(self.sel_edges), len(self.sel_faces)
@RFTool.on_events('target change', 'view change')
@FSM.onlyinstate('previs insert')
@RFTool.not_while_navigating
def gather_visible(self):
self.vis_verts, self.vis_edges, self.vis_faces = self.rfcontext.get_vis_geom()
@FSM.on_state('previs insert', 'enter')
def modal_previs_enter(self):
self.draw_coords = []
self.gather_visible()
self.gather_selection()
self.set_next_state()
self.rfcontext.fast_update_timer.enable(True)
self.modal_previs_mousemove()
tag_redraw_all('PolyPen insert mouse move')
@RFTool.on_mouse_move
@FSM.onlyinstate('previs insert')
def modal_previs_mousemove(self):
if self.next_state == 'knife selected edge':
self.set_widget('knife')
else:
self.set_widget('insert')
@FSM.on_state('previs insert')
def modal_previs(self):
if self.handle_inactive_passthrough(): return
if self.actions.pressed('insert'):
return 'insert'
if not self.actions.using_onlymods('insert'):
return 'main'
@FSM.on_state('previs insert', 'exit')
def modal_previs_exit(self):
self.rfcontext.fast_update_timer.enable(False)
@DrawCallbacks.on_draw('post2d')
@FSM.onlyinstate('previs insert')
@RFTool.not_while_navigating
def draw_postpixel(self):
gpustate.blend('ALPHA')
CC_DRAW.stipple(pattern=[4,4])
CC_DRAW.point_size(8)
CC_DRAW.line_width(2)
poly_alpha = 0.2
line_color = themes['new']
poly_color = [line_color[0], line_color[1], line_color[2], line_color[3] * poly_alpha]
for coords in self.draw_coords:
coords = [self.rfcontext.Point_to_Point2D(co) for co in coords]
if not all(coords): return
match len(coords):
case 1:
with Globals.drawing.draw(CC_2D_POINTS) as draw:
draw.color(line_color)
for c in coords:
draw.vertex(c)
case 2:
with Globals.drawing.draw(CC_2D_LINES) as draw:
draw.color(line_color)
draw.vertex(coords[0])
draw.vertex(coords[1])
case _:
with Globals.drawing.draw(CC_2D_LINE_LOOP) as draw:
draw.color(line_color)
for co in coords: draw.vertex(co)
with Globals.drawing.draw(CC_2D_TRIANGLE_FAN) as draw:
draw.color(poly_color)
draw.vertex(coords[0])
for co1,co2 in iter_pairs(coords[1:], False):
draw.vertex(co1)
draw.vertex(co2)
CC_DRAW.stipple()
@FSM.on_state('insert')
def insert(self):
self.rfcontext.undo_push('insert')
return self._insert()
@RFTool.on_mouse_move
@RFTool.once_per_frame
# @RFTool.on_events('new frame')
@RFTool.not_while_navigating
@FSM.onlyinstate('previs insert')
def set_next_state(self):
'''
determines what the next state will be, based on selected mode, selected geometry, and hovered geometry
'''
self.draw_coords = []
self.nearest_vert, self.nearest_edge, self.nearest_face, self.nearest_geom = None, None, None, None
self.insert_edge = None
if not self.actions.mouse: return
hit_pos = self.actions.hit_pos
if not hit_pos: return
if True: # with profiler.code('getting nearest geometry'):
self.nearest_vert,_ = self.rfcontext.accel_nearest2D_vert(max_dist=options['polypen merge dist'])
self.nearest_edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['polypen merge dist'])
self.nearest_face,_ = self.rfcontext.accel_nearest2D_face(max_dist=options['polypen merge dist'])
self.nearest_geom = self.nearest_vert or self.nearest_edge or self.nearest_face
self.insert_edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['polypen insert dist'])
if self.insert_edge and self.insert_edge.select: # overriding: if hovering over a selected edge, knife it!
self.next_state = 'knife selected edge'
elif options['polypen insert mode'] == 'Tri/Quad':
if self.num_sel_verts == 1 and self.num_sel_edges == 0 and self.num_sel_faces == 0:
self.next_state = 'vert-edge'
elif self.num_sel_edges and self.num_sel_faces == 0:
quad_snap = (
(not self.nearest_vert and self.nearest_edge) and
(len(self.nearest_edge.link_faces) <= 1) and
(not any(v in self.sel_verts for v in self.nearest_edge.verts)) and
(not any(e in f.edges for v in self.nearest_edge.verts for f in v.link_faces for e in self.sel_edges))
)
if quad_snap:
self.next_state = 'edge-quad-snap'
else:
self.next_state = 'edge-face'
elif self.num_sel_verts == 3 and self.num_sel_edges == 3 and self.num_sel_faces == 1:
self.next_state = 'tri-quad'
else:
self.next_state = 'new vertex'
elif options['polypen insert mode'] == 'Quad-Only':
# a Desmos construction of how this works: https://www.desmos.com/geometry/bmmx206thi
if self.num_sel_verts == 1 and self.num_sel_edges == 0 and self.num_sel_faces == 0:
self.next_state = 'vert-edge'
elif self.num_sel_edges:
quad_snap = (
(not self.nearest_vert and self.nearest_edge) and
(len(self.nearest_edge.link_faces) <= 1) and
(not any(v in self.sel_verts for v in self.nearest_edge.verts)) and
(not any(e in f.edges for v in self.nearest_edge.verts for f in v.link_faces for e in self.sel_edges))
)
self.next_state = 'edge-quad-snap' if quad_snap else 'edge-quad'
else:
self.next_state = 'new vertex'
elif options['polypen insert mode'] == 'Tri-Only':
if self.num_sel_verts == 1 and self.num_sel_edges == 0 and self.num_sel_faces == 0:
self.next_state = 'vert-edge'
elif self.num_sel_edges and self.num_sel_faces == 0:
quad = (
(not self.nearest_vert and self.nearest_edge) and
(len(self.nearest_edge.link_faces) <= 1) and
(not any(v in self.sel_verts for v in self.nearest_edge.verts)) and
(not any(e in f.edges for v in self.nearest_edge.verts for f in v.link_faces for e in self.sel_edges))
)
if quad:
self.next_state = 'edge-quad-snap'
else:
self.next_state = 'edge-face'
elif self.num_sel_verts == 3 and self.num_sel_edges == 3 and self.num_sel_faces == 1:
self.next_state = 'edge-face'
else:
self.next_state = 'new vertex'
elif options['polypen insert mode'] == 'Edge-Only':
if self.num_sel_verts == 0:
self.next_state = 'new vertex'
else:
if self.insert_edge:
self.next_state = 'vert-edge'
else:
self.next_state = 'vert-edge-vert'
else:
assert False, f'Unhandled PolyPen insert mode: {options["polypen insert mode"]}'
tag_redraw_all('PolyPen next state')
match self.next_state:
case 'unset':
return
case 'knife selected edge':
bmv1,bmv2 = self.insert_edge.verts
faces = self.insert_edge.link_faces
if faces:
for f in faces:
lco = []
for v0,v1 in iter_pairs(f.verts, True):
lco.append(v0.co)
if (v0 == bmv1 and v1 == bmv2) or (v0 == bmv2 and v1 == bmv1):
lco.append(hit_pos)
self.draw_coords.append(lco)
else:
self.draw_coords.append([bmv1.co, hit_pos])
self.draw_coords.append([bmv2.co, hit_pos])
return
case 'new vertex':
p0 = hit_pos
if self.insert_edge:
bmv1,bmv2 = self.insert_edge.verts
if f := next(iter(self.insert_edge.link_faces), None):
lco = []
for v0,v1 in iter_pairs(f.verts, True):
lco.append(v0.co)
if (v0 == bmv1 and v1 == bmv2) or (v0 == bmv2 and v1 == bmv1):
lco.append(p0)
self.draw_coords.append(lco)
else:
self.draw_coords.append([bmv1.co, hit_pos])
self.draw_coords.append([bmv2.co, hit_pos])
else:
self.draw_coords.append([hit_pos])
return
case 'vert-edge' | 'vert-edge-vert':
bmv0,_ = self.rfcontext.nearest2D_vert(verts=self.sel_verts)
if self.nearest_vert:
p0 = self.nearest_vert.co
elif self.next_state == 'vert-edge':
p0 = hit_pos
if self.insert_edge:
bmv1,bmv2 = self.insert_edge.verts
if f := next(iter(self.insert_edge.link_faces), None):
lco = []
for v0,v1 in iter_pairs(f.verts, True):
lco.append(v0.co)
if (v0 == bmv1 and v1 == bmv2) or (v0 == bmv2 and v1 == bmv1):
lco.append(p0)
self.draw_coords.append(lco)
else:
self.draw_coords.append([bmv1.co, p0])
self.draw_coords.append([bmv2.co, p0])
elif self.next_state == 'vert-edge-vert':
p0 = hit_pos
else:
return
if bmv0: self.draw_coords.append([bmv0.co, p0])
return
case 'edge-face':
e0,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
e1 = self.insert_edge
if not e0: return
if e1 and e0 == e1:
bmv1,bmv2 = e1.verts
p0 = hit_pos
f = next(iter(e1.link_faces), None)
if f:
lco = []
for v0,v1 in iter_pairs(f.verts, True):
lco.append(v0.co)
if (v0 == bmv1 and v1 == bmv2) or (v0 == bmv2 and v1 == bmv1):
lco.append(p0)
self.draw_coords.append(lco)
else:
self.draw_coords.append([bmv1.co, hit_pos])
self.draw_coords.append([bmv2.co, hit_pos])
else:
# self.draw_coords.append([hit_pos])
bmv1,bmv2 = e0.verts
if self.nearest_vert and not self.nearest_vert.select:
p0 = self.nearest_vert.co
else:
p0 = hit_pos
self.draw_coords.append([p0, bmv1.co, bmv2.co])
return
case 'edge-quad':
# a Desmos construction of how this works: https://www.desmos.com/geometry/bmmx206thi
xy0, xy1, xy2, xy3 = self._get_edge_quad_verts()
if xy0 is None: return
co0 = self.rfcontext.raycast_sources_Point2D(xy0)[0]
co1 = self.rfcontext.raycast_sources_Point2D(xy1)[0]
co2 = self.rfcontext.raycast_sources_Point2D(xy2)[0]
co3 = self.rfcontext.raycast_sources_Point2D(xy3)[0]
self.draw_coords.append([co1, co2, co3, co0])
return
case 'edge-quad-snap':
e0,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
e1 = self.nearest_edge
if not e0 or not e1: return
bmv0,bmv1 = e0.verts
bmv2,bmv3 = e1.verts
p0,p1 = self.rfcontext.Point_to_Point2D(bmv0.co),self.rfcontext.Point_to_Point2D(bmv1.co)
p2,p3 = self.rfcontext.Point_to_Point2D(bmv2.co),self.rfcontext.Point_to_Point2D(bmv3.co)
if intersect2d_segment_segment(p1, p2, p3, p0): bmv2,bmv3 = bmv3,bmv2
# if e0.vector2D(self.rfcontext.Point_to_Point2D).dot(e1.vector2D(self.rfcontext.Point_to_Point2D)) > 0:
# bmv2,bmv3 = bmv3,bmv2
self.draw_coords.append([bmv0.co, bmv1.co, bmv2.co, bmv3.co])
return
case 'tri-quad':
if self.nearest_vert and not self.nearest_vert.select:
p0 = self.nearest_vert.co
else:
p0 = hit_pos
e1,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
if not e1: return
bmv1,bmv2 = e1.verts
f = next(iter(e1.link_faces), None)
if not f: return
lco = []
for v0,v1 in iter_pairs(f.verts, True):
lco.append(v0.co)
if (v0 == bmv1 and v1 == bmv2) or (v0 == bmv2 and v1 == bmv1):
lco.append(p0)
self.draw_coords.append(lco)
#self.draw_coords.append([p0, bmv1.co, bmv2.co])
return
case _:
pass
# case 'edges-face':
# if self.nearest_vert and not self.nearest_vert.select:
# p0 = self.nearest_vert.co
# else:
# p0 = hit_pos
# e1,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
# bmv1,bmv2 = e1.verts
# self.draw_coords.append([p0, bmv1.co, bmv2.co])
# if self.actions.shift and not self.actions.ctrl:
# # TODO: ALTERNATIVE INSERT, BUT NOT BEING USED!?!?
# # not in docs, not in main polypen.py FSM state
# match self.next_state:
# case 'edge-face' | 'edge-quad' | 'edge-quad-snap' | 'tri-quad':
# nearest_sel_vert,_ = self.rfcontext.nearest2D_vert(verts=self.sel_verts, max_dist=options['polypen merge dist'])
# if nearest_sel_vert:
# self.draw_coords.append([nearest_sel_vert.co, hit_pos])
# return
# case _:
# return
def _get_edge_quad_verts(self):
'''
this function is used in quad-only mode to find positions of quad verts based on selected edge and mouse position
a Desmos construction of how this works: https://www.desmos.com/geometry/5w40xowuig
'''
e0,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
if not e0: return (None, None, None, None)
bmv0,bmv1 = e0.verts
xy0 = self.rfcontext.Point_to_Point2D(bmv0.co)
xy1 = self.rfcontext.Point_to_Point2D(bmv1.co)
d01 = (xy0 - xy1).length
mid01 = xy0 + (xy1 - xy0) / 2
mid23 = self.actions.mouse
mid0123 = mid01 + (mid23 - mid01) / 2
between = mid23 - mid01
if between.length < 0.0001: return (None, None, None, None)
perp = Direction2D((-between.y, between.x))
if perp.dot(xy1 - xy0) < 0: perp.reverse()
#pts = intersect_line_line(xy0, xy1, mid0123, mid0123 + perp)
#if not pts: return (None, None, None, None)
#intersection = pts[1]
intersection = intersection2d_line_line(xy0, xy1, mid0123, mid0123 + perp)
if not intersection: return (None, None, None, None)
intersection = Point2D(intersection)
toward = Direction2D(mid23 - intersection)
if toward.dot(perp) < 0: d01 = -d01
# push intersection out just a bit to make it more stable (prevent crossing) when |between| < d01
between_len = between.length * Direction2D(xy1 - xy0).dot(perp)
for tries in range(32):
v = toward * (d01 / 2)
xy2, xy3 = mid23 + v, mid23 - v
# try to prevent quad from crossing
v03 = xy3 - xy0
if v03.dot(between) < 0 or v03.length < between_len:
xy3 = xy0 + Direction2D(v03) * (between_len * (-1 if v03.dot(between) < 0 else 1))
v12 = xy2 - xy1
if v12.dot(between) < 0 or v12.length < between_len:
xy2 = xy1 + Direction2D(v12) * (between_len * (-1 if v12.dot(between) < 0 else 1))
if self.rfcontext.raycast_sources_Point2D(xy2)[0] and self.rfcontext.raycast_sources_Point2D(xy3)[0]: break
d01 /= 2
else:
return (None, None, None, None)
nearest_vert,_ = self.rfcontext.nearest2D_vert(point=xy2, verts=self.vis_verts, max_dist=options['polypen merge dist'])
if nearest_vert: xy2 = self.rfcontext.Point_to_Point2D(nearest_vert.co)
nearest_vert,_ = self.rfcontext.nearest2D_vert(point=xy3, verts=self.vis_verts, max_dist=options['polypen merge dist'])
if nearest_vert: xy3 = self.rfcontext.Point_to_Point2D(nearest_vert.co)
return (xy0, xy1, xy2, xy3)
@RFTool.dirty_when_done
def _insert(self):
if self.actions.shift and not self.actions.ctrl and not self.next_state in ['new vertex', 'vert-edge']:
self.next_state = 'vert-edge'
nearest_vert,_ = self.rfcontext.nearest2D_vert(verts=self.sel_verts, max_dist=options['polypen merge dist'])
self.rfcontext.select(nearest_vert)
sel_verts = self.sel_verts
sel_edges = self.sel_edges
sel_faces = self.sel_faces
if self.next_state == 'knife selected edge': # overriding: if hovering over a selected edge, knife it!
# self.nearest_edge and self.nearest_edge.select:
#print('knifing selected, hovered edge')
bmv = self.rfcontext.new2D_vert_mouse()
if not bmv:
self.rfcontext.undo_cancel()
return 'main'
bme0,bmv2 = self.insert_edge.split()
bmv.merge(bmv2)
self.rfcontext.select(bmv)
self.mousedown = self.actions.mousedown
xy = self.rfcontext.Point_to_Point2D(bmv.co)
if not xy:
#print('Could not insert: ' + str(bmv.co))
self.rfcontext.undo_cancel()
return 'main'
self.prep_move(
bmverts=[bmv],
action_confirm=(lambda: self.actions.released('insert')),
)
return 'move'
if self.next_state in {'vert-edge', 'vert-edge-vert'}:
bmv0,_ = self.rfcontext.nearest2D_vert(verts=self.sel_verts)
if self.next_state == 'vert-edge':
if self.nearest_vert:
bmv1 = self.nearest_vert
if bmv0 == bmv1:
self.prep_move(
bmverts=[bmv0],
action_confirm=(lambda: self.actions.released('insert')),
)
return 'move'
lbmf = bmv0.shared_faces(bmv1)
bme = bmv0.shared_edge(bmv1)
if len(lbmf) == 1 and not bmv0.share_edge(bmv1):
# split face
bmf = lbmf[0]
bmf.split(bmv0, bmv1)
self.rfcontext.select(bmv1)
return 'main'
if not bme:
bme = self.rfcontext.new_edge((bmv0, bmv1))
self.rfcontext.select(bme)
self.prep_move(
bmverts=[bmv1],
action_confirm=(lambda: self.actions.released('insert')),
)
return 'move'
bmv1 = self.rfcontext.new2D_vert_mouse()
if not bmv1:
self.rfcontext.undo_cancel()
return 'main'
if self.nearest_edge:
if bmv0 in self.nearest_edge.verts:
# selected vert already part of edge; split
bme0,bmv2 = self.nearest_edge.split()
bmv1.merge(bmv2)
self.rfcontext.select(bmv1)
else:
bme0,bmv2 = self.nearest_edge.split()
bmv1.merge(bmv2)
bmf = next(iter(bmv0.shared_faces(bmv1)), None)
if bmf:
if not bmv0.share_edge(bmv1):
bmf.split(bmv0, bmv1)
if not bmv0.share_face(bmv1):
bme = self.rfcontext.new_edge((bmv0, bmv1))
self.rfcontext.select(bme)
self.rfcontext.select(bmv1)
else:
bme = self.rfcontext.new_edge((bmv0, bmv1))
self.rfcontext.select(bme)
elif self.next_state == 'vert-edge-vert':
if self.nearest_vert:
bmv1 = self.nearest_vert
else:
bmv1 = self.rfcontext.new2D_vert_mouse()
if not bmv1:
self.rfcontext.undo_cancel()
return 'main'
if bmv0 == bmv1:
return 'main'
bme = bmv0.shared_edge(bmv1) or self.rfcontext.new_edge((bmv0, bmv1))
self.rfcontext.select(bmv1)
else:
return 'main'
self.mousedown = self.actions.mousedown
xy = self.rfcontext.Point_to_Point2D(bmv1.co)
if not xy:
# dprint('Could not insert: ' + str(bmv1.co))
pass
self.rfcontext.undo_cancel()
return 'main'
self.prep_move(
bmverts=[bmv1],
action_confirm=(lambda: self.actions.released('insert')),
)
return 'move'
if self.next_state == 'edge-face':
bme,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
if not bme: return
bmv0,bmv1 = bme.verts
if self.nearest_vert and not self.nearest_vert.select:
bmv2 = self.nearest_vert
bmf = self.rfcontext.new_face([bmv0, bmv1, bmv2])
self.rfcontext.clean_duplicate_bmedges(bmv2)
else:
bmv2 = self.rfcontext.new2D_vert_mouse()
if not bmv2:
self.rfcontext.undo_cancel()
return 'main'
bmf = self.rfcontext.new_face([bmv0, bmv1, bmv2])
if bmf: self.rfcontext.select(bmf)
self.mousedown = self.actions.mousedown
xy = self.rfcontext.Point_to_Point2D(bmv2.co)
if not xy:
# dprint('Could not insert: ' + str(bmv2.co))
pass
self.rfcontext.undo_cancel()
return 'main'
self.prep_move(
bmverts=[bmv2],
action_confirm=(lambda: self.actions.released('insert')),
)
return 'move'
if self.next_state == 'edge-quad':
xy0,xy1,xy2,xy3 = self._get_edge_quad_verts()
if xy0 is None or xy1 is None or xy2 is None or xy3 is None: return
# a Desmos construction of how this works: https://www.desmos.com/geometry/bmmx206thi
e0,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
if not e0: return
bmv0,bmv1 = e0.verts
bmv2,_ = self.rfcontext.nearest2D_vert(point=xy2, verts=self.vis_verts, max_dist=options['polypen merge dist'])
if not bmv2: bmv2 = self.rfcontext.new2D_vert_point(xy2)
bmv3,_ = self.rfcontext.nearest2D_vert(point=xy3, verts=self.vis_verts, max_dist=options['polypen merge dist'])
if not bmv3: bmv3 = self.rfcontext.new2D_vert_point(xy3)
if not bmv2 or not bmv3:
self.rfcontext.undo_cancel()
return 'main'
e1 = bmv2.shared_edge(bmv3)
if not e1: e1 = self.rfcontext.new_edge([bmv2, bmv3])
self.rfcontext.new_face([bmv0, bmv1, bmv2, bmv3])
bmes = [bmv1.shared_edge(bmv2), bmv0.shared_edge(bmv3), bmv2.shared_edge(bmv3)]
self.rfcontext.select(bmes, subparts=False)
self.mousedown = self.actions.mousedown
self.prep_move(
bmverts=[bmv2, bmv3],
action_confirm=(lambda: self.actions.released('insert')),
)
return 'move'
if self.next_state == 'edge-quad-snap':
e0,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
e1 = self.nearest_edge
if not e0 or not e1: return
bmv0,bmv1 = e0.verts
bmv2,bmv3 = e1.verts
p0,p1 = self.rfcontext.Point_to_Point2D(bmv0.co),self.rfcontext.Point_to_Point2D(bmv1.co)
p2,p3 = self.rfcontext.Point_to_Point2D(bmv2.co),self.rfcontext.Point_to_Point2D(bmv3.co)
if intersect2d_segment_segment(p1, p2, p3, p0): bmv2,bmv3 = bmv3,bmv2
# if e0.vector2D(self.rfcontext.Point_to_Point2D).dot(e1.vector2D(self.rfcontext.Point_to_Point2D)) > 0:
# bmv2,bmv3 = bmv3,bmv2
self.rfcontext.new_face([bmv0, bmv1, bmv2, bmv3])
# select all non-manifold edges that share vertex with e1
bmes = [e for e in bmv2.link_edges + bmv3.link_edges if not e.is_manifold and not e.share_face(e1)]
if not bmes:
bmes = [bmv1.shared_edge(bmv2), bmv0.shared_edge(bmv3)]
self.rfcontext.select(bmes, subparts=False)
return 'main'
if self.next_state == 'tri-quad':
hit_pos = self.actions.hit_pos
if not hit_pos:
self.rfcontext.undo_cancel()
return 'main'
if not self.sel_edges:
return 'main'
bme0,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
if not bme0: return
bmv0,bmv2 = bme0.verts
bme1,bmv1 = bme0.split()
bme0.select = True
bme1.select = True
self.rfcontext.select(bmv1.link_edges)
if self.nearest_vert and not self.nearest_vert.select:
self.nearest_vert.merge(bmv1)
bmv1 = self.nearest_vert
self.rfcontext.clean_duplicate_bmedges(bmv1)
for bme in bmv1.link_edges: bme.select &= len(bme.link_faces)==1
bme01,bme12 = bmv0.shared_edge(bmv1),bmv1.shared_edge(bmv2)
if len(bme01.link_faces) == 1: bme01.select = True
if len(bme12.link_faces) == 1: bme12.select = True
else:
bmv1.co = hit_pos
self.mousedown = self.actions.mousedown
self.rfcontext.select(bmv1, only=False)
xy = self.rfcontext.Point_to_Point2D(bmv1.co)
if not xy:
# dprint('Could not insert: ' + str(bmv1.co))
pass
self.rfcontext.undo_cancel()
return 'main'
self.prep_move(
bmverts=[bmv1],
action_confirm=(lambda: self.actions.released('insert')),
)
return 'move'
nearest_edge,d = self.rfcontext.nearest2D_edge(edges=self.vis_edges)
bmv = self.rfcontext.new2D_vert_mouse()
if not bmv:
self.rfcontext.undo_cancel()
return 'main'
if d is not None and d < self.rfcontext.drawing.scale(options['polypen insert dist']):
bme0,bmv2 = nearest_edge.split()
bmv.merge(bmv2)
self.rfcontext.select(bmv)
self.mousedown = self.actions.mousedown
xy = self.rfcontext.Point_to_Point2D(bmv.co)
if not xy:
# dprint('Could not insert: ' + str(bmv.co))
pass
self.rfcontext.undo_cancel()
return 'main'
self.prep_move(
bmverts=[bmv],
action_confirm=(lambda: self.actions.released('insert')),
)
return 'move'
@@ -0,0 +1,44 @@
<details id="polypen-options">
<summary id="polypen-summary-label">PolyPen</summary>
<div class="contents">
<div class="collection">
<h1>Automerge</h1>
<div class="contents">
<label>
<input type="checkbox" checked="BoundBool('''options['polypen automerge']''')" title="If enabled, grabbed vertices automatically merged with nearby vertices">
Enable Automerge
</label>
<div class="labeled-input-text">
<label for="polypen-merge-distance">Merge distance</label>
<input id="polypen-merge-distance" type="number" value="BoundInt( '''options['polypen merge dist'] ''')" title="Pixel distance for merging and snapping">
</div>
</div>
</div>
<div class="labeled-input-text">
<label for="polypen-insert-distance">Insert distance</label>
<input id="polypen-insert-distance" type="number" value="BoundInt( '''options['polypen insert dist'] ''')" title="Pixel distance for inserting into existing geometry">
</div>
<div class="collection">
<h1>Insert Mode</h1>
<div class="contents" id="polypen-insert-modes">
<label class="half-size">
Tri/Quad
<input type="radio" value="Tri/Quad" checked="BoundString('''options['polypen insert mode']''')" name="polypen-insert-mode" on_input="self.update_insert_mode()" title="Inserting alternates between Triangles and Quads">
</label>
<label class="half-size">
<input type="radio" value="Quad-Only" checked="BoundString('''options['polypen insert mode']''')" name="polypen-insert-mode" on_input="self.update_insert_mode()" title="Inserting Quads only">
Quad-Only
</label>
<br> <!-- this is a hack to make Tri-Only radio below size correctly -->
<label class="half-size">
<input type="radio" value="Tri-Only" checked="BoundString('''options['polypen insert mode']''')" name="polypen-insert-mode" on_input="self.update_insert_mode()" title="Inserting Triangles only">
Tri-Only
</label>
<label class="half-size">
<input type="radio" value="Edge-Only" checked="BoundString('''options['polypen insert mode']''')" name="polypen-insert-mode" on_input="self.update_insert_mode()" title="Inserting Edges only">
Edge-Only
</label>
</div>
</div>
</div>
</details>