├── .gitignore ├── _images ├── demo.png └── mechanic_icon.png ├── Rotator.roboFontExt ├── info.plist └── lib │ └── rotator.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /_images/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankrolf/Rotator/HEAD/_images/demo.png -------------------------------------------------------------------------------- /_images/mechanic_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankrolf/Rotator/HEAD/_images/mechanic_icon.png -------------------------------------------------------------------------------- /Rotator.roboFontExt/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | addToMenu 6 | 7 | 8 | path 9 | rotator.py 10 | preferredName 11 | Rotator 12 | shortKey 13 | 14 | 15 | 16 | developer 17 | Frank Grießhammer 18 | developerURL 19 | www.frgr.de 20 | html 21 | 22 | launchAtStartUp 23 | 0 24 | mainScript 25 | 26 | name 27 | Rotator 28 | requiresVersionMajor 29 | 4 30 | requiresVersionMinor 31 | 0 32 | timeStamp 33 | 1691364974 34 | version 35 | 1.1.1 36 | repository 37 | frankrolf/Rotator 38 | extensionPath 39 | Rotator.roboFontExt 40 | 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rotator 2 | 3 | 4 | 5 | The rotation center can be set by entering coordinate values, or by dragging the crosshair center across the canvas. 6 | A preview of the rotation is shown in the glyph window; and will dynamically update when new values are given, or other outlines are selected. 7 | 8 | 9 | ## Versions 10 | 1.1 2023-08-03 EZUI, better Merz + Subscriber, click-drag crosshair 11 | 1.0 2022-03-17 Support for RF4 - Merz + Subscriber 12 | 0.6.0 2019-12-16 Allow dragging, add crosshair cursor 13 | 0.5.3 2019-10-18 Do not limit to a single glyph 14 | (which means the window can stay open while toggling through fonts) 15 | 0.5.2 2019-10-17 Limit imports 16 | 0.5.1 2018-02-07 Python 3 support and some common sense modifications. 17 | (Why wouldn’t the window be closeable in a normal way? 18 | That was silly.) 19 | 0.5 2015-03-01 Make text boxes better with digesting (ignoring) 20 | malicious input. 21 | 0.4 2014-07-30 Update UI, get rid of plist files, add preview glyph, 22 | add optional rounding for resulting glyph. 23 | 0.3 2013-11-08 Add click capture for setting rotation center. 24 | 0.2 2013-03 Re-write for Robofont. 25 | 0.1 2013-02-28 Update with plist for storing preferences. 26 | 0.0 2012 FL version. 27 | 28 | 29 | ## Background 30 | 31 | I originally wrote this when drawing [Zapf Dingbats](http://en.wikipedia.org/wiki/Zapf_Dingbats) for [FF Quixo](https://www.fontfont.com/fonts/quixo): 32 | 33 |

34 | ✁✂✃✄✅✆✇✈✉✊✋✌✍✎✏✐✑✒
35 | ✓✔✕✖✗✘✙✚✛✜✝✞✟✠✡✢✣✤✥✦✧✨✩
36 | ✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿
37 | ❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔
38 | ❕❖❗❘❙❚❛❜❝❞❟❠❡❢❣❤❥❦❧❨❩❪❫❬❭❮❯❰❱❲❳❴❵
39 | ❶❷❸❹❺❻❼❽❾❿➀➁➂➃➄➅➆➇➈➉
40 | ➊➋➌➍➎➏➐➑➒➓➔➕➖➗
41 | ➘➙➚➛➜➝➞➟➠➡➢➣➤➥➦➧➨➩➪➫
42 | ➬➭➮➯➰➱➲➳➴➵➶➷➸➹➺➻➼➽➾
43 |

44 | This script was very useful for the flowery- and asterisky glyphs. No procrastination involved whatsoever! 45 | 46 | 47 | ## MIT License 48 | 49 | Copyright (c) 2015 Frank Grießhammer 50 | 51 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 52 | 53 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 54 | 55 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 56 | -------------------------------------------------------------------------------- /Rotator.roboFontExt/lib/rotator.py: -------------------------------------------------------------------------------- 1 | # menuTitle: Rotator 2 | 3 | from AppKit import NSColor 4 | from fontTools.pens.cocoaPen import CocoaPen 5 | from fontTools.misc.roundTools import otRound 6 | 7 | import ezui 8 | import merz 9 | from merz.tools.drawingTools import NSImageDrawingTools 10 | 11 | from mojo.roboFont import version 12 | from mojo.subscriber import Subscriber, registerRoboFontSubscriber 13 | from mojo.events import getActiveEventTool 14 | from mojo.extensions import getExtensionDefault, setExtensionDefault 15 | from mojo.UI import getDefault, UpdateCurrentGlyphView, CurrentGlyphWindow 16 | if version > "4.4": 17 | from mojo.UI import appearanceColorKey 18 | 19 | 20 | EXTENSION_KEY = 'de.frgr.rotator' 21 | 22 | def rotator_symbol_factory( 23 | position=(0,0), 24 | width=20, 25 | strokeColor=(1, 0, 0, 1), 26 | strokeWidth=1 27 | ): 28 | bot = NSImageDrawingTools((width, width)) 29 | 30 | pen = bot.BezierPath() 31 | pen.moveTo((width/2, 0)) 32 | pen.lineTo((width/2, width)) 33 | pen.closePath() 34 | 35 | pen2 = bot.BezierPath() 36 | pen2.moveTo((0, width/2)) 37 | pen2.lineTo((width, width/2)) 38 | pen2.closePath() 39 | 40 | bot.fill(None) 41 | bot.stroke(*strokeColor) 42 | bot.strokeWidth(strokeWidth) 43 | bot.drawPath(pen) 44 | bot.drawPath(pen2) 45 | bot.oval(width/4,width/4,width/2,width/2) 46 | 47 | return bot.getImage() 48 | 49 | merz.SymbolImageVendor.registerImageFactory("rotator.circleCrosshair", rotator_symbol_factory) 50 | 51 | 52 | def disable(): 53 | return False 54 | def enable(): 55 | return True 56 | 57 | def nice_angle_string(angle): 58 | angle_result_string = u'%.2f' % angle 59 | if angle_result_string.endswith('.00'): 60 | angle_result_string = angle_result_string[0:-3] 61 | return u'%s°' % angle_result_string 62 | 63 | def round_integer(value): 64 | """Same as int(), but accepts None.""" 65 | if value is None: 66 | return None 67 | return otRound(value) 68 | 69 | def is_near(coords, check_coords, tol=5): 70 | # Account for glyph editor zoom level when determining click hitbox 71 | sc_tol = tol * (1/CurrentGlyphWindow().getGlyphViewScale()) 72 | if check_coords[0] - sc_tol < coords[0] < check_coords[0] + sc_tol and check_coords[1] - sc_tol < coords[1] < check_coords[1] + sc_tol: 73 | return True 74 | return False 75 | 76 | 77 | class Rotator(Subscriber, ezui.WindowController): 78 | 79 | 80 | def build(self): 81 | 82 | content = """ 83 | = TwoColumnForm 84 | 85 | : Steps: 86 | [_ _] @stepsField 87 | 88 | --- 89 | 90 | : Origin: 91 | * HorizontalStack @xyStack 92 | > [_ _] @xField 93 | > [_ _] @yField 94 | 95 | * HorizontalStack @alignmentStack 96 | > ({square.grid.3x3.bottommiddle.filled}) @alignBottomButton 97 | > ({square.grid.3x3.topmiddle.filled}) @alignTopButton 98 | > ({square.grid.3x3.middleleft.filled}) @alignLeftButton 99 | > ({square.grid.3x3.middleright.filled}) @alignRightButton 100 | 101 | --- 102 | 103 | : Color: 104 | * ColorWell @strokeColorWell 105 | 106 | --- 107 | 108 | : 109 | [ ] Round points @roundPointsCheckbox 110 | 111 | : 112 | (Apply) @applyButton 113 | """ 114 | 115 | column_1_width = 50 116 | column_2_width = 118 117 | field_width = 40 118 | symbol_config = { 119 | 'scale' : 'large', 120 | 'weight' : 'light', 121 | 'renderingMode': 'hierarchical', 122 | } 123 | descriptionData = dict( 124 | content=dict( 125 | titleColumnWidth=column_1_width, 126 | itemColumnWidth=column_2_width 127 | ), 128 | xyStack=dict( 129 | distribution="gravity", 130 | ), 131 | alignmentStack=dict( 132 | height=15, 133 | distribution="equalSpacing", 134 | ), 135 | alignBottomButton=dict( 136 | symbolConfiguration=symbol_config 137 | ), 138 | alignTopButton=dict( 139 | symbolConfiguration=symbol_config 140 | ), 141 | alignLeftButton=dict( 142 | symbolConfiguration=symbol_config 143 | ), 144 | alignRightButton=dict( 145 | symbolConfiguration=symbol_config 146 | ), 147 | stepsField=dict( 148 | value=5, 149 | valueWidth=field_width, 150 | valueType='integer', 151 | valueIncrement=1, 152 | trailingText="0°", 153 | ), 154 | xField=dict( 155 | value=0, 156 | valueWidth=field_width, 157 | valueType='integer', 158 | valueIncrement=1, 159 | trailingText="X", 160 | ), 161 | yField=dict( 162 | value=0, 163 | valueWidth=field_width, 164 | valueType='integer', 165 | valueIncrement=1, 166 | trailingText="Y", 167 | ), 168 | roundPointsCheckbox=dict( 169 | value=True, 170 | ), 171 | strokeColorWell=dict( 172 | color=(0,0,0,1), 173 | width=column_2_width, 174 | height=20 175 | ), 176 | applyButton=dict( 177 | width=120 178 | ), 179 | ) 180 | self.w = ezui.EZPanel( 181 | title="Rotator", 182 | content=content, 183 | descriptionData=descriptionData, 184 | margins=12, 185 | controller=self 186 | ) 187 | try: 188 | self.w.setItemValues(getExtensionDefault(EXTENSION_KEY, self.w.getItemValues())) 189 | except KeyError: 190 | self.save_defaults() 191 | 192 | self.crosshair_color = (1, 0, 0, 0.8) 193 | self.containers_setup = False 194 | self.g = CurrentGlyph() 195 | self.tool = getActiveEventTool() 196 | self.steps_text = self.w.getItem("stepsField") 197 | self.steps = self.steps_text.get() 198 | if self.steps: 199 | self.set_angle(self.steps) 200 | self.x_value_text = self.w.getItem("xField") 201 | self.x_value = self.x_value_text.get() 202 | self.y_value_text = self.w.getItem("yField") 203 | self.y_value = self.y_value_text.get() 204 | self.rounding = self.w.getItem('roundPointsCheckbox').get() 205 | self.w.getNSWindow().setTitlebarHeight_(22) 206 | self.w.getNSWindow().setTitlebarAppearsTransparent_(True) 207 | self.w.setDefaultButton(self.w.getItem("applyButton")) 208 | self.set_point_dragging(False) 209 | self.recently_applied = False 210 | self.set_stroke_color() 211 | 212 | 213 | def started(self): 214 | self.glyph_editor = CurrentGlyphWindow() 215 | if self.glyph_editor: 216 | # Position the window to the top-left of your current glyph editor. 217 | gwx, gwy, gww, gwh = self.glyph_editor.window().getPosSize() 218 | wx, wy, ww, wh = self.w.getPosSize() 219 | self.w.setPosSize((gwx + 6, gwy + 28, ww, wh)) 220 | self.set_up_containers() 221 | self.set_preview_color() 222 | self.draw_rotation_preview() 223 | self.w.open() 224 | 225 | 226 | def destroy(self): 227 | if self.containers_setup == True: 228 | self.bg_container.clearSublayers() 229 | self.pv_container.clearSublayers() 230 | self.containers_setup = False 231 | self.save_defaults() 232 | 233 | 234 | def save_defaults(self): 235 | setExtensionDefault(EXTENSION_KEY, self.w.getItemValues()) 236 | 237 | 238 | def clear_selection(self): 239 | self.g.selectedContours = () 240 | self.g.changed() 241 | 242 | 243 | def update_x_y(self): 244 | self.x_value_text.set(self.x_value) 245 | self.y_value_text.set(self.y_value) 246 | self.draw_rotation_preview() 247 | UpdateCurrentGlyphView() 248 | 249 | 250 | def set_angle(self, steps): 251 | if steps == 0: 252 | self.angle = 0 253 | else: 254 | self.angle = 360 / steps 255 | 256 | # Change the angle readout in the UI 257 | ns_stack = self.steps_text.getNSStackView() 258 | ns_views = ns_stack.views() 259 | ns_text_field = ns_views[-1] 260 | ez_label = ns_text_field.vanillaWrapper() 261 | ez_label.set(nice_angle_string(self.angle)) 262 | 263 | 264 | def set_preview_color(self): 265 | if version > "4.4": 266 | self.preview_color = getDefault(appearanceColorKey("glyphViewPreviewFillColor")) 267 | else: 268 | self.preview_color = getDefault("glyphViewPreviewFillColor") 269 | 270 | 271 | def set_stroke_color(self): 272 | try: 273 | self.stroke_color = self.w.getItem('strokeColorWell').get() 274 | except: 275 | self.stroke_color = (0, 0, 1, 1) 276 | 277 | 278 | # === CALLBACKS === # 279 | 280 | 281 | def xFieldCallback(self, sender): 282 | x_value = sender.get() 283 | try: 284 | self.x_value = round_integer(x_value) 285 | except ValueError: 286 | x_value = self.x_value 287 | self.x_value_text.set(x_value) 288 | self.draw_rotation_preview() 289 | UpdateCurrentGlyphView() 290 | 291 | 292 | def yFieldCallback(self, sender): 293 | y_value = sender.get() 294 | try: 295 | self.y_value = round_integer(y_value) 296 | except ValueError: 297 | y_value = self.y_value 298 | self.y_value_text.set(y_value) 299 | self.draw_rotation_preview() 300 | UpdateCurrentGlyphView() 301 | 302 | 303 | def alignBottomButtonCallback(self, sender): 304 | if self.g: 305 | self.x_value = round_integer((self.g.bounds[2] + self.g.bounds[0]) / 2) 306 | self.y_value = self.g.bounds[1] 307 | self.update_x_y() 308 | 309 | 310 | def alignTopButtonCallback(self, sender): 311 | if self.g: 312 | self.x_value = round_integer((self.g.bounds[2] + self.g.bounds[0]) / 2) 313 | self.y_value = self.g.bounds[3] 314 | self.update_x_y() 315 | 316 | 317 | def alignLeftButtonCallback(self, sender): 318 | if self.g: 319 | self.x_value = self.g.bounds[0] 320 | self.y_value = round_integer((self.g.bounds[3] + self.g.bounds[1]) / 2) 321 | self.update_x_y() 322 | 323 | 324 | def alignRightButtonCallback(self, sender): 325 | if self.g: 326 | self.x_value = self.g.bounds[2] 327 | self.y_value = round_integer((self.g.bounds[3] + self.g.bounds[1]) / 2) 328 | self.update_x_y() 329 | 330 | 331 | def roundPointsCheckboxCallback(self, sender): 332 | self.rounding = sender.get() 333 | self.save_defaults() 334 | 335 | 336 | def stepsFieldCallback(self, sender): 337 | try: 338 | step_value = float(sender.get()) 339 | step_value = int(round(step_value)) 340 | except ValueError: 341 | step_value = self.w.steps_text.get() 342 | 343 | self.steps = step_value 344 | self.set_angle(self.steps) 345 | self.draw_rotation_preview() 346 | 347 | UpdateCurrentGlyphView() 348 | 349 | 350 | def strokeColorWellCallback(self, sender): 351 | self.set_stroke_color() 352 | self.draw_rotation_preview() 353 | 354 | 355 | def applyButtonCallback(self, sender): 356 | with self.g.undo('Rotator: Apply Rotation'): 357 | self.g.appendGlyph(self.get_rotated_glyph()) 358 | self.save_defaults() 359 | self.g.changed() 360 | # Remove everything but the crosshairs 361 | if self.stroked_preview: 362 | self.stroked_preview.setPath(None) 363 | self.recently_applied = True 364 | 365 | 366 | # === SUBSCRIBERS === # 367 | 368 | 369 | def glyphEditorDidSetGlyph(self, info): 370 | if self.containers_setup == True: 371 | self.bg_container.clearSublayers() 372 | self.pv_container.clearSublayers() 373 | self.containers_setup = False 374 | self.g = info["glyph"] 375 | self.glyph_editor = info["glyphEditor"] 376 | self.set_up_containers() 377 | self.draw_rotation_preview() 378 | 379 | 380 | glyphEditorGlyphDidChangeDelay = 0 381 | def currentGlyphDidChangeContours(self, info): 382 | self.g = info["glyph"] 383 | self.draw_rotation_preview() 384 | 385 | 386 | def glyphEditorDidMouseDown(self, info): 387 | self.down_point = (info['lowLevelEvents'][0]['point'].x, info['lowLevelEvents'][0]['point'].y) 388 | self.g = info["glyph"] 389 | if is_near(self.down_point, (self.x_value, self.y_value)): 390 | self.set_point_dragging(True) 391 | self.clear_selection() 392 | self.mouse_update_origin(info) 393 | else: 394 | self.set_point_dragging(False) 395 | if self.recently_applied == False: 396 | self.mouse_update_origin(info) 397 | 398 | 399 | def set_point_dragging(self, value): 400 | if value == False: 401 | self.point_dragging = False 402 | # self.tool.shouldShowMarqueRect = enable 403 | # self.tool.canSelectWithMarque = enable 404 | # print("self.tool.shouldShowMarqueRect = enable, self.tool.canSelectWithMarque = enable") 405 | else: 406 | self.point_dragging = True 407 | self.recently_applied = False # You may have hit Apply earlier, but now things can start moving. 408 | # self.tool.shouldShowMarqueRect = disable 409 | # self.tool.canSelectWithMarque = disable 410 | # print("self.tool.shouldShowMarqueRect = disable, self.tool.canSelectWithMarque = disable") 411 | 412 | 413 | glyphEditorDidMouseDragDelay = 0 414 | def glyphEditorDidMouseDrag(self, info): 415 | self.g = info["glyph"] 416 | if self.recently_applied == False: 417 | self.mouse_update_origin(info) 418 | 419 | 420 | def glyphEditorDidUndo(self, info): 421 | self.recently_applied = False 422 | self.draw_rotation_preview() 423 | 424 | 425 | def glyphEditorDidMouseUp(self, info): 426 | self.g = info["glyph"] 427 | 428 | # Deselect stuff if you just came back from dragging 429 | if self.point_dragging: 430 | self.clear_selection() 431 | self.mouse_update_origin(info) 432 | self.set_point_dragging(False) 433 | if self.recently_applied == False: 434 | self.mouse_update_origin(info) 435 | 436 | 437 | def glyphEditorWillOpen(self, info): 438 | self.glyph_editor = info["glyphEditor"] 439 | self.set_up_containers() 440 | 441 | 442 | def glyphEditorDidOpen(self, info): 443 | self.draw_rotation_preview() 444 | 445 | 446 | # Change the preview layer color if the app switches to dark mode. 447 | def roboFontAppearanceChanged(self, info): 448 | self.set_preview_color() 449 | 450 | 451 | def roboFontDidChangePreferences(self, info): 452 | self.set_preview_color() 453 | 454 | 455 | # === MERZ === # 456 | 457 | 458 | def set_up_containers(self): 459 | self.bg_container = self.glyph_editor.extensionContainer( 460 | identifier="rotator.foreground", 461 | location="foreground", 462 | clear=True 463 | ) 464 | self.pv_container = self.glyph_editor.extensionContainer( 465 | identifier="rotator.preview", 466 | location="preview", 467 | clear=True 468 | ) 469 | self.containers_setup = True 470 | 471 | 472 | def mouse_update_origin(self, info): 473 | point = info['lowLevelEvents'][0]['point'] 474 | if self.point_dragging: 475 | self.x_value, self.y_value = round_integer(point.x), round_integer(point.y) 476 | self.x_value_text.set(self.x_value) 477 | self.y_value_text.set(self.y_value) 478 | self.draw_rotation_preview() 479 | 480 | 481 | def draw_rotation_preview(self): 482 | self.bg_container.clearSublayers() 483 | self.pv_container.clearSublayers() 484 | 485 | # Draw outlined glyph 486 | self.stroked_preview = self.bg_container.appendPathSublayer( 487 | strokeColor=self.stroke_color, 488 | fillColor=None, 489 | strokeWidth=1 490 | ) 491 | outline = self.get_rotated_glyph() 492 | glyph_path = outline.getRepresentation("merz.CGPath") 493 | self.stroked_preview.setPath(glyph_path) 494 | 495 | # Draw solid preview 496 | self.filled_preview = self.pv_container.appendPathSublayer( 497 | strokeColor=None, 498 | fillColor=self.preview_color, 499 | strokeWidth=0 500 | ) 501 | self.filled_preview.setPath(glyph_path) 502 | 503 | # Draw crosshair 504 | center_x = self.x_value 505 | center_y = self.y_value 506 | self.crosshair = self.bg_container.appendSymbolSublayer( 507 | position = (center_x, center_y), 508 | imageSettings = dict( 509 | name = "rotator.circleCrosshair", 510 | strokeColor = self.crosshair_color 511 | ) 512 | ) 513 | self.preview_crosshair = self.pv_container.appendSymbolSublayer( 514 | position = (center_x, center_y), 515 | imageSettings = dict( 516 | name = "rotator.circleCrosshair", 517 | strokeColor = self.crosshair_color 518 | ) 519 | ) 520 | 521 | 522 | def get_rotated_glyph(self): 523 | x = round_integer(self.x_value_text.get()) 524 | y = round_integer(self.y_value_text.get()) 525 | 526 | if x == None or y == None: 527 | x = self.g.width / 2 528 | y = (self.g.bounds[3] - self.g.bounds[1]) / 2 529 | 530 | steps = self.steps 531 | angle = self.angle 532 | 533 | center = (x, y) 534 | rotation_result_glyph = RGlyph() 535 | rotation_step_glyph = RGlyph() 536 | pen = rotation_step_glyph.getPointPen() 537 | 538 | contour_list = [] 539 | for idx, contour in enumerate(self.g): 540 | if contour.selected: 541 | contour_list.append(idx) 542 | 543 | # if nothing is selected, the whole glyph will be rotated. 544 | if len(contour_list) == 0: 545 | for idx, contour in enumerate(self.g): 546 | contour_list.append(idx) 547 | 548 | for contour in contour_list: 549 | self.g[contour].drawPoints(pen) 550 | 551 | # Don't draw the original shape again 552 | step_count = steps - 1 553 | 554 | for i in range(step_count): 555 | rotation_step_glyph.rotateBy(angle, center) 556 | rotation_result_glyph.appendGlyph(rotation_step_glyph) 557 | 558 | if self.rounding: 559 | rotation_result_glyph.round() 560 | 561 | return rotation_result_glyph 562 | 563 | 564 | 565 | registerRoboFontSubscriber(Rotator) --------------------------------------------------------------------------------