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/scene3d.py ADDED
@@ -0,0 +1,1215 @@
1
+ """Native Python scene geometry for experimental 3D chart families.
2
+
3
+ World coordinates, camera projection and source identities follow the pinned
4
+ fastPlotJs scene3d implementation. Metadata decoration is built separately.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from math import acos, ceil, cos, floor, hypot, isfinite, log10, pi, radians, sin, tan
10
+ from re import fullmatch
11
+ from typing import Any, Mapping
12
+
13
+ from .defaults import DEFAULT_TRACE_COLORS, get_defaults
14
+ from .color_scale import interpolate as interpolate_color, legend_ticks, resolve_stops, scale_ratio
15
+ from .lod3d import resolve_scatter_lod, select_scatter_lod
16
+ from .line3d_lod import resolve_line_lod, select_line_lod, source_runs
17
+
18
+
19
+ def _traces(options: Mapping) -> list[dict]:
20
+ if options.get("traces"):
21
+ return list(options["traces"])
22
+ return [{"data": options["data"]}] if options.get("data") is not None else []
23
+
24
+
25
+ def _subtract(a, b):
26
+ return tuple(a[i] - b[i] for i in range(3))
27
+
28
+
29
+ def _dot(a, b):
30
+ return sum(a[i] * b[i] for i in range(3))
31
+
32
+
33
+ def _cross(a, b):
34
+ return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0])
35
+
36
+
37
+ def _normalize(vector):
38
+ magnitude = hypot(*vector)
39
+ if magnitude <= 1e-12:
40
+ raise ValueError("scene3d camera direction must not be degenerate")
41
+ return tuple(item / magnitude for item in vector)
42
+
43
+
44
+ def _camera(options: Mapping) -> dict:
45
+ defaults = get_defaults("scene3d")
46
+ source = (options.get("scene3d") or {}).get("camera") or {}
47
+ fields = ("projection", "eye", "target", "up", "fieldOfViewDegrees", "orthographicScale", "near", "far")
48
+ camera = {key: source.get(key, defaults[key]) for key in fields}
49
+ if camera["projection"] not in ("perspective", "orthographic"):
50
+ raise ValueError("scene3d.camera.projection must be 'perspective' or 'orthographic'")
51
+ for vector in ("eye", "target", "up"):
52
+ value = camera[vector]
53
+ if not isinstance(value, (list, tuple)) or len(value) != 3 or any(not isinstance(item, (int, float)) or not isfinite(item) for item in value):
54
+ raise ValueError(f"scene3d.camera.{vector} must contain exactly three finite numbers")
55
+ if not 1 < camera["fieldOfViewDegrees"] < 179:
56
+ raise ValueError("scene3d.camera.fieldOfViewDegrees must be finite and between 1 and 179")
57
+ if not camera["orthographicScale"] > 0:
58
+ raise ValueError("scene3d.camera.orthographicScale must be a positive finite number")
59
+ if not 0 < camera["near"] < camera["far"]:
60
+ raise ValueError("scene3d.camera clipping requires 0 < near < far")
61
+ _normalize(_cross(_normalize(_subtract(camera["target"], camera["eye"])), _normalize(camera["up"])))
62
+ return camera
63
+
64
+
65
+ def _source_points(options: Mapping) -> list[list[tuple[int, tuple[float, float, float], dict]]]:
66
+ parsed = []
67
+ for trace in _traces(options):
68
+ if trace.get("visible") is False:
69
+ parsed.append([])
70
+ continue
71
+ trace_points = []
72
+ for index, value in enumerate(trace.get("data") or []):
73
+ if any(value.get(axis) is None for axis in "xyz"):
74
+ continue
75
+ point = tuple(value[axis] for axis in "xyz")
76
+ if any(isinstance(item, bool) or not isinstance(item, (int, float)) or not isfinite(item) for item in point):
77
+ raise ValueError(f"scene3d requires finite numeric traces[].data[{index}] coordinates")
78
+ trace_points.append((index, point, value))
79
+ parsed.append(trace_points)
80
+ return parsed
81
+
82
+
83
+ def _domains(parsed: list, scene_options: Mapping) -> dict:
84
+ output = {}
85
+ all_points = [point for trace in parsed for _, point, _ in trace]
86
+ defaults = get_defaults("scene3d")
87
+ for index, axis in enumerate("xyz"):
88
+ axis_options = scene_options.get(f"{axis}Axis") or {}
89
+ explicit = axis_options.get("range")
90
+ if explicit is not None:
91
+ low, high = explicit["min"], explicit["max"]
92
+ if low == high:
93
+ raise ValueError(f"scene3d.{axis}Axis.range must have distinct endpoints")
94
+ elif not all_points:
95
+ low, high = -1, 1
96
+ else:
97
+ values = [point[index] for point in all_points]
98
+ low, high = min(values), max(values)
99
+ if low == high:
100
+ half = max(.5, abs(low) * .05)
101
+ low, high = low - half, high + half
102
+ else:
103
+ padding = (high - low) * axis_options.get("padding", defaults["axisPadding"])
104
+ low, high = low - padding, high + padding
105
+ output[axis] = {"min": low, "max": high}
106
+ return output
107
+
108
+
109
+ def _world(point, domains):
110
+ return tuple((point[index] - domains[axis]["min"]) / (domains[axis]["max"] - domains[axis]["min"]) - .5 for index, axis in enumerate("xyz"))
111
+
112
+
113
+ class _Projector:
114
+ def __init__(self, camera: Mapping, width: float, height: float):
115
+ self.camera, self.width, self.height = camera, width, height
116
+ self.forward = _normalize(_subtract(camera["target"], camera["eye"]))
117
+ self.right = _normalize(_cross(self.forward, _normalize(camera["up"])))
118
+ self.up = _cross(self.right, self.forward)
119
+ self.aspect = width / max(1, height)
120
+ self.tangent = tan(radians(camera["fieldOfViewDegrees"] / 2))
121
+
122
+ def project(self, world) -> dict | None:
123
+ relative = _subtract(world, self.camera["eye"])
124
+ x, y, z = _dot(relative, self.right), _dot(relative, self.up), _dot(relative, self.forward)
125
+ camera = self.camera
126
+ if z < camera["near"] or z > camera["far"]:
127
+ return None
128
+ if camera["projection"] == "perspective":
129
+ half_x, half_y = z * self.tangent * self.aspect, z * self.tangent
130
+ else:
131
+ half_y = camera["orthographicScale"] / 2
132
+ half_x = half_y * self.aspect
133
+ if x < -half_x or x > half_x or y < -half_y or y > half_y:
134
+ return None
135
+ ndc_x, ndc_y = x / half_x, y / half_y
136
+ return {"x": (ndc_x + 1) * self.width / 2,
137
+ "y": (1 - ndc_y) * self.height / 2,
138
+ "depth": (z - camera["near"]) / (camera["far"] - camera["near"])}
139
+
140
+ def project_segment(self, first_world, second_world):
141
+ def to_camera(world):
142
+ relative = _subtract(world, self.camera["eye"])
143
+ return (_dot(relative, self.right), _dot(relative, self.up), _dot(relative, self.forward))
144
+ first, second = to_camera(first_world), to_camera(second_world)
145
+ camera = self.camera
146
+ if camera["projection"] == "perspective":
147
+ planes = (lambda point: point[2] - camera["near"],
148
+ lambda point: camera["far"] - point[2],
149
+ lambda point: point[0] + point[2] * self.tangent * self.aspect,
150
+ lambda point: point[2] * self.tangent * self.aspect - point[0],
151
+ lambda point: point[1] + point[2] * self.tangent,
152
+ lambda point: point[2] * self.tangent - point[1])
153
+ else:
154
+ half_height = camera["orthographicScale"] / 2
155
+ half_width = half_height * self.aspect
156
+ planes = (lambda point: point[2] - camera["near"],
157
+ lambda point: camera["far"] - point[2],
158
+ lambda point: point[0] + half_width,
159
+ lambda point: half_width - point[0],
160
+ lambda point: point[1] + half_height,
161
+ lambda point: half_height - point[1])
162
+ start_ratio, end_ratio = 0, 1
163
+ for plane in planes:
164
+ first_distance, second_distance = plane(first), plane(second)
165
+ if first_distance < 0 and second_distance < 0:
166
+ return None
167
+ if first_distance >= 0 and second_distance >= 0:
168
+ continue
169
+ crossing = -first_distance / (second_distance - first_distance)
170
+ if first_distance < 0:
171
+ start_ratio = max(start_ratio, crossing)
172
+ else:
173
+ end_ratio = min(end_ratio, crossing)
174
+ if start_ratio > end_ratio:
175
+ return None
176
+ def project_camera(point):
177
+ if camera["projection"] == "perspective":
178
+ ndc_x = point[0] / (point[2] * self.tangent * self.aspect)
179
+ ndc_y = point[1] / (point[2] * self.tangent)
180
+ else:
181
+ ndc_x = point[0] / half_width
182
+ ndc_y = point[1] / half_height
183
+ return {"x": (max(-1, min(1, ndc_x)) + 1) * self.width / 2,
184
+ "y": (1 - max(-1, min(1, ndc_y))) * self.height / 2,
185
+ "depth": max(0, min(1, (point[2] - camera["near"]) / (camera["far"] - camera["near"])))}
186
+ return {"from": project_camera(_interpolate3(first, second, start_ratio)),
187
+ "to": project_camera(_interpolate3(first, second, end_ratio)),
188
+ "startRatio": start_ratio, "endRatio": end_ratio}
189
+
190
+
191
+ def _interpolate3(first, second, ratio):
192
+ return tuple(first[index] + (second[index] - first[index]) * ratio for index in range(3))
193
+
194
+
195
+ def _clip_domain_segment(first, second):
196
+ start_ratio, end_ratio = 0, 1
197
+ delta = _subtract(second, first)
198
+ for axis in range(3):
199
+ origin, direction = first[axis], delta[axis]
200
+ if abs(direction) < 1e-12:
201
+ if origin < -.5 or origin > .5:
202
+ return None
203
+ continue
204
+ lower = (-.5 - origin) / direction
205
+ upper = (.5 - origin) / direction
206
+ start_ratio = max(start_ratio, min(lower, upper))
207
+ end_ratio = min(end_ratio, max(lower, upper))
208
+ if start_ratio > end_ratio:
209
+ return None
210
+ return {"from": _interpolate3(first, second, start_ratio),
211
+ "to": _interpolate3(first, second, end_ratio),
212
+ "startRatio": start_ratio, "endRatio": end_ratio}
213
+
214
+
215
+ def _smooth_line_point(start_control, start, end, end_control, ratio):
216
+ if ratio <= 0:
217
+ return start
218
+ if ratio >= 1:
219
+ return end
220
+ squared, cubed = ratio * ratio, ratio * ratio * ratio
221
+ def coordinate(axis):
222
+ value = .5 * (2 * start[axis]
223
+ + (-start_control[axis] + end[axis]) * ratio
224
+ + (2 * start_control[axis] - 5 * start[axis] + 4 * end[axis] - end_control[axis]) * squared
225
+ + (-start_control[axis] + 3 * start[axis] - 3 * end[axis] + end_control[axis]) * cubed)
226
+ return min(max(value, min(start[axis], end[axis])), max(start[axis], end[axis]))
227
+ return tuple(coordinate(axis) for axis in range(3))
228
+
229
+
230
+ def _segment(projector, kind, start, end, axis=None):
231
+ first, second = projector.project(start), projector.project(end)
232
+ if first is None or second is None:
233
+ return None
234
+ result = {"kind": kind}
235
+ if axis is not None:
236
+ result["axis"] = axis
237
+ result.update({"x1": first["x"], "y1": first["y"], "depth1": first["depth"],
238
+ "x2": second["x"], "y2": second["y"], "depth2": second["depth"]})
239
+ return result
240
+
241
+
242
+ def _guides(options, projector):
243
+ defaults = get_defaults("scene3d")
244
+ scene = options.get("scene3d") or {}
245
+ segments, labels = [], []
246
+ steps = scene.get("gridSteps", defaults["gridSteps"])
247
+ if not isinstance(steps, int) or not 1 <= steps <= 20:
248
+ raise ValueError("scene3d.gridSteps must be an integer between 1 and 20")
249
+ if scene.get("showGrid", defaults["showGrid"]):
250
+ for step in range(steps + 1):
251
+ coordinate = -.5 + step / steps
252
+ for start, end in (((coordinate, -.5, -.5), (coordinate, -.5, .5)),
253
+ ((-.5, -.5, coordinate), (.5, -.5, coordinate))):
254
+ segment = _segment(projector, "grid", start, end)
255
+ if segment is not None:
256
+ segments.append(segment)
257
+ if scene.get("showAxes", defaults["showAxes"]):
258
+ for axis, endpoint in (("x", (.5, -.5, -.5)), ("y", (-.5, .5, -.5)), ("z", (-.5, -.5, .5))):
259
+ axis_options = scene.get(f"{axis}Axis") or {}
260
+ if axis_options.get("show") is False:
261
+ continue
262
+ segment = _segment(projector, "axis", (-.5, -.5, -.5), endpoint, axis)
263
+ projected = projector.project(endpoint)
264
+ if segment is not None:
265
+ segments.append(segment)
266
+ if projected is not None:
267
+ labels.append({"axis": axis, "text": axis_options.get("title") or axis.upper(), **projected})
268
+ return segments, labels
269
+
270
+
271
+ def _tick_text(value, step, decimals, defaults):
272
+ normalized = 0 if abs(value) < max(defaults["axisTickZeroToleranceFloor"], abs(step) * defaults["axisTickStepFloor"]) else value
273
+ if decimals is not None:
274
+ return f"{normalized:.{decimals}f}"
275
+ magnitude = abs(normalized)
276
+ if magnitude >= defaults["axisTickScientificUpperMagnitude"] or 0 < magnitude < defaults["axisTickScientificLowerMagnitude"]:
277
+ return f"{normalized:.{defaults['axisTickScientificDecimals']}e}"
278
+ precision = max(0, min(defaults["axisTickAutoMaxDecimals"], ceil(-log10(max(abs(step), defaults["axisTickStepFloor"]))) + 1))
279
+ return f"{normalized:.{precision}f}".rstrip("0").rstrip(".")
280
+
281
+
282
+ def _ticks(options, domains, projector):
283
+ defaults = get_defaults("scene3d")
284
+ scene = options.get("scene3d") or {}
285
+ if scene.get("showAxes") is False:
286
+ return [], []
287
+ segments, candidates = [], []
288
+ for axis in "xyz":
289
+ axis_options = scene.get(f"{axis}Axis") or {}
290
+ if axis_options.get("show") is False:
291
+ continue
292
+ count = axis_options.get("tickCount", defaults["axisTickCount"])
293
+ if not isinstance(count, int) or count < defaults["axisTickMinCount"] or count > defaults["axisTickMaxCount"]:
294
+ raise ValueError("scene3d axis tickCount out of range")
295
+ low, high = domains[axis]["min"], domains[axis]["max"]
296
+ step = (high - low) / (count - 1)
297
+ for index in range(count):
298
+ ratio = index / (count - 1)
299
+ coordinate = -.5 + ratio
300
+ offset = defaults["axisTickWorldLength"]
301
+ if axis == "x":
302
+ start, end = (coordinate, -.5, -.5), (coordinate, -.5 - offset, -.5)
303
+ screen_offset = (0, defaults["axisTickLabelOffset"])
304
+ elif axis == "y":
305
+ start, end = (-.5, coordinate, -.5), (-.5 - offset, coordinate, -.5)
306
+ screen_offset = (-defaults["axisTickLabelOffset"], 0)
307
+ else:
308
+ start, end = (-.5, -.5, coordinate), (-.5 - offset, -.5, coordinate)
309
+ screen_offset = (defaults["axisTickLabelOffset"], defaults["axisTickLabelOffset"] / 2)
310
+ segment, anchor = _segment(projector, "tick", start, end, axis), projector.project(end)
311
+ if segment is None or anchor is None:
312
+ continue
313
+ segments.append(segment)
314
+ if axis_options.get("showTickLabels") is False:
315
+ continue
316
+ value = low + (high - low) * ratio
317
+ candidates.append({"axis": axis, "value": value,
318
+ "text": _tick_text(value, step, axis_options.get("tickDecimals"), defaults),
319
+ "x": anchor["x"] + screen_offset[0], "y": anchor["y"] + screen_offset[1],
320
+ "depth": anchor["depth"]})
321
+ labels, occupied = [], []
322
+ for candidate in candidates:
323
+ label_width = max(defaults["axisTickLabelMinWidth"], len(candidate["text"]) * defaults["axisTickLabelGlyphWidth"])
324
+ half_height = defaults["axisTickLabelLineHeight"] / 2
325
+ box = (candidate["x"] - label_width / 2, candidate["y"] - half_height,
326
+ candidate["x"] + label_width / 2, candidate["y"] + half_height)
327
+ inset = defaults["axisTickLabelBoundsInset"]
328
+ gap = defaults["axisTickLabelMinGap"]
329
+ if box[0] < inset or box[2] > options["width"] - inset or box[1] < inset or box[3] > options["height"] - inset:
330
+ continue
331
+ if any(box[0] < current[2] + gap and box[2] + gap > current[0] and box[1] < current[3] + gap and box[3] + gap > current[1] for current in occupied):
332
+ continue
333
+ occupied.append(box)
334
+ labels.append(candidate)
335
+ return segments, labels
336
+
337
+
338
+ def _metadata(options, camera, domains, count, interaction_count, width, height):
339
+ defaults = get_defaults("scene3d")
340
+ scene_options = options.get("scene3d") or {}
341
+ projector = _Projector(camera, width, height)
342
+ segments, labels = _guides(options, projector)
343
+ tick_segments, tick_labels = _ticks(options, domains, projector)
344
+ segments.extend(tick_segments)
345
+ return {"experimental": True, "subplotId": "scene3d", "handedness": "right-handed",
346
+ "depthOrder": "far-to-near", "sourcePointCount": count,
347
+ "interactionItemCount": interaction_count, "projection": camera["projection"],
348
+ "camera": camera, "domains": domains,
349
+ "bounds": {"left": 0, "top": 0, "right": width, "bottom": height},
350
+ "segments": segments, "labels": labels, "tickLabels": tick_labels,
351
+ "tickLabelFont": defaults["axisTickLabelFont"],
352
+ "axisColor": scene_options.get("axisColor", defaults["axisColor"]),
353
+ "gridColor": scene_options.get("gridColor", defaults["gridColor"]),
354
+ "axisLineWidth": scene_options.get("axisLineWidth", defaults["axisLineWidth"]),
355
+ "gridLineWidth": scene_options.get("gridLineWidth", defaults["gridLineWidth"]),
356
+ "renderer": {"requested": scene_options.get("renderer", defaults["renderer"]), "resolved": "renderer-neutral"}}
357
+
358
+
359
+ def _scatter_encoding(options, parsed):
360
+ settings = options.get("scatter3d") or {}
361
+ defaults = get_defaults("scatter3d")
362
+ all_inputs = [item for trace in parsed for _, _, item in trace]
363
+ result = {}
364
+ for name, field in (("colorScale", "value"), ("sizeScale", "size"),
365
+ ("opacityScale", "opacity")):
366
+ raw = settings.get(name)
367
+ if name == "colorScale":
368
+ if raw is None:
369
+ continue
370
+ if not isinstance(raw, Mapping):
371
+ raise ValueError("scatter3d.colorScale must be an object")
372
+ enabled = True
373
+ else:
374
+ source = raw if isinstance(raw, Mapping) else {}
375
+ enabled = raw if isinstance(raw, bool) else source.get("enabled", True) if raw is not None else defaults[name]["enabled"]
376
+ if not enabled:
377
+ continue
378
+ raw = source
379
+ label = f"scatter3d.{name}"
380
+ if name == "colorScale":
381
+ value_scale = raw.get("valueScale", defaults["valueScale"])
382
+ if value_scale not in ("linear", "logarithmic"):
383
+ raise ValueError(f"{label}.valueScale must be 'linear' or 'logarithmic'")
384
+ low_color = raw.get("lowColor", defaults["lowColor"])
385
+ high_color = raw.get("highColor", defaults["highColor"])
386
+ for key, value in (("lowColor", low_color), ("highColor", high_color)):
387
+ if not isinstance(value, str) or not value.strip():
388
+ raise ValueError(f"{label}.{key} must be a non-empty string")
389
+ stops = resolve_stops(raw.get("colorStops"), low_color.strip(), high_color.strip(),
390
+ f"{label}.colorStops")
391
+ elif name == "sizeScale":
392
+ minimum = raw.get("minSize", defaults[name]["minSize"])
393
+ maximum = raw.get("maxSize", defaults[name]["maxSize"])
394
+ if not isinstance(minimum, (int, float)) or not isfinite(minimum) or minimum < get_defaults("markers")["minSize"]:
395
+ raise ValueError(f"{label}.minSize must be >= {get_defaults('markers')['minSize']}")
396
+ if not isinstance(maximum, (int, float)) or not isfinite(maximum) or maximum < minimum:
397
+ raise ValueError(f"{label}.maxSize must be >= minSize")
398
+ else:
399
+ minimum = raw.get("minOpacity", defaults[name]["minOpacity"])
400
+ maximum = raw.get("maxOpacity", defaults[name]["maxOpacity"])
401
+ for key, value in (("minOpacity", minimum), ("maxOpacity", maximum)):
402
+ if not isinstance(value, (int, float)) or not isfinite(value) or not 0 <= value <= 1:
403
+ raise ValueError(f"{label}.{key} must be finite and between 0 and 1")
404
+ if maximum < minimum:
405
+ raise ValueError(f"{label}.maxOpacity must be >= minOpacity")
406
+ low, high = raw.get("minValue"), raw.get("maxValue")
407
+ for key, value in (("minValue", low), ("maxValue", high)):
408
+ if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value)):
409
+ raise ValueError(f"{label}.{key} must be finite")
410
+ if low is not None and high is not None and low > high:
411
+ raise ValueError(f"{label}.minValue must be <= maxValue")
412
+ if name == "colorScale" and value_scale == "logarithmic" and (
413
+ low is not None and low <= 0 or high is not None and high <= 0):
414
+ raise ValueError(f"{label} logarithmic values and bounds must be > 0")
415
+ values = []
416
+ for item in all_inputs:
417
+ value = item.get(field)
418
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value):
419
+ raise ValueError(f"{label} points require a finite numeric '{field}' field")
420
+ values.append(value)
421
+ if not values:
422
+ continue
423
+ domain = {"min": min(values) if low is None else low,
424
+ "max": max(values) if high is None else high}
425
+ if domain["min"] > domain["max"]:
426
+ raise ValueError(f"{label}.minValue must be <= maxValue")
427
+ if name == "colorScale" and value_scale == "logarithmic" and (
428
+ domain["min"] <= 0 or domain["max"] <= 0 or any(value <= 0 for value in values)):
429
+ raise ValueError(f"{label} logarithmic values and bounds must be > 0")
430
+ entry = {"domain": domain}
431
+ if name == "colorScale":
432
+ entry.update({"valueScale": value_scale, "stops": stops})
433
+ else:
434
+ entry.update({"min": minimum, "max": maximum})
435
+ result[name] = entry
436
+ return result
437
+
438
+
439
+ def _scatter_legend(width, height, scale):
440
+ defaults = get_defaults("scatter3d")
441
+ domain = scale["domain"]
442
+ low, high = domain["min"], domain["max"]
443
+ values = legend_ticks(low, high, defaults["legendTickCount"], [], scale["valueScale"])
444
+ ticks = []
445
+ for index, value in enumerate(values):
446
+ text = str(int(value)) if float(value).is_integer() else format(value, ".4g")
447
+ ratio = index / (len(values) - 1) if low == high else scale_ratio(value, low, high, scale["valueScale"])
448
+ ticks.append({"value": value, "text": text, "y": ratio})
449
+ label_width = max(len(defaults["legendTitle"]), *(len(tick["text"]) for tick in ticks)) * defaults["legendGlyphWidth"]
450
+ labels_visible = width >= (defaults["legendOffset"] * 2 + defaults["legendWidth"] +
451
+ defaults["legendLabelGap"] + label_width) and height >= defaults["legendMinimumLabelHeight"]
452
+ horizontal = min(defaults["legendOffset"], max(0, width - defaults["legendWidth"]))
453
+ vertical = min(defaults["legendOffset"], max(0, (height - 1) / 2))
454
+ x = (width - defaults["legendOffset"] - defaults["legendWidth"] - defaults["legendLabelGap"] - label_width
455
+ if labels_visible else max(0, width - horizontal - defaults["legendWidth"]))
456
+ return {"x": x, "y": vertical, "width": min(defaults["legendWidth"], width),
457
+ "height": min(defaults["legendHeight"], max(1, height - vertical * 2)),
458
+ "minValue": low, "maxValue": high, "lowColor": scale["stops"][0]["color"],
459
+ "highColor": scale["stops"][-1]["color"], "colorStops": scale["stops"],
460
+ "title": defaults["legendTitle"], "ticks": ticks, "font": defaults["legendFont"],
461
+ "color": defaults["legendColor"], "labelGap": defaults["legendLabelGap"],
462
+ "labelWidth": label_width, "labelsVisible": labels_visible}
463
+
464
+
465
+ def scatter3d_scene(options: Mapping, width: float, height: float) -> dict:
466
+ from .lighting3d import resolve_scene3d_lighting
467
+ if resolve_scene3d_lighting((options.get("scene3d") or {}).get("lighting", ...)) is not None:
468
+ raise ValueError("scene3d.lighting is currently supported only for surface and mesh3d")
469
+ camera = _camera(options)
470
+ scene_options = options.get("scene3d") or {}
471
+ parsed = _source_points(options)
472
+ encodings = _scatter_encoding(options, parsed)
473
+ count = sum(len(trace) for trace in parsed)
474
+ max_points = scene_options.get("maxPoints", get_defaults("scene3d")["maxPoints"])
475
+ if count > max_points:
476
+ raise ValueError(f"scatter3d visible point count {count} exceeds scene3d.maxPoints {max_points}")
477
+ domains = _domains(parsed, scene_options)
478
+ lod = resolve_scatter_lod((options.get("scatter3d") or {}).get("lod", ...))
479
+ selected, candidate_count = select_scatter_lod(parsed, domains, lod["maxPoints"]) if lod else (None, None)
480
+ projector = _Projector(camera, width, height)
481
+ traces = _traces(options)
482
+ primitives = []
483
+ legend_assigned = False
484
+ for trace_index, trace in enumerate(traces):
485
+ if trace.get("visible") is False:
486
+ continue
487
+ settings = dict(get_defaults("scatter3d"))
488
+ settings.update(options.get("scatter3d") or {})
489
+ settings.update(trace.get("scatter3d") or {})
490
+ color = ((trace.get("lineStyle") or {}).get("color") or trace.get("color")
491
+ or (options.get("lineStyle") or {}).get("color")
492
+ or ((((options.get("style") or {}).get("trace") or {}).get("line") or {}).get("color"))
493
+ or DEFAULT_TRACE_COLORS[trace_index % len(DEFAULT_TRACE_COLORS)])
494
+ shape = settings["markerShape"]
495
+ if shape not in ("circle", "square", "diamond", "triangle", "pentagon", "hexagon"):
496
+ raise ValueError("scatter3d.markerShape must be 'circle', 'square', 'diamond', 'triangle', 'pentagon', or 'hexagon'")
497
+ marker_size = settings["markerSize"]
498
+ if isinstance(marker_size, bool) or not isinstance(marker_size, (int, float)) or not isfinite(marker_size) or marker_size <= 0:
499
+ raise ValueError("scatter3d.markerSize must be a positive finite number")
500
+ marker_opacity = settings["markerOpacity"]
501
+ if isinstance(marker_opacity, bool) or not isinstance(marker_opacity, (int, float)) or not isfinite(marker_opacity) or not 0 <= marker_opacity <= 1:
502
+ raise ValueError("scatter3d.markerOpacity must be finite and between 0 and 1")
503
+ points = []
504
+ for source_index, source, source_input in parsed[trace_index]:
505
+ if selected is not None and (trace_index, source_index) not in selected:
506
+ continue
507
+ if any(source[axis_index] < min(domains[axis]["min"], domains[axis]["max"])
508
+ or source[axis_index] > max(domains[axis]["min"], domains[axis]["max"])
509
+ for axis_index, axis in enumerate("xyz")):
510
+ continue
511
+ projected = projector.project(_world(source, domains))
512
+ if projected is None:
513
+ continue
514
+ point = {**projected, "sourceIndex": source_index,
515
+ "sourceX": source[0], "sourceY": source[1], "sourceZ": source[2],
516
+ "radius": marker_size, "opacity": marker_opacity,
517
+ "fillColor": color, "markerShape": shape}
518
+ for name, field, target in (("colorScale", "value", "colorValue"),
519
+ ("sizeScale", "size", "sizeValue"),
520
+ ("opacityScale", "opacity", "opacityValue")):
521
+ scale = encodings.get(name)
522
+ if scale is None:
523
+ continue
524
+ value = source_input[field]
525
+ point[target] = value
526
+ domain = scale["domain"]
527
+ if name == "colorScale":
528
+ point["fillColor"] = interpolate_color(value, domain["min"], domain["max"],
529
+ scale["stops"], scale["valueScale"])
530
+ else:
531
+ if domain["min"] == domain["max"]:
532
+ value_out = (scale["min"] + scale["max"]) / 2
533
+ else:
534
+ ratio = max(0, min(1, (value - domain["min"]) / (domain["max"] - domain["min"])))
535
+ value_out = scale["min"] + (scale["max"] - scale["min"]) * ratio
536
+ point["radius" if name == "sizeScale" else "opacity"] = value_out
537
+ points.append(point)
538
+ points.sort(key=lambda point: (-point["depth"], point["sourceIndex"]))
539
+ primitive = {"type": "scatter3d", "points": [{"x": point["x"], "y": point["y"]} for point in points],
540
+ "scatter3dPoints": points, "color": color, "lineWidth": 0,
541
+ "traceIndex": trace_index, "subplot": "scene3d",
542
+ "bounds": {"left": 0, "top": 0, "right": width, "bottom": height}}
543
+ if encodings.get("colorScale") and settings.get("colorScaleLegend", get_defaults("scatter3d")["colorScaleLegend"]) and not legend_assigned:
544
+ primitive["scene3dColorScaleLegend"] = _scatter_legend(width, height, encodings["colorScale"])
545
+ legend_assigned = True
546
+ primitives.append(primitive)
547
+ metadata = _metadata(options, camera, domains, count, len(selected) if selected is not None else count, width, height)
548
+ if lod is not None:
549
+ metadata["scatter3dLod"] = {"strategy": lod["strategy"], "maxPoints": lod["maxPoints"],
550
+ "candidatePointCount": candidate_count, "sampledPointCount": len(selected)}
551
+ return {"width": width, "height": height, "primitives": primitives, "scene3d": metadata}
552
+
553
+
554
+ def line3d_scene(options: Mapping, width: float, height: float) -> dict:
555
+ from .lighting3d import resolve_scene3d_lighting
556
+ if resolve_scene3d_lighting((options.get("scene3d") or {}).get("lighting", ...)) is not None:
557
+ raise ValueError("scene3d.lighting is currently supported only for surface and mesh3d")
558
+ camera = _camera(options)
559
+ parsed = _source_points(options)
560
+ domains = _domains(parsed, options.get("scene3d") or {})
561
+ projector = _Projector(camera, width, height)
562
+ count = sum(len(trace) for trace in parsed)
563
+ max_points = (options.get("scene3d") or {}).get("maxPoints", get_defaults("scene3d")["maxPoints"])
564
+ if count > max_points:
565
+ raise ValueError(f"line3d visible point count {count} exceeds scene3d.maxPoints {max_points}")
566
+ line_options = options.get("line3d") or {}
567
+ maximum = line_options.get("maxSegments", get_defaults("line3d")["maxSegments"])
568
+ if type(maximum) is not int or maximum < 1 or maximum > 2_000_000:
569
+ raise ValueError("line3d.maxSegments must be an integer between 1 and 2000000")
570
+ lod = resolve_line_lod(line_options.get("lod", ...))
571
+ runs = source_runs(parsed)
572
+ if lod:
573
+ selected_runs, candidate_segments, sampled_segments, sampled_points = select_line_lod(
574
+ runs, domains, lod["maxSourceSegments"])
575
+ else:
576
+ selected_runs, candidate_segments, sampled_segments, sampled_points = runs, None, None, None
577
+ traces = _traces(options)
578
+ interpolations = []
579
+ for trace in traces:
580
+ interpolation = (trace.get("line3d") or {}).get("interpolation", line_options.get(
581
+ "interpolation", get_defaults("line3d")["interpolation"]))
582
+ if interpolation not in ("linear", "smooth"):
583
+ raise ValueError("line3d.interpolation must be 'linear' or 'smooth'")
584
+ interpolations.append(interpolation)
585
+ samples = get_defaults("line3d")["smoothSamplesPerSegment"]
586
+ prepared_segments = sum((len(run["points"]) - 1) * (samples if interpolations[trace_index] == "smooth" else 1)
587
+ for trace_index, trace_runs in enumerate(selected_runs)
588
+ if traces[trace_index].get("visible") is not False for run in trace_runs)
589
+ smooth_enabled = any(interpolation == "smooth" and traces[index].get("visible") is not False
590
+ for index, interpolation in enumerate(interpolations))
591
+ if prepared_segments > maximum:
592
+ raise ValueError(f"line3d prepared segment count {prepared_segments} exceeds line3d.maxSegments {maximum}")
593
+ primitives = []
594
+ for trace_index, trace in enumerate(traces):
595
+ if trace.get("visible") is False:
596
+ continue
597
+ settings = dict(get_defaults("line3d"))
598
+ settings.update(options.get("line3d") or {})
599
+ settings.update(trace.get("line3d") or {})
600
+ if (isinstance(settings["lineWidth"], bool) or not isinstance(settings["lineWidth"], (int, float))
601
+ or not isfinite(settings["lineWidth"]) or settings["lineWidth"] <= 0):
602
+ raise ValueError("line3d.lineWidth must be a positive finite number")
603
+ if (isinstance(settings["lineOpacity"], bool) or not isinstance(settings["lineOpacity"], (int, float))
604
+ or not isfinite(settings["lineOpacity"]) or not 0 <= settings["lineOpacity"] <= 1):
605
+ raise ValueError("line3d.lineOpacity must be finite and between 0 and 1")
606
+ color = ((trace.get("lineStyle") or {}).get("color") or trace.get("color")
607
+ or (options.get("lineStyle") or {}).get("color")
608
+ or ((((options.get("style") or {}).get("trace") or {}).get("line") or {}).get("color"))
609
+ or DEFAULT_TRACE_COLORS[trace_index % len(DEFAULT_TRACE_COLORS)])
610
+ interpolation = interpolations[trace_index]
611
+ sample_count = samples if interpolation == "smooth" else 1
612
+ segments = []
613
+ for run in selected_runs[trace_index]:
614
+ run_points = run["points"]
615
+ for pair_index, (left, right) in enumerate(zip(run_points, run_points[1:])):
616
+ source_index_1, source_1, _ = left
617
+ source_index_2, source_2, _ = right
618
+ start_control = run_points[pair_index - 1][1] if pair_index > 0 else source_1
619
+ end_control = run_points[pair_index + 2][1] if pair_index + 2 < len(run_points) else source_2
620
+ for sample in range(sample_count):
621
+ sample_start, sample_end = sample / sample_count, (sample + 1) / sample_count
622
+ sampled_from = (_smooth_line_point(start_control, source_1, source_2, end_control, sample_start)
623
+ if interpolation == "smooth" else source_1)
624
+ sampled_to = (_smooth_line_point(start_control, source_1, source_2, end_control, sample_end)
625
+ if interpolation == "smooth" else source_2)
626
+ clipped = _clip_domain_segment(_world(sampled_from, domains), _world(sampled_to, domains))
627
+ if clipped is None:
628
+ continue
629
+ projected = projector.project_segment(clipped["from"], clipped["to"])
630
+ if projected is None:
631
+ continue
632
+ raw_start = clipped["startRatio"] + (clipped["endRatio"] - clipped["startRatio"]) * projected["startRatio"]
633
+ raw_end = clipped["startRatio"] + (clipped["endRatio"] - clipped["startRatio"]) * projected["endRatio"]
634
+ first, second = projected["from"], projected["to"]
635
+ source_from = _interpolate3(sampled_from, sampled_to, raw_start)
636
+ source_to = _interpolate3(sampled_from, sampled_to, raw_end)
637
+ segment = {"x1": first["x"], "y1": first["y"], "depth1": first["depth"],
638
+ "x2": second["x"], "y2": second["y"], "depth2": second["depth"],
639
+ "sourceIndex1": source_index_1, "sourceIndex2": source_index_2,
640
+ "sourceX1": source_from[0], "sourceY1": source_from[1], "sourceZ1": source_from[2],
641
+ "sourceX2": source_to[0], "sourceY2": source_to[1], "sourceZ2": source_to[2],
642
+ "opacity": settings["lineOpacity"]}
643
+ if interpolation == "smooth":
644
+ segment["sourceRatio1"] = sample_start + (sample_end - sample_start) * raw_start
645
+ segment["sourceRatio2"] = sample_start + (sample_end - sample_start) * raw_end
646
+ segments.append(segment)
647
+ segments.sort(key=lambda segment: (-max(segment["depth1"], segment["depth2"]), segment["sourceIndex1"],
648
+ segment.get("sourceRatio1", 0)))
649
+ primitive = {"type": "line3d", "points": [point for segment in segments for point in ({"x": segment["x1"], "y": segment["y1"]}, {"x": segment["x2"], "y": segment["y2"]})],
650
+ "line3dSegments": segments, "color": color, "lineWidth": settings["lineWidth"],
651
+ "lineOpacity": settings["lineOpacity"], "traceIndex": trace_index,
652
+ "subplot": "scene3d", "bounds": {"left": 0, "top": 0, "right": width, "bottom": height}}
653
+ if interpolation == "smooth":
654
+ primitive["interpolation"] = "smooth"
655
+ primitives.append(primitive)
656
+ interaction_count = (sampled_points if lod else count) + prepared_segments if smooth_enabled else (sampled_points if lod else count)
657
+ metadata = _metadata(options, camera, domains, count, interaction_count, width, height)
658
+ if smooth_enabled:
659
+ metadata["preparedLineSegmentCount"] = prepared_segments
660
+ if lod:
661
+ metadata["line3dLod"] = {"strategy": lod["strategy"], "maxSourceSegments": lod["maxSourceSegments"],
662
+ "candidateSourceSegmentCount": candidate_segments,
663
+ "sampledSourceSegmentCount": sampled_segments,
664
+ "sampledPointCount": sampled_points,
665
+ "preparedSegmentCount": prepared_segments}
666
+ return {"width": width, "height": height, "primitives": primitives, "scene3d": metadata}
667
+
668
+
669
+ def _triangle(source_vertices, projector, fill_color, opacity, source_key, source_identity, camera, mesh=False):
670
+ world = [vertex[1] for vertex in source_vertices]
671
+ raw_normal = _cross(_subtract(world[1], world[0]), _subtract(world[2], world[0]))
672
+ magnitude = hypot(*raw_normal)
673
+ if magnitude <= 2.220446049250313e-16:
674
+ return None
675
+ normal = [value / magnitude for value in raw_normal]
676
+ vertices = []
677
+ for source_index, point, source in source_vertices:
678
+ projected = projector.project(point)
679
+ if projected is None:
680
+ return None
681
+ vertices.append({**projected, "sourceIndex": source_index,
682
+ "sourceX": source[0], "sourceY": source[1], "sourceZ": source[2]})
683
+ centroid = tuple(sum(point[index] / 3 for point in world) for index in range(3))
684
+ view = _subtract(camera["eye"], camera["target"] if camera["projection"] == "orthographic" else centroid)
685
+ output = {"vertices": vertices, source_key: source_identity, "normal": normal,
686
+ "fillColor": fill_color, "opacity": opacity,
687
+ "depth": sum(vertex["depth"] for vertex in vertices) / 3}
688
+ if mesh:
689
+ output["frontFacing"] = _dot(normal, view) > 1e-12
690
+ return output
691
+
692
+
693
+ def _triangle_fragments(source_vertices, domains, projector, fill_color, opacity,
694
+ source_key, source_identity, camera, mesh=False):
695
+ from .polygon_clip3d import clip_domain_triangle, project_polygon
696
+
697
+ world = [vertex[1] for vertex in source_vertices]
698
+ raw_normal = _cross(_subtract(world[1], world[0]), _subtract(world[2], world[0]))
699
+ magnitude = hypot(*raw_normal)
700
+ if magnitude <= 2.220446049250313e-16:
701
+ return []
702
+ normal = [value / magnitude for value in raw_normal]
703
+ source = [{"world": position, "sourceIndex": source_index,
704
+ "sourceX": point[0], "sourceY": point[1], "sourceZ": point[2]}
705
+ for source_index, position, point in source_vertices]
706
+ projected = project_polygon(clip_domain_triangle(source, domains), projector)
707
+ centroid = tuple(sum(point[index] / 3 for point in world) for index in range(3))
708
+ view = _subtract(camera["eye"], camera["target"] if camera["projection"] == "orthographic" else centroid)
709
+ result = []
710
+ for item in projected:
711
+ vertices = item["vertices"]
712
+ triangle = {"vertices": vertices, source_key: source_identity, "normal": normal,
713
+ "fillColor": fill_color, "opacity": opacity,
714
+ "depth": sum(vertex["depth"] for vertex in vertices) / 3,
715
+ "_boundaryEdges": item["boundaryEdges"]}
716
+ if mesh:
717
+ triangle["frontFacing"] = _dot(normal, view) > 1e-12
718
+ result.append(triangle)
719
+ return result
720
+
721
+
722
+ def _color(value, low, high, start, end):
723
+ def parse(color):
724
+ if len(color) == 7 and color.startswith("#"):
725
+ return [int(color[index:index + 2], 16) for index in (1, 3, 5)]
726
+ return None
727
+ first, last = parse(start), parse(end)
728
+ if first is None or last is None:
729
+ return end if value >= high else start
730
+ ratio = max(0, min(1, (value - low) / (high - low))) if high > low else 1
731
+ channels = [floor(a + (b - a) * ratio + .5) for a, b in zip(first, last)]
732
+ return f"rgb({channels[0]}, {channels[1]}, {channels[2]})"
733
+
734
+
735
+ def _hex_channels(value, label):
736
+ if not isinstance(value, str):
737
+ raise ValueError(f"{label} must be a hexadecimal color string")
738
+ match = fullmatch(r"#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})", value.strip())
739
+ if match is None:
740
+ raise ValueError(f"{label} must use #rgb or #rrggbb hexadecimal syntax")
741
+ body = "".join(digit * 2 for digit in match.group(1)) if len(match.group(1)) == 3 else match.group(1)
742
+ return [int(body[index:index + 2], 16) for index in (0, 2, 4)]
743
+
744
+
745
+ def _surface_legend(width, height, low, high, settings, color_stops=None):
746
+ defaults = get_defaults("surface")
747
+ color_stops = color_stops or [{"offset": 0, "color": settings["lowColor"]},
748
+ {"offset": 1, "color": settings["highColor"]}]
749
+ count = defaults["legendTickCount"]
750
+ values = [low + (high - low) * index / (count - 1) for index in range(count)]
751
+ ticks = [{"value": value, "text": format(float(f"{value:.4g}"), "g"),
752
+ "y": index / (count - 1)} for index, value in enumerate(values)]
753
+ label_width = max(len(defaults["legendTitle"]), *(len(item["text"]) for item in ticks)) * defaults["legendGlyphWidth"]
754
+ visible = width >= (defaults["legendOffset"] * 2 + defaults["legendWidth"] + defaults["legendLabelGap"] + label_width) and height >= defaults["legendMinimumLabelHeight"]
755
+ horizontal_offset = min(defaults["legendOffset"], max(0, width - defaults["legendWidth"]))
756
+ vertical_offset = min(defaults["legendOffset"], max(0, (height - 1) / 2))
757
+ x = (width - defaults["legendOffset"] - defaults["legendWidth"] - defaults["legendLabelGap"] - label_width) if visible else max(0, width - horizontal_offset - defaults["legendWidth"])
758
+ return {"x": x, "y": vertical_offset, "width": min(defaults["legendWidth"], width),
759
+ "height": min(defaults["legendHeight"], max(1, height - vertical_offset * 2)),
760
+ "minValue": low, "maxValue": high, "lowColor": color_stops[0]["color"],
761
+ "highColor": color_stops[-1]["color"],
762
+ "colorStops": color_stops,
763
+ "title": defaults["legendTitle"], "ticks": ticks, "font": defaults["legendFont"],
764
+ "color": defaults["legendColor"], "labelGap": defaults["legendLabelGap"],
765
+ "labelWidth": label_width, "labelsVisible": visible}
766
+
767
+
768
+ def surface_scene(options: Mapping, width: float, height: float) -> dict:
769
+ from .surface_lod import resolve_surface_lod, select_surface_lod_grids
770
+ from .triangle_wireframe import resolve_triangle_wireframe
771
+ from .lighting3d import resolve_scene3d_lighting, resolve_triangle_lighting
772
+ from .surface_contours import (resolve_surface_contours, contour_source_segment,
773
+ contour_segment_key, project_contour_segment)
774
+
775
+ camera = _camera(options)
776
+ lighting = resolve_scene3d_lighting((options.get("scene3d") or {}).get("lighting", ...))
777
+ settings = dict(get_defaults("surface"))
778
+ settings.update(options.get("surface") or {})
779
+ lod = resolve_surface_lod((options.get("surface") or {}).get("lod", ...))
780
+ color_stops = resolve_stops(settings.get("colorStops"), settings["lowColor"],
781
+ settings["highColor"], "surface.colorStops")
782
+ max_points = (options.get("scene3d") or {}).get("maxPoints", get_defaults("scene3d")["maxPoints"])
783
+ if type(max_points) is not int or not 1 <= max_points <= 1_000_000:
784
+ raise ValueError("scene3d.maxPoints must be an integer between 1 and 1000000")
785
+ max_triangles = settings["maxTriangles"]
786
+ if type(max_triangles) is not int or not 1 <= max_triangles <= 2_000_000:
787
+ raise ValueError("surface.maxTriangles must be an integer between 1 and 2000000")
788
+ max_contours = settings["maxContourSegments"]
789
+ if type(max_contours) is not int or not 1 <= max_contours <= 2_000_000:
790
+ raise ValueError("surface.maxContourSegments must be an integer between 1 and 2000000")
791
+ missing = settings["missingCells"]
792
+ if missing not in ("gap", "zero"):
793
+ raise ValueError("surface.missingCells must be 'gap' or 'zero'")
794
+ parsed = []
795
+ prepared_count = count = 0
796
+ for trace in _traces(options):
797
+ if trace.get("visible") is False:
798
+ parsed.append([])
799
+ continue
800
+ values = []
801
+ for index, value in enumerate(trace.get("data") or []):
802
+ prepared_count += 1
803
+ if prepared_count > max_points:
804
+ raise ValueError(f"surface grid point count {prepared_count} exceeds scene3d.maxPoints {max_points}")
805
+ if value.get("x") is None or value.get("y") is None:
806
+ raise ValueError(f"surface regular grid requires x and y at traces[].data[{index}]")
807
+ x, y = value["x"], value["y"]
808
+ z = 0 if value.get("z") is None and missing == "zero" else value.get("z")
809
+ for axis, coordinate in (("x", x), ("y", y), ("z", z)):
810
+ if coordinate is not None and (type(coordinate) not in (int, float) or not isfinite(coordinate)):
811
+ raise ValueError(f"traces[].data[{index}].{axis} must be a finite number")
812
+ if z is not None:
813
+ count += 1
814
+ if count > max_points:
815
+ raise ValueError(f"surface visible point count {count} exceeds scene3d.maxPoints {max_points}")
816
+ values.append((index, (x, y, z), value))
817
+ xs = {point[0] for _, point, _ in values}
818
+ ys = {point[1] for _, point, _ in values}
819
+ if len(values) != len(xs) * len(ys):
820
+ raise ValueError("surface data must provide exactly one point for every regular x/y grid coordinate")
821
+ if len({(point[0], point[1]) for _, point, _ in values}) != len(values):
822
+ raise ValueError("surface data must not contain duplicate x/y grid coordinates")
823
+ parsed.append(values)
824
+ visible = [[item for item in trace if item[1][2] is not None] for trace in parsed]
825
+ domains = _domains(visible, options.get("scene3d") or {})
826
+ projector = _Projector(camera, width, height)
827
+ lod_points = []
828
+ for trace in parsed:
829
+ lod_points.append([{"sourceIndex": index, "x": source[0], "y": source[1], "z": source[2]}
830
+ for index, source, _ in trace])
831
+ lod_selection = select_surface_lod_grids(lod_points, lod["maxCells"]) if lod else None
832
+ z_values = [point[2] for trace in visible for _, point, _ in trace]
833
+ z_low, z_high = (min(z_values), max(z_values)) if z_values else (0, 0)
834
+ if not z_values:
835
+ z_high = 1
836
+ for name in ("minValue", "maxValue"):
837
+ if name in (options.get("surface") or {}):
838
+ value = settings[name]
839
+ if type(value) not in (int, float) or not isfinite(value):
840
+ raise ValueError(f"scene3d requires finite numeric surface.{name}")
841
+ z_low = settings.get("minValue", z_low)
842
+ z_high = settings.get("maxValue", z_high)
843
+ if z_low > z_high:
844
+ raise ValueError("surface color scale requires minValue <= maxValue")
845
+ contours_by_trace = [resolve_surface_contours((options.get("surface") or {}).get("contours", ...),
846
+ (trace.get("surface") or {}).get("contours", ...),
847
+ (z_low, z_high)) for trace in _traces(options)]
848
+ primitives = []
849
+ triangle_count = 0
850
+ sampled_cells = 0
851
+ sampled_points = set()
852
+ legend_assigned = False
853
+ contour_count = 0
854
+ for trace_index, trace in enumerate(_traces(options)):
855
+ if trace.get("visible") is False:
856
+ continue
857
+ trace_settings = {**settings, **(trace.get("surface") or {})}
858
+ wireframe = resolve_triangle_wireframe((options.get("surface") or {}).get("wireframe", ...),
859
+ (trace.get("surface") or {}).get("wireframe", ...),
860
+ get_defaults("surface"), "surface.wireframe")
861
+ color = trace.get("color") or DEFAULT_TRACE_COLORS[trace_index % len(DEFAULT_TRACE_COLORS)]
862
+ points_by_grid = {(source[0], source[1]): (source_index, source) for source_index, source, _ in parsed[trace_index]}
863
+ xs = sorted({coordinate[0] for coordinate in points_by_grid})
864
+ ys = sorted({coordinate[1] for coordinate in points_by_grid})
865
+ selected_grid = lod_selection["byTrace"][trace_index] if lod_selection else None
866
+ x_indexes = selected_grid["xIndexes"] if selected_grid else list(range(len(xs)))
867
+ y_indexes = selected_grid["yIndexes"] if selected_grid else list(range(len(ys)))
868
+ triangles = []
869
+ contour_segments = []
870
+ contour_keys = set()
871
+ contours = contours_by_trace[trace_index]
872
+ for row in range(max(0, len(y_indexes) - 1)):
873
+ for column in range(max(0, len(x_indexes) - 1)):
874
+ if selected_grid and (row, column) not in selected_grid["renderable"]:
875
+ continue
876
+ source_row, source_column = y_indexes[row], x_indexes[column]
877
+ corners = [points_by_grid.get((xs[source_column], ys[source_row])),
878
+ points_by_grid.get((xs[x_indexes[column + 1]], ys[source_row])),
879
+ points_by_grid.get((xs[source_column], ys[y_indexes[row + 1]])),
880
+ points_by_grid.get((xs[x_indexes[column + 1]], ys[y_indexes[row + 1]]))]
881
+ if lod_selection:
882
+ sampled_cells += 1
883
+ sampled_points.update((trace_index, corner[0]) for corner in corners if corner is not None)
884
+ for triangle_index, positions in enumerate(((0, 1, 2), (1, 3, 2))):
885
+ selected = [corners[position] for position in positions]
886
+ if any(corner is None for corner in selected):
887
+ continue
888
+ source_points = [(source_index, _world(source, domains), source) for source_index, source in selected]
889
+ color_value = sum(source[2] for _, source in selected) / 3
890
+ fill_color = interpolate_color(color_value, z_low, z_high, color_stops)
891
+ source_cell = {"index": (source_row * max(0, len(xs) - 1) + source_column) * 2 + triangle_index,
892
+ "row": source_row, "column": source_column, "triangle": triangle_index}
893
+ if contours:
894
+ source_vertices = [{"world": world, "sourceX": source[0], "sourceY": source[1], "sourceZ": source[2]}
895
+ for _, world, source in source_points]
896
+ for level in contours["levels"]:
897
+ segment = contour_source_segment(source_vertices, level)
898
+ if segment is None:
899
+ continue
900
+ key = contour_segment_key(level, segment)
901
+ if key in contour_keys:
902
+ continue
903
+ contour_keys.add(key)
904
+ contour_count += 1
905
+ if contour_count > max_contours:
906
+ raise ValueError(f"surface contour segment count {contour_count} exceeds surface.maxContourSegments {max_contours}")
907
+ projected = project_contour_segment(segment, projector, _clip_domain_segment)
908
+ if projected is not None:
909
+ contour_segments.append({**projected, "level": level, "sourceCell": source_cell,
910
+ "style": contours["style"]})
911
+ triangle_count += 1
912
+ if triangle_count > max_triangles:
913
+ raise ValueError(f"surface triangle count {triangle_count} exceeds surface.maxTriangles {max_triangles}")
914
+ for triangle in _triangle_fragments(source_points, domains, projector, fill_color,
915
+ trace_settings["opacity"], "sourceCell", source_cell, camera):
916
+ triangle.update(resolve_triangle_lighting(fill_color, triangle["normal"], lighting,
917
+ [point[1] for point in source_points], camera))
918
+ boundary_edges = triangle.pop("_boundaryEdges")
919
+ if wireframe:
920
+ triangle["wireframe"] = wireframe
921
+ triangle["wireframeEdges"] = boundary_edges
922
+ triangles.append(triangle)
923
+ triangles.sort(key=lambda triangle: (-triangle["depth"], triangle["sourceCell"]["row"],
924
+ triangle["sourceCell"]["column"], triangle["sourceCell"]["triangle"]))
925
+ contour_segments.sort(key=lambda segment: (-max(segment["depth1"], segment["depth2"]),
926
+ segment["level"], segment["sourceCell"]["index"]))
927
+ primitive = {"type": "surface", "points": [point for triangle in triangles for point in ({"x": vertex["x"], "y": vertex["y"]} for vertex in triangle["vertices"])], "surface3dTriangles": triangles}
928
+ if contours:
929
+ primitive["surface3dContourSegments"] = contour_segments
930
+ primitive["surface3dContourLevels"] = list(contours["levels"])
931
+ if options.get("surface", {}).get("colorScaleLegend", settings["colorScaleLegend"]) and not legend_assigned:
932
+ primitive["surface3dColorScaleLegend"] = _surface_legend(width, height, z_low, z_high, trace_settings, color_stops)
933
+ legend_assigned = True
934
+ primitive.update({"color": color, "lineWidth": 0, "traceIndex": trace_index,
935
+ "subplot": "scene3d", "bounds": {"left": 0, "top": 0, "right": width, "bottom": height}})
936
+ primitives.append(primitive)
937
+ metadata = _metadata(options, camera, domains, count, count, width, height)
938
+ if lighting:
939
+ metadata["lighting"] = lighting
940
+ if lod_selection:
941
+ metadata["surface3dLod"] = {"strategy": lod["strategy"], "maxCells": lod["maxCells"],
942
+ "candidateCellCount": lod_selection["candidateCellCount"],
943
+ "sampledCellCount": sampled_cells,
944
+ "sampledPointCount": len(sampled_points),
945
+ "preparedTriangleCount": triangle_count}
946
+ metadata["interactionItemCount"] = len(sampled_points) + triangle_count
947
+ return {"width": width, "height": height, "primitives": primitives, "scene3d": metadata}
948
+
949
+
950
+ def mesh3d_scene(options: Mapping, width: float, height: float) -> dict:
951
+ from .triangle_wireframe import resolve_triangle_wireframe
952
+ from .lighting3d import resolve_scene3d_lighting, resolve_triangle_lighting
953
+ from .mesh3d_lod import resolve_mesh_lod, select_mesh_lod_faces
954
+
955
+ camera = _camera(options)
956
+ lighting = resolve_scene3d_lighting((options.get("scene3d") or {}).get("lighting", ...))
957
+ traces = _traces(options)
958
+ settings = dict(get_defaults("mesh3d"))
959
+ settings.update(options.get("mesh3d") or {})
960
+ max_points = (options.get("scene3d") or {}).get("maxPoints", get_defaults("scene3d")["maxPoints"])
961
+ if type(max_points) is not int or not 1 <= max_points <= 1_000_000:
962
+ raise ValueError("scene3d.maxPoints must be an integer between 1 and 1000000")
963
+ max_faces = settings["maxFaces"]
964
+ if type(max_faces) is not int or not 1 <= max_faces <= 2_000_000:
965
+ raise ValueError("mesh3d.maxFaces must be an integer between 1 and 2000000")
966
+ if "faceColors" in (options.get("mesh3d") or {}) and "vertexColors" in (options.get("mesh3d") or {}):
967
+ raise ValueError("mesh3d cannot configure both faceColors and vertexColors")
968
+ lod = resolve_mesh_lod((options.get("mesh3d") or {}).get("lod", ...))
969
+ parsed = []
970
+ count = 0
971
+ for trace_index, trace in enumerate(traces):
972
+ if trace.get("visible") is False:
973
+ parsed.append([])
974
+ continue
975
+ values = trace.get("data") or []
976
+ if not isinstance(values, list):
977
+ raise ValueError(f"mesh3d vertex data for trace {trace_index} must be a dense array")
978
+ parsed_trace = []
979
+ for source_index, value in enumerate(values):
980
+ count += 1
981
+ if count > max_points:
982
+ raise ValueError(f"mesh3d vertex count {count} exceeds scene3d.maxPoints {max_points}")
983
+ if any(axis in value and value[axis] is None for axis in ("x", "y")) or value.get("z") is None:
984
+ raise ValueError(f"mesh3d requires x, y, and z at traces[{trace_index}].data[{source_index}]")
985
+ point = []
986
+ for axis in "xyz":
987
+ coordinate = value.get(axis)
988
+ if type(coordinate) not in (int, float) or not isfinite(coordinate):
989
+ raise ValueError(f"scene3d requires finite numeric traces[{trace_index}].data[{source_index}].{axis}")
990
+ point.append(coordinate)
991
+ parsed_trace.append((source_index, tuple(point), value))
992
+ parsed.append(parsed_trace)
993
+ domains = _domains(parsed, options.get("scene3d") or {})
994
+ projector = _Projector(camera, width, height)
995
+ parsed_faces = []
996
+ source_face_count = 0
997
+ for trace_index, trace in enumerate(traces):
998
+ if trace.get("visible") is False:
999
+ parsed_faces.append({"vertices": [], "faces": []})
1000
+ continue
1001
+ faces = (trace.get("mesh3d") or {}).get("faces", (options.get("mesh3d") or {}).get("faces", []))
1002
+ if not isinstance(faces, list):
1003
+ raise ValueError(f"mesh3d faces for trace {trace_index} must be a dense array")
1004
+ source_face_count += len(faces)
1005
+ if source_face_count > max_faces:
1006
+ raise ValueError(f"mesh3d face count {source_face_count} exceeds mesh3d.maxFaces {max_faces}")
1007
+ vertices = {index: point for index, point, _ in parsed[trace_index]}
1008
+ for face_index, face in enumerate(faces):
1009
+ if not isinstance(face, list) or len(face) != 3:
1010
+ raise ValueError(f"mesh3d face {face_index} for trace {trace_index} must contain exactly three vertex indexes")
1011
+ for vertex_index, value in enumerate(face):
1012
+ if type(value) is not int or value < 0 or value not in vertices:
1013
+ raise ValueError(f"mesh3d face {face_index} index {vertex_index} for trace {trace_index} must reference an existing vertex")
1014
+ if len(set(face)) != 3:
1015
+ raise ValueError(f"mesh3d face {face_index} for trace {trace_index} must reference three distinct vertices")
1016
+ trace_mesh = trace.get("mesh3d") or {}
1017
+ plot_mesh = options.get("mesh3d") or {}
1018
+ if "faceColors" in trace_mesh and "vertexColors" in trace_mesh:
1019
+ raise ValueError(f"mesh3d trace {trace_index} cannot configure both faceColors and vertexColors")
1020
+ policy = trace_mesh if "faceColors" in trace_mesh or "vertexColors" in trace_mesh else plot_mesh
1021
+ face_colors = policy.get("faceColors", ...)
1022
+ vertex_colors = policy.get("vertexColors", ...)
1023
+ if face_colors is not ...:
1024
+ if not isinstance(face_colors, list) or len(face_colors) != len(faces):
1025
+ raise ValueError(f"mesh3d.faceColors for trace {trace_index} must match the face count")
1026
+ for index, color in enumerate(face_colors):
1027
+ _hex_channels(color, f"mesh3d.faceColors[{index}] for trace {trace_index}")
1028
+ vertex_channels = None
1029
+ if vertex_colors is not ...:
1030
+ if not isinstance(vertex_colors, list) or len(vertex_colors) != len(vertices):
1031
+ raise ValueError(f"mesh3d.vertexColors for trace {trace_index} must match the vertex count")
1032
+ vertex_channels = [_hex_channels(color, f"mesh3d.vertexColors[{index}] for trace {trace_index}")
1033
+ for index, color in enumerate(vertex_colors)]
1034
+ parsed_faces.append({"vertices": vertices, "faces": faces,
1035
+ "faceColors": face_colors, "vertexChannels": vertex_channels})
1036
+ lod_selection = select_mesh_lod_faces(parsed_faces, domains, lod["maxFaces"]) if lod else None
1037
+ primitives = []
1038
+ face_count = 0
1039
+ for trace_index, trace in enumerate(traces):
1040
+ if trace.get("visible") is False:
1041
+ continue
1042
+ trace_settings = {**settings, **(trace.get("mesh3d") or {})}
1043
+ wireframe = resolve_triangle_wireframe((options.get("mesh3d") or {}).get("wireframe", ...),
1044
+ (trace.get("mesh3d") or {}).get("wireframe", ...),
1045
+ get_defaults("mesh3d"), "mesh3d.wireframe")
1046
+ color = trace.get("color") or DEFAULT_TRACE_COLORS[trace_index % len(DEFAULT_TRACE_COLORS)]
1047
+ colors = parsed_faces[trace_index]["faceColors"]
1048
+ vertex_channels = parsed_faces[trace_index]["vertexChannels"]
1049
+ culling = trace_settings.get("backfaceCulling", get_defaults("mesh3d")["backfaceCulling"])
1050
+ if type(culling) is not bool:
1051
+ raise ValueError("mesh3d.backfaceCulling must be boolean")
1052
+ lookup = parsed_faces[trace_index]["vertices"]
1053
+ triangles = []
1054
+ for face_index, face in enumerate(parsed_faces[trace_index]["faces"]):
1055
+ if lod_selection and face_index not in lod_selection["selectedFaceIndexesByTrace"][trace_index]:
1056
+ continue
1057
+ vertices = [(vertex, _world(lookup[vertex], domains), lookup[vertex]) for vertex in face]
1058
+ if colors is not ...:
1059
+ face_color = colors[face_index]
1060
+ elif vertex_channels is not None:
1061
+ channels = [floor(sum(vertex_channels[index][channel] for index in face) / 3 + .5)
1062
+ for channel in range(3)]
1063
+ face_color = "#" + "".join(f"{channel:02x}" for channel in channels)
1064
+ else:
1065
+ face_color = color
1066
+ for triangle in _triangle_fragments(vertices, domains, projector, face_color,
1067
+ trace_settings["opacity"], "sourceFace",
1068
+ {"index": face_index, "vertexIndexes": list(face)}, camera, mesh=True):
1069
+ if culling and not triangle["frontFacing"]:
1070
+ continue
1071
+ triangle.update(resolve_triangle_lighting(triangle["fillColor"], triangle["normal"], lighting,
1072
+ [vertex[1] for vertex in vertices], camera))
1073
+ boundary_edges = triangle.pop("_boundaryEdges")
1074
+ if wireframe:
1075
+ triangle["wireframe"] = wireframe
1076
+ triangle["wireframeEdges"] = boundary_edges
1077
+ triangles.append(triangle)
1078
+ face_count += 1
1079
+ triangles.sort(key=lambda triangle: (-triangle["depth"], triangle["sourceFace"]["index"]))
1080
+ primitives.append({"type": "mesh3d", "points": [point for triangle in triangles for point in ({"x": vertex["x"], "y": vertex["y"]} for vertex in triangle["vertices"])], "mesh3dTriangles": triangles,
1081
+ "color": color, "lineWidth": 0, "traceIndex": trace_index,
1082
+ "subplot": "scene3d", "bounds": {"left": 0, "top": 0, "right": width, "bottom": height}})
1083
+ metadata = _metadata(options, camera, domains, count,
1084
+ (lod_selection["sampledVertexCount"] + lod_selection["sampledFaceCount"]
1085
+ if lod_selection else count + source_face_count), width, height)
1086
+ if lighting:
1087
+ metadata["lighting"] = lighting
1088
+ metadata["sourceFaceCount"] = source_face_count
1089
+ if lod_selection:
1090
+ metadata["mesh3dLod"] = {"strategy": lod["strategy"], "maxFaces": lod["maxFaces"],
1091
+ "candidateFaceCount": lod_selection["candidateFaceCount"],
1092
+ "sampledFaceCount": lod_selection["sampledFaceCount"],
1093
+ "sampledVertexCount": lod_selection["sampledVertexCount"],
1094
+ "preparedTriangleCount": face_count}
1095
+ return {"width": width, "height": height, "primitives": primitives, "scene3d": metadata}
1096
+
1097
+
1098
+ def _shade(color: str, intensity: float) -> str:
1099
+ if not color.startswith("#") or len(color) != 7:
1100
+ return color
1101
+ channels = [int(color[index:index + 2], 16) for index in (1, 3, 5)]
1102
+ return "#" + "".join(f"{max(0, min(255, int(channel * intensity + .5))):02x}" for channel in channels)
1103
+
1104
+
1105
+ def pie3d_scene(options: Mapping, width: float, height: float) -> dict:
1106
+ from .scene import _pie_scene
1107
+ from .lighting3d import resolve_scene3d_lighting, resolve_triangle_lighting
1108
+
1109
+ if "subplots" in options or (options.get("scene3d") or {}).get("subplots"):
1110
+ raise ValueError("experimental pie3d currently requires one full-chart 3D scene")
1111
+ pie = options.get("pie") or {}
1112
+ if "borderColor" in pie or "borderWidth" in pie:
1113
+ raise ValueError("pie3d does not support 2D pie.borderColor or pie.borderWidth; 3D face boundaries use projected geometry")
1114
+ scene3d = options.get("scene3d") or {}
1115
+ if any(f"{axis}Axis" in scene3d and scene3d[f"{axis}Axis"] is not None for axis in "xyz"):
1116
+ raise ValueError("pie3d does not support Cartesian scene3d axis options")
1117
+ pie_settings = get_defaults("pie")
1118
+ settings = {**get_defaults("pie3d"), **(options.get("pie3d") or {})}
1119
+ tolerance = settings["curveTolerance"]
1120
+ if type(tolerance) not in (int, float) or not isfinite(tolerance) or tolerance <= 0:
1121
+ raise ValueError("pie3d.curveTolerance must be a positive finite number")
1122
+ depth = settings["extrusionDepth"]
1123
+ if type(depth) not in (int, float) or not isfinite(depth) or not 0 < depth <= 1:
1124
+ raise ValueError("pie3d.extrusionDepth must be finite and in (0, 1]")
1125
+ max_triangles = scene3d.get("maxPoints", get_defaults("scene3d")["maxPoints"])
1126
+ if type(max_triangles) is not int or not 1 <= max_triangles <= 1_000_000:
1127
+ raise ValueError("scene3d.maxPoints must be an integer between 1 and 1000000")
1128
+ camera = _camera(options)
1129
+ lighting = resolve_scene3d_lighting(scene3d.get("lighting", ...))
1130
+ sectors = (_pie_scene(options, width, height)["primitives"] or [{}])[0].get("pieSectors", [])
1131
+ projector = _Projector(camera, width, height)
1132
+ radius = pie.get("outerRadiusRatio", pie_settings["outerRadiusRatio"]) / 2
1133
+ inner_radius = radius * pie.get("innerRadiusRatio", pie_settings["innerRadiusRatio"])
1134
+ top, bottom = depth / 2, -depth / 2
1135
+ primitives, prepared = [], 0
1136
+ for sector in sectors:
1137
+ triangles = []
1138
+ source_index, trace_index = sector["sourceIndex"], sector["traceIndex"]
1139
+ start_angle, sweep = sector["startAngle"], sector["sweep"]
1140
+
1141
+ def point(angle, distance, elevation):
1142
+ return (cos(angle) * distance, elevation, sin(angle) * distance)
1143
+
1144
+ def add_triangle(face, verts, intensity):
1145
+ nonlocal prepared
1146
+ prepared += 1
1147
+ if prepared > max_triangles:
1148
+ raise ValueError(f"pie3d triangle count exceeds scene3d.maxPoints {max_triangles}")
1149
+ world = list(verts)
1150
+ raw = _cross(_subtract(world[1], world[0]), _subtract(world[2], world[0]))
1151
+ radial_angle = start_angle if face == "start" else start_angle + sweep
1152
+ direction = 1 if sweep >= 0 else -1
1153
+ if face == "top": desired = (0, 1, 0)
1154
+ elif face == "bottom": desired = (0, -1, 0)
1155
+ elif face in ("outer", "inner"):
1156
+ sign = 1 if face == "outer" else -1
1157
+ desired = (sum(vertex[0] for vertex in world) * sign, 0, sum(vertex[2] for vertex in world) * sign)
1158
+ elif face == "start": desired = (sin(radial_angle) * direction, 0, -cos(radial_angle) * direction)
1159
+ else: desired = (-sin(radial_angle) * direction, 0, cos(radial_angle) * direction)
1160
+ if _dot(raw, desired) < 0:
1161
+ world[1], world[2] = world[2], world[1]
1162
+ if hypot(*_cross(_subtract(world[1], world[0]), _subtract(world[2], world[0]))) <= 2.220446049250313e-16:
1163
+ return
1164
+ vertices = [(source_index, vertex, vertex) for vertex in world]
1165
+ triangle = _triangle(vertices, projector, sector["color"] if face == "top" else _shade(sector["color"], intensity),
1166
+ sector["opacity"], "sourceFace", {"index": source_index, "vertexIndexes": [0, 1, 2]}, camera, mesh=True)
1167
+ if triangle is not None:
1168
+ if lighting is not None:
1169
+ resolved_fill = resolve_triangle_lighting(triangle["fillColor"], triangle["normal"],
1170
+ lighting, world, camera)
1171
+ triangle.update(resolved_fill)
1172
+ triangle["pie3d"] = {"label": sector["label"], "value": sector["value"],
1173
+ "fraction": sector["fraction"], "color": sector["color"], "face": face}
1174
+ triangles.append(triangle)
1175
+
1176
+ def add_quad(face, a, b, c, d, intensity):
1177
+ add_triangle(face, (a, b, c), intensity)
1178
+ add_triangle(face, (a, c, d), intensity)
1179
+
1180
+ pixel_radius = max(1, min(width, height) * radius / 2)
1181
+ step = min(radians(get_defaults("pie3d")["maxCurveStepDegrees"]), 2 * acos(max(-1, 1 - tolerance / pixel_radius)))
1182
+ segments = max(1, ceil(abs(sweep) / step))
1183
+ for index in range(segments):
1184
+ a = start_angle + sweep * index / segments
1185
+ b = start_angle + sweep * (index + 1) / segments
1186
+ os_top, oe_top = point(a, radius, top), point(b, radius, top)
1187
+ is_top, ie_top = point(a, inner_radius, top), point(b, inner_radius, top)
1188
+ os_bottom, oe_bottom = point(a, radius, bottom), point(b, radius, bottom)
1189
+ is_bottom, ie_bottom = point(a, inner_radius, bottom), point(b, inner_radius, bottom)
1190
+ add_quad("top", is_top, os_top, oe_top, ie_top, 1)
1191
+ add_quad("bottom", ie_bottom, oe_bottom, os_bottom, is_bottom, .62)
1192
+ add_quad("outer", os_top, os_bottom, oe_bottom, oe_top, .76)
1193
+ if inner_radius > 0:
1194
+ add_quad("inner", ie_top, ie_bottom, is_bottom, is_top, .68)
1195
+ if abs(sweep) < 2 * pi - 2.220446049250313e-16:
1196
+ add_quad("start", point(start_angle, inner_radius, top), point(start_angle, inner_radius, bottom),
1197
+ point(start_angle, radius, bottom), point(start_angle, radius, top), .7)
1198
+ end = start_angle + sweep
1199
+ add_quad("end", point(end, radius, top), point(end, radius, bottom),
1200
+ point(end, inner_radius, bottom), point(end, inner_radius, top), .7)
1201
+ primitives.append({"type": "pie3d", "points": [], "color": sector["color"], "lineWidth": 0,
1202
+ "traceIndex": trace_index, "subplot": "scene3d",
1203
+ "bounds": {"left": 0, "top": 0, "right": width, "bottom": height},
1204
+ "mesh3dTriangles": triangles})
1205
+ metadata_options = dict(options)
1206
+ metadata_options["scene3d"] = {**(options.get("scene3d") or {}),
1207
+ "showAxes": (options.get("scene3d") or {}).get("showAxes", False),
1208
+ "showGrid": (options.get("scene3d") or {}).get("showGrid", False)}
1209
+ domains = {axis: {"min": -.5, "max": .5} for axis in "xyz"}
1210
+ metadata = _metadata(metadata_options, camera, domains, len(sectors), len(sectors) + prepared, width, height)
1211
+ metadata["sourceFaceCount"] = prepared
1212
+ if lighting is not None:
1213
+ metadata["lighting"] = lighting
1214
+ metadata["tickLabels"] = []
1215
+ return {"width": width, "height": height, "primitives": primitives, "scene3d": metadata}