fastplotpy 0.3.0a1__py3-none-win_amd64.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.
- fastplotpy/README.md +70 -0
- fastplotpy/__init__.py +33 -0
- fastplotpy/assets/dist/assets/scene.worker-CcnexaKr.js +1 -0
- fastplotpy/assets/dist/index.js +15071 -0
- fastplotpy/assets/native/NotoSans-OFL.txt +96 -0
- fastplotpy/assets/native/THIRD_PARTY_NOTICES.md +24 -0
- fastplotpy/assets/native/fastplot_python_abi.dll +0 -0
- fastplotpy/assets/package.json +1 -0
- fastplotpy/axes_validation.py +102 -0
- fastplotpy/camera3d.py +104 -0
- fastplotpy/color_scale.py +88 -0
- fastplotpy/defaults/area-fill.json +5 -0
- fastplotpy/defaults/bar.json +22 -0
- fastplotpy/defaults/camera3d.json +22 -0
- fastplotpy/defaults/cartesian-references.json +10 -0
- fastplotpy/defaults/control.json +3 -0
- fastplotpy/defaults/crosshair.json +19 -0
- fastplotpy/defaults/error-bars.json +7 -0
- fastplotpy/defaults/heatmap.json +70 -0
- fastplotpy/defaults/interaction.json +3 -0
- fastplotpy/defaults/layout.json +32 -0
- fastplotpy/defaults/legend.json +24 -0
- fastplotpy/defaults/line-axes.json +28 -0
- fastplotpy/defaults/line3d.json +13 -0
- fastplotpy/defaults/markers.json +6 -0
- fastplotpy/defaults/mesh3d.json +13 -0
- fastplotpy/defaults/pan.json +7 -0
- fastplotpy/defaults/pie.json +27 -0
- fastplotpy/defaults/pie3d.json +7 -0
- fastplotpy/defaults/point-labels.json +13 -0
- fastplotpy/defaults/pointClick.json +6 -0
- fastplotpy/defaults/pointSelection.json +13 -0
- fastplotpy/defaults/polar.json +71 -0
- fastplotpy/defaults/polarRotate.json +5 -0
- fastplotpy/defaults/scatter3d.json +39 -0
- fastplotpy/defaults/scene3d.json +61 -0
- fastplotpy/defaults/surface.json +35 -0
- fastplotpy/defaults/tooltip.json +18 -0
- fastplotpy/defaults/trace-colors.json +10 -0
- fastplotpy/defaults/trace-options.json +24 -0
- fastplotpy/defaults/wonderplot.json +58 -0
- fastplotpy/defaults/zoom.json +12 -0
- fastplotpy/defaults.py +15 -0
- fastplotpy/error_bars.py +118 -0
- fastplotpy/exports.py +153 -0
- fastplotpy/families2d.py +896 -0
- fastplotpy/heatmap_labels.py +146 -0
- fastplotpy/interactions.py +243 -0
- fastplotpy/interactive.py +1922 -0
- fastplotpy/js_serialize.py +47 -0
- fastplotpy/lighting3d.py +113 -0
- fastplotpy/line3d_lod.py +138 -0
- fastplotpy/lod3d.py +105 -0
- fastplotpy/mesh3d_lod.py +107 -0
- fastplotpy/native.py +163 -0
- fastplotpy/overlays.py +561 -0
- fastplotpy/point_labels.py +86 -0
- fastplotpy/polar.py +445 -0
- fastplotpy/polygon_clip3d.py +148 -0
- fastplotpy/references.py +72 -0
- fastplotpy/rendering.py +65 -0
- fastplotpy/rust_scenes.py +314 -0
- fastplotpy/scene.py +1185 -0
- fastplotpy/scene3d.py +1215 -0
- fastplotpy/scene3d_accessibility.py +183 -0
- fastplotpy/scene3d_collection.py +158 -0
- fastplotpy/subplots.py +115 -0
- fastplotpy/surface_contours.py +122 -0
- fastplotpy/surface_lod.py +109 -0
- fastplotpy/svg3d.py +235 -0
- fastplotpy/trendline.py +236 -0
- fastplotpy/triangle_wireframe.py +51 -0
- fastplotpy/types.py +3027 -0
- fastplotpy-0.3.0a1.dist-info/METADATA +86 -0
- fastplotpy-0.3.0a1.dist-info/RECORD +78 -0
- fastplotpy-0.3.0a1.dist-info/WHEEL +5 -0
- fastplotpy-0.3.0a1.dist-info/licenses/LICENSE +21 -0
- fastplotpy-0.3.0a1.dist-info/top_level.txt +1 -0
fastplotpy/polar.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Native polar windrose scene geometry adapted from fastPlotJs core/polar.ts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal, ROUND_HALF_UP
|
|
6
|
+
from math import cos, floor, isfinite, log10, pi, sin
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
|
|
9
|
+
from .defaults import DEFAULT_TRACE_COLORS, get_defaults
|
|
10
|
+
from .native import rustCall
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _finite(value, fallback):
|
|
14
|
+
return value if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) else fallback
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _positive(value, fallback):
|
|
18
|
+
return value if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) and value > 0 else fallback
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _nonnegative(value, fallback):
|
|
22
|
+
return value if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) and value >= 0 else fallback
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _clamp(value, fallback, low, high):
|
|
26
|
+
return min(high, max(low, _finite(value, fallback)))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _integer(value, fallback, low, high):
|
|
30
|
+
return int(min(high, max(low, floor(_finite(value, fallback) + .5))))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _string(value, fallback):
|
|
34
|
+
return value if isinstance(value, str) and value.strip() else fallback
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _angle(value):
|
|
38
|
+
return value % 360
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _radians(value):
|
|
42
|
+
return value * pi / 180
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _format(value, decimals, trim=False):
|
|
46
|
+
quantum = Decimal(1).scaleb(-decimals)
|
|
47
|
+
result = format(Decimal(str(value)).quantize(quantum, rounding=ROUND_HALF_UP), f".{decimals}f")
|
|
48
|
+
return result.rstrip("0").rstrip(".") if trim and decimals else result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _traces(options):
|
|
52
|
+
if options.get("traces"):
|
|
53
|
+
return list(options["traces"])
|
|
54
|
+
return [{"data": options["data"]}] if options.get("data") is not None else []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _angle_categories(raw, defaults):
|
|
58
|
+
if raw is None:
|
|
59
|
+
return list(defaults["angleCategories"])
|
|
60
|
+
if not isinstance(raw, list) or not raw:
|
|
61
|
+
raise ValueError("Polar angleCategories must be a non-empty array when provided")
|
|
62
|
+
if len(raw) > 360:
|
|
63
|
+
raise ValueError("Polar angleCategories must contain at most 360 entries")
|
|
64
|
+
if any(not isinstance(item, str) or not item.strip() for item in raw):
|
|
65
|
+
raise ValueError("Polar angleCategories must contain only non-empty strings")
|
|
66
|
+
if len(set(raw)) != len(raw):
|
|
67
|
+
raise ValueError("Polar angleCategories must contain unique strings")
|
|
68
|
+
return list(raw)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve_angle(value, categories):
|
|
72
|
+
if categories:
|
|
73
|
+
if not isinstance(value, str):
|
|
74
|
+
raise ValueError("Categorical polar angles must be configured category strings")
|
|
75
|
+
if value not in categories:
|
|
76
|
+
raise ValueError(f"Unknown polar angle category '{value}'")
|
|
77
|
+
return categories.index(value) * 360 / len(categories)
|
|
78
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value):
|
|
79
|
+
raise ValueError("Polar angles must be finite numbers")
|
|
80
|
+
return _angle(value)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _label_config(defaults, plot_raw, trace_raw):
|
|
84
|
+
labels = dict(defaults)
|
|
85
|
+
for raw in (plot_raw, trace_raw):
|
|
86
|
+
if raw is None:
|
|
87
|
+
continue
|
|
88
|
+
if isinstance(raw, bool):
|
|
89
|
+
labels["show"] = raw
|
|
90
|
+
elif isinstance(raw, Mapping):
|
|
91
|
+
labels["show"] = raw.get("show", True)
|
|
92
|
+
for key in ("color", "font"):
|
|
93
|
+
labels[key] = _string(raw.get(key), labels[key])
|
|
94
|
+
for key in ("lineHeight", "minSectorRadius", "minSectorAngleDegrees", "collisionPadding"):
|
|
95
|
+
labels[key] = _nonnegative(raw.get(key), labels[key])
|
|
96
|
+
labels["decimals"] = _integer(raw.get("decimals"), labels["decimals"], 0, 12)
|
|
97
|
+
labels["radiusRatio"] = _clamp(raw.get("radiusRatio"), labels["radiusRatio"], 0, 1)
|
|
98
|
+
for key in ("prefix", "suffix"):
|
|
99
|
+
if isinstance(raw.get(key), str):
|
|
100
|
+
labels[key] = raw[key]
|
|
101
|
+
return labels
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _sector_labels(sectors, labels):
|
|
105
|
+
if not labels["show"]:
|
|
106
|
+
return None
|
|
107
|
+
items = []
|
|
108
|
+
for index, sector in enumerate(sectors):
|
|
109
|
+
depth = sector["outerRadius"] - sector["innerRadius"]
|
|
110
|
+
span = abs(sector["endAngle"] - sector["startAngle"]) * 180 / pi
|
|
111
|
+
if depth < labels["minSectorRadius"] or span < labels["minSectorAngleDegrees"]:
|
|
112
|
+
continue
|
|
113
|
+
angle = (sector["startAngle"] + sector["endAngle"]) / 2
|
|
114
|
+
radius = sector["innerRadius"] + depth * labels["radiusRatio"]
|
|
115
|
+
items.append({"x": sector["centerX"] + cos(angle) * radius,
|
|
116
|
+
"y": sector["centerY"] + sin(angle) * radius,
|
|
117
|
+
"angle": sector["angle"], "value": sector["value"],
|
|
118
|
+
"text": f"{labels['prefix']}{_format(sector['value'], labels['decimals'])}{labels['suffix']}",
|
|
119
|
+
"color": labels["color"], "font": labels["font"],
|
|
120
|
+
"lineHeight": labels["lineHeight"], "sectorIndex": index,
|
|
121
|
+
"collisionPadding": labels["collisionPadding"]})
|
|
122
|
+
return items or None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _grid_primitive(raw, defaults, categories, low, high, scale, radius, width, height,
|
|
126
|
+
center_x, center_y, outer_limit, start, clockwise, bounds):
|
|
127
|
+
grid_raw = raw.get("grid")
|
|
128
|
+
if grid_raw is None:
|
|
129
|
+
return None
|
|
130
|
+
grid = grid_raw if isinstance(grid_raw, Mapping) else {}
|
|
131
|
+
if grid.get("show", True) is False:
|
|
132
|
+
return None
|
|
133
|
+
settings = defaults["grid"]
|
|
134
|
+
rings_count = _integer(grid.get("rings"), settings["rings"], 0, 64)
|
|
135
|
+
spokes = _integer(grid.get("spokes"), len(categories) if categories else settings["spokes"], 0, 360)
|
|
136
|
+
explicit = grid.get("ringValues", settings["ringValues"])
|
|
137
|
+
if scale == "logarithmic" and any(isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value) or value <= 0 for value in explicit):
|
|
138
|
+
raise ValueError("Polar logarithmic ringValues must contain only finite values > 0")
|
|
139
|
+
ring_values = sorted({value for value in explicit if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) and value >= 0})
|
|
140
|
+
if ring_values:
|
|
141
|
+
ring_values = [value for value in ring_values if low <= value <= high]
|
|
142
|
+
elif rings_count:
|
|
143
|
+
if low == high:
|
|
144
|
+
ring_values = [high]
|
|
145
|
+
elif scale == "logarithmic":
|
|
146
|
+
ring_values = [10 ** (log10(low) + (index + 1) / rings_count * (log10(high) - log10(low))) for index in range(rings_count)]
|
|
147
|
+
else:
|
|
148
|
+
ring_values = [low + (index + 1) / rings_count * (high - low) for index in range(rings_count)]
|
|
149
|
+
label_raw = grid.get("labels")
|
|
150
|
+
angle_raw = grid.get("angleLabels")
|
|
151
|
+
base_label = settings["labels"]
|
|
152
|
+
base_angle = settings["angleLabels"]
|
|
153
|
+
label = {**base_label, **(label_raw if isinstance(label_raw, Mapping) else {})}
|
|
154
|
+
angle_label = {**base_angle, **(angle_raw if isinstance(angle_raw, Mapping) else {})}
|
|
155
|
+
label_show = label_raw if isinstance(label_raw, bool) else label.get("show", True) if label_raw is not None else base_label["show"]
|
|
156
|
+
angle_show = angle_raw if isinstance(angle_raw, bool) else angle_label.get("show", True) if angle_raw is not None else base_angle["show"]
|
|
157
|
+
if categories and angle_raw is not None and not isinstance(angle_raw, Mapping):
|
|
158
|
+
angle_label["labels"] = categories
|
|
159
|
+
elif categories and not isinstance(angle_raw, Mapping):
|
|
160
|
+
angle_label["labels"] = categories
|
|
161
|
+
ring_items, guide_items, labels, angle_labels = [], [], [], []
|
|
162
|
+
for value in ring_values:
|
|
163
|
+
r = radius(value)
|
|
164
|
+
ring_items.append({"centerX": center_x, "centerY": center_y, "radius": r, "value": value})
|
|
165
|
+
if label_show:
|
|
166
|
+
angle = _angle(_finite(label.get("angleDegrees"), base_label["angleDegrees"]))
|
|
167
|
+
theta = _radians(start + (angle if clockwise else -angle))
|
|
168
|
+
label_radius = r + _nonnegative(label.get("offset"), base_label["offset"])
|
|
169
|
+
decimals = _integer(label.get("decimals"), base_label["decimals"], 0, 12)
|
|
170
|
+
labels.append({"x": center_x + cos(theta) * label_radius,
|
|
171
|
+
"y": center_y + sin(theta) * label_radius,
|
|
172
|
+
"radius": r, "value": value,
|
|
173
|
+
"text": f"{label.get('prefix', base_label['prefix'])}{_format(value, decimals, True)}{label.get('suffix', base_label['suffix'])}",
|
|
174
|
+
"angle": angle})
|
|
175
|
+
for index in range(spokes):
|
|
176
|
+
angle = index * 360 / spokes
|
|
177
|
+
theta = _radians(start + (angle if clockwise else -angle))
|
|
178
|
+
guide_items.append({"centerX": center_x, "centerY": center_y,
|
|
179
|
+
"endX": center_x + cos(theta) * outer_limit,
|
|
180
|
+
"endY": center_y + sin(theta) * outer_limit, "angle": angle})
|
|
181
|
+
if angle_show:
|
|
182
|
+
label_radius = outer_limit + _nonnegative(angle_label.get("offset"), base_angle["offset"])
|
|
183
|
+
explicit_labels = angle_label.get("labels", categories if categories else base_angle["labels"])
|
|
184
|
+
text = explicit_labels[index] if index < len(explicit_labels) and isinstance(explicit_labels[index], str) and explicit_labels[index].strip() else f"{angle_label.get('prefix', base_angle['prefix'])}{_format(angle, _integer(angle_label.get('decimals'), base_angle['decimals'], 0, 12), True)}{angle_label.get('suffix', base_angle['suffix'])}"
|
|
185
|
+
angle_labels.append({"x": center_x + cos(theta) * label_radius,
|
|
186
|
+
"y": center_y + sin(theta) * label_radius, "angle": angle, "text": text})
|
|
187
|
+
if not ring_items and not guide_items:
|
|
188
|
+
return None
|
|
189
|
+
ring_color = _string(grid.get("ringColor"), settings["ringColor"])
|
|
190
|
+
spoke_color = _string(grid.get("spokeColor"), settings["spokeColor"])
|
|
191
|
+
ring_width = _nonnegative(grid.get("ringLineWidth"), settings["ringLineWidth"])
|
|
192
|
+
spoke_width = _nonnegative(grid.get("spokeLineWidth"), settings["spokeLineWidth"])
|
|
193
|
+
points = ([{"x": item["centerX"] + item["radius"], "y": item["centerY"]} for item in ring_items]
|
|
194
|
+
+ [{"x": item["endX"], "y": item["endY"]} for item in guide_items]
|
|
195
|
+
+ [{"x": item["x"], "y": item["y"]} for item in labels]
|
|
196
|
+
+ [{"x": item["x"], "y": item["y"]} for item in angle_labels])
|
|
197
|
+
return {"type": "polar", "points": points, "color": ring_color, "lineWidth": ring_width,
|
|
198
|
+
"polarGrid": {"rings": ring_items, "angleGuides": guide_items, "labels": labels,
|
|
199
|
+
"angleLabels": angle_labels, "ringColor": ring_color, "spokeColor": spoke_color,
|
|
200
|
+
"labelColor": _string(label.get("color"), base_label["color"]),
|
|
201
|
+
"labelFont": _string(label.get("font"), base_label["font"]),
|
|
202
|
+
"angleLabelColor": _string(angle_label.get("color"), base_angle["color"]),
|
|
203
|
+
"angleLabelFont": _string(angle_label.get("font"), base_angle["font"]),
|
|
204
|
+
"ringLineWidth": ring_width, "spokeLineWidth": spoke_width},
|
|
205
|
+
"subplot": "main", "bounds": bounds}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _vector_style(defaults, raw, fallback=None):
|
|
209
|
+
base = dict(defaults if fallback is None else fallback)
|
|
210
|
+
if isinstance(raw, bool):
|
|
211
|
+
base["show"] = raw
|
|
212
|
+
base["directions"] = []
|
|
213
|
+
return base
|
|
214
|
+
if not isinstance(raw, Mapping):
|
|
215
|
+
return base
|
|
216
|
+
base["show"] = raw.get("show", True)
|
|
217
|
+
base["color"] = _string(raw.get("color"), base["color"])
|
|
218
|
+
for key in ("lineWidth", "headSize"):
|
|
219
|
+
base[key] = _nonnegative(raw.get(key), base[key])
|
|
220
|
+
base["headAngleDegrees"] = _clamp(_positive(raw.get("headAngleDegrees"), base["headAngleDegrees"]), base["headAngleDegrees"], 5, 85)
|
|
221
|
+
directions = []
|
|
222
|
+
for item in raw.get("directions") or []:
|
|
223
|
+
value = item if isinstance(item, (int, float)) and not isinstance(item, bool) else item.get("directionDegrees", item.get("direction")) if isinstance(item, Mapping) else None
|
|
224
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value):
|
|
225
|
+
continue
|
|
226
|
+
direction = {**base, "angle": _angle(value)}
|
|
227
|
+
if isinstance(item, Mapping):
|
|
228
|
+
direction["color"] = _string(item.get("color"), base["color"])
|
|
229
|
+
for key in ("lineWidth", "headSize"):
|
|
230
|
+
direction[key] = _nonnegative(item.get(key), base[key])
|
|
231
|
+
direction["headAngleDegrees"] = _clamp(_positive(item.get("headAngleDegrees"), base["headAngleDegrees"]), base["headAngleDegrees"], 5, 85)
|
|
232
|
+
directions.append(direction)
|
|
233
|
+
base["directions"] = directions
|
|
234
|
+
return base
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _vector_primitive(angles, style, color, value, center_x, center_y, outer_limit,
|
|
238
|
+
start, clockwise, bounds):
|
|
239
|
+
if not style["show"] or not angles:
|
|
240
|
+
return None
|
|
241
|
+
vectors = []
|
|
242
|
+
for angle, number, selected in angles:
|
|
243
|
+
theta = _radians(start + (angle if clockwise else -angle))
|
|
244
|
+
vectors.append({"centerX": center_x, "centerY": center_y,
|
|
245
|
+
"endX": center_x + cos(theta) * outer_limit,
|
|
246
|
+
"endY": center_y + sin(theta) * outer_limit,
|
|
247
|
+
"angle": angle, "value": number if number is not None else value,
|
|
248
|
+
"color": selected.get("color") or color,
|
|
249
|
+
"lineWidth": selected["lineWidth"], "headSize": selected["headSize"],
|
|
250
|
+
"headAngle": _radians(selected["headAngleDegrees"])})
|
|
251
|
+
return {"type": "polar", "points": [{"x": item["endX"], "y": item["endY"]} for item in vectors],
|
|
252
|
+
"color": color, "lineWidth": style["lineWidth"], "polarVectors": vectors,
|
|
253
|
+
"subplot": "main", "bounds": bounds}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def polar_scene(options: Mapping, width: float, height: float) -> dict:
|
|
257
|
+
defaults = get_defaults("polar")
|
|
258
|
+
raw = options.get("polar") or {}
|
|
259
|
+
renderer_style_fields = {"backgroundColor", "text", "title", "subtitle", "plotArea", "axes", "legend", "tooltip"}
|
|
260
|
+
style = options.get("style") or {}
|
|
261
|
+
trace_style = style.get("trace") or {}
|
|
262
|
+
style_line_color = (trace_style.get("line") or {}).get("color")
|
|
263
|
+
plot_line_color = (options.get("lineStyle") or {}).get("color")
|
|
264
|
+
if (options.get("engine") != "rust" and not options.get("traces") and not raw and not options.get("polarVector")
|
|
265
|
+
and not (isinstance(plot_line_color, str) and plot_line_color.strip())
|
|
266
|
+
and not (set(style) - renderer_style_fields - {"trace"})
|
|
267
|
+
and not (set(trace_style) - {"line", "marker"})
|
|
268
|
+
and not (isinstance(style_line_color, str) and style_line_color.strip())):
|
|
269
|
+
from .families2d import polar_scene as baseline_polar_scene
|
|
270
|
+
return baseline_polar_scene(options, width, height)
|
|
271
|
+
if raw.get("mode", defaults["mode"]) != "windrose":
|
|
272
|
+
raise ValueError("Only polar mode 'windrose' is supported")
|
|
273
|
+
scale = raw.get("radialScale", defaults["radialScale"])
|
|
274
|
+
if scale not in ("linear", "logarithmic"):
|
|
275
|
+
raise ValueError("Polar radialScale must be 'linear' or 'logarithmic'")
|
|
276
|
+
mode = raw.get("sectorMode", defaults["sectorMode"])
|
|
277
|
+
if mode not in ("grouped", "stacked"):
|
|
278
|
+
raise ValueError("Polar sectorMode must be 'grouped' or 'stacked'")
|
|
279
|
+
if mode == "stacked" and scale == "logarithmic":
|
|
280
|
+
raise ValueError("Polar stacked sectors require a linear radialScale")
|
|
281
|
+
for name in ("minRadius", "maxRadius"):
|
|
282
|
+
if scale == "logarithmic" and name in raw:
|
|
283
|
+
value = raw[name]
|
|
284
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value) or value <= 0:
|
|
285
|
+
raise ValueError(f"Polar logarithmic {name} must be finite and > 0")
|
|
286
|
+
if scale == "logarithmic" and "minRadius" in raw and "maxRadius" in raw and raw["maxRadius"] <= raw["minRadius"]:
|
|
287
|
+
raise ValueError("Polar logarithmic maxRadius must be greater than minRadius")
|
|
288
|
+
categories = _angle_categories(raw.get("angleCategories"), defaults)
|
|
289
|
+
traces = _traces(options)
|
|
290
|
+
parsed = []
|
|
291
|
+
running = {}
|
|
292
|
+
grouped = [index for index, trace in enumerate(traces) if trace.get("visible") is not False]
|
|
293
|
+
observed = []
|
|
294
|
+
for trace_index, trace in enumerate(traces):
|
|
295
|
+
points = []
|
|
296
|
+
if trace.get("visible") is not False:
|
|
297
|
+
for source_index, point in enumerate(trace.get("data", [])):
|
|
298
|
+
if point.get("x") is None or point.get("y") is None:
|
|
299
|
+
continue
|
|
300
|
+
angle = _resolve_angle(point["x"], categories)
|
|
301
|
+
value = point["y"]
|
|
302
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value):
|
|
303
|
+
raise ValueError("Polar windrose values require finite y")
|
|
304
|
+
if scale == "logarithmic" and value <= 0:
|
|
305
|
+
raise ValueError("Polar logarithmic radial scale requires y > 0")
|
|
306
|
+
if value < 0:
|
|
307
|
+
raise ValueError("Polar windrose values require y >= 0")
|
|
308
|
+
base = running.get(angle, 0) if mode == "stacked" and value > 0 else 0
|
|
309
|
+
total = base + value
|
|
310
|
+
if mode == "stacked" and value > 0:
|
|
311
|
+
running[angle] = total
|
|
312
|
+
points.append((source_index, angle, value, base, total))
|
|
313
|
+
observed.append(total)
|
|
314
|
+
parsed.append(points)
|
|
315
|
+
if not observed and not (raw.get("minRadius") is not None and raw.get("maxRadius") is not None):
|
|
316
|
+
return {"width": width, "height": height, "primitives": []}
|
|
317
|
+
if scale == "logarithmic":
|
|
318
|
+
low = raw.get("minRadius", min(observed))
|
|
319
|
+
high = raw.get("maxRadius", max(observed))
|
|
320
|
+
if "maxRadius" in raw and high < low:
|
|
321
|
+
raise ValueError("Polar logarithmic maxRadius must not be below the inferred radial minimum")
|
|
322
|
+
high = max(high, low)
|
|
323
|
+
else:
|
|
324
|
+
low = _nonnegative(raw.get("minRadius"), defaults["minRadius"])
|
|
325
|
+
configured_high = raw.get("maxRadius")
|
|
326
|
+
high = configured_high if isinstance(configured_high, (int, float)) and isfinite(configured_high) and configured_high > low else max(observed)
|
|
327
|
+
if high <= low:
|
|
328
|
+
high = low + 1
|
|
329
|
+
outer_ratio = _clamp(raw.get("outerRadiusRatio"), defaults["outerRadiusRatio"], .05, 1)
|
|
330
|
+
inner_ratio = _clamp(raw.get("innerRadiusRatio"), defaults["innerRadiusRatio"], 0, .95)
|
|
331
|
+
center_x, center_y = width / 2, height / 2
|
|
332
|
+
outer_limit = min(width, height) / 2 * outer_ratio
|
|
333
|
+
inner_limit = outer_limit * inner_ratio
|
|
334
|
+
available = max(0, outer_limit - inner_limit)
|
|
335
|
+
def radius(value):
|
|
336
|
+
if high == low:
|
|
337
|
+
return outer_limit if value >= high else inner_limit
|
|
338
|
+
selected, minimum, maximum = (log10(value), log10(low), log10(high)) if scale == "logarithmic" else (value, low, high)
|
|
339
|
+
ratio = max(0, min(1, (selected - minimum) / max(2.220446049250313e-16, maximum - minimum)))
|
|
340
|
+
return inner_limit + ratio * available
|
|
341
|
+
start = _finite(raw.get("startAngleDegrees"), defaults["startAngleDegrees"])
|
|
342
|
+
clockwise = raw.get("clockwise", defaults["clockwise"])
|
|
343
|
+
full_width = min(360, max(1, _positive(raw.get("sectorWidthDegrees"), defaults["sectorWidthDegrees"])))
|
|
344
|
+
group_count = 1 if mode == "stacked" else max(1, len(grouped))
|
|
345
|
+
sector_width = full_width / group_count
|
|
346
|
+
plot_gap = min(360, _nonnegative(raw.get("sectorGapDegrees"), defaults["sectorGapDegrees"]))
|
|
347
|
+
plot_opacity = _clamp(raw.get("sectorOpacity"), defaults["sectorOpacity"], 0, 1)
|
|
348
|
+
plot_border_width = _nonnegative(raw.get("borderWidth"), defaults["borderWidth"])
|
|
349
|
+
plot_border_color = _string(raw.get("borderColor"), defaults["borderColor"])
|
|
350
|
+
bounds = {"left": 0, "top": 0, "right": width, "bottom": height}
|
|
351
|
+
primitives = []
|
|
352
|
+
grid_primitive = _grid_primitive(raw, defaults, categories, low, high, scale, radius,
|
|
353
|
+
width, height, center_x, center_y, outer_limit, start, clockwise, bounds)
|
|
354
|
+
if grid_primitive is not None:
|
|
355
|
+
primitives.append(grid_primitive)
|
|
356
|
+
for trace_index, trace in enumerate(traces):
|
|
357
|
+
if trace.get("visible") is False:
|
|
358
|
+
continue
|
|
359
|
+
trace_raw = trace.get("polar") or {}
|
|
360
|
+
color = (trace.get("lineStyle") or {}).get("color") or trace.get("color") or (options.get("lineStyle") or {}).get("color") or (((options.get("style") or {}).get("trace") or {}).get("line") or {}).get("color") or DEFAULT_TRACE_COLORS[trace_index % len(DEFAULT_TRACE_COLORS)]
|
|
361
|
+
opacity = _clamp(trace_raw.get("sectorOpacity"), plot_opacity, 0, 1)
|
|
362
|
+
border_width = _nonnegative(trace_raw.get("borderWidth"), plot_border_width)
|
|
363
|
+
border_color = _string(trace_raw.get("borderColor"), plot_border_color)
|
|
364
|
+
gap = min(360, _nonnegative(trace_raw.get("sectorGapDegrees"), plot_gap), sector_width * .95)
|
|
365
|
+
group_index = 0 if mode == "stacked" else grouped.index(trace_index)
|
|
366
|
+
if options.get("engine") == "rust":
|
|
367
|
+
active = [(source_index, angle, value, base, total) for source_index, angle, value, base, total in parsed[trace_index] if value > 0]
|
|
368
|
+
native = rustCall("polar", input={
|
|
369
|
+
"width": width, "height": height,
|
|
370
|
+
"traces": [{
|
|
371
|
+
"points": [{"x": angle, "y": total} for _, angle, _, _, total in active],
|
|
372
|
+
"innerValues": [base for _, _, _, base, _ in active] if mode == "stacked" else None,
|
|
373
|
+
"values": [value for _, _, value, _, _ in active] if mode == "stacked" else None,
|
|
374
|
+
"centerX": center_x, "centerY": center_y,
|
|
375
|
+
"innerRadius": inner_limit, "availableRadius": available,
|
|
376
|
+
"sectorWidthDegrees": full_width, "sectorGapDegrees": gap,
|
|
377
|
+
"startAngleDegrees": start, "clockwise": clockwise,
|
|
378
|
+
"radialScale": scale, "minRadius": low, "maxRadius": high,
|
|
379
|
+
"groupIndex": group_index, "groupCount": group_count,
|
|
380
|
+
"color": color, "lineWidth": border_width,
|
|
381
|
+
"borderColor": border_color, "borderWidth": border_width,
|
|
382
|
+
"subplot": "main", "bounds": bounds,
|
|
383
|
+
}],
|
|
384
|
+
})
|
|
385
|
+
if not native["primitives"]:
|
|
386
|
+
continue
|
|
387
|
+
primitive = native["primitives"][0]
|
|
388
|
+
for sector, (source_index, *_rest) in zip(primitive["polarSectors"], active):
|
|
389
|
+
sector["opacity"] = opacity
|
|
390
|
+
sector["sourceIndex"] = source_index
|
|
391
|
+
primitive["traceIndex"] = trace_index
|
|
392
|
+
labels = _sector_labels(primitive["polarSectors"], _label_config(defaults["sectorLabels"], raw.get("sectorLabels"), trace_raw.get("sectorLabels")))
|
|
393
|
+
if labels:
|
|
394
|
+
primitive["polarSectorLabels"] = labels
|
|
395
|
+
primitives.append(primitive)
|
|
396
|
+
continue
|
|
397
|
+
sectors = []
|
|
398
|
+
for source_index, angle, value, base, total in parsed[trace_index]:
|
|
399
|
+
if value == 0:
|
|
400
|
+
continue
|
|
401
|
+
oriented = start + (angle if clockwise else -angle)
|
|
402
|
+
begin = oriented - full_width / 2 + group_index * sector_width + gap / 2
|
|
403
|
+
end = begin + sector_width - gap
|
|
404
|
+
sectors.append({"centerX": center_x, "centerY": center_y,
|
|
405
|
+
"innerRadius": radius(base) if mode == "stacked" else inner_limit,
|
|
406
|
+
"outerRadius": radius(total), "startAngle": _radians(begin),
|
|
407
|
+
"endAngle": _radians(end), "angle": angle, "value": value,
|
|
408
|
+
"opacity": opacity, "sourceIndex": source_index})
|
|
409
|
+
if not sectors:
|
|
410
|
+
continue
|
|
411
|
+
points = [{"x": center_x + cos((sector["startAngle"] + sector["endAngle"]) / 2) * sector["outerRadius"],
|
|
412
|
+
"y": center_y + sin((sector["startAngle"] + sector["endAngle"]) / 2) * sector["outerRadius"]} for sector in sectors]
|
|
413
|
+
primitive = {"type": "polar", "points": points, "polarSectors": sectors,
|
|
414
|
+
"color": color, "lineWidth": border_width, "borderColor": border_color,
|
|
415
|
+
"borderWidth": border_width, "traceIndex": trace_index, "subplot": "main", "bounds": bounds}
|
|
416
|
+
labels = _sector_labels(sectors, _label_config(defaults["sectorLabels"], raw.get("sectorLabels"), trace_raw.get("sectorLabels")))
|
|
417
|
+
if labels:
|
|
418
|
+
primitive["polarSectorLabels"] = labels
|
|
419
|
+
primitives.append(primitive)
|
|
420
|
+
global_vectors = _vector_style(defaults["vectors"], raw.get("vectors"))
|
|
421
|
+
if global_vectors["show"] and global_vectors["directions"]:
|
|
422
|
+
color = global_vectors["color"] or DEFAULT_TRACE_COLORS[0]
|
|
423
|
+
angles = [(item["angle"], None, item) for item in global_vectors["directions"]]
|
|
424
|
+
direction_primitive = _vector_primitive(angles, global_vectors, color, high, center_x, center_y,
|
|
425
|
+
outer_limit, start, clockwise, bounds)
|
|
426
|
+
if direction_primitive:
|
|
427
|
+
primitives.append(direction_primitive)
|
|
428
|
+
for trace_index, trace in enumerate(traces):
|
|
429
|
+
if trace.get("visible") is False:
|
|
430
|
+
continue
|
|
431
|
+
trace_raw = trace.get("polarVector")
|
|
432
|
+
vector_style = _vector_style(defaults["vectors"], trace_raw, global_vectors)
|
|
433
|
+
if not vector_style["show"] or trace_raw is None and global_vectors["directions"]:
|
|
434
|
+
continue
|
|
435
|
+
trace_color = (trace.get("lineStyle") or {}).get("color") or trace.get("color") or DEFAULT_TRACE_COLORS[trace_index % len(DEFAULT_TRACE_COLORS)]
|
|
436
|
+
color = vector_style["color"] or trace_color
|
|
437
|
+
if vector_style["directions"]:
|
|
438
|
+
angles = [(item["angle"], None, item) for item in vector_style["directions"]]
|
|
439
|
+
else:
|
|
440
|
+
angles = [(angle, value, vector_style) for _, angle, value, _, _ in parsed[trace_index] if value > 0]
|
|
441
|
+
vector_primitive = _vector_primitive(angles, vector_style, color, high, center_x, center_y,
|
|
442
|
+
outer_limit, start, clockwise, bounds)
|
|
443
|
+
if vector_primitive:
|
|
444
|
+
primitives.append(vector_primitive)
|
|
445
|
+
return {"width": width, "height": height, "primitives": primitives}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Source-compatible domain and camera clipping for 3D triangle fragments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import fabs
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Pinned scene3d.ts constants: coordinate deduplication, plane tolerance,
|
|
9
|
+
# and maximum possible fragments after clipping a triangle by six planes.
|
|
10
|
+
_CLIP_EPSILON = 1e-12
|
|
11
|
+
_MAX_FRAGMENTS = 13
|
|
12
|
+
_BINARY64_EPSILON = 2.220446049250313e-16
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _interpolate(first, second, ratio, position_key):
|
|
16
|
+
source_index = (first["sourceIndex"] if ratio < .5 else
|
|
17
|
+
second["sourceIndex"] if ratio > .5 else
|
|
18
|
+
min(first["sourceIndex"], second["sourceIndex"]))
|
|
19
|
+
return {position_key: tuple(first[position_key][axis] +
|
|
20
|
+
(second[position_key][axis] - first[position_key][axis]) * ratio
|
|
21
|
+
for axis in range(3)),
|
|
22
|
+
"sourceIndex": source_index,
|
|
23
|
+
**{key: first[key] + (second[key] - first[key]) * ratio
|
|
24
|
+
for key in ("sourceX", "sourceY", "sourceZ")}}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _same(first, second, position_key):
|
|
28
|
+
return all(abs(first[position_key][axis] - second[position_key][axis]) <= _CLIP_EPSILON
|
|
29
|
+
for axis in range(3))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _deduplicate(vertices, position_key):
|
|
33
|
+
result = []
|
|
34
|
+
for vertex in vertices:
|
|
35
|
+
if not result or not _same(result[-1], vertex, position_key):
|
|
36
|
+
result.append(vertex)
|
|
37
|
+
if len(result) > 1 and _same(result[0], result[-1], position_key):
|
|
38
|
+
result.pop()
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _clip_polygon(vertices, planes, position_key):
|
|
43
|
+
polygon = list(vertices)
|
|
44
|
+
for plane in planes:
|
|
45
|
+
if len(polygon) < 3:
|
|
46
|
+
return []
|
|
47
|
+
clipped = []
|
|
48
|
+
previous = polygon[-1]
|
|
49
|
+
previous_distance = plane(previous[position_key])
|
|
50
|
+
previous_inside = previous_distance >= -_CLIP_EPSILON
|
|
51
|
+
for current in polygon:
|
|
52
|
+
current_distance = plane(current[position_key])
|
|
53
|
+
current_inside = current_distance >= -_CLIP_EPSILON
|
|
54
|
+
if previous_inside != current_inside:
|
|
55
|
+
denominator = previous_distance - current_distance
|
|
56
|
+
ratio = (0 if abs(denominator) <= _CLIP_EPSILON else
|
|
57
|
+
max(0, min(1, previous_distance / denominator)))
|
|
58
|
+
clipped.append(_interpolate(previous, current, ratio, position_key))
|
|
59
|
+
if current_inside:
|
|
60
|
+
clipped.append(current)
|
|
61
|
+
previous, previous_distance, previous_inside = current, current_distance, current_inside
|
|
62
|
+
polygon = _deduplicate(clipped, position_key)
|
|
63
|
+
return polygon if len(polygon) >= 3 else []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def clip_domain_triangle(vertices, domains):
|
|
67
|
+
planes = (lambda point: point[0] + .5, lambda point: .5 - point[0],
|
|
68
|
+
lambda point: point[1] + .5, lambda point: .5 - point[1],
|
|
69
|
+
lambda point: point[2] + .5, lambda point: .5 - point[2])
|
|
70
|
+
polygon = _clip_polygon(vertices, planes, "world")
|
|
71
|
+
if not polygon:
|
|
72
|
+
return []
|
|
73
|
+
result = []
|
|
74
|
+
for vertex in polygon:
|
|
75
|
+
vertex = {**vertex, "world": list(vertex["world"])}
|
|
76
|
+
for axis, name in enumerate("xyz"):
|
|
77
|
+
world = vertex["world"][axis]
|
|
78
|
+
if abs(world + .5) <= _CLIP_EPSILON:
|
|
79
|
+
vertex["world"][axis] = -.5
|
|
80
|
+
vertex[f"source{name.upper()}"] = domains[name]["min"]
|
|
81
|
+
elif abs(world - .5) <= _CLIP_EPSILON:
|
|
82
|
+
vertex["world"][axis] = .5
|
|
83
|
+
vertex[f"source{name.upper()}"] = domains[name]["max"]
|
|
84
|
+
vertex["world"] = tuple(vertex["world"])
|
|
85
|
+
result.append(vertex)
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def project_polygon(vertices, projector):
|
|
90
|
+
if not vertices:
|
|
91
|
+
return []
|
|
92
|
+
camera = projector.camera
|
|
93
|
+
def to_camera(world):
|
|
94
|
+
from .scene3d import _subtract, _dot
|
|
95
|
+
relative = _subtract(world, camera["eye"])
|
|
96
|
+
return (_dot(relative, projector.right), _dot(relative, projector.up),
|
|
97
|
+
_dot(relative, projector.forward))
|
|
98
|
+
polygon = [{"camera": to_camera(vertex["world"]),
|
|
99
|
+
**{key: vertex[key] for key in ("sourceIndex", "sourceX", "sourceY", "sourceZ")}}
|
|
100
|
+
for vertex in vertices]
|
|
101
|
+
if camera["projection"] == "perspective":
|
|
102
|
+
planes = (lambda point: point[2] - camera["near"],
|
|
103
|
+
lambda point: camera["far"] - point[2],
|
|
104
|
+
lambda point: point[0] + point[2] * projector.tangent * projector.aspect,
|
|
105
|
+
lambda point: point[2] * projector.tangent * projector.aspect - point[0],
|
|
106
|
+
lambda point: point[1] + point[2] * projector.tangent,
|
|
107
|
+
lambda point: point[2] * projector.tangent - point[1])
|
|
108
|
+
else:
|
|
109
|
+
half_height = camera["orthographicScale"] / 2
|
|
110
|
+
half_width = half_height * projector.aspect
|
|
111
|
+
planes = (lambda point: point[2] - camera["near"],
|
|
112
|
+
lambda point: camera["far"] - point[2],
|
|
113
|
+
lambda point: point[0] + half_width,
|
|
114
|
+
lambda point: half_width - point[0],
|
|
115
|
+
lambda point: point[1] + half_height,
|
|
116
|
+
lambda point: half_height - point[1])
|
|
117
|
+
polygon = _clip_polygon(polygon, planes, "camera")
|
|
118
|
+
if not polygon:
|
|
119
|
+
return []
|
|
120
|
+
projected = []
|
|
121
|
+
for vertex in polygon:
|
|
122
|
+
point = vertex["camera"]
|
|
123
|
+
if camera["projection"] == "perspective":
|
|
124
|
+
ndc_x = point[0] / (point[2] * projector.tangent * projector.aspect)
|
|
125
|
+
ndc_y = point[1] / (point[2] * projector.tangent)
|
|
126
|
+
else:
|
|
127
|
+
ndc_x = point[0] / half_width
|
|
128
|
+
ndc_y = point[1] / half_height
|
|
129
|
+
projected.append({"x": (max(-1, min(1, ndc_x)) + 1) * projector.width / 2,
|
|
130
|
+
"y": (1 - max(-1, min(1, ndc_y))) * projector.height / 2,
|
|
131
|
+
"depth": max(0, min(1, (point[2] - camera["near"]) /
|
|
132
|
+
(camera["far"] - camera["near"]))),
|
|
133
|
+
**{key: vertex[key] for key in ("sourceIndex", "sourceX", "sourceY", "sourceZ")}})
|
|
134
|
+
result = []
|
|
135
|
+
for index in range(1, len(projected) - 1):
|
|
136
|
+
first, second, third = projected[0], projected[index], projected[index + 1]
|
|
137
|
+
twice_area = ((second["x"] - first["x"]) * (third["y"] - first["y"])
|
|
138
|
+
- (second["y"] - first["y"]) * (third["x"] - first["x"]))
|
|
139
|
+
max_delta = max(abs(second["x"] - first["x"]), abs(second["y"] - first["y"]),
|
|
140
|
+
abs(third["x"] - first["x"]), abs(third["y"] - first["y"]))
|
|
141
|
+
tolerance = _BINARY64_EPSILON * max(1, max_delta * max_delta) * 64
|
|
142
|
+
if abs(twice_area) <= tolerance:
|
|
143
|
+
continue
|
|
144
|
+
result.append({"vertices": [first, second, third],
|
|
145
|
+
"boundaryEdges": [index == 1, True, index == len(projected) - 2]})
|
|
146
|
+
if len(result) > _MAX_FRAGMENTS:
|
|
147
|
+
raise ValueError("scene3d triangle clipping exceeded its bounded fragment limit")
|
|
148
|
+
return result
|