fastplotpy 0.3.0a1__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastplotpy/README.md +70 -0
- fastplotpy/__init__.py +33 -0
- fastplotpy/assets/dist/assets/scene.worker-CcnexaKr.js +1 -0
- fastplotpy/assets/dist/index.js +15071 -0
- fastplotpy/assets/native/NotoSans-OFL.txt +96 -0
- fastplotpy/assets/native/THIRD_PARTY_NOTICES.md +24 -0
- fastplotpy/assets/native/fastplot_python_abi.dll +0 -0
- fastplotpy/assets/package.json +1 -0
- fastplotpy/axes_validation.py +102 -0
- fastplotpy/camera3d.py +104 -0
- fastplotpy/color_scale.py +88 -0
- fastplotpy/defaults/area-fill.json +5 -0
- fastplotpy/defaults/bar.json +22 -0
- fastplotpy/defaults/camera3d.json +22 -0
- fastplotpy/defaults/cartesian-references.json +10 -0
- fastplotpy/defaults/control.json +3 -0
- fastplotpy/defaults/crosshair.json +19 -0
- fastplotpy/defaults/error-bars.json +7 -0
- fastplotpy/defaults/heatmap.json +70 -0
- fastplotpy/defaults/interaction.json +3 -0
- fastplotpy/defaults/layout.json +32 -0
- fastplotpy/defaults/legend.json +24 -0
- fastplotpy/defaults/line-axes.json +28 -0
- fastplotpy/defaults/line3d.json +13 -0
- fastplotpy/defaults/markers.json +6 -0
- fastplotpy/defaults/mesh3d.json +13 -0
- fastplotpy/defaults/pan.json +7 -0
- fastplotpy/defaults/pie.json +27 -0
- fastplotpy/defaults/pie3d.json +7 -0
- fastplotpy/defaults/point-labels.json +13 -0
- fastplotpy/defaults/pointClick.json +6 -0
- fastplotpy/defaults/pointSelection.json +13 -0
- fastplotpy/defaults/polar.json +71 -0
- fastplotpy/defaults/polarRotate.json +5 -0
- fastplotpy/defaults/scatter3d.json +39 -0
- fastplotpy/defaults/scene3d.json +61 -0
- fastplotpy/defaults/surface.json +35 -0
- fastplotpy/defaults/tooltip.json +18 -0
- fastplotpy/defaults/trace-colors.json +10 -0
- fastplotpy/defaults/trace-options.json +24 -0
- fastplotpy/defaults/wonderplot.json +58 -0
- fastplotpy/defaults/zoom.json +12 -0
- fastplotpy/defaults.py +15 -0
- fastplotpy/error_bars.py +118 -0
- fastplotpy/exports.py +153 -0
- fastplotpy/families2d.py +896 -0
- fastplotpy/heatmap_labels.py +146 -0
- fastplotpy/interactions.py +243 -0
- fastplotpy/interactive.py +1922 -0
- fastplotpy/js_serialize.py +47 -0
- fastplotpy/lighting3d.py +113 -0
- fastplotpy/line3d_lod.py +138 -0
- fastplotpy/lod3d.py +105 -0
- fastplotpy/mesh3d_lod.py +107 -0
- fastplotpy/native.py +163 -0
- fastplotpy/overlays.py +561 -0
- fastplotpy/point_labels.py +86 -0
- fastplotpy/polar.py +445 -0
- fastplotpy/polygon_clip3d.py +148 -0
- fastplotpy/references.py +72 -0
- fastplotpy/rendering.py +65 -0
- fastplotpy/rust_scenes.py +314 -0
- fastplotpy/scene.py +1185 -0
- fastplotpy/scene3d.py +1215 -0
- fastplotpy/scene3d_accessibility.py +183 -0
- fastplotpy/scene3d_collection.py +158 -0
- fastplotpy/subplots.py +115 -0
- fastplotpy/surface_contours.py +122 -0
- fastplotpy/surface_lod.py +109 -0
- fastplotpy/svg3d.py +235 -0
- fastplotpy/trendline.py +236 -0
- fastplotpy/triangle_wireframe.py +51 -0
- fastplotpy/types.py +3027 -0
- fastplotpy-0.3.0a1.dist-info/METADATA +86 -0
- fastplotpy-0.3.0a1.dist-info/RECORD +78 -0
- fastplotpy-0.3.0a1.dist-info/WHEEL +5 -0
- fastplotpy-0.3.0a1.dist-info/licenses/LICENSE +21 -0
- fastplotpy-0.3.0a1.dist-info/top_level.txt +1 -0
fastplotpy/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}
|