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.
Files changed (78) hide show
  1. fastplotpy/README.md +70 -0
  2. fastplotpy/__init__.py +33 -0
  3. fastplotpy/assets/dist/assets/scene.worker-CcnexaKr.js +1 -0
  4. fastplotpy/assets/dist/index.js +15071 -0
  5. fastplotpy/assets/native/NotoSans-OFL.txt +96 -0
  6. fastplotpy/assets/native/THIRD_PARTY_NOTICES.md +24 -0
  7. fastplotpy/assets/native/fastplot_python_abi.dll +0 -0
  8. fastplotpy/assets/package.json +1 -0
  9. fastplotpy/axes_validation.py +102 -0
  10. fastplotpy/camera3d.py +104 -0
  11. fastplotpy/color_scale.py +88 -0
  12. fastplotpy/defaults/area-fill.json +5 -0
  13. fastplotpy/defaults/bar.json +22 -0
  14. fastplotpy/defaults/camera3d.json +22 -0
  15. fastplotpy/defaults/cartesian-references.json +10 -0
  16. fastplotpy/defaults/control.json +3 -0
  17. fastplotpy/defaults/crosshair.json +19 -0
  18. fastplotpy/defaults/error-bars.json +7 -0
  19. fastplotpy/defaults/heatmap.json +70 -0
  20. fastplotpy/defaults/interaction.json +3 -0
  21. fastplotpy/defaults/layout.json +32 -0
  22. fastplotpy/defaults/legend.json +24 -0
  23. fastplotpy/defaults/line-axes.json +28 -0
  24. fastplotpy/defaults/line3d.json +13 -0
  25. fastplotpy/defaults/markers.json +6 -0
  26. fastplotpy/defaults/mesh3d.json +13 -0
  27. fastplotpy/defaults/pan.json +7 -0
  28. fastplotpy/defaults/pie.json +27 -0
  29. fastplotpy/defaults/pie3d.json +7 -0
  30. fastplotpy/defaults/point-labels.json +13 -0
  31. fastplotpy/defaults/pointClick.json +6 -0
  32. fastplotpy/defaults/pointSelection.json +13 -0
  33. fastplotpy/defaults/polar.json +71 -0
  34. fastplotpy/defaults/polarRotate.json +5 -0
  35. fastplotpy/defaults/scatter3d.json +39 -0
  36. fastplotpy/defaults/scene3d.json +61 -0
  37. fastplotpy/defaults/surface.json +35 -0
  38. fastplotpy/defaults/tooltip.json +18 -0
  39. fastplotpy/defaults/trace-colors.json +10 -0
  40. fastplotpy/defaults/trace-options.json +24 -0
  41. fastplotpy/defaults/wonderplot.json +58 -0
  42. fastplotpy/defaults/zoom.json +12 -0
  43. fastplotpy/defaults.py +15 -0
  44. fastplotpy/error_bars.py +118 -0
  45. fastplotpy/exports.py +153 -0
  46. fastplotpy/families2d.py +896 -0
  47. fastplotpy/heatmap_labels.py +146 -0
  48. fastplotpy/interactions.py +243 -0
  49. fastplotpy/interactive.py +1922 -0
  50. fastplotpy/js_serialize.py +47 -0
  51. fastplotpy/lighting3d.py +113 -0
  52. fastplotpy/line3d_lod.py +138 -0
  53. fastplotpy/lod3d.py +105 -0
  54. fastplotpy/mesh3d_lod.py +107 -0
  55. fastplotpy/native.py +163 -0
  56. fastplotpy/overlays.py +561 -0
  57. fastplotpy/point_labels.py +86 -0
  58. fastplotpy/polar.py +445 -0
  59. fastplotpy/polygon_clip3d.py +148 -0
  60. fastplotpy/references.py +72 -0
  61. fastplotpy/rendering.py +65 -0
  62. fastplotpy/rust_scenes.py +314 -0
  63. fastplotpy/scene.py +1185 -0
  64. fastplotpy/scene3d.py +1215 -0
  65. fastplotpy/scene3d_accessibility.py +183 -0
  66. fastplotpy/scene3d_collection.py +158 -0
  67. fastplotpy/subplots.py +115 -0
  68. fastplotpy/surface_contours.py +122 -0
  69. fastplotpy/surface_lod.py +109 -0
  70. fastplotpy/svg3d.py +235 -0
  71. fastplotpy/trendline.py +236 -0
  72. fastplotpy/triangle_wireframe.py +51 -0
  73. fastplotpy/types.py +3027 -0
  74. fastplotpy-0.3.0a1.dist-info/METADATA +86 -0
  75. fastplotpy-0.3.0a1.dist-info/RECORD +78 -0
  76. fastplotpy-0.3.0a1.dist-info/WHEEL +5 -0
  77. fastplotpy-0.3.0a1.dist-info/licenses/LICENSE +21 -0
  78. 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