Source code for rkviewer.canvas.elements

# from __future__ import annotations
# pylint: disable=maybe-no-member
from abc import abstractmethod
import enum
from functools import partial
from itertools import chain
from math import pi, cos, sin
from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Set, Tuple, Union, cast
from copy import copy

from numpy.lib.utils import source
from rkviewer.canvas.data import CirclePrim, CompositeShape, RectanglePrim, TrianglePrim, Transform, TextPrim, HexagonPrim, LinePrim

import wx

from ..config import Color, get_setting, get_theme
from ..events import (
    CanvasEvent, DidChangeCompartmentOfNodesEvent, DidCommitDragEvent, DidMoveBezierHandleEvent, DidMoveReactionCenterEvent, DidResizeCompartmentsEvent, DidResizeNodesEvent, DidMoveCompartmentsEvent,
    DidMoveNodesEvent, bind_handler,
    post_event, unbind_handler,
)
from ..mvc import IController
from ..utils import change_opacity, even_round, gchain, int_round
from .data import Compartment, HandleData, ModifierTipStyle, Node, Reaction, ReactionBezier, RectData, SpeciesBezier, PolygonPrim, TextAlignment, TextPosition
from .geometry import (
    Rect,
    Vec2,
    clamp_point, clamp_rect_pos,
    get_bounding_rect,
    padded_rect,
    pt_in_circle,
    pt_in_rect, rotate_unit, segment_rect_intersection,
    pt_on_rect_sides,
)
from .state import InputMode, cstate
from .utils import draw_rect

# SetCursorFn = Callable[[wx.Cursor], None]
Layer = Union[int, Tuple[int, ...]]


[docs]def layer_above(layer: Layer, count: int = 1) -> Layer: """ Return the next layer above this layer, without increasing the length of the layer list. count is optionally the layer number increment. """ if count < 1: raise ValueError('Layer count must be at least 1!') if isinstance(layer, int): return layer + count else: last = len(layer) - 1 return layer[0:last] + (layer[last] + count,)
[docs]class CanvasElement: """Base class for an element positioned on the canvas. Attributes: layers: The layer(s) number of this element. enabled: Whether the element is enabled. destroyed: Whether the object was destroyed (if this is True then you shouldn't use this) """ layers: Layer enabled: bool destroyed: bool def __init__(self, layers: Layer): if isinstance(layers, int): layers = (layers,) self.layers = layers self.enabled = True self.destroyed = False
[docs] def set_layers(self, layers: Layer): if isinstance(layers, int): layers = (layers,) self.layers = layers
[docs] def destroy(self): """Destroy this element; override this for specific implementations.""" self.destroyed = True
[docs] def pos_inside(self, logical_pos: Vec2) -> bool: """Returns whether logical_pos is inside the diplayed shape of this element.""" return False
[docs] @abstractmethod def on_paint(self, gc: wx.GraphicsContext): """Paint the shape onto the given GraphicsContext. This draws onto the scrolled canvas, i.e. the position of the drawn item will respond to scrolling, so you don't need to account for that. """ pass
[docs] def on_paint_cue(self, gc: wx.GraphicsContext): '''This is called to paint special visual cues tha tthe element may need to display''' pass
[docs] def on_mouse_enter(self, logical_pos: Vec2) -> bool: """Handler for when the mouse has entered the shape.""" return False
[docs] def on_mouse_leave(self, logical_pos: Vec2) -> bool: """Handler for when the mouse has exited the shape""" return False
[docs] def on_mouse_move(self, logical_pos: Vec2) -> bool: """Handler for when the mouse moves inside the shape, with the left mouse button up.""" return False
[docs] def on_mouse_drag(self, logical_pos: Vec2, rel_pos: Vec2) -> bool: """Handler for when the mouse drags inside the shape, with the left mouse button down.""" return False
[docs] def on_left_down(self, logical_pos: Vec2) -> bool: """Handler for when the mouse left button is pressed down inside the shape.""" return False
[docs] def on_left_up(self, logical_pos: Vec2) -> bool: """Handler for when the mouse left button is springs up inside the shape.""" return False
[docs] def bounding_rect(self) -> Rect: """Return the bounding rectangle of the element.""" return Rect(Vec2(), Vec2())
[docs]class NodeElement(CanvasElement): """CanvasElement for nodes.""" node: Node canvas: Any # HACK no type specified for canvas since otherwise there would be circular dependency def __init__(self, node: Node, canvas, layers: Layer): super().__init__(layers) self.node = node self.canvas = canvas self.gfont = None # In the future self.font_scale = 1
[docs] def pos_inside(self, logical_pos: Vec2) -> bool: return pt_in_rect(logical_pos, self.node.s_rect)
[docs] def on_paint(self, gc: wx.GraphicsContext): if self.gfont is None or self.font_scale != cstate.scale: font = wx.Font(wx.FontInfo(10)) self.gfont = gc.CreateFont(font, wx.BLACK) gc.SetFont(self.gfont) # boundaryFactor = 1 # if not self.node.floatingNode: # boundaryFactor = 2 # Store this in a theme? s_aligned_rect = self.node.s_rect.aligned() # aligned_border_width = max(even_round( # self.node.border_width * boundaryFactor), 2) width, height = s_aligned_rect.size assert self.node.composite_shape is not None draw_composite_shape( gc, self.node.rect, self.node) if self.node.lockNode: lock_color = self.node.border_color or Color(255, 0, 0) pen = gc.CreatePen(wx.GraphicsPenInfo( lock_color.to_wxcolour()).Width(2)) gc.SetPen(pen) path = gc.CreatePath() path.AddCircle(self.node.position.x, self.node.position.y, .1*height) gc.StrokePath(path) if not self.node.floatingNode: boundary_color = self.node.border_color or Color(255, 0, 0) pen = gc.CreatePen(wx.GraphicsPenInfo( boundary_color.to_wxcolour()).Width(2)) gc.SetPen(pen) path = gc.CreatePath() path.AddRectangle(self.node.position.x-round(self.node.border_width/2 + 2)-2, self.node.position.y-round(self.node.border_width/2 + 2)-2, 2*round(self.node.border_width/2 + 2)+self.node.size.x+4, 2*round(self.node.border_width/2 + 2)+self.node.size.y+4) gc.StrokePath(path)
[docs] def on_left_down(self, _: Vec2): return True
[docs] def bounding_rect(self) -> Rect: return self.node.rect
[docs]class BezierHandle(CanvasElement): """Class that keeps track of a Bezier control handle tip. Attributes: HANDLE_RADIUS: radius of the control handle. data: The associated HandleData> on_moved: The function called when the handle is moved. on_dropped: The function called when the handle is dropped (i.e. mouse up). reaction: The associated Reaction. twin: The twin BezierHandle; used only for the center handles. node_idx: The index of the node associated with this handle. -1 if this is a source centroid handle, and -2 if this is a target centroid handle. """ HANDLE_RADIUS = 5 # Radius of the control handle data: HandleData on_moved: Callable[[Vec2], None] on_dropped: Callable[[Vec2], None] reaction: Reaction twin = Any node_idx: int def __init__(self, data: HandleData, layer: Layer, on_moved: Callable[[Vec2], None], on_dropped: Callable[[Vec2], None], reaction: Reaction, node_idx: int): super().__init__(layer) self.data = data self.on_moved = on_moved self.on_dropped = on_dropped self.reaction = reaction self.hovering = False self.enabled = False self.twin = None self.node_idx = node_idx
[docs] def pos_inside(self, logical_pos: Vec2): return pt_in_circle(logical_pos, BezierHandle.HANDLE_RADIUS, self.data.tip)
def _paint(self, gc: wx.GraphicsContext, handle_color: wx.Colour): brush = wx.Brush(handle_color) pen = gc.CreatePen(wx.GraphicsPenInfo(handle_color)) sbase = self.data.base stip = self.data.tip gc.SetPen(pen) # Draw handle lines gc.StrokeLine(*sbase, *stip) # Draw handle circles gc.SetBrush(brush) gc.DrawEllipse(stip.x - BezierHandle.HANDLE_RADIUS, stip.y - BezierHandle.HANDLE_RADIUS, 2 * BezierHandle.HANDLE_RADIUS, 2 * BezierHandle.HANDLE_RADIUS)
[docs] def on_paint(self, gc: wx.GraphicsContext): """Paint the handle as given by its base and tip positions, highlighting it if hovering.""" assert self.data.base is not None if self.reaction.bezierCurves: c = get_theme('handle_color') self._paint(gc, c)
[docs] def on_paint_cue(self, gc: wx.GraphicsContext): if self.hovering and self.reaction.bezierCurves: assert self.data.base is not None c = get_theme('highlighted_handle_color') self._paint(gc, c)
[docs] def on_mouse_enter(self, logical_pos: Vec2) -> bool: self.hovering = True if self.twin: self.twin.hovering = True return True
[docs] def on_mouse_leave(self, logical_pos: Vec2) -> bool: self.hovering = False if self.twin: self.twin.hovering = False return True
[docs] def on_left_down(self, logical_pos: Vec2) -> bool: return True
[docs] def on_mouse_drag(self, logical_pos: Vec2, rel_pos: Vec2) -> bool: self.data.tip += rel_pos self.on_moved(self.data.tip) neti = 0 post_event(DidMoveBezierHandleEvent(neti, self.reaction.index, self.node_idx, by_user=True, direct=True)) return True
[docs] def on_left_up(self, logical_pos: Vec2): self.on_dropped(self.data.tip) return True
[docs]class ReactionCenter(CanvasElement): parent: 'ReactionElement' _moved: bool def __init__(self, parent: 'ReactionElement', layers: Layer): super().__init__(layers) self.parent = parent self._moved = False self.hovering = False def _paint(self, gc: wx.GraphicsContext, color: wx.Colour): pen = wx.Pen(color) brush = wx.Brush(color) gc.SetPen(pen) gc.SetBrush(brush) radius = get_theme('reaction_radius') center = self.parent.bezier.real_center - Vec2.repeat(radius) gc.DrawEllipse(center.x, center.y, radius * 2, radius * 2)
[docs] def on_paint(self, gc: wx.GraphicsContext): if not self.parent.selected: return # draw centroid color = self.parent.reaction.fill_color if self.parent.selected: color = get_theme('handle_color') self._paint(gc, color)
[docs] def on_paint_cue(self, gc: wx.GraphicsContext): if self.parent.selected and self.hovering: color = get_theme('highlighted_handle_color') self._paint(gc, color)
[docs] def on_left_down(self, logical_pos: Vec2) -> bool: # If not selected, then nothing is done to prevent accidental dragging return True
[docs] def on_mouse_drag(self, logical_pos: Vec2, rel_pos: Vec2) -> bool: offset = rel_pos reaction = self.parent.reaction reaction.center_pos = self.parent.bezier.real_center + offset self.parent.bezier.center_moved(offset) self._moved = True net_index = 0 post_event(DidMoveReactionCenterEvent(net_index, reaction.index, offset, True)) return True
[docs] def on_left_up(self, logical_pos: Vec2) -> bool: ctrl = self.parent.controller neti = self.parent.canvas.net_index reai = self.parent.reaction.index with ctrl.group_action(): ctrl.set_reaction_center(neti, reai, self.parent.reaction.center_pos) ctrl.set_center_handle(neti, reai, self.parent.reaction.src_c_handle.tip) post_event(DidCommitDragEvent(self)) self._moved = False return True
[docs] def pos_inside(self, logical_pos: Vec2) -> bool: # TODO works witih zoom? radius = get_theme('reaction_radius') return pt_in_circle(self.parent.bezier.real_center, radius, logical_pos)
[docs] def on_mouse_enter(self, logical_pos: Vec2) -> bool: self.hovering = True return True
[docs] def on_mouse_leave(self, logical_pos: Vec2) -> bool: self.hovering = False return True
[docs] def bounding_rect(self) -> Rect: radius = get_theme('reaction_radius') return Rect(self.parent.bezier.real_center - radius, Vec2.repeat(radius) * 2)
# Uniquely identifies nodes in a reaction: (regular node index; whether the node is a reactant) RIndex = Tuple[int, bool]
[docs]class ReactionElement(CanvasElement): """CanvasElement for reactions. Note that if new nodes are constructed, all ReactionBezier instances that use these nodes should be re-constructed with the new nodes. On the other hand, if the nodes are merely modified, the corresponding update methods should be called. """ reaction: Reaction center_el: ReactionCenter index_to_bz: Dict[RIndex, SpeciesBezier] bezier: ReactionBezier bhandles: List[BezierHandle] moved_handler_id: int #: Set of indices of the nodes that have been moved, but not committed. _dirty_node_indices: Set[int] #: Works in tandem with _dirty_indices. True if all nodes of the reaction are being moved. _moving_all: bool _selected: bool canvas: Any # avoid circular dependency controller: IController # HACK no type for canvas since otherwise there is circular dependency def __init__(self, reaction: Reaction, bezier: ReactionBezier, canvas, layers: Layer, handle_layer: Layer): super().__init__(layers) self.reaction = reaction self.containing_node_indices = set(chain(reaction.sources, reaction.targets)) self.bezier = bezier self.moved_handler_id = bind_handler(DidMoveNodesEvent, self.nodes_moved) # i is 0 for source Beziers, but 1 for dest Beziers. "not" it to get the correct bool. self.index_to_bz = {(bz.node_idx, not gi): bz for gi, bz in gchain(bezier.src_beziers, bezier.dest_beziers)} self.canvas = canvas self.controller = canvas.controller self._hovered_handle = None self._dirty_indices = set() self._moving_all = False self.bhandles = list() self._selected = False neti = canvas.net_index reai = reaction.index ctrl = canvas.controller # create elements for species for gi, sb in gchain(bezier.src_beziers, bezier.dest_beziers): dropped_func = self.make_drop_handle_func(ctrl, neti, reai, sb.node_idx, not gi) el = BezierHandle(sb.handle, handle_layer, bezier.make_handle_moved_func(sb), dropped_func, reaction, sb.node_idx) self.bhandles.append(el) def centroid_handle_dropped(p: Vec2): with ctrl.group_action(): ctrl.set_center_handle(neti, reai, reaction.src_c_handle.tip) # NOTE important to do this within the group action post_event(DidCommitDragEvent(self)) src_bh = BezierHandle(reaction.src_c_handle, handle_layer, lambda _: bezier.src_handle_moved(), centroid_handle_dropped, reaction, -1) dest_bh = BezierHandle(reaction.dest_c_handle, handle_layer, lambda _: bezier.dest_handle_moved(), centroid_handle_dropped, reaction, -2) src_bh.twin = dest_bh dest_bh.twin = src_bh self.bhandles.append(src_bh) self.bhandles.append(dest_bh) center_layers = layer_above(layers, count=2) self.center_el = ReactionCenter(self, center_layers)
[docs] def make_drop_handle_func(self, ctrl: IController, neti: int, reai: int, nodei: int, is_source: bool): if is_source: def ret(p): ctrl.set_src_node_handle(neti, reai, nodei, p) post_event(DidCommitDragEvent(self)) return ret else: # Avoid IDE warnings def ret1(p): ctrl.set_dest_node_handle(neti, reai, nodei, p) post_event(DidCommitDragEvent(self)) return ret1
@property def selected(self) -> bool: return self._selected @selected.setter def selected(self, val: bool): # Enable/disable Handles based on whether the curve is selected self._selected = val for bz in self.bhandles: bz.enabled = val self.center_el.enabled = val
[docs] def nodes_moved(self, evt: CanvasEvent): """Handler for after a node has moved.""" # If already moving (i.e. self._dirty_indices is not empty), then skip forward c_evt = cast(DidMoveNodesEvent, evt) node_indices = set(c_evt.node_indices) & self.containing_node_indices if not node_indices: return offset = c_evt.offset rects = [self.canvas.node_idx_map[idx].rect for idx in chain( self.reaction.sources, self.reaction.targets)] self.bezier.nodes_moved(rects) if len(self._dirty_indices) == 0: self._dirty_indices = node_indices my_indices = set(chain(self.reaction.sources, self.reaction.targets)) self._moving_all = my_indices <= self._dirty_indices neti = 0 for i, idx in enumerate(node_indices): for in_src in [True, False]: if (idx, in_src) in self.index_to_bz: bz = self.index_to_bz[(idx, in_src)] off = offset if isinstance(offset, Vec2) else offset[i] bz.handle.tip += off post_event(DidMoveBezierHandleEvent(neti, self.reaction.index, bz.node_idx, by_user=True, direct=False)) bz.update_curve(self.bezier.real_center) if self._moving_all and isinstance(offset, Vec2): # Only move src_handle_tip if moving all nodes and they are moved by the same amount. self.reaction.src_c_handle.tip += offset # move center pos as well if it is not auto-set as the centroid if self.reaction.center_pos: self.reaction.center_pos += offset self.bezier.src_handle_moved() post_event(DidMoveBezierHandleEvent(neti, self.reaction.index, -1, by_user=True, direct=False))
[docs] def commit_node_pos(self): """Handler for after the controller is told to move a node.""" ctrl: IController = self.canvas.controller neti = self.canvas.net_index reai = self.reaction.index for bz in self.bezier.src_beziers: if bz.node_idx in self._dirty_indices: ctrl.set_src_node_handle(neti, reai, bz.node_idx, bz.handle.tip) for bz in self.bezier.dest_beziers: if bz.node_idx in self._dirty_indices: ctrl.set_dest_node_handle(neti, reai, bz.node_idx, bz.handle.tip) if self._moving_all: ctrl.set_center_handle(neti, reai, self.reaction.src_c_handle.tip) if self.reaction.center_pos: ctrl.set_reaction_center(neti, reai, self.reaction.center_pos) self._dirty_indices = set()
[docs] def destroy(self): unbind_handler(self.moved_handler_id) super().destroy()
[docs] def pos_inside(self, logical_pos: Vec2) -> bool: return self.bezier.is_mouse_on(logical_pos)
[docs] def on_mouse_enter(self, logical_pos: Vec2): self.on_mouse_move(logical_pos)
[docs] def on_left_down(self, logical_pos: Vec2) -> bool: return True # Return True so that this can be selected
[docs] def on_paint(self, gc: wx.GraphicsContext): self.bezier.do_paint(gc, self.reaction.fill_color, self.selected) MOD_NODE_PAD = 10 RXN_PAD = 15 MODIFIER_RADIUS = 3 TEE_LENGTH = 5 line_width = get_theme('modifier_line_width') pen = gc.CreatePen(wx.GraphicsPenInfo(get_theme('modifier_line_color')).Width(line_width)) brush = gc.CreateBrush(wx.Brush(get_theme('modifier_line_color'))) gc.SetPen(pen) gc.SetBrush(brush) # Draw modifier lines for modifier in self.reaction.modifiers: mod_node = self.canvas.node_idx_map[modifier] rect = mod_node.rect clipping_rect = padded_rect(rect, MOD_NODE_PAD) node_center = rect.center_point rxn_center = self.bezier.real_center # If too close, don't draw. Pad rectangle and circle with 1 to avoid floating point # shenanigans if pt_in_rect(rxn_center, padded_rect(clipping_rect, 1)): continue elif pt_in_circle(node_center, RXN_PAD + 1, rxn_center): continue # create segment segment = (node_center, rxn_center) diff = node_center - rxn_center rxn_intersection = (rxn_center + diff.normalized(RXN_PAD)) node_intersection = segment_rect_intersection(segment, clipping_rect) # gc.StrokeLine(*node_intersection, *rxn_intersection) path = gc.CreatePath() path.MoveToPoint(*node_intersection) path.AddLineToPoint(*rxn_intersection) if self.reaction.modifier_tip_style == ModifierTipStyle.CIRCLE: path.AddCircle(*rxn_intersection, MODIFIER_RADIUS) elif self.reaction.modifier_tip_style == ModifierTipStyle.TEE: # draw T-shaped tip ortho = rotate_unit(diff, pi / 2) pt1 = rxn_intersection + ortho * TEE_LENGTH pt2 = rxn_intersection - ortho * TEE_LENGTH path.MoveToPoint(*pt1) path.AddLineToPoint(*pt2) gc.StrokePath(path) gc.FillPath(path)
# path.MoveToPoint(0.0, 50.0)
[docs] def bounding_rect(self) -> Rect: return self.bezier.get_bounding_rect()
[docs]class CompartmentElt(CanvasElement): def __init__(self, compartment: Compartment, major_layer: int, minor_layer: int): super().__init__((major_layer, minor_layer)) self.compartment = compartment
[docs] def pos_inside(self, logical_pos: Vec2) -> bool: thickness = max(int(self.compartment.border_width), 1) return pt_on_rect_sides(logical_pos, self.compartment.rect, thickness)
[docs] def on_left_down(self, logical_pos: Vec2) -> bool: return True
def _paint(self, gc: wx.GraphicsContext, highlight: bool): rect = Rect(self.compartment.position, self.compartment.size) if highlight: border = wx.Colour(255, 255, 255, 100) fill = wx.Colour(255, 255, 255, 100) else: border = self.compartment.border fill = self.compartment.fill draw_rect(gc, rect, border=border, border_width=self.compartment.border_width, fill=fill, corner_radius=get_theme('comp_corner_radius'))
[docs] def on_paint(self, gc: wx.GraphicsContext): self._paint(gc, False)
[docs] def highlight_paint(self, gc: wx.GraphicsContext): self._paint(gc, True)
[docs] def bounding_rect(self) -> Rect: return self.compartment.rect
[docs]class SelectBox(CanvasElement): """Class that represents a select box, i.e. the bounding box draw around the selected nodes. Supports moving and resizing operations. Attributes: CURSOR_TYPES: List of cursor types starting with that for the top-left handle and going clockwise. nodes: List of selected nodes, as contained in this select box. related_elts: List of NodeElements related to each node instance; matches the node list 1-1. bounding_rect: The exact bounding rectangle (without padding). mode: Current input mode of the SelectBox. Note: The behavior of the SelectBox depends on the nodes and compartments selected, but not the reactions. The cases of behaviors are documented here: 1) Only compartments are selected. Nodes within these compartments are dragged along with them, but they are not resized. 3) Only nodes are selected, and they are all in the same compartment. In this case, the nodes may be moved normally. They also may be moved outside of their compartment to be assigned to another compartment (this is the only case where this is possible). However note that the nodes may not be resized to be larger than the containing compartment. 3) Otherwise, there are two cases depending on if the selected nodes are in the union of the selected compartments. a) If the selected nodes are entirely contained in the list of selected compartments, then everything is moved and resized together, as usual. b) Otherwise (i.e. some node is not in any selected compartment), then dragging and resizing are disabled. Note that in case 2), if all selected nodes are in the base compartment (i.e. no compartment), then the base compartment is assumed to be selected, and resizing and moving work as usual. """ CURSOR_TYPES = [wx.CURSOR_SIZENWSE, wx.CURSOR_SIZENS, wx.CURSOR_SIZENESW, wx.CURSOR_SIZEWE, wx.CURSOR_SIZENWSE, wx.CURSOR_SIZENS, wx.CURSOR_SIZENESW, wx.CURSOR_SIZEWE] HANDLE_MULT = [Vec2(), Vec2(1/2, 0), Vec2(1, 0), Vec2(1, 1/2), Vec2(1, 1), Vec2(1/2, 1), Vec2(0, 1), Vec2(0, 1/2)] nodes: List[Node] node_indices: List[int] compartments: List[Compartment] comp_indices: List[int] # List of nodes that are not selected, but are within selected compartments. Used only # for SMode.CONTAINED peripheral_nodes: List[Node] related_elts: List[CanvasElement] bounding_rect: Rect _padding: float #: padding for the bounding rectangle around the selected nodes _drag_rel: Vec2 #: relative position of the mouse to the bounding rect when dragging started #: whether the node was drag-moved between left_down and left_up. _did_move: bool _orig_rpos: List[Vec2] #: relative positions of the comps and nodes to the select box _orig_rsize: List[Vec2] #: sizes of the comps and nodes to the select box #: relative positions of the compartments and nodes to cursor; updated when dragging starts _rel_positions: List[Vec2] _resize_handle: int #: the node resize handle. node_min_ratio: Optional[Vec2] comp_min_ratio: Optional[Vec2] #: the minimum resize ratio for each axis, to avoid making the nodes too small _min_resize_ratio: Vec2 #: the bounding rect when dragging/resizing started _orig_rect: Optional[Rect] _bounds: Rect #: the bounds that the bounding rect may not exceed special_mode: 'SelectBox.SMode'
[docs] class Mode(enum.Enum): IDLE = 0 MOVING = 1 RESIZING = 2
[docs] class SMode(enum.Enum): """For what this does, see "Notes" section of the class documentation.""" COMP_ONLY = 0 """Only compartments are selected.""" NODES_IN_ONE = 1 """Only nodes are selected, and they are in a single compartment.""" CONTAINED = 2 """Nodes are entirely contained in the selected compartments, or they are all in the base compartment. """ NOT_CONTAINED = 3 """Nodes are not entirely contained in the selected compartments."""
def __init__(self, canvas, nodes: List[Node], compartments: List[Compartment], bounds: Rect, controller: IController, net_index: int, layer: int): super().__init__(layer) self.canvas = canvas self.peripheral_nodes = list() self.update(nodes, compartments) self.controller = controller self.net_index = net_index self._mode = SelectBox.Mode.IDLE self._drag_rel = Vec2() self._did_move = False self._orig_rpos = list() self._orig_rsizes = list() self._rel_positions = list() self._orig_rect = None self._resize_handle = -1 self._min_resize_ratio = Vec2() self._hovered_part = -2 self._bounds = bounds @property def mode(self): return self._mode
[docs] def update(self, nodes: List[Node], compartments: List[Compartment]): self.nodes = nodes self.compartments = compartments self.node_indices = [n.index for n in self.nodes] self.comp_indices = [c.index for c in self.compartments] if len(nodes) + len(compartments) > 0: if len(nodes) == 1 and len(compartments) == 0: # If only one node is selected, reduce padding self._padding = get_theme('select_outline_padding') else: self._padding = get_theme('select_box_padding') # Align bounding box if only one node is selected, see NodeElement::on_paint for # explanations. Note that self._padding should be an integer self._padding = int_round(self._padding) self.bounding_rect = get_bounding_rect( [n.rect for n in nodes] + [c.rect for c in compartments]) selected_comps = set(c.index for c in compartments) | {-1} # Determine SMode if len(nodes) == 0: self.special_mode = SelectBox.SMode.COMP_ONLY else: assoc_comps = {n.comp_idx for n in nodes} if len(compartments) == 0: if len(assoc_comps) == 1: # Nodes are in one compartment self.special_mode = SelectBox.SMode.NODES_IN_ONE else: # Cannot possibly contain self.special_mode = SelectBox.SMode.NOT_CONTAINED else: if selected_comps >= assoc_comps: self.special_mode = SelectBox.SMode.CONTAINED else: self.special_mode = SelectBox.SMode.NOT_CONTAINED # Compute peripheral nodes if self.special_mode == SelectBox.SMode.NOT_CONTAINED: self.peripheral_nodes = list() else: peri_indices = set() for comp in compartments: if comp.index in selected_comps: peri_indices |= set(comp.nodes) peri_indices -= set(n.index for n in nodes) self.peripheral_nodes = [self.canvas.node_idx_map[i] for i in peri_indices]
[docs] def outline_rect(self) -> Rect: """Helper that returns the scaled, padded bounding rectangle.""" return padded_rect((self.bounding_rect).aligned(), self._padding)
def _resize_handle_rects(self): """Helper that computes the scaled positions and sizes of the resize handles. Returns: A list of (pos, size) tuples representing the resize handle rectangles. They are ordered such that the top-left handle is the first element, and all other handles follow in clockwise fashion. """ outline_rect = self.outline_rect() pos, size = outline_rect.as_tuple() centers = [pos, pos + Vec2(size.x / 2, 0), pos + Vec2(size.x, 0), pos + Vec2(size.x, size.y / 2), pos + size, pos + Vec2(size.x / 2, size.y), pos + Vec2(0, size.y), pos + Vec2(0, size.y / 2)] centers = [pos + size.elem_mul(m) for m in SelectBox.HANDLE_MULT] side = get_theme('select_handle_length') return [Rect(c - Vec2.repeat(side/2), Vec2.repeat(side)) for c in centers] def _resize_handle_pos(self, n: int): pos, size = self.outline_rect().as_tuple() assert n >= 0 and n < 8 return pos + size.elem_mul(SelectBox.HANDLE_MULT[n]) def _pos_inside_part(self, logical_pos: Vec2) -> int: """Helper for determining if logical_pos is within which, if any, part of this widget. Returns: The handle index (0-3) if pos is within a handle, -1 if pos is not within a handle but within a node or on the compartment edge, or -2 if pos is outside. """ if len(self.nodes) + len(self.compartments) == 0: return -2 rects = self._resize_handle_rects() for i, rect in enumerate(rects): if pt_in_rect(logical_pos, rect): return i nrects = [n.rect for n in self.nodes] crects = [c.rect for c in self.compartments] if any(pt_in_rect(logical_pos, r) for r in nrects) \ or any(pt_on_rect_sides(logical_pos, r) for r in crects): return -1 else: return -2
[docs] def pos_inside(self, logical_pos: Vec2): return self._pos_inside_part(logical_pos) != -2
[docs] def on_mouse_enter(self, logical_pos: Vec2): self.on_mouse_move(logical_pos)
[docs] def on_mouse_move(self, logical_pos: Vec2): if cstate.input_mode != InputMode.SELECT: return False # clearly, don't change the cursor if we're not in select mode self._hovered_part = self._pos_inside_part(logical_pos) if self._hovered_part >= 0: cursor = SelectBox.CURSOR_TYPES[self._hovered_part] self.canvas.SetCursor(wx.Cursor(cursor)) elif self._hovered_part == -1: self.canvas.SetCursor(wx.Cursor(wx.CURSOR_SIZING)) return True
[docs] def on_mouse_leave(self, logical_pos: Vec2): self._hovered_part = -2 # HACK re-set input_mode with the same value to make canvas update the cursor # See issue #9 for details cstate.input_mode = cstate.input_mode return True
[docs] def on_paint(self, gc: wx.GraphicsContext): if len(self.nodes) + len(self.compartments) > 0: outline_width = max(even_round(get_theme('select_outline_width')), 2) pos, size = self.outline_rect().as_tuple() # draw main outline draw_rect(gc, Rect(pos, size), border=get_theme('handle_color'), border_width=outline_width, corner_radius=0) for handle_rect in self._resize_handle_rects(): # convert to device position for drawing draw_rect(gc, handle_rect, fill=get_theme('handle_color'))
[docs] def map_rel_pos(self, positions: Iterable[Vec2]) -> List[Vec2]: temp = [p - self._orig_rect.position - Vec2.repeat(self._padding) for p in positions] return temp
[docs] def on_left_down(self, logical_pos: Vec2): if len(self.nodes) + len(self.compartments) == 0: return False # if multi-selecting and clicked on a node/reaction, then the user must mean to de-select # that element. In this exceptional case we return False so that canvas can continue the # pos_inside loop and find the node/reaction in question later. if cstate.multi_select: for elt in self.related_elts: if elt.pos_inside(logical_pos): return False elif len(self.compartments) != 0: # make sure that user can select items inside compartment, even if the compartment is # itself selected. # for elt in self.related_elts: # if elt.pos_inside(logical_pos): # if isinstance(elt, ReactionElement): # return False # elif isinstance(elt, NodeElement) and elt.node.comp_idx != -1: # return False # else: # break pass handle = self._hovered_part # assert self._mode == SelectBox.Mode.IDLE if handle >= 0: self._mode = SelectBox.Mode.RESIZING self._resize_handle = handle # calculate minimum resize ratio, enforcing min size constraints on nodes and comps self._update_min_resize_ratio() #self._orig_rect = self.outline_rect() # Take unaligned bounding rect as orig_rect for better accuracy self._orig_rect = padded_rect( (self.bounding_rect), self._padding) # relative starting positions to the select box orig_node_pos = self.map_rel_pos((n.position for n in self.nodes)) orig_comp_pos = self.map_rel_pos((c.position for c in self.compartments)) orig_node_sizes = [n.size for n in self.nodes] orig_comp_sizes = [c.size for c in self.compartments] self._orig_rpos = orig_comp_pos + orig_node_pos self._orig_rsizes = orig_comp_sizes + orig_node_sizes self._resize_handle_offset = self._resize_handle_pos(handle) - logical_pos return True elif handle == -1: self._mode = SelectBox.Mode.MOVING # relative starting positions to the mouse positions rel_node_pos = [n.position - logical_pos for n in self.nodes if not n.lockNode] rel_comp_pos = [c.position - logical_pos for c in self.compartments] self._rel_positions = rel_comp_pos + rel_node_pos self._drag_rel = self.bounding_rect.position - logical_pos return True return False
[docs] def compute_min_ratio(self) -> Tuple[Optional[Vec2], Optional[Vec2]]: """Compute minimum size ratio resizing nodes and compartments. Returns: A tuple containing (node mininum resize ratio, comp minimum resize ratio). Each ratio may be None, in the case that no elements of that type is selected. """ node_min_ratio = None comp_min_ratio = None if len(self.nodes) != 0: min_width = min(n.size.x for n in self.nodes) min_height = min(n.size.y for n in self.nodes) node_min_ratio = Vec2(get_setting('min_node_width') / min_width, get_setting('min_node_height') / min_height) if len(self.compartments) != 0: min_comp_width = min(c.size.x for c in self.compartments) min_comp_height = min(c.size.y for c in self.compartments) comp_min_ratio = Vec2(get_setting('min_comp_width') / min_comp_width, get_setting('min_comp_height') / min_comp_height) for node in self.peripheral_nodes: comp = self.canvas.comp_idx_map[node.comp_idx] ratio = node.size.elem_div(comp.size) comp_min_ratio = comp_min_ratio.reduce2(max, ratio) return node_min_ratio, comp_min_ratio
def _update_min_resize_ratio(self): node_min_ratio, comp_min_ratio = self.compute_min_ratio() if node_min_ratio is not None and comp_min_ratio is not None: self._min_resize_ratio = node_min_ratio.reduce2(max, comp_min_ratio) elif node_min_ratio is not None: self._min_resize_ratio = node_min_ratio elif comp_min_ratio is not None: self._min_resize_ratio = comp_min_ratio else: assert False, 'Cannot possibly click on handle when nothing is selected.'
[docs] def on_left_up(self, logical_pos: Vec2): assert len(self.nodes) + len(self.compartments) != 0 if self._mode == SelectBox.Mode.MOVING: if self._did_move: self._did_move = False self._commit_move() elif self._mode == SelectBox.Mode.RESIZING: assert not self._did_move with self.controller.group_action(): for node in self.peripheral_nodes: self.controller.move_node(self.net_index, node.index, node.position) for node in self.nodes: self.controller.move_node(self.net_index, node.index, node.position) self.controller.set_node_size(self.net_index, node.index, node.size) for comp in self.compartments: self.controller.move_compartment(self.net_index, comp.index, comp.position) self.controller.set_compartment_size(self.net_index, comp.index, comp.size) post_event(DidCommitDragEvent(self)) # Need to do this in case _special_mode == NOT_CONTAINED, so that the mouse has now # left the handle. on_mouse_leave is not triggered because it's considered to be dragging. self._hovered_part = self._pos_inside_part(logical_pos) self._mode = SelectBox.Mode.IDLE
[docs] def on_mouse_drag(self, logical_pos: Vec2, rel_pos: Vec2) -> bool: assert self._mode != SelectBox.Mode.IDLE rect_data = cast(List[RectData], self.compartments) + cast(List[RectData], self.nodes) if self.special_mode == SelectBox.SMode.NOT_CONTAINED: # Return True since we still want this to appear to be dragging, just not working. return True if self._mode == SelectBox.Mode.RESIZING: rect_data = cast(List[RectData], self.compartments) + cast(List[RectData], self.nodes) # TODO move the orig_rpos, etc. code to update() bounds = self._bounds if self.special_mode == SelectBox.SMode.NODES_IN_ONE: # constrain resizing to within this compartment if self.nodes[0].comp_idx != -1: containing_comp = self.canvas.GetCompartment(self.nodes[0].comp_idx) assert containing_comp is not None bounds = containing_comp.rect self._resize(logical_pos, rect_data, self._orig_rpos, self._orig_rsizes, bounds) else: nodes = [n for n in self.nodes if not n.lockNode] rect_data = cast(List[RectData], self.compartments) + cast(List[RectData], nodes) if len(rect_data) == 0: return True self._move(logical_pos, rect_data, self._rel_positions) return True
def _resize(self, pos: Vec2, rect_data: List[RectData], orig_pos: List[Vec2], orig_sizes: List[Vec2], bounds: Rect): """Helper that performs resize on the bounding box, given the logical mouse position.""" # STEP 1, get new rect vertices # see class comment for resize handle format. For side-handles, get the vertex in the # counter-clockwise direction dragged_idx = self._resize_handle // 2 # get the vertex opposite dragged idx as fixed_idx fixed_idx = int((dragged_idx + 2) % 4) orig_dragged_point = self._orig_rect.nth_vertex(dragged_idx) cur_dragged_point = self.outline_rect().nth_vertex(dragged_idx) fixed_point = self._orig_rect.nth_vertex(fixed_idx) target_point = pos + self._resize_handle_offset # if a side-handle, then only resize one axis if self._resize_handle % 2 == 1: if self._resize_handle % 4 == 1: # vertical resize; keep x the same target_point = target_point.swapped(0, orig_dragged_point.x) else: assert self._resize_handle % 4 == 3 target_point = target_point.swapped(1, orig_dragged_point.y) # clamp target point target_point = clamp_point(target_point, bounds) # STEP 2, get and validate rect ratio # raw difference between (current/target) dragged vertex and fixed vertex. Raw as in this # is the visual difference shown on the bounding rect. orig_diff = orig_dragged_point - fixed_point target_diff = target_point - fixed_point signs = orig_diff.elem_mul(target_diff) # bounding_rect flipped? if signs.x < 0: target_point = target_point.swapped(0, cur_dragged_point.x) if signs.y < 0: target_point = target_point.swapped(1, cur_dragged_point.y) # take absolute value and subtract padding to get actual difference (i.e. sizing) pad_off = Vec2.repeat(self._padding) orig_bb_size = (orig_dragged_point - fixed_point).elem_abs() - pad_off * 2 target_size = (target_point - fixed_point).elem_abs() - pad_off * 2 size_ratio = target_size.elem_div(orig_bb_size) # size too small? if size_ratio.x < self._min_resize_ratio.x: size_ratio = size_ratio.swapped(0, self._min_resize_ratio.x) if size_ratio.y < self._min_resize_ratio.y: size_ratio = size_ratio.swapped(1, self._min_resize_ratio.y) # re-calculate target_size in case size_ratio changed target_size = orig_bb_size.elem_mul(size_ratio) # STEP 3 calculate new bounding_rect position and size br_pos = Vec2(min(fixed_point.x, target_point.x), min(fixed_point.y, target_point.y)) # STEP 4 calculate and apply new node positions and sizes # calculate and keep incremental ratio for the event arguments inc_ratio = orig_bb_size.elem_mul(size_ratio).elem_div(rect_data[0].size) offsets = list() index = 0 for index, (rdata, opos, osize) in enumerate(zip(rect_data, orig_pos, orig_sizes)): #assert opos.x >= -1e-6 and opos.y >= -1e-6 pos = (br_pos + opos.elem_mul(size_ratio) + pad_off) # HACK rect_data is compartments + nodes. So we only append to offsets we've reached # the nodes sectoin if index >= len(self.compartments): offsets.append(pos - rdata.position) rdata.position = pos rdata.size = osize.elem_mul(size_ratio) # STEP 5 apply new bounding_rect position and size self.bounding_rect.position = (br_pos + pad_off) self.bounding_rect.size = target_size # STEP 6 post main events if len(self.nodes) != 0: post_event(DidResizeNodesEvent(node_indices=self.node_indices, ratio=inc_ratio, dragged=True)) post_event(DidMoveNodesEvent(node_indices=self.node_indices, offset=offsets[len(self.compartments):], dragged=True)) if len(self.compartments) != 0: post_event(DidResizeCompartmentsEvent( compartment_indices=self.comp_indices, ratio=inc_ratio, dragged=True)) post_event(DidMoveCompartmentsEvent( compartment_indices=self.comp_indices, offset=offsets[:len(self.compartments)], dragged=True)) # STEP 7 adjust peripheral node positions so that they are inside the compartment adjusted_nodes = list() offsets = list() for node in self.peripheral_nodes: assert node.comp_idx != -1 comp = self.canvas.comp_idx_map[node.comp_idx] pos = clamp_rect_pos(node.rect, comp.rect) if node.position != pos: adjusted_nodes.append(node) offsets.append(pos - node.position) node.position = pos if len(adjusted_nodes) != 0: post_event(DidMoveNodesEvent(adjusted_nodes, offsets, dragged=True)) def _move(self, pos: Vec2, rect_data: List[RectData], rel_positions: List[Vec2]): """Helper that performs resize on the bounding box, given the logical mouse position.""" assert len(rect_data) == len(rel_positions) assert len(rect_data) != 0 # campute tentative new positions. May need to clamp it. self._did_move = True new_positions = [pos + rp for rp in rel_positions] min_x = min(p.x for p in new_positions) min_y = min(p.y for p in new_positions) max_x = max(p.x + r.size.x for p, r in zip(new_positions, rect_data)) max_y = max(p.y + r.size.y for p, r in zip(new_positions, rect_data)) offset = Vec2(0, 0) s_bounds = self._bounds lim_topleft = s_bounds.position lim_botright = s_bounds.position + s_bounds.size if min_x < lim_topleft.x: assert max_x <= lim_botright.x offset += Vec2(lim_topleft.x - min_x, 0) elif max_x > lim_botright.x: offset += Vec2(lim_botright.x - max_x, 0) if min_y < lim_topleft.y: assert max_y <= lim_botright.y offset += Vec2(0, lim_topleft.y - min_y) elif max_y > lim_botright.y: offset += Vec2(0, lim_botright.y - max_y) self.bounding_rect.position = (pos + offset + self._drag_rel) # The actual amount moved by the rects pos_offset = (new_positions[0] + offset) - rect_data[0].position for rdata, np in zip(rect_data, new_positions): rdata.position = (np + offset) # Note that don't need to test if out of bounds by peripheral nodes, since all # peripheral nodes must be inside selected compartments. for node in self.peripheral_nodes: node.position += pos_offset all_nodes = self.nodes + self.peripheral_nodes if len(all_nodes) != 0: post_event(DidMoveNodesEvent([n.index for n in all_nodes], pos_offset, dragged=True)) if len(self.compartments) != 0: post_event(DidMoveCompartmentsEvent(compartment_indices=[c.index for c in self.compartments], offset=pos_offset, dragged=True))
[docs] def move_offset(self, offset: Vec2): nodes = [n for n in self.nodes if not n.lockNode] rect_data = cast(List[RectData], self.compartments) + cast(List[RectData], nodes) pos = self.bounding_rect.position rel_node_pos = [n.position - pos for n in self.nodes if not n.lockNode] rel_comp_pos = [c.position - pos for c in self.compartments] rel_positions = rel_comp_pos + rel_node_pos if len(rect_data) == 0: return self._move(pos + offset, rect_data, rel_positions) self._commit_move()
def _commit_move(self): with self.controller.group_action(): for node in chain(self.nodes, self.peripheral_nodes): self.controller.move_node(self.net_index, node.index, node.position) for comp in self.compartments: self.controller.move_compartment( self.net_index, comp.index, comp.position) if self.special_mode == SelectBox.SMode.NODES_IN_ONE: compi = self.canvas.InWhichCompartment(self.nodes) old_compi = self.nodes[0].comp_idx if compi != old_compi: for node in self.nodes: self.controller.set_compartment_of_node( self.net_index, node.index, compi) post_event(DidChangeCompartmentOfNodesEvent( node_indices=[n.index for n in self.nodes], old_compi=old_compi, new_compi=compi )) post_event(DidCommitDragEvent(self))
[docs]def primitive_peninfo(color: Color, width: float, is_alias: bool): info = wx.GraphicsPenInfo(color.to_wxcolour(), width) if is_alias: info = info.Style(wx.PENSTYLE_SHORT_DASH) return info
[docs]def primitive_brush(color: Color, is_alias: bool): # style = wx.BRUSHSTYLE_FIRST_HATCH if is_alias else wx.BRUSHSTYLE_SOLID style = wx.BRUSHSTYLE_SOLID brush = wx.Brush(color.to_wxcolour(), style=style) return brush
[docs]def draw_circle_to_gc(gc: wx.GraphicsContext, box: Rect, circle: CirclePrim, is_alias: bool): peninfo = primitive_peninfo(circle.border_color, circle.border_width, is_alias) pen = gc.CreatePen(peninfo) brush = gc.CreateBrush(primitive_brush(circle.fill_color, is_alias)) gc.SetPen(pen) gc.SetBrush(brush) path = gc.CreatePath() path.AddEllipse(box.position.x, box.position.y, box.size.x, box.size.y) gc.DrawPath(path)
[docs]def draw_rect_to_gc(gc: wx.GraphicsContext, box: Rect, rect: RectanglePrim, is_alias: bool): # restrict the border width of the node to an even integer # this is necessary for aligning the node rectangle to the selection rectangle # why? see Rect::aligned(). box = box.aligned() if rect.border_width == 0: border_width = 0 else: border_width = max(even_round(rect.border_width), 2) pen = gc.CreatePen(primitive_peninfo(rect.border_color, border_width, is_alias)) brush = gc.CreateBrush(primitive_brush(rect.fill_color, is_alias)) gc.SetPen(pen) gc.SetBrush(brush) gc.DrawRoundedRectangle(box.position.x, box.position.y, box.size.x, box.size.y, rect.corner_radius)
[docs]def draw_polygon_to_gc(gc: wx.GraphicsContext, box: Rect, poly: PolygonPrim, is_alias: bool): pen = gc.CreatePen(primitive_peninfo(poly.border_color, poly.border_width, is_alias)) brush = gc.CreateBrush(primitive_brush(poly.fill_color, is_alias)) gc.SetPen(pen) gc.SetBrush(brush) # offset so polygon is centered at (0.5, 0.5) points = [p + Vec2.repeat(0.5) for p in poly.points] transformed_pts = [box.position + p.elem_mul(box.size) for p in points] gc.DrawLines([wx.Point2D(*p) for p in transformed_pts])
# HACK bypass Python typing warnings with Type variables draw_fn_map: Dict[Any, Any] = { CirclePrim: draw_circle_to_gc, RectanglePrim: draw_rect_to_gc, HexagonPrim: draw_polygon_to_gc, LinePrim: draw_polygon_to_gc, TrianglePrim: draw_polygon_to_gc } # def apply_transform_to_gc(gc: wx.GraphicsContext, transform: Transform): # gc.Translate(*transform.translation) # gc.Rotate(transform.rotation) # gc.Scale(*transform.scale)
[docs]def draw_composite_shape(gc: wx.GraphicsContext, bounding_rect: Rect, node: Node): shape = node.composite_shape gc.PushState() # gc.Translate(*(bounding_rect.position.elem_mul(bounding_rect.size))) # gc.Scale(*bounding_rect.size) for primitive, transform in shape.items: gc.PushState() # apply_transform_to_gc(gc, transform) box = Rect(bounding_rect.position + bounding_rect.size.elem_mul(transform.translation), bounding_rect.size.elem_mul(transform.scale)) draw_fn_map[primitive.__class__](gc, box, primitive, node.original_index != -1) gc.PopState() gc.PopState() gc.PushState() # apply_transform_to_gc(gc, node.composite_shape.text_item[1]) draw_text_to_gc(gc, bounding_rect, node.id, node.composite_shape.text_item) gc.PopState()
def _truncate_text(gc: wx.GraphicsContext, max_width: float, text: str): '''Truncate the given text (add ellipsis to the end) so that its width fits inside `max_width`. Assume that we are using the currently selected font in gc. ''' tw, th, _, _ = gc.GetFullTextExtent(text) # very rough chopping if tw > max_width: text_len = int((max_width / tw) * len(text)) text = text[:text_len] if len(text) > 4: text = text[:-3] + '....' else: text = '....' return text
[docs]def draw_text_to_gc(gc: wx.GraphicsContext, bounding_rect: Rect, full_text_string, text_item: Tuple[TextPrim, Transform]): primitive, transform = text_item # Maybe cache this? fg_color = primitive.font_color.to_wxcolour() font = wx.Font(wx.FontInfo(primitive.font_size) .Family(primitive.font_family) .Style(primitive.font_style) .Weight(primitive.font_weight)) gfont = gc.CreateFont(font, fg_color) gc.SetFont(gfont) brush = gc.CreateBrush(wx.Brush(primitive.bg_color.to_wxcolour())) width, height = bounding_rect.size text_string = _truncate_text(gc, bounding_rect.size.x, full_text_string) tw, th, _, _ = gc.GetFullTextExtent(text_string) tw_full, th_full, _, _ = gc.GetFullTextExtent(full_text_string) # remaining x and y rx = width - tw ry = height - th # upper left corner of node text_pos = bounding_rect.position + transform.translation.elem_mul(bounding_rect.size) # do not truncate text if outside node if not primitive.position == TextPosition.IN_NODE: text_string = full_text_string text_pos = text_pos - Vec2(tw_full + th/2, 0) rx = th + tw_full + width if primitive.alignment == TextAlignment.LEFT: draw_pos = text_pos + Vec2(0, ry / 2) elif primitive.alignment == TextAlignment.CENTER: draw_pos = text_pos + Vec2(rx, ry) / 2 elif primitive.alignment == TextAlignment.RIGHT: draw_pos = text_pos + Vec2(rx, ry / 2) else: assert False, "This should not happen" if primitive.position == TextPosition.ABOVE: draw_pos = draw_pos + Vec2(0, -height/2 - 2*th) elif primitive.position == TextPosition.BELOW: draw_pos = draw_pos + Vec2(0, height/2 + 2*th) gc.DrawText(text_string, draw_pos.x, draw_pos.y, brush)