├── .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)
--------------------------------------------------------------------------------