CreativePython 1.1.5__py3-none-any.whl → 1.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CreativePython/GuiRenderer.py +39 -89
- {creativepython-1.1.5.dist-info → creativepython-1.1.6.dist-info}/METADATA +1 -1
- {creativepython-1.1.5.dist-info → creativepython-1.1.6.dist-info}/RECORD +11 -11
- gui.py +463 -15
- midi.py +52 -52
- music.py +1 -1
- osc.py +10 -10
- {creativepython-1.1.5.dist-info → creativepython-1.1.6.dist-info}/WHEEL +0 -0
- {creativepython-1.1.5.dist-info → creativepython-1.1.6.dist-info}/licenses/LICENSE +0 -0
- {creativepython-1.1.5.dist-info → creativepython-1.1.6.dist-info}/licenses/LICENSE-PSF +0 -0
- {creativepython-1.1.5.dist-info → creativepython-1.1.6.dist-info}/top_level.txt +0 -0
CreativePython/GuiRenderer.py
CHANGED
|
@@ -624,7 +624,30 @@ class _QPolygonItem(_QtGraphicsItemEventMixin, QtWidgets.QGraphicsPolygonItem):
|
|
|
624
624
|
pass
|
|
625
625
|
|
|
626
626
|
class _QPixmapItem(_QtGraphicsItemEventMixin, QtWidgets.QGraphicsPixmapItem):
|
|
627
|
-
|
|
627
|
+
_colorFilter = None # (r, g, b, a) set by IconMirror._applyColor; None = no filter
|
|
628
|
+
|
|
629
|
+
def paint(self, painter, option, widget=None):
|
|
630
|
+
if self._colorFilter is None:
|
|
631
|
+
super().paint(painter, option, widget)
|
|
632
|
+
return
|
|
633
|
+
|
|
634
|
+
pixmap = self.pixmap()
|
|
635
|
+
if pixmap.isNull():
|
|
636
|
+
return
|
|
637
|
+
|
|
638
|
+
r, g, b, a = self._colorFilter
|
|
639
|
+
image = pixmap.toImage().convertToFormat(QtGui.QImage.Format.Format_ARGB32)
|
|
640
|
+
|
|
641
|
+
filtered = QtGui.QImage(image.size(), QtGui.QImage.Format.Format_ARGB32)
|
|
642
|
+
filtered.fill(QtCore.Qt.GlobalColor.transparent)
|
|
643
|
+
|
|
644
|
+
p = QtGui.QPainter(filtered)
|
|
645
|
+
p.drawImage(0, 0, image)
|
|
646
|
+
p.setCompositionMode(QtGui.QPainter.CompositionMode.CompositionMode_SourceOver)
|
|
647
|
+
p.fillRect(image.rect(), QtGui.QColor(r, g, b, a))
|
|
648
|
+
p.end()
|
|
649
|
+
|
|
650
|
+
painter.drawImage(self.offset(), filtered)
|
|
628
651
|
|
|
629
652
|
class _QGroupItem(_QtGraphicsItemEventMixin, QtWidgets.QGraphicsItemGroup):
|
|
630
653
|
pass
|
|
@@ -1688,15 +1711,12 @@ class _DrawableMirror:
|
|
|
1688
1711
|
self._rotation = 0
|
|
1689
1712
|
self._toolTipText = None
|
|
1690
1713
|
|
|
1691
|
-
# GuiRenderer only handles commands that alter an object visually
|
|
1692
|
-
#
|
|
1714
|
+
# GuiRenderer only handles commands that alter an object visually.
|
|
1715
|
+
# Hit testing (contains/intersects/encloses) is handled entirely in gui.py.
|
|
1693
1716
|
self._commandHandlers = {
|
|
1694
1717
|
'setPosition': self._setPosition,
|
|
1695
1718
|
'setSize': self._setSize,
|
|
1696
1719
|
'setRotation': self._setRotation,
|
|
1697
|
-
'contains': self._contains,
|
|
1698
|
-
'intersects': self._intersects,
|
|
1699
|
-
'encloses': self._encloses,
|
|
1700
1720
|
'setToolTipText': self._setToolTipText,
|
|
1701
1721
|
'show': self._show,
|
|
1702
1722
|
'hide': self._hide,
|
|
@@ -1759,43 +1779,6 @@ class _DrawableMirror:
|
|
|
1759
1779
|
self.qObject.prepareGeometryChange()
|
|
1760
1780
|
self.qObject.setRotation(qtDegree)
|
|
1761
1781
|
|
|
1762
|
-
# ── Hit testing ────────────────────────────────────────────────────────────
|
|
1763
|
-
|
|
1764
|
-
def _contains(self, args, responseId):
|
|
1765
|
-
"""Returns True if the point (x, y) is inside this item."""
|
|
1766
|
-
x = args.get('x', 0)
|
|
1767
|
-
y = args.get('y', 0)
|
|
1768
|
-
globalPoint = QtCore.QPointF(x, y)
|
|
1769
|
-
localPoint = self.qObject.mapFromScene(globalPoint)
|
|
1770
|
-
result = self.qObject.contains(localPoint)
|
|
1771
|
-
self.guiRenderer.sendResponse(responseId, [result])
|
|
1772
|
-
|
|
1773
|
-
def _intersects(self, args, responseId):
|
|
1774
|
-
"""Returns True if this item intersects another item."""
|
|
1775
|
-
otherObjectId = args.get('otherObjectId')
|
|
1776
|
-
otherMirror = self.guiRenderer._objectRegistry.get(otherObjectId)
|
|
1777
|
-
result = False
|
|
1778
|
-
|
|
1779
|
-
if otherMirror is not None:
|
|
1780
|
-
pathA = self.qObject.mapToScene(self.qObject.shape())
|
|
1781
|
-
pathB = otherMirror.qObject.mapToScene(otherMirror.qObject.shape())
|
|
1782
|
-
result = pathA.intersects(pathB)
|
|
1783
|
-
|
|
1784
|
-
self.guiRenderer.sendResponse(responseId, [result])
|
|
1785
|
-
|
|
1786
|
-
def _encloses(self, args, responseId):
|
|
1787
|
-
"""Returns True if this item fully encloses another item."""
|
|
1788
|
-
otherObjectId = args.get('otherObjectId')
|
|
1789
|
-
otherMirror = self.guiRenderer._objectRegistry.get(otherObjectId)
|
|
1790
|
-
result = False
|
|
1791
|
-
|
|
1792
|
-
if otherMirror is not None:
|
|
1793
|
-
pathA = self.qObject.mapToScene(self.qObject.shape())
|
|
1794
|
-
pathB = otherMirror.qObject.mapToScene(otherMirror.qObject.shape())
|
|
1795
|
-
result = pathA.contains(pathB)
|
|
1796
|
-
|
|
1797
|
-
self.guiRenderer.sendResponse(responseId, [result])
|
|
1798
|
-
|
|
1799
1782
|
# ── Visibility ─────────────────────────────────────────────────────────────
|
|
1800
1783
|
|
|
1801
1784
|
def _show(self, args, responseId):
|
|
@@ -2405,6 +2388,7 @@ class PolygonMirror(_GraphicsMirror):
|
|
|
2405
2388
|
# IconMirror — mirror of gui.py's Icon
|
|
2406
2389
|
#######################################################################################
|
|
2407
2390
|
|
|
2391
|
+
|
|
2408
2392
|
class IconMirror(_GraphicsMirror):
|
|
2409
2393
|
"""
|
|
2410
2394
|
Mirror of gui.py's Icon. Backed by a QGraphicsPixmapItem.
|
|
@@ -2474,16 +2458,25 @@ class IconMirror(_GraphicsMirror):
|
|
|
2474
2458
|
'getPixels': self._getPixels,
|
|
2475
2459
|
'setPixels': self._setPixels,
|
|
2476
2460
|
'write': self._write,
|
|
2461
|
+
'setAlpha': self._setAlpha,
|
|
2477
2462
|
})
|
|
2478
2463
|
|
|
2479
2464
|
def _applyColor(self):
|
|
2480
|
-
|
|
2481
|
-
|
|
2465
|
+
r, g, b, a = self._color
|
|
2466
|
+
if [r, g, b, a] == [0, 0, 0, 0]:
|
|
2467
|
+
self.qObject._colorFilter = None
|
|
2468
|
+
else:
|
|
2469
|
+
self.qObject._colorFilter = (r, g, b, a)
|
|
2470
|
+
self.qObject.update()
|
|
2482
2471
|
|
|
2483
2472
|
def _applyThickness(self):
|
|
2484
2473
|
"""No-op — QGraphicsPixmapItem has no pen or brush."""
|
|
2485
2474
|
pass
|
|
2486
2475
|
|
|
2476
|
+
def _setAlpha(self, args, responseId):
|
|
2477
|
+
alpha = args.get('alpha', 255)
|
|
2478
|
+
self.qObject.setOpacity(alpha / 255.0)
|
|
2479
|
+
|
|
2487
2480
|
# ── Size ───────────────────────────────────────────────────────────────────
|
|
2488
2481
|
|
|
2489
2482
|
def _getSize(self, args, responseId):
|
|
@@ -2922,8 +2915,7 @@ class _ControlMirror(_DrawableMirror):
|
|
|
2922
2915
|
- dimensions: setRect(), setPath(), etc. -> setFixedSize()
|
|
2923
2916
|
- color: [r, g, b] -> stylesheets
|
|
2924
2917
|
|
|
2925
|
-
Controls
|
|
2926
|
-
handle their own input directly.
|
|
2918
|
+
Controls handle their own input directly via Qt's widget event system.
|
|
2927
2919
|
|
|
2928
2920
|
Concrete classes must:
|
|
2929
2921
|
1. Call super().__init__(objectId, guiRenderer, args) first.
|
|
@@ -2964,48 +2956,6 @@ class _ControlMirror(_DrawableMirror):
|
|
|
2964
2956
|
def _setRotation(self, args, responseId):
|
|
2965
2957
|
pass # Controls cannot be rotated; gui.py warns at the call site
|
|
2966
2958
|
|
|
2967
|
-
# ── Hit testing ────────────────────────────────────────────────────────────
|
|
2968
|
-
|
|
2969
|
-
def _contains(self, args, responseId):
|
|
2970
|
-
"""Returns True if the point (x, y) is inside this widget's bounding box."""
|
|
2971
|
-
x = args.get('x', 0)
|
|
2972
|
-
y = args.get('y', 0)
|
|
2973
|
-
rect = QtCore.QRectF(self.qObject.geometry())
|
|
2974
|
-
result = rect.contains(QtCore.QPointF(x, y))
|
|
2975
|
-
self.guiRenderer.sendResponse(responseId, [result])
|
|
2976
|
-
|
|
2977
|
-
def _intersects(self, args, responseId):
|
|
2978
|
-
"""Returns True if this widget's bounding box intersects another item."""
|
|
2979
|
-
otherObjectId = args.get('otherObjectId')
|
|
2980
|
-
otherMirror = self.guiRenderer._objectRegistry.get(otherObjectId)
|
|
2981
|
-
result = False
|
|
2982
|
-
|
|
2983
|
-
if otherMirror is not None:
|
|
2984
|
-
rectA = QtCore.QRectF(self.qObject.geometry())
|
|
2985
|
-
if isinstance(otherMirror, _ControlMirror):
|
|
2986
|
-
rectB = QtCore.QRectF(otherMirror.qObject.geometry())
|
|
2987
|
-
else:
|
|
2988
|
-
rectB = otherMirror.qObject.sceneBoundingRect()
|
|
2989
|
-
result = rectA.intersects(rectB)
|
|
2990
|
-
|
|
2991
|
-
self.guiRenderer.sendResponse(responseId, [result])
|
|
2992
|
-
|
|
2993
|
-
def _encloses(self, args, responseId):
|
|
2994
|
-
"""Returns True if this widget's bounding box fully encloses another item."""
|
|
2995
|
-
otherObjectId = args.get('otherObjectId')
|
|
2996
|
-
otherMirror = self.guiRenderer._objectRegistry.get(otherObjectId)
|
|
2997
|
-
result = False
|
|
2998
|
-
|
|
2999
|
-
if otherMirror is not None:
|
|
3000
|
-
rectA = QtCore.QRectF(self.qObject.geometry())
|
|
3001
|
-
if isinstance(otherMirror, _ControlMirror):
|
|
3002
|
-
rectB = QtCore.QRectF(otherMirror.qObject.geometry())
|
|
3003
|
-
else:
|
|
3004
|
-
rectB = otherMirror.qObject.sceneBoundingRect()
|
|
3005
|
-
result = rectA.contains(rectB)
|
|
3006
|
-
|
|
3007
|
-
self.guiRenderer.sendResponse(responseId, [result])
|
|
3008
|
-
|
|
3009
2959
|
# ── Size ───────────────────────────────────────────────────────────────────
|
|
3010
2960
|
|
|
3011
2961
|
def _getSize(self, args, responseId):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: CreativePython
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.6
|
|
4
4
|
Summary: A Python-based software environment for developing algorithmic art projects.
|
|
5
5
|
Author-email: "Dr. Bill Manaris" <manaris@cofc.edu>, Taj Ballinger <ballingertj@g.cofc.edu>, Trevor Ritchie <ritchiets@g.cofc.edu>
|
|
6
6
|
License: MIT License
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
gui.py,sha256=
|
|
1
|
+
gui.py,sha256=io7p7aYK9EqcP9lBbBO3CBycaAvaHs3IxNw5BrOhhgs,154434
|
|
2
2
|
iannix.py,sha256=jlBJhPzBcIl2iayG01ldoslAytxYOvP3z_vN-EgiJA4,17188
|
|
3
3
|
image.py,sha256=wohpTTZH6QzN3wRfXI5ac7VTRsOKcwqk8_d3yha8oDw,3763
|
|
4
4
|
markov.py,sha256=VhTXZfDDHETj6KhriFmOLHKNjgx08cWZvpLECuGRTG0,24653
|
|
5
|
-
midi.py,sha256=
|
|
6
|
-
music.py,sha256=
|
|
7
|
-
osc.py,sha256=
|
|
5
|
+
midi.py,sha256=avVY6dyTHbFazt10w2Uy0zUPXvRYleZM2O89gDX3-h4,27203
|
|
6
|
+
music.py,sha256=VyYcjxEIRYG7YWFzBIbSFaR4mVcO2fHDZnkdCh0G4sM,268532
|
|
7
|
+
osc.py,sha256=GtjWV5LD4xiW9Yen3PzFYstWHhEZ_QyrKunAu-VsPrc,8353
|
|
8
8
|
timer.py,sha256=uQ1BHWTjZ6DpJo-leGhxgOJcVcd1Lj0jvQQodXx1kw4,16341
|
|
9
9
|
zipf.py,sha256=J6fWwUYz88LFAwanc0TmXJlU7VtpBM24_Rw5XJTOn9c,40312
|
|
10
10
|
CreativePython/GuiHandler.py,sha256=ONnxhqivBrzCHQv7GcE4vkMgnONbuXXq7Fwe0Au5QPs,30974
|
|
11
|
-
CreativePython/GuiRenderer.py,sha256=
|
|
11
|
+
CreativePython/GuiRenderer.py,sha256=XUXhPjtLRUA3D0Q05ZP85gb3gPqg8Zvs732bSYBOVo4,136843
|
|
12
12
|
CreativePython/RealtimeAudioPlayer.py,sha256=tjqBjSOvSvH1kP0t6q35xsbKHPr0wuLUvGdxdWAY73E,44590
|
|
13
13
|
CreativePython/__init__.py,sha256=bT1nZ-Fp8-7uiKqhVdnFeOwL-8Jps2rvE6BfoOE8fZ0,451
|
|
14
14
|
CreativePython/notationRenderer.py,sha256=2KIMvInNLEUZyn_r1KMmyt7X2XDrfekVkggFeHLRu1M,35440
|
|
@@ -77,9 +77,9 @@ CreativePython/nevmuse/utilities/CSVWriter.py,sha256=k2GccvzD5IwdkmFdN5qtRA2qZX1
|
|
|
77
77
|
CreativePython/nevmuse/utilities/PowerLawRandom.py,sha256=WIEI-p8wiiWhkkBR5pO9gJ2TwxZBw453T_xXzCNdKPY,3752
|
|
78
78
|
CreativePython/nevmuse/utilities/__init__.py,sha256=pAGIMMGfLvR2DQfxr8qkfKQJ-4fIkVqlhnGK657JPL8,166
|
|
79
79
|
bin/libportaudio.2.dylib,sha256=ooJlm7QeL7jYzc1c8YMmH-C8PhYHwDvBJZkNDVPFRbE,286592
|
|
80
|
-
creativepython-1.1.
|
|
81
|
-
creativepython-1.1.
|
|
82
|
-
creativepython-1.1.
|
|
83
|
-
creativepython-1.1.
|
|
84
|
-
creativepython-1.1.
|
|
85
|
-
creativepython-1.1.
|
|
80
|
+
creativepython-1.1.6.dist-info/licenses/LICENSE,sha256=tz38qDa5RhHMqzZR1Q5QafqWdXn_EhheXYBP7FjgtEM,1992
|
|
81
|
+
creativepython-1.1.6.dist-info/licenses/LICENSE-PSF,sha256=JN1jUPIsEUH1mq3AXxet-b1IdhAr-XzscXBZiz74c9c,3371
|
|
82
|
+
creativepython-1.1.6.dist-info/METADATA,sha256=MGKp9oy0r3G1TsnJxa2M9WtRlP_4X7bGv-05HyB2z0s,8703
|
|
83
|
+
creativepython-1.1.6.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
84
|
+
creativepython-1.1.6.dist-info/top_level.txt,sha256=PzvvUyy-3YzTedVGnLS02ju1TCMuo76h1LuVd8XvHhA,69
|
|
85
|
+
creativepython-1.1.6.dist-info/RECORD,,
|
gui.py
CHANGED
|
@@ -529,9 +529,14 @@ class _Interactable:
|
|
|
529
529
|
def _registerCallback(self, eventType, action):
|
|
530
530
|
"""
|
|
531
531
|
Registers a single event callback via IPC to the renderer process.
|
|
532
|
+
Skips the IPC sendCommand if the event type is already registered,
|
|
533
|
+
since the renderer's _registeredEvents is a set (idempotent) and only
|
|
534
|
+
the callback stored locally needs updating.
|
|
532
535
|
"""
|
|
536
|
+
alreadyRegistered = eventType in self._actionList
|
|
533
537
|
self._actionList[eventType] = action
|
|
534
|
-
|
|
538
|
+
if not alreadyRegistered:
|
|
539
|
+
_handler().registerEvent(self._objectId, eventType, action)
|
|
535
540
|
|
|
536
541
|
def onMouseClick(self, action):
|
|
537
542
|
"""
|
|
@@ -1123,6 +1128,155 @@ class Display(_Interactable):
|
|
|
1123
1128
|
"""
|
|
1124
1129
|
self.drawLabel(text, x, y, color, font)
|
|
1125
1130
|
|
|
1131
|
+
#######################################################################################
|
|
1132
|
+
# Geometry helpers — used by contains(), intersects(), encloses()
|
|
1133
|
+
#######################################################################################
|
|
1134
|
+
|
|
1135
|
+
def _pointInPolygon(x, y, points):
|
|
1136
|
+
"""
|
|
1137
|
+
Returns True if (x, y) lies inside the closed polygon defined by points.
|
|
1138
|
+
Uses the ray-casting (even-odd) algorithm. Points are (x, y) tuples.
|
|
1139
|
+
"""
|
|
1140
|
+
inside = False
|
|
1141
|
+
n = len(points)
|
|
1142
|
+
j = n - 1
|
|
1143
|
+
for i in range(n):
|
|
1144
|
+
xi, yi = points[i]
|
|
1145
|
+
xj, yj = points[j]
|
|
1146
|
+
if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi) + xi):
|
|
1147
|
+
inside = not inside
|
|
1148
|
+
j = i
|
|
1149
|
+
return inside
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _pointToSegmentDistSq(px, py, x1, y1, x2, y2):
|
|
1153
|
+
"""
|
|
1154
|
+
Returns the squared distance from point (px, py) to segment (x1,y1)-(x2,y2).
|
|
1155
|
+
"""
|
|
1156
|
+
dx, dy = x2 - x1, y2 - y1
|
|
1157
|
+
lenSq = dx * dx + dy * dy
|
|
1158
|
+
if lenSq == 0:
|
|
1159
|
+
return (px - x1) ** 2 + (py - y1) ** 2
|
|
1160
|
+
t = max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / lenSq))
|
|
1161
|
+
nearX = x1 + t * dx
|
|
1162
|
+
nearY = y1 + t * dy
|
|
1163
|
+
return (px - nearX) ** 2 + (py - nearY) ** 2
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def _pointNearPath(x, y, points, halfThickness):
|
|
1167
|
+
"""
|
|
1168
|
+
Returns True if (x, y) is within halfThickness of any segment in points.
|
|
1169
|
+
Used for open paths (Polyline, Arc OPEN) where the interior has no fill.
|
|
1170
|
+
"""
|
|
1171
|
+
threshSq = halfThickness * halfThickness
|
|
1172
|
+
result = False
|
|
1173
|
+
i = 0
|
|
1174
|
+
while not result and i < len(points) - 1:
|
|
1175
|
+
x1, y1 = points[i]
|
|
1176
|
+
x2, y2 = points[i + 1]
|
|
1177
|
+
result = _pointToSegmentDistSq(x, y, x1, y1, x2, y2) <= threshSq
|
|
1178
|
+
i += 1
|
|
1179
|
+
return result
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4):
|
|
1183
|
+
"""
|
|
1184
|
+
Returns True if segment (x1,y1)-(x2,y2) intersects segment (x3,y3)-(x4,y4).
|
|
1185
|
+
Uses parametric intersection; parallel or collinear segments return False.
|
|
1186
|
+
"""
|
|
1187
|
+
d1x, d1y = x2 - x1, y2 - y1
|
|
1188
|
+
d2x, d2y = x4 - x3, y4 - y3
|
|
1189
|
+
cross = d1x * d2y - d1y * d2x
|
|
1190
|
+
if cross == 0:
|
|
1191
|
+
result = False # parallel or collinear
|
|
1192
|
+
else:
|
|
1193
|
+
t = ((x3 - x1) * d2y - (y3 - y1) * d2x) / cross
|
|
1194
|
+
u = ((x3 - x1) * d1y - (y3 - y1) * d1x) / cross
|
|
1195
|
+
result = 0.0 <= t <= 1.0 and 0.0 <= u <= 1.0
|
|
1196
|
+
return result
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def _polygonEdgesIntersect(polyA, polyB):
|
|
1200
|
+
"""
|
|
1201
|
+
Returns True if any edge of polyA crosses any edge of polyB.
|
|
1202
|
+
Each polygon is a list of (x, y) tuples; edges wrap from last to first.
|
|
1203
|
+
"""
|
|
1204
|
+
nA = len(polyA)
|
|
1205
|
+
nB = len(polyB)
|
|
1206
|
+
result = False
|
|
1207
|
+
i = 0
|
|
1208
|
+
while not result and i < nA:
|
|
1209
|
+
x1, y1 = polyA[i]
|
|
1210
|
+
x2, y2 = polyA[(i + 1) % nA]
|
|
1211
|
+
j = 0
|
|
1212
|
+
while not result and j < nB:
|
|
1213
|
+
x3, y3 = polyB[j]
|
|
1214
|
+
x4, y4 = polyB[(j + 1) % nB]
|
|
1215
|
+
result = _segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4)
|
|
1216
|
+
j += 1
|
|
1217
|
+
i += 1
|
|
1218
|
+
return result
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def _polygonsIntersect(polyA, polyB):
|
|
1222
|
+
"""
|
|
1223
|
+
Returns True if two closed polygons overlap.
|
|
1224
|
+
Covers three cases: edges crossing, A fully inside B, and B fully inside A.
|
|
1225
|
+
"""
|
|
1226
|
+
result = (_polygonEdgesIntersect(polyA, polyB) or
|
|
1227
|
+
_pointInPolygon(*polyA[0], polyB) or
|
|
1228
|
+
_pointInPolygon(*polyB[0], polyA))
|
|
1229
|
+
return result
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def _openPathIntersectsPolygon(pathPoints, polyPoints):
|
|
1233
|
+
"""
|
|
1234
|
+
Returns True if an open path intersects a closed polygon.
|
|
1235
|
+
Checks whether any segment of the path crosses a polygon edge, or whether
|
|
1236
|
+
any path point lies inside the polygon (path running through the interior).
|
|
1237
|
+
"""
|
|
1238
|
+
nPath = len(pathPoints)
|
|
1239
|
+
nPoly = len(polyPoints)
|
|
1240
|
+
result = False
|
|
1241
|
+
i = 0
|
|
1242
|
+
while not result and i < nPath - 1:
|
|
1243
|
+
x1, y1 = pathPoints[i]
|
|
1244
|
+
x2, y2 = pathPoints[i + 1]
|
|
1245
|
+
j = 0
|
|
1246
|
+
while not result and j < nPoly:
|
|
1247
|
+
x3, y3 = polyPoints[j]
|
|
1248
|
+
x4, y4 = polyPoints[(j + 1) % nPoly]
|
|
1249
|
+
result = _segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4)
|
|
1250
|
+
j += 1
|
|
1251
|
+
i += 1
|
|
1252
|
+
i = 0
|
|
1253
|
+
while not result and i < nPath:
|
|
1254
|
+
result = _pointInPolygon(*pathPoints[i], polyPoints)
|
|
1255
|
+
i += 1
|
|
1256
|
+
return result
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def _pathsIntersect(pathA, pathB):
|
|
1260
|
+
"""
|
|
1261
|
+
Returns True if any segment of open path A crosses any segment of open path B.
|
|
1262
|
+
"""
|
|
1263
|
+
nA = len(pathA)
|
|
1264
|
+
nB = len(pathB)
|
|
1265
|
+
result = False
|
|
1266
|
+
i = 0
|
|
1267
|
+
while not result and i < nA - 1:
|
|
1268
|
+
x1, y1 = pathA[i]
|
|
1269
|
+
x2, y2 = pathA[i + 1]
|
|
1270
|
+
j = 0
|
|
1271
|
+
while not result and j < nB - 1:
|
|
1272
|
+
x3, y3 = pathB[j]
|
|
1273
|
+
x4, y4 = pathB[j + 1]
|
|
1274
|
+
result = _segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4)
|
|
1275
|
+
j += 1
|
|
1276
|
+
i += 1
|
|
1277
|
+
return result
|
|
1278
|
+
|
|
1279
|
+
|
|
1126
1280
|
#######################################################################################
|
|
1127
1281
|
# Drawable - items that can be added to a Display
|
|
1128
1282
|
#######################################################################################
|
|
@@ -1150,6 +1304,7 @@ class _Drawable(_Interactable):
|
|
|
1150
1304
|
self._anchorX = None # last used global rotation anchor (None = center)
|
|
1151
1305
|
self._anchorY = None # ...
|
|
1152
1306
|
self._toolTipText = None # text to Display on mouse over (None == disabled)
|
|
1307
|
+
self._cachedBoundary = None # lazily computed by _getBoundary(), cleared by _invalidateBoundary()
|
|
1153
1308
|
|
|
1154
1309
|
# renderer visibility toggle
|
|
1155
1310
|
# only used by Toggle and Push, but doesn't hurt to have as part of Drawable
|
|
@@ -1170,6 +1325,7 @@ class _Drawable(_Interactable):
|
|
|
1170
1325
|
"""
|
|
1171
1326
|
self._localCornerX = localX
|
|
1172
1327
|
self._localCornerY = localY
|
|
1328
|
+
self._invalidateBoundary()
|
|
1173
1329
|
|
|
1174
1330
|
_handler().sendCommand('setPosition', self._objectId, {'x': localX, 'y': localY})
|
|
1175
1331
|
if isinstance(self._parent, Group):
|
|
@@ -1257,8 +1413,9 @@ class _Drawable(_Interactable):
|
|
|
1257
1413
|
y2 = (height / 2)
|
|
1258
1414
|
corners = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)]
|
|
1259
1415
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1416
|
+
# visual CCW: x' = dx*cos + dy*sin, y' = -dx*sin + dy*cos
|
|
1417
|
+
rotatedXs = [centerX + (px * cos) + (py * sin) for px, py in corners]
|
|
1418
|
+
rotatedYs = [centerY - (px * sin) + (py * cos) for px, py in corners]
|
|
1262
1419
|
|
|
1263
1420
|
# get bounding box dimensions
|
|
1264
1421
|
x = min(rotatedXs)
|
|
@@ -1268,6 +1425,28 @@ class _Drawable(_Interactable):
|
|
|
1268
1425
|
|
|
1269
1426
|
return x, y, width, height
|
|
1270
1427
|
|
|
1428
|
+
def _computeBoundary(self, n=32):
|
|
1429
|
+
"""
|
|
1430
|
+
Computes this shape's boundary as a list of (x, y) tuples in global coordinates.
|
|
1431
|
+
Default returns the 4 corners of the axis-aligned bounding box.
|
|
1432
|
+
Subclasses override this for their exact geometry.
|
|
1433
|
+
"""
|
|
1434
|
+
x, y, w, h = self._getBoundingBox()
|
|
1435
|
+
return [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
|
|
1436
|
+
|
|
1437
|
+
def _getBoundary(self, n=32):
|
|
1438
|
+
"""
|
|
1439
|
+
Returns this shape's boundary, computing and caching it on first call.
|
|
1440
|
+
Invalidated by any change to position, size, or rotation.
|
|
1441
|
+
"""
|
|
1442
|
+
if self._cachedBoundary is None:
|
|
1443
|
+
self._cachedBoundary = self._computeBoundary(n)
|
|
1444
|
+
return self._cachedBoundary
|
|
1445
|
+
|
|
1446
|
+
def _invalidateBoundary(self):
|
|
1447
|
+
"""Clears the cached boundary so it is recomputed on next access."""
|
|
1448
|
+
self._cachedBoundary = None
|
|
1449
|
+
|
|
1271
1450
|
def _rotatePoints(self, xPoints, yPoints):
|
|
1272
1451
|
"""
|
|
1273
1452
|
Rotates a list of global-coordinate points around this item's center.
|
|
@@ -1404,6 +1583,7 @@ class _Drawable(_Interactable):
|
|
|
1404
1583
|
# update internal coordinates
|
|
1405
1584
|
self._width = localWidth
|
|
1406
1585
|
self._height = localHeight
|
|
1586
|
+
self._invalidateBoundary()
|
|
1407
1587
|
|
|
1408
1588
|
# update rendering canvas
|
|
1409
1589
|
_handler().sendCommand('setSize', self._objectId, {'width': localWidth, 'height': localHeight})
|
|
@@ -1492,6 +1672,7 @@ class _Drawable(_Interactable):
|
|
|
1492
1672
|
|
|
1493
1673
|
# update internal rotation
|
|
1494
1674
|
self._rotation = rotation
|
|
1675
|
+
self._invalidateBoundary()
|
|
1495
1676
|
|
|
1496
1677
|
# update the renderer
|
|
1497
1678
|
_handler().sendCommand('setRotation', self._objectId, {
|
|
@@ -1500,38 +1681,67 @@ class _Drawable(_Interactable):
|
|
|
1500
1681
|
|
|
1501
1682
|
# ── Hit testing ────────────────────────────────────────────────────────────
|
|
1502
1683
|
|
|
1684
|
+
def _isOpen(self):
|
|
1685
|
+
"""
|
|
1686
|
+
Returns True if this shape is an open path (no interior).
|
|
1687
|
+
Open shapes use proximity-to-path tests rather than point-in-polygon.
|
|
1688
|
+
Overridden by Polyline (always open) and Arc (open when style == OPEN).
|
|
1689
|
+
"""
|
|
1690
|
+
return False
|
|
1691
|
+
|
|
1503
1692
|
def contains(self, x, y):
|
|
1504
1693
|
"""
|
|
1505
1694
|
Returns True if the point (x, y) is inside the item.
|
|
1695
|
+
Uses ray-casting on the shape's boundary polygon.
|
|
1506
1696
|
"""
|
|
1507
|
-
|
|
1508
|
-
return result[0]
|
|
1697
|
+
return _pointInPolygon(x, y, self._getBoundary())
|
|
1509
1698
|
|
|
1510
1699
|
def intersects(self, other):
|
|
1511
1700
|
"""
|
|
1512
|
-
Returns True if this item
|
|
1701
|
+
Returns True if this item overlaps the other item.
|
|
1702
|
+
Dispatches to the appropriate algorithm based on whether each shape is
|
|
1703
|
+
an open path (Polyline, Arc OPEN) or a closed shape (everything else).
|
|
1513
1704
|
"""
|
|
1514
|
-
result = None
|
|
1515
|
-
|
|
1516
1705
|
if not isinstance(other, _Drawable):
|
|
1517
1706
|
raise TypeError(f'{type(self).__name__}.intersects(): other should be a Drawable object (it was {type(other).__name__})')
|
|
1707
|
+
|
|
1708
|
+
# AABB pre-check: if bounding boxes don't overlap, nothing can intersect.
|
|
1709
|
+
ax, ay, aw, ah = self._getBoundingBox()
|
|
1710
|
+
bx, by, bw, bh = other._getBoundingBox()
|
|
1711
|
+
if ax + aw < bx or bx + bw < ax or ay + ah < by or by + bh < ay:
|
|
1712
|
+
result = False
|
|
1518
1713
|
else:
|
|
1519
|
-
|
|
1520
|
-
|
|
1714
|
+
boundaryA = self._getBoundary()
|
|
1715
|
+
boundaryB = other._getBoundary()
|
|
1716
|
+
selfOpen = self._isOpen()
|
|
1717
|
+
otherOpen = other._isOpen()
|
|
1718
|
+
|
|
1719
|
+
if not selfOpen and not otherOpen:
|
|
1720
|
+
result = _polygonsIntersect(boundaryA, boundaryB)
|
|
1721
|
+
elif selfOpen and not otherOpen:
|
|
1722
|
+
result = _openPathIntersectsPolygon(boundaryA, boundaryB)
|
|
1723
|
+
elif not selfOpen and otherOpen:
|
|
1724
|
+
result = _openPathIntersectsPolygon(boundaryB, boundaryA)
|
|
1725
|
+
else:
|
|
1726
|
+
result = _pathsIntersect(boundaryA, boundaryB)
|
|
1521
1727
|
|
|
1522
1728
|
return result
|
|
1523
1729
|
|
|
1524
1730
|
def encloses(self, other):
|
|
1525
1731
|
"""
|
|
1526
|
-
Returns True if this item fully
|
|
1732
|
+
Returns True if this item fully contains the other item.
|
|
1733
|
+
An open path has no interior and cannot enclose anything.
|
|
1734
|
+
Otherwise, checks that every point of other's boundary lies inside self.
|
|
1527
1735
|
"""
|
|
1528
|
-
result = None
|
|
1529
|
-
|
|
1530
1736
|
if not isinstance(other, _Drawable):
|
|
1531
1737
|
raise TypeError(f'{type(self).__name__}.encloses(): other should be a Drawable object (it was {type(other).__name__})')
|
|
1738
|
+
|
|
1739
|
+
if self._isOpen():
|
|
1740
|
+
result = False
|
|
1532
1741
|
else:
|
|
1533
|
-
|
|
1534
|
-
|
|
1742
|
+
boundaryA = self._getBoundary()
|
|
1743
|
+
boundaryB = other._getBoundary()
|
|
1744
|
+
result = all(_pointInPolygon(px, py, boundaryA) for px, py in boundaryB)
|
|
1535
1745
|
|
|
1536
1746
|
return result
|
|
1537
1747
|
|
|
@@ -1601,6 +1811,21 @@ class _Graphics(_Drawable):
|
|
|
1601
1811
|
self._color = [r, g, b, a]
|
|
1602
1812
|
_handler().sendCommand('setColor', self._objectId, {'color': [r, g, b, a]})
|
|
1603
1813
|
|
|
1814
|
+
# ── Alpha ────────────────────────────────────────────────────────────────
|
|
1815
|
+
|
|
1816
|
+
def getAlpha(self):
|
|
1817
|
+
"""
|
|
1818
|
+
Returns the item's transparency (0-255, where 0 is fully transparent).
|
|
1819
|
+
"""
|
|
1820
|
+
return self._color[3]
|
|
1821
|
+
|
|
1822
|
+
def setAlpha(self, alpha):
|
|
1823
|
+
"""
|
|
1824
|
+
Sets the item's transparency (0-255, where 0 is fully transparent).
|
|
1825
|
+
"""
|
|
1826
|
+
self._color[3] = max(0, min(255, int(alpha)))
|
|
1827
|
+
_handler().sendCommand('setColor', self._objectId, {'color': self._color})
|
|
1828
|
+
|
|
1604
1829
|
# ── Fill ────────────────────────────────────────────────────────────────
|
|
1605
1830
|
|
|
1606
1831
|
def getFill(self):
|
|
@@ -1694,6 +1919,16 @@ class Rectangle(_Graphics):
|
|
|
1694
1919
|
xPoints, yPoints = self._getEndpoints()
|
|
1695
1920
|
return self._rotatePoints(xPoints, yPoints)
|
|
1696
1921
|
|
|
1922
|
+
# ── Boundary ───────────────────────────────────────────────────────────────
|
|
1923
|
+
|
|
1924
|
+
def _computeBoundary(self, n=32):
|
|
1925
|
+
"""
|
|
1926
|
+
Computes the 4 rotated corners of the rectangle as (x, y) tuples in global
|
|
1927
|
+
coordinates. Rotation is applied around the rectangle's center.
|
|
1928
|
+
"""
|
|
1929
|
+
xPoints, yPoints = self.getEndpoints()
|
|
1930
|
+
return list(zip(xPoints, yPoints))
|
|
1931
|
+
|
|
1697
1932
|
|
|
1698
1933
|
class Oval(_Graphics):
|
|
1699
1934
|
"""
|
|
@@ -1734,6 +1969,38 @@ class Oval(_Graphics):
|
|
|
1734
1969
|
thickness = self.getThickness()
|
|
1735
1970
|
return f'Oval(x1 = {x1}, y1 = {y1}, x2 = {x2}, y2 = {y2}, color = {color}, fill = {fill}, thickness = {thickness}, rotation = {rotation})'
|
|
1736
1971
|
|
|
1972
|
+
# ── Boundary ───────────────────────────────────────────────────────────────
|
|
1973
|
+
|
|
1974
|
+
def _computeBoundary(self, n=32):
|
|
1975
|
+
"""
|
|
1976
|
+
Computes n points around the ellipse boundary as (x, y) tuples in global
|
|
1977
|
+
coordinates, with rotation applied around the ellipse's center.
|
|
1978
|
+
Circle inherits this; its rotation is always 0 so the rotation branch
|
|
1979
|
+
is never taken.
|
|
1980
|
+
"""
|
|
1981
|
+
gx, gy = self._getGlobalCorner()
|
|
1982
|
+
cx = gx + self._width / 2
|
|
1983
|
+
cy = gy + self._height / 2
|
|
1984
|
+
rx = self._width / 2
|
|
1985
|
+
ry = self._height / 2
|
|
1986
|
+
|
|
1987
|
+
if self._rotation != 0:
|
|
1988
|
+
rad = np.radians(self._rotation)
|
|
1989
|
+
cos = np.cos(rad)
|
|
1990
|
+
sin = np.sin(rad)
|
|
1991
|
+
points = []
|
|
1992
|
+
for i in range(n):
|
|
1993
|
+
t = 2 * np.pi * i / n
|
|
1994
|
+
dx = rx * np.cos(t)
|
|
1995
|
+
dy = ry * np.sin(t)
|
|
1996
|
+
# visual CCW rotation: x' = dx*cos + dy*sin, y' = -dx*sin + dy*cos
|
|
1997
|
+
points.append((cx + dx * cos + dy * sin,
|
|
1998
|
+
cy - dx * sin + dy * cos))
|
|
1999
|
+
return points
|
|
2000
|
+
else:
|
|
2001
|
+
return [(cx + rx * np.cos(2 * np.pi * i / n),
|
|
2002
|
+
cy + ry * np.sin(2 * np.pi * i / n)) for i in range(n)]
|
|
2003
|
+
|
|
1737
2004
|
|
|
1738
2005
|
class Circle(Oval):
|
|
1739
2006
|
"""
|
|
@@ -1794,6 +2061,29 @@ class Circle(Oval):
|
|
|
1794
2061
|
diameter = radius * 2
|
|
1795
2062
|
self.setWidth(diameter)
|
|
1796
2063
|
|
|
2064
|
+
# ── Hit testing ────────────────────────────────────────────────────────────
|
|
2065
|
+
|
|
2066
|
+
def intersects(self, other):
|
|
2067
|
+
"""
|
|
2068
|
+
Returns True if this circle overlaps the other item.
|
|
2069
|
+
For Circle-Circle pairs uses an exact O(1) distance check.
|
|
2070
|
+
Falls back to the base-class AABB + polygon path for all other pairings.
|
|
2071
|
+
"""
|
|
2072
|
+
if isinstance(other, Circle):
|
|
2073
|
+
ax, ay = self._getGlobalCorner()
|
|
2074
|
+
bx, by = other._getGlobalCorner()
|
|
2075
|
+
selfCX = ax + self._width / 2
|
|
2076
|
+
selfCY = ay + self._height / 2
|
|
2077
|
+
otherCX = bx + other._width / 2
|
|
2078
|
+
otherCY = by + other._height / 2
|
|
2079
|
+
radiiSum = (self._width + other._width) / 2
|
|
2080
|
+
dx = selfCX - otherCX
|
|
2081
|
+
dy = selfCY - otherCY
|
|
2082
|
+
result = dx * dx + dy * dy <= radiiSum * radiiSum
|
|
2083
|
+
else:
|
|
2084
|
+
result = _Drawable.intersects(self, other)
|
|
2085
|
+
return result
|
|
2086
|
+
|
|
1797
2087
|
|
|
1798
2088
|
class Point(Circle):
|
|
1799
2089
|
"""
|
|
@@ -1859,6 +2149,78 @@ class Arc(_Graphics):
|
|
|
1859
2149
|
thickness = self.getThickness()
|
|
1860
2150
|
return f'Arc(x1 = {x1}, y1 = {y1}, x2 = {x2}, y2 = {y2}, startAngle = {startAngle}, endAngle = {endAngle}, style = {style}, color = {color}, fill = {fill}, thickness = {thickness}, rotation = {rotation})'
|
|
1861
2151
|
|
|
2152
|
+
# ── Boundary ───────────────────────────────────────────────────────────────
|
|
2153
|
+
|
|
2154
|
+
def _computeBoundary(self, n=32):
|
|
2155
|
+
"""
|
|
2156
|
+
Computes boundary points for the arc as (x, y) tuples in global coordinates,
|
|
2157
|
+
with any outer rotation applied.
|
|
2158
|
+
|
|
2159
|
+
Angles are in degrees: 0 = east, positive = CCW visual. The arc sweeps
|
|
2160
|
+
from startAngle toward endAngle following the same path the renderer draws.
|
|
2161
|
+
|
|
2162
|
+
Returned point layout by style:
|
|
2163
|
+
OPEN — n+1 points along the arc curve only (open path).
|
|
2164
|
+
CHORD — n+1 arc curve points; first and last are implicitly connected.
|
|
2165
|
+
PIE — center point followed by n+1 arc curve points; last connects
|
|
2166
|
+
back to center to close the pie slice.
|
|
2167
|
+
|
|
2168
|
+
ArcCircle inherits this implementation.
|
|
2169
|
+
"""
|
|
2170
|
+
gx, gy = self._getGlobalCorner()
|
|
2171
|
+
cx = gx + self._width / 2
|
|
2172
|
+
cy = gy + self._height / 2
|
|
2173
|
+
rx = self._width / 2
|
|
2174
|
+
ry = self._height / 2
|
|
2175
|
+
|
|
2176
|
+
# The renderer uses arcWidth = -(endAngle - startAngle) and passes it to
|
|
2177
|
+
# Qt as the sweep, so the arc steps from startAngle by that delta.
|
|
2178
|
+
# Replicating that here: t steps from startAngle_rad toward
|
|
2179
|
+
# startAngle_rad + sweep_rad, where sweep = -(endAngle - startAngle).
|
|
2180
|
+
startRad = np.radians(self._startAngle)
|
|
2181
|
+
sweepRad = np.radians(-(self._endAngle - self._startAngle))
|
|
2182
|
+
|
|
2183
|
+
# Qt point formula for CCW-convention angles in a Y-down screen:
|
|
2184
|
+
# x = cx + rx * cos(t), y = cy - ry * sin(t)
|
|
2185
|
+
arcPoints = []
|
|
2186
|
+
for i in range(n + 1):
|
|
2187
|
+
t = startRad + sweepRad * i / n
|
|
2188
|
+
arcPoints.append((cx + rx * np.cos(t),
|
|
2189
|
+
cy - ry * np.sin(t)))
|
|
2190
|
+
|
|
2191
|
+
if self._style == PIE:
|
|
2192
|
+
points = [(cx, cy)] + arcPoints
|
|
2193
|
+
else:
|
|
2194
|
+
points = arcPoints # OPEN and CHORD share the same point list;
|
|
2195
|
+
# CHORD's implicit closing segment is handled
|
|
2196
|
+
# by the polygon hit-test closing first-to-last.
|
|
2197
|
+
|
|
2198
|
+
# Apply outer rotation around the shape's center if set
|
|
2199
|
+
if self._rotation != 0:
|
|
2200
|
+
rad = np.radians(self._rotation)
|
|
2201
|
+
cos = np.cos(rad)
|
|
2202
|
+
sin = np.sin(rad)
|
|
2203
|
+
points = [(cx + (px - cx) * cos + (py - cy) * sin,
|
|
2204
|
+
cy - (px - cx) * sin + (py - cy) * cos)
|
|
2205
|
+
for px, py in points]
|
|
2206
|
+
|
|
2207
|
+
return points
|
|
2208
|
+
|
|
2209
|
+
# ── Hit testing ────────────────────────────────────────────────────────────
|
|
2210
|
+
|
|
2211
|
+
def _isOpen(self):
|
|
2212
|
+
return self._style == OPEN
|
|
2213
|
+
|
|
2214
|
+
def contains(self, x, y):
|
|
2215
|
+
"""
|
|
2216
|
+
Returns True if (x, y) is inside the arc shape.
|
|
2217
|
+
PIE and CHORD styles form closed polygons and use ray-casting.
|
|
2218
|
+
OPEN style has no interior, so it uses a proximity-to-path check instead.
|
|
2219
|
+
"""
|
|
2220
|
+
if self._isOpen():
|
|
2221
|
+
return _pointNearPath(x, y, self._getBoundary(), max(self._thickness, 1) / 2)
|
|
2222
|
+
return _pointInPolygon(x, y, self._getBoundary())
|
|
2223
|
+
|
|
1862
2224
|
# ── ArcWidth ────────────────────────────────────────────────────────────────
|
|
1863
2225
|
|
|
1864
2226
|
def _setArcWidth(self, arcWidth):
|
|
@@ -1998,6 +2360,39 @@ class Line(_Graphics):
|
|
|
1998
2360
|
xPoints, yPoints = self._getEndpoints()
|
|
1999
2361
|
return self._rotatePoints(xPoints, yPoints)
|
|
2000
2362
|
|
|
2363
|
+
# ── Boundary ───────────────────────────────────────────────────────────────
|
|
2364
|
+
|
|
2365
|
+
def _computeBoundary(self, n=32):
|
|
2366
|
+
"""
|
|
2367
|
+
Computes the 4 corners of a thin rectangle enclosing the line as (x, y)
|
|
2368
|
+
tuples in global coordinates. The rectangle's width equals the line length
|
|
2369
|
+
and its height equals the line's thickness (minimum 1), giving accurate
|
|
2370
|
+
hit-test coverage for both thick and thin lines. Any rotation is already
|
|
2371
|
+
baked into the post-rotation endpoints returned by getEndpoints().
|
|
2372
|
+
"""
|
|
2373
|
+
xs, ys = self.getEndpoints()
|
|
2374
|
+
x1, x2 = xs
|
|
2375
|
+
y1, y2 = ys
|
|
2376
|
+
|
|
2377
|
+
dx = x2 - x1
|
|
2378
|
+
dy = y2 - y1
|
|
2379
|
+
length = np.sqrt(dx * dx + dy * dy)
|
|
2380
|
+
|
|
2381
|
+
if length == 0:
|
|
2382
|
+
return [(x1, y1)]
|
|
2383
|
+
|
|
2384
|
+
# unit vector along the line and its CCW perpendicular
|
|
2385
|
+
ux, uy = dx / length, dy / length
|
|
2386
|
+
px, py = -uy, ux
|
|
2387
|
+
halfT = max(self._thickness, 1) / 2
|
|
2388
|
+
|
|
2389
|
+
return [
|
|
2390
|
+
(x1 + px * halfT, y1 + py * halfT),
|
|
2391
|
+
(x1 - px * halfT, y1 - py * halfT),
|
|
2392
|
+
(x2 - px * halfT, y2 - py * halfT),
|
|
2393
|
+
(x2 + px * halfT, y2 + py * halfT),
|
|
2394
|
+
]
|
|
2395
|
+
|
|
2001
2396
|
# ── Length ────────────────────────────────────────────────────────────────
|
|
2002
2397
|
|
|
2003
2398
|
def getLength(self):
|
|
@@ -2134,6 +2529,30 @@ class Polyline(_Graphics):
|
|
|
2134
2529
|
xPoints, yPoints = self._getEndpoints()
|
|
2135
2530
|
return self._rotatePoints(xPoints, yPoints)
|
|
2136
2531
|
|
|
2532
|
+
# ── Boundary ───────────────────────────────────────────────────────────────
|
|
2533
|
+
|
|
2534
|
+
def _computeBoundary(self, n=32):
|
|
2535
|
+
"""
|
|
2536
|
+
Computes the polyline's joint points as (x, y) tuples in global coordinates,
|
|
2537
|
+
with rotation applied. Forms an open path — the last point does not
|
|
2538
|
+
implicitly connect back to the first.
|
|
2539
|
+
"""
|
|
2540
|
+
xPoints, yPoints = self.getEndpoints()
|
|
2541
|
+
return list(zip(xPoints, yPoints))
|
|
2542
|
+
|
|
2543
|
+
# ── Hit testing ────────────────────────────────────────────────────────────
|
|
2544
|
+
|
|
2545
|
+
def _isOpen(self):
|
|
2546
|
+
return True
|
|
2547
|
+
|
|
2548
|
+
def contains(self, x, y):
|
|
2549
|
+
"""
|
|
2550
|
+
Returns True if (x, y) lies within half the stroke thickness of any
|
|
2551
|
+
segment. A Polyline has no interior, so proximity to the path is the
|
|
2552
|
+
correct containment test.
|
|
2553
|
+
"""
|
|
2554
|
+
return _pointNearPath(x, y, self._getBoundary(), max(self._thickness, 1) / 2)
|
|
2555
|
+
|
|
2137
2556
|
|
|
2138
2557
|
class Polygon(_Graphics):
|
|
2139
2558
|
"""
|
|
@@ -2208,6 +2627,17 @@ class Polygon(_Graphics):
|
|
|
2208
2627
|
xPoints, yPoints = self._getEndpoints()
|
|
2209
2628
|
return self._rotatePoints(xPoints, yPoints)
|
|
2210
2629
|
|
|
2630
|
+
# ── Boundary ───────────────────────────────────────────────────────────────
|
|
2631
|
+
|
|
2632
|
+
def _computeBoundary(self, n=32):
|
|
2633
|
+
"""
|
|
2634
|
+
Computes the polygon's vertices as (x, y) tuples in global coordinates,
|
|
2635
|
+
with rotation applied. Forms a closed boundary (first point implicitly
|
|
2636
|
+
connects back to last).
|
|
2637
|
+
"""
|
|
2638
|
+
xPoints, yPoints = self.getEndpoints()
|
|
2639
|
+
return list(zip(xPoints, yPoints))
|
|
2640
|
+
|
|
2211
2641
|
|
|
2212
2642
|
class Icon(_Graphics):
|
|
2213
2643
|
"""
|
|
@@ -2223,6 +2653,7 @@ class Icon(_Graphics):
|
|
|
2223
2653
|
|
|
2224
2654
|
self._filename = filename
|
|
2225
2655
|
self._rotation = rotation
|
|
2656
|
+
self._alpha = 255
|
|
2226
2657
|
|
|
2227
2658
|
# width/height are resolved by the renderer (from the actual pixmap)
|
|
2228
2659
|
# For now, cache what the user provided; the renderer will resolve None values.
|
|
@@ -2254,6 +2685,23 @@ class Icon(_Graphics):
|
|
|
2254
2685
|
rotation = self.getRotation()
|
|
2255
2686
|
return f'Icon(filename = "{filename}", width = {width}, height = {height}, rotation = {rotation})'
|
|
2256
2687
|
|
|
2688
|
+
# ── Alpha ────────────────────────────────────────────────────────────────
|
|
2689
|
+
|
|
2690
|
+
def getAlpha(self):
|
|
2691
|
+
"""
|
|
2692
|
+
Returns the icon's global transparency (0-255, where 0 is fully transparent).
|
|
2693
|
+
This is independent of the color filter set by setColor().
|
|
2694
|
+
"""
|
|
2695
|
+
return self._alpha
|
|
2696
|
+
|
|
2697
|
+
def setAlpha(self, alpha):
|
|
2698
|
+
"""
|
|
2699
|
+
Sets the icon's global transparency (0-255, where 0 is fully transparent).
|
|
2700
|
+
This is independent of the color filter set by setColor().
|
|
2701
|
+
"""
|
|
2702
|
+
self._alpha = max(0, min(255, int(alpha)))
|
|
2703
|
+
_handler().sendCommand('setAlpha', self._objectId, {'alpha': self._alpha})
|
|
2704
|
+
|
|
2257
2705
|
# ── Save ────────────────────────────────────────────────────────────────
|
|
2258
2706
|
|
|
2259
2707
|
def save(self, filename, width=None, height=None):
|
midi.py
CHANGED
|
@@ -62,8 +62,8 @@ notesCurrentlyPlaying = [] # (pitch, channel) tuples; prevents early note-off
|
|
|
62
62
|
# Usage:
|
|
63
63
|
# midiIn = MidiIn()
|
|
64
64
|
#
|
|
65
|
-
# def onEvent(
|
|
66
|
-
# print(
|
|
65
|
+
# def onEvent(eventType, channel, data1, data2):
|
|
66
|
+
# print(eventType, channel, data1, data2)
|
|
67
67
|
#
|
|
68
68
|
# midiIn.onInput(ALL_EVENTS, onEvent)
|
|
69
69
|
|
|
@@ -411,54 +411,54 @@ class MidiOut:
|
|
|
411
411
|
start = int(startTime)
|
|
412
412
|
duration = int(note.getLength() * FACTOR)
|
|
413
413
|
startTime += note.getDuration() * FACTOR
|
|
414
|
-
|
|
414
|
+
dynamic = note.getDynamic()
|
|
415
415
|
|
|
416
416
|
if frequency != REST:
|
|
417
|
-
noteList.append((start, duration, frequency,
|
|
417
|
+
noteList.append((start, duration, frequency, dynamic, channel, instrument, panning))
|
|
418
418
|
|
|
419
419
|
# sort by start time so chords (same start time, duration=0) sort before their anchor note
|
|
420
420
|
noteList.sort()
|
|
421
421
|
|
|
422
422
|
# schedule all notes; chords are sequences of duration=0 notes followed by one note with the real duration
|
|
423
423
|
chordNotes = []
|
|
424
|
-
for start, duration, pitch,
|
|
424
|
+
for start, duration, pitch, dynamic, channel, instrument, panning in noteList:
|
|
425
425
|
self.setInstrument(instrument, channel)
|
|
426
426
|
|
|
427
427
|
if duration == 0: # chord member — collect it
|
|
428
|
-
chordNotes.append([start, duration, pitch,
|
|
428
|
+
chordNotes.append([start, duration, pitch, dynamic, channel, panning])
|
|
429
429
|
|
|
430
430
|
elif chordNotes == []: # regular solo note
|
|
431
|
-
self.note(pitch, start, duration,
|
|
431
|
+
self.note(pitch, start, duration, dynamic, channel, panning)
|
|
432
432
|
|
|
433
433
|
else: # final note of a chord — schedule all collected chord members with this duration
|
|
434
|
-
chordNotes.append([start, duration, pitch,
|
|
435
|
-
for start, _, pitch,
|
|
436
|
-
self.note(pitch, start, duration,
|
|
434
|
+
chordNotes.append([start, duration, pitch, dynamic, channel, panning])
|
|
435
|
+
for start, _, pitch, dynamic, channel, panning in chordNotes:
|
|
436
|
+
self.note(pitch, start, duration, dynamic, channel, panning)
|
|
437
437
|
chordNotes = []
|
|
438
438
|
|
|
439
439
|
|
|
440
|
-
def noteOn(self, pitch,
|
|
440
|
+
def noteOn(self, pitch, dynamic=100, channel=0, panning=-1):
|
|
441
441
|
"""Sends NOTE_ON for 'pitch' (MIDI int 0-127 or Hz float) on 'channel'."""
|
|
442
442
|
|
|
443
443
|
if isinstance(pitch, int) and (0 <= pitch <= 127):
|
|
444
444
|
if panning != -1:
|
|
445
445
|
self.sendMidiMessage(CONTROL_CHANGE, channel, 10, panning)
|
|
446
|
-
self.sendMidiMessage(NOTE_ON, channel, pitch,
|
|
446
|
+
self.sendMidiMessage(NOTE_ON, channel, pitch, dynamic)
|
|
447
447
|
notesCurrentlyPlaying.append((pitch, channel))
|
|
448
448
|
|
|
449
449
|
elif isinstance(pitch, float):
|
|
450
|
-
self.frequencyOn(pitch,
|
|
450
|
+
self.frequencyOn(pitch, dynamic, channel, panning)
|
|
451
451
|
|
|
452
452
|
else:
|
|
453
453
|
print(f"MidiOut.noteOn(): Unrecognized pitch {pitch}, expected int 0-127 or float Hz 8.17-12600.0.")
|
|
454
454
|
|
|
455
455
|
|
|
456
|
-
def frequencyOn(self, frequency,
|
|
456
|
+
def frequencyOn(self, frequency, dynamic=100, channel=0, panning=-1):
|
|
457
457
|
"""Sends NOTE_ON for 'frequency' (in Hz) on 'channel', applying pitch bend for microtones."""
|
|
458
458
|
if isinstance(frequency, float) and (8.17 <= frequency <= 12600.0):
|
|
459
459
|
pitch, bend = freqToNote(frequency)
|
|
460
460
|
notesCurrentlyPlaying.append((pitch, channel))
|
|
461
|
-
self.noteOnPitchBend(pitch, bend,
|
|
461
|
+
self.noteOnPitchBend(pitch, bend, dynamic, channel, panning)
|
|
462
462
|
else:
|
|
463
463
|
print(f"MidiOut.frequencyOn(): Invalid frequency {frequency}, expected float Hz 8.17-12600.0.")
|
|
464
464
|
|
|
@@ -497,16 +497,16 @@ class MidiOut:
|
|
|
497
497
|
print(f"MidiOut.frequencyOff(): Invalid frequency {frequency}, expected float Hz 8.17-12600.0.")
|
|
498
498
|
|
|
499
499
|
|
|
500
|
-
def note(self, pitch, start, duration,
|
|
500
|
+
def note(self, pitch, start, duration, dynamic=100, channel=0, panning=-1):
|
|
501
501
|
"""Schedules a note to start at 'start' ms from now and last 'duration' ms."""
|
|
502
|
-
Timer(start, self.noteOn, [pitch,
|
|
503
|
-
Timer(start+duration, self.noteOff, [pitch, channel],
|
|
502
|
+
Timer(start, self.noteOn, [pitch, dynamic, channel, panning], False).start()
|
|
503
|
+
Timer(start+duration, self.noteOff, [pitch, channel], False).start()
|
|
504
504
|
|
|
505
505
|
|
|
506
|
-
def frequency(self, frequency, start, duration,
|
|
506
|
+
def frequency(self, frequency, start, duration, dynamic=100, channel=0, panning=-1):
|
|
507
507
|
"""Schedules a frequency (in Hz) to start at 'start' ms from now and last 'duration' ms."""
|
|
508
|
-
Timer(start, self.frequencyOn, [frequency,
|
|
509
|
-
Timer(start+duration, self.frequencyOff, [frequency, channel],
|
|
508
|
+
Timer(start, self.frequencyOn, [frequency, dynamic, channel, panning], False).start()
|
|
509
|
+
Timer(start+duration, self.frequencyOff, [frequency, channel], False).start()
|
|
510
510
|
|
|
511
511
|
|
|
512
512
|
def setPitchBend(self, bend=0, channel=0):
|
|
@@ -525,12 +525,12 @@ class MidiOut:
|
|
|
525
525
|
return self.pitchBend[channel]
|
|
526
526
|
|
|
527
527
|
|
|
528
|
-
def noteOnPitchBend(self, pitch, bend=0,
|
|
528
|
+
def noteOnPitchBend(self, pitch, bend=0, dynamic=100, channel=0, panning=-1):
|
|
529
529
|
"""Sends NOTE_ON for 'pitch' with 'bend' applied on 'channel'."""
|
|
530
530
|
self.setPitchBend(bend, channel)
|
|
531
531
|
if panning != -1:
|
|
532
532
|
self.sendMidiMessage(CONTROL_CHANGE, channel, 10, panning)
|
|
533
|
-
self.sendMidiMessage(NOTE_ON, channel, pitch,
|
|
533
|
+
self.sendMidiMessage(NOTE_ON, channel, pitch, dynamic)
|
|
534
534
|
|
|
535
535
|
|
|
536
536
|
def allNotesOff(self):
|
|
@@ -583,61 +583,61 @@ class MidiOut:
|
|
|
583
583
|
return self.panning[channel]
|
|
584
584
|
|
|
585
585
|
|
|
586
|
-
def sendMidiMessage(self,
|
|
586
|
+
def sendMidiMessage(self, eventType, channel, data1, data2):
|
|
587
587
|
"""Sends a raw MIDI message to the output device."""
|
|
588
588
|
try:
|
|
589
|
-
if
|
|
590
|
-
msg = mido.Message('note_on', channel=
|
|
589
|
+
if eventType == NOTE_ON:
|
|
590
|
+
msg = mido.Message('note_on', channel=channel, note=data1, velocity=data2)
|
|
591
591
|
|
|
592
|
-
elif
|
|
593
|
-
msg = mido.Message('note_off', channel=
|
|
592
|
+
elif eventType == NOTE_OFF:
|
|
593
|
+
msg = mido.Message('note_off', channel=channel, note=data1, velocity=data2)
|
|
594
594
|
|
|
595
|
-
elif
|
|
596
|
-
msg = mido.Message('program_change', channel=
|
|
595
|
+
elif eventType == SET_INSTRUMENT:
|
|
596
|
+
msg = mido.Message('program_change', channel=channel, program=data1)
|
|
597
597
|
|
|
598
|
-
elif
|
|
599
|
-
msg = mido.Message('control_change', channel=
|
|
598
|
+
elif eventType == CONTROL_CHANGE:
|
|
599
|
+
msg = mido.Message('control_change', channel=channel, control=data1, value=data2)
|
|
600
600
|
|
|
601
|
-
elif
|
|
601
|
+
elif eventType == PITCH_BEND:
|
|
602
602
|
# Recombine 7-bit LSB/MSB into a 14-bit value, then shift to mido's -8192..8191 range.
|
|
603
|
-
bendValue = ((
|
|
604
|
-
msg = mido.Message('pitchwheel', channel=
|
|
603
|
+
bendValue = ((data2 << 7) + data1) - PITCHBEND_NORMAL
|
|
604
|
+
msg = mido.Message('pitchwheel', channel=channel, pitch=bendValue)
|
|
605
605
|
|
|
606
|
-
elif
|
|
607
|
-
msg = mido.Message('aftertouch', channel=
|
|
606
|
+
elif eventType == AFTERTOUCH:
|
|
607
|
+
msg = mido.Message('aftertouch', channel=channel, value=data1)
|
|
608
608
|
|
|
609
|
-
elif
|
|
610
|
-
msg = mido.Message('polytouch', channel=
|
|
609
|
+
elif eventType == POLYTOUCH:
|
|
610
|
+
msg = mido.Message('polytouch', channel=channel, note=data1, value=data2)
|
|
611
611
|
|
|
612
|
-
elif
|
|
613
|
-
msg = mido.Message('sysex', data=[
|
|
612
|
+
elif eventType == SYSTEM_MESSAGE_VALUES['system_exclusive']:
|
|
613
|
+
msg = mido.Message('sysex', data=[data1, data2])
|
|
614
614
|
|
|
615
|
-
elif
|
|
616
|
-
msg = mido.Message('songpos', pos=(
|
|
615
|
+
elif eventType == SYSTEM_MESSAGE_VALUES['songpos']:
|
|
616
|
+
msg = mido.Message('songpos', pos=(data2 << 7) + data1)
|
|
617
617
|
|
|
618
|
-
elif
|
|
619
|
-
msg = mido.Message('songsel', song=
|
|
618
|
+
elif eventType == SYSTEM_MESSAGE_VALUES['songsel']:
|
|
619
|
+
msg = mido.Message('songsel', song=data1)
|
|
620
620
|
|
|
621
|
-
elif
|
|
621
|
+
elif eventType == SYSTEM_MESSAGE_VALUES['tune_request']:
|
|
622
622
|
msg = mido.Message('tune_request')
|
|
623
623
|
|
|
624
|
-
elif
|
|
624
|
+
elif eventType == SYSTEM_MESSAGE_VALUES['system_reset']:
|
|
625
625
|
msg = mido.Message('reset')
|
|
626
626
|
|
|
627
|
-
elif
|
|
627
|
+
elif eventType == REALTIME_MESSAGE_VALUES['clock']:
|
|
628
628
|
msg = mido.Message('clock')
|
|
629
629
|
|
|
630
|
-
elif
|
|
630
|
+
elif eventType == REALTIME_MESSAGE_VALUES['start']:
|
|
631
631
|
msg = mido.Message('start')
|
|
632
632
|
|
|
633
|
-
elif
|
|
633
|
+
elif eventType == REALTIME_MESSAGE_VALUES['continue']:
|
|
634
634
|
msg = mido.Message('continue')
|
|
635
635
|
|
|
636
|
-
elif
|
|
636
|
+
elif eventType == REALTIME_MESSAGE_VALUES['stop']:
|
|
637
637
|
msg = mido.Message('stop')
|
|
638
638
|
|
|
639
639
|
else:
|
|
640
|
-
print(f"Unsupported MIDI message type: {
|
|
640
|
+
print(f"Unsupported MIDI message type: {eventType}")
|
|
641
641
|
return
|
|
642
642
|
|
|
643
643
|
if self._port:
|
music.py
CHANGED
|
@@ -4696,7 +4696,7 @@ class AudioSample:
|
|
|
4696
4696
|
delayMs=float(effectiveDelay),
|
|
4697
4697
|
startValue=currentVolumeFactor,
|
|
4698
4698
|
endValue=targetVolumeFactor,
|
|
4699
|
-
|
|
4699
|
+
action=rampCallback,
|
|
4700
4700
|
stepMs=stepMs
|
|
4701
4701
|
)
|
|
4702
4702
|
volumeRamp.start() # start volume ramp
|
osc.py
CHANGED
|
@@ -63,10 +63,12 @@ class OscIn:
|
|
|
63
63
|
alternativeIPs = []
|
|
64
64
|
|
|
65
65
|
try:
|
|
66
|
-
hostname
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
hostname = socket.gethostname()
|
|
67
|
+
allIPs = socket.gethostbyname_ex(hostname)[2]
|
|
68
|
+
nonLoopback = sorted(set(ip for ip in allIPs if ip != "127.0.0.1"))
|
|
69
|
+
if nonLoopback:
|
|
70
|
+
hostIP = nonLoopback[0]
|
|
71
|
+
alternativeIPs = nonLoopback[1:]
|
|
70
72
|
except socket.gaierror:
|
|
71
73
|
pass
|
|
72
74
|
|
|
@@ -79,8 +81,6 @@ class OscIn:
|
|
|
79
81
|
print(f'Accepting OSC input on IP address {self.ipAddress}, at port {self.port}')
|
|
80
82
|
if alternativeIPs:
|
|
81
83
|
print(f'(Alternative IP addresses: {", ".join(alternativeIPs)})')
|
|
82
|
-
elif self.ipAddress != "127.0.0.1" and "127.0.0.1" in socket.gethostbyname_ex(socket.gethostname())[2]:
|
|
83
|
-
print('(Alternative IP addresses: 127.0.0.1)')
|
|
84
84
|
print('Use this info to configure OSC clients.\n')
|
|
85
85
|
|
|
86
86
|
self.showIncomingMessages = True
|
|
@@ -112,13 +112,12 @@ class OscIn:
|
|
|
112
112
|
"""Routes an incoming OSC message to all callbacks with a matching pattern."""
|
|
113
113
|
address = rawMsg.addrpattern
|
|
114
114
|
msg = OscMessage(address, rawMsg.arguments)
|
|
115
|
-
|
|
115
|
+
|
|
116
|
+
if self.showIncomingMessages:
|
|
117
|
+
self._printIncomingMessage(msg)
|
|
116
118
|
|
|
117
119
|
for pattern, callback in self._handlers:
|
|
118
120
|
if re.fullmatch(pattern, address):
|
|
119
|
-
if self.showIncomingMessages and not printed:
|
|
120
|
-
self._printIncomingMessage(msg)
|
|
121
|
-
printed = True
|
|
122
121
|
self._executeCallback(callback, msg)
|
|
123
122
|
|
|
124
123
|
|
|
@@ -206,6 +205,7 @@ class OscOut:
|
|
|
206
205
|
"""Sends an OSC message to 'oscAddress' with optional 'args'."""
|
|
207
206
|
message = oscbuildparse.OSCMessage(oscAddress, None, args)
|
|
208
207
|
osc_send(message, self.client)
|
|
208
|
+
osc_process()
|
|
209
209
|
|
|
210
210
|
|
|
211
211
|
#################### OscMessage #######################################################
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|