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
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""JSON.stringify-compatible serialization for scene hashes and export bytes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from math import isfinite
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _number(value: int | float) -> str:
|
|
12
|
+
if isinstance(value, int):
|
|
13
|
+
return str(value)
|
|
14
|
+
if not isfinite(value):
|
|
15
|
+
return "null"
|
|
16
|
+
if value == 0:
|
|
17
|
+
return "0"
|
|
18
|
+
magnitude = abs(value)
|
|
19
|
+
shortest = repr(value)
|
|
20
|
+
if 1e-6 <= magnitude < 1e21:
|
|
21
|
+
fixed = format(Decimal(shortest), "f")
|
|
22
|
+
return fixed.rstrip("0").rstrip(".") if "." in fixed else fixed
|
|
23
|
+
mantissa, exponent = shortest.lower().split("e") if "e" in shortest.lower() else (shortest, "0")
|
|
24
|
+
if mantissa.endswith(".0"):
|
|
25
|
+
mantissa = mantissa[:-2]
|
|
26
|
+
power = int(exponent)
|
|
27
|
+
return f"{mantissa}e{'+' if power >= 0 else '-'}{abs(power)}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def js_json_stringify(value: Any) -> str:
|
|
31
|
+
"""Serialize finite scene-shaped values with JavaScript number spelling."""
|
|
32
|
+
if value is None:
|
|
33
|
+
return "null"
|
|
34
|
+
if value is True:
|
|
35
|
+
return "true"
|
|
36
|
+
if value is False:
|
|
37
|
+
return "false"
|
|
38
|
+
if isinstance(value, (int, float)):
|
|
39
|
+
return _number(value)
|
|
40
|
+
if isinstance(value, str):
|
|
41
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
42
|
+
if isinstance(value, (list, tuple)):
|
|
43
|
+
return "[" + ",".join(js_json_stringify(item) for item in value) + "]"
|
|
44
|
+
if isinstance(value, dict):
|
|
45
|
+
return "{" + ",".join(js_json_stringify(str(key)) + ":" + js_json_stringify(item)
|
|
46
|
+
for key, item in value.items()) + "}"
|
|
47
|
+
raise TypeError(f"Scene JSON cannot serialize {type(value).__name__}")
|
fastplotpy/lighting3d.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Opt-in surface and mesh lighting ported from pinned scene3d.ts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import floor, hypot, isfinite
|
|
6
|
+
from re import IGNORECASE, fullmatch
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
|
|
9
|
+
from .defaults import get_defaults
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _dot(a, b):
|
|
13
|
+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _subtract(a, b):
|
|
17
|
+
return tuple(a[index] - b[index] for index in range(3))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normalize(vector, label):
|
|
21
|
+
magnitude = hypot(*vector)
|
|
22
|
+
if magnitude <= 1e-12:
|
|
23
|
+
raise ValueError(f"{label} must not be degenerate")
|
|
24
|
+
return tuple(value / magnitude for value in vector)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_scene3d_lighting(value=...):
|
|
28
|
+
defaults = get_defaults("scene3d")
|
|
29
|
+
if value is ...:
|
|
30
|
+
if not defaults["lightingShow"]:
|
|
31
|
+
return None
|
|
32
|
+
value = True
|
|
33
|
+
if type(value) is not bool and not isinstance(value, Mapping):
|
|
34
|
+
raise ValueError("scene3d.lighting must be boolean or an object")
|
|
35
|
+
source = {} if type(value) is bool else value
|
|
36
|
+
if "show" in source and type(source["show"]) is not bool:
|
|
37
|
+
raise ValueError("scene3d.lighting.show must be boolean")
|
|
38
|
+
direction = source.get("direction", defaults["lightingDirection"])
|
|
39
|
+
if not isinstance(direction, list) or len(direction) != 3:
|
|
40
|
+
raise ValueError("scene3d.lighting.direction must contain exactly three finite numbers")
|
|
41
|
+
if any(type(item) not in (int, float) or not isfinite(item) for item in direction):
|
|
42
|
+
raise ValueError("scene3d.lighting.direction must be a dense tuple of three finite numbers")
|
|
43
|
+
direction = _normalize(direction, "scene3d.lighting.direction")
|
|
44
|
+
result = {"direction": list(direction)}
|
|
45
|
+
for key in ("ambient", "diffuse", "specular"):
|
|
46
|
+
entry = source.get(key, defaults[f"lighting{key.title()}"])
|
|
47
|
+
if type(entry) not in (int, float) or not isfinite(entry) or not 0 <= entry <= 1:
|
|
48
|
+
raise ValueError(f"scene3d.lighting.{key} must be a finite number between 0 and 1")
|
|
49
|
+
result[key] = entry
|
|
50
|
+
shininess = source.get("shininess", defaults["lightingShininess"])
|
|
51
|
+
if type(shininess) is not int or not 1 <= shininess <= 256:
|
|
52
|
+
raise ValueError("scene3d.lighting.shininess must be an integer between 1 and 256")
|
|
53
|
+
result["shininess"] = shininess
|
|
54
|
+
two_sided = source.get("twoSided", defaults["lightingTwoSided"])
|
|
55
|
+
if type(two_sided) is not bool:
|
|
56
|
+
raise ValueError("scene3d.lighting.twoSided must be boolean")
|
|
57
|
+
result["twoSided"] = two_sided
|
|
58
|
+
show = value if type(value) is bool else source.get("show", True)
|
|
59
|
+
return result if show else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def shade_scene3d_color(color, intensity, specular_intensity=0):
|
|
63
|
+
if not isinstance(color, str):
|
|
64
|
+
raise ValueError("3D lighting base color must be a hexadecimal color string")
|
|
65
|
+
rgb = fullmatch(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", color.strip(), flags=IGNORECASE)
|
|
66
|
+
if rgb:
|
|
67
|
+
channels = [int(item) for item in rgb.groups()]
|
|
68
|
+
else:
|
|
69
|
+
hexadecimal = fullmatch(r"#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})", color.strip())
|
|
70
|
+
if hexadecimal is None:
|
|
71
|
+
raise ValueError("3D lighting base color must use #rgb or #rrggbb hexadecimal syntax")
|
|
72
|
+
body = "".join(part * 2 for part in hexadecimal.group(1)) if len(hexadecimal.group(1)) == 3 else hexadecimal.group(1)
|
|
73
|
+
channels = [int(body[index:index + 2], 16) for index in (0, 2, 4)]
|
|
74
|
+
if any(not 0 <= channel <= 255 for channel in channels):
|
|
75
|
+
raise ValueError("3D lighting base color RGB channels must be between 0 and 255")
|
|
76
|
+
output = []
|
|
77
|
+
for channel in channels:
|
|
78
|
+
shaded = channel * intensity
|
|
79
|
+
highlighted = shaded + (255 - shaded) * specular_intensity
|
|
80
|
+
output.append(f"{floor(min(255, max(0, highlighted)) + .5):02x}")
|
|
81
|
+
return "#" + "".join(output)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_triangle_lighting(base_color, normal, lighting, world, camera):
|
|
85
|
+
if lighting is None:
|
|
86
|
+
return {"fillColor": base_color}
|
|
87
|
+
direction = lighting["direction"]
|
|
88
|
+
raw_dot = _dot(normal, direction)
|
|
89
|
+
normal_dot = abs(raw_dot) if lighting["twoSided"] else max(0, raw_dot)
|
|
90
|
+
intensity = min(1, max(0, lighting["ambient"] + lighting["diffuse"] * normal_dot))
|
|
91
|
+
if lighting["specular"] == 0:
|
|
92
|
+
return {"fillColor": shade_scene3d_color(base_color, intensity),
|
|
93
|
+
"lighting": {"baseColor": base_color, "normalDot": normal_dot,
|
|
94
|
+
"intensity": intensity, "specularDot": 0, "specularIntensity": 0}}
|
|
95
|
+
centroid = tuple((world[0][index] + world[1][index] + world[2][index]) / 3 for index in range(3))
|
|
96
|
+
raw_view = _subtract(camera["eye"], camera["target"] if camera["projection"] == "orthographic" else centroid)
|
|
97
|
+
view_magnitude = hypot(*raw_view)
|
|
98
|
+
view = tuple(value / view_magnitude for value in raw_view) if view_magnitude > 1e-12 else None
|
|
99
|
+
specular_dot = 0
|
|
100
|
+
if view is not None:
|
|
101
|
+
view_normal_dot = _dot(normal, view)
|
|
102
|
+
oriented_normal = tuple(-value for value in normal) if lighting["twoSided"] and view_normal_dot < 0 else normal
|
|
103
|
+
oriented_light_dot = _dot(oriented_normal, direction)
|
|
104
|
+
oriented_view_dot = _dot(oriented_normal, view)
|
|
105
|
+
half = tuple(direction[index] + view[index] for index in range(3))
|
|
106
|
+
half_magnitude = hypot(*half)
|
|
107
|
+
if oriented_light_dot > 0 and oriented_view_dot > 0 and half_magnitude > 1e-12:
|
|
108
|
+
specular_dot = min(1, max(0, _dot(oriented_normal, tuple(value / half_magnitude for value in half))))
|
|
109
|
+
specular_intensity = lighting["specular"] * specular_dot ** lighting["shininess"]
|
|
110
|
+
return {"fillColor": shade_scene3d_color(base_color, intensity, specular_intensity),
|
|
111
|
+
"lighting": {"baseColor": base_color, "normalDot": normal_dot,
|
|
112
|
+
"intensity": intensity, "specularDot": specular_dot,
|
|
113
|
+
"specularIntensity": specular_intensity}}
|
fastplotpy/line3d_lod.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Source-compatible Line3D run-aware largest-triangle sampling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import floor
|
|
6
|
+
from typing import Mapping
|
|
7
|
+
|
|
8
|
+
from .defaults import get_defaults
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_line_lod(input_value):
|
|
12
|
+
if input_value is False or input_value is ...:
|
|
13
|
+
return None
|
|
14
|
+
if input_value is None or not (input_value is True or isinstance(input_value, Mapping)):
|
|
15
|
+
raise ValueError("line3d.lod must be a boolean or an options object")
|
|
16
|
+
source = {} if input_value is True else input_value
|
|
17
|
+
enabled = source.get("enabled", True)
|
|
18
|
+
if type(enabled) is not bool:
|
|
19
|
+
raise ValueError("line3d.lod.enabled must be a boolean")
|
|
20
|
+
defaults = get_defaults("line3d")["lod"]
|
|
21
|
+
strategy = source.get("strategy", defaults["strategy"])
|
|
22
|
+
if strategy != "largest-triangle-3d":
|
|
23
|
+
raise ValueError("line3d.lod.strategy must be 'largest-triangle-3d'")
|
|
24
|
+
maximum = source.get("maxSourceSegments", defaults["maxSourceSegments"])
|
|
25
|
+
if type(maximum) is not int or maximum < 1 or maximum > 1_000_000:
|
|
26
|
+
raise ValueError("line3d.lod.maxSourceSegments must be an integer between 1 and 1000000")
|
|
27
|
+
return {"strategy": strategy, "maxSourceSegments": maximum} if enabled else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def source_runs(parsed_by_trace):
|
|
31
|
+
output = []
|
|
32
|
+
for trace_index, parsed in enumerate(parsed_by_trace):
|
|
33
|
+
runs = []
|
|
34
|
+
current = []
|
|
35
|
+
def flush():
|
|
36
|
+
nonlocal current
|
|
37
|
+
if len(current) >= 2:
|
|
38
|
+
runs.append({"traceIndex": trace_index, "runIndex": len(runs), "points": current})
|
|
39
|
+
current = []
|
|
40
|
+
prior = None
|
|
41
|
+
for entry in parsed:
|
|
42
|
+
if prior is not None and entry[0] != prior + 1:
|
|
43
|
+
flush()
|
|
44
|
+
current.append(entry)
|
|
45
|
+
prior = entry[0]
|
|
46
|
+
flush()
|
|
47
|
+
output.append(runs)
|
|
48
|
+
return output
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _evenly(values, count):
|
|
52
|
+
if count <= 0 or not values:
|
|
53
|
+
return []
|
|
54
|
+
if count >= len(values):
|
|
55
|
+
return list(values)
|
|
56
|
+
if count == 1:
|
|
57
|
+
return [values[(len(values) - 1) // 2]]
|
|
58
|
+
return [values[index * (len(values) - 1) // (count - 1)] for index in range(count)]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _largest_triangle(points, target_count, domains):
|
|
62
|
+
if target_count >= len(points):
|
|
63
|
+
return list(points)
|
|
64
|
+
if target_count <= 2:
|
|
65
|
+
return [points[0], points[-1]]
|
|
66
|
+
normalized = [tuple((entry[1][axis_index] - domains[axis]["min"])
|
|
67
|
+
/ (domains[axis]["max"] - domains[axis]["min"]) - .5
|
|
68
|
+
for axis_index, axis in enumerate("xyz")) for entry in points]
|
|
69
|
+
width = (len(points) - 2) / (target_count - 2)
|
|
70
|
+
selected = [points[0]]
|
|
71
|
+
selected_index = 0
|
|
72
|
+
for bucket in range(target_count - 2):
|
|
73
|
+
candidate_start = floor(bucket * width) + 1
|
|
74
|
+
candidate_end = min(len(points) - 1, floor((bucket + 1) * width) + 1)
|
|
75
|
+
average_start = floor((bucket + 1) * width) + 1
|
|
76
|
+
average_end = min(len(points), floor((bucket + 2) * width) + 1)
|
|
77
|
+
average_range_end = max(average_start + 1, average_end)
|
|
78
|
+
values = normalized[min(average_start, len(points) - 1):min(average_range_end, len(points))]
|
|
79
|
+
average = tuple(sum(point[axis] for point in values) / len(values) for axis in range(3))
|
|
80
|
+
next_index, largest_area = candidate_start, -1
|
|
81
|
+
for index in range(candidate_start, candidate_end):
|
|
82
|
+
ab = tuple(normalized[index][axis] - normalized[selected_index][axis] for axis in range(3))
|
|
83
|
+
ac = tuple(average[axis] - normalized[selected_index][axis] for axis in range(3))
|
|
84
|
+
cross = (ab[1] * ac[2] - ab[2] * ac[1],
|
|
85
|
+
ab[2] * ac[0] - ab[0] * ac[2],
|
|
86
|
+
ab[0] * ac[1] - ab[1] * ac[0])
|
|
87
|
+
area = sum(value * value for value in cross)
|
|
88
|
+
if area > largest_area or (area == largest_area and points[index][0] < points[next_index][0]):
|
|
89
|
+
largest_area, next_index = area, index
|
|
90
|
+
selected.append(points[next_index])
|
|
91
|
+
selected_index = next_index
|
|
92
|
+
selected.append(points[-1])
|
|
93
|
+
return selected
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def select_line_lod(runs_by_trace, domains, maximum):
|
|
97
|
+
runs = [run for trace_runs in runs_by_trace for run in trace_runs]
|
|
98
|
+
candidate_segments = sum(len(run["points"]) - 1 for run in runs)
|
|
99
|
+
if candidate_segments <= maximum:
|
|
100
|
+
return runs_by_trace, candidate_segments, candidate_segments, sum(len(run["points"]) for run in runs)
|
|
101
|
+
representatives = [max(trace_runs, key=lambda run: (len(run["points"]), -run["runIndex"]))
|
|
102
|
+
for trace_runs in runs_by_trace if trace_runs]
|
|
103
|
+
included = {id(run): run for run in _evenly(representatives, min(maximum, len(representatives)))}
|
|
104
|
+
if len(included) < maximum:
|
|
105
|
+
remaining_runs = [run for run in runs if id(run) not in included]
|
|
106
|
+
for run in _evenly(remaining_runs, min(maximum - len(included), len(remaining_runs))):
|
|
107
|
+
included[id(run)] = run
|
|
108
|
+
allocation = {identifier: 1 for identifier in included}
|
|
109
|
+
remaining = maximum - len(allocation)
|
|
110
|
+
if remaining > 0:
|
|
111
|
+
available = [(run, len(run["points"]) - 2) for run in included.values() if len(run["points"]) > 2]
|
|
112
|
+
total_capacity = sum(capacity for _, capacity in available)
|
|
113
|
+
ranked = []
|
|
114
|
+
for run, capacity in available:
|
|
115
|
+
exact = remaining * capacity / total_capacity if total_capacity else 0
|
|
116
|
+
added = floor(exact)
|
|
117
|
+
allocation[id(run)] += added
|
|
118
|
+
ranked.append((-(exact - added), run["traceIndex"], run["runIndex"], run))
|
|
119
|
+
allocated = sum(allocation.values())
|
|
120
|
+
for _, _, _, run in sorted(ranked):
|
|
121
|
+
if allocated >= maximum:
|
|
122
|
+
break
|
|
123
|
+
if allocation[id(run)] < len(run["points"]) - 1:
|
|
124
|
+
allocation[id(run)] += 1
|
|
125
|
+
allocated += 1
|
|
126
|
+
remaining = maximum - allocated
|
|
127
|
+
if remaining:
|
|
128
|
+
raise ValueError("line3d LOD could not allocate the configured source-segment budget")
|
|
129
|
+
selected_by_trace = []
|
|
130
|
+
for trace_runs in runs_by_trace:
|
|
131
|
+
selected_runs = []
|
|
132
|
+
for run in trace_runs:
|
|
133
|
+
segments = allocation.get(id(run))
|
|
134
|
+
if segments is not None:
|
|
135
|
+
selected_runs.append({**run, "points": _largest_triangle(run["points"], segments + 1, domains)})
|
|
136
|
+
selected_by_trace.append(selected_runs)
|
|
137
|
+
sampled_points = sum(len(run["points"]) for trace_runs in selected_by_trace for run in trace_runs)
|
|
138
|
+
return selected_by_trace, candidate_segments, maximum, sampled_points
|
fastplotpy/lod3d.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Deterministic source-compatible spatial sampling for Scatter3D."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import floor
|
|
6
|
+
from typing import Mapping
|
|
7
|
+
|
|
8
|
+
from .defaults import get_defaults
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_scatter_lod(input_value):
|
|
12
|
+
if input_value is False or input_value is ...:
|
|
13
|
+
return None
|
|
14
|
+
if input_value is None or not (input_value is True or isinstance(input_value, Mapping)):
|
|
15
|
+
raise ValueError("scatter3d.lod must be a boolean or an options object")
|
|
16
|
+
source = {} if input_value is True else input_value
|
|
17
|
+
enabled = source.get("enabled", True)
|
|
18
|
+
if type(enabled) is not bool:
|
|
19
|
+
raise ValueError("scatter3d.lod.enabled must be a boolean")
|
|
20
|
+
defaults = get_defaults("scatter3d")["lod"]
|
|
21
|
+
strategy = source.get("strategy", defaults["strategy"])
|
|
22
|
+
if strategy != "spatial-grid":
|
|
23
|
+
raise ValueError("scatter3d.lod.strategy must be 'spatial-grid'")
|
|
24
|
+
maximum = source.get("maxPoints", defaults["maxPoints"])
|
|
25
|
+
if type(maximum) is not int or maximum < 1 or maximum > 1_000_000:
|
|
26
|
+
raise ValueError("scatter3d.lod.maxPoints must be an integer between 1 and 1000000")
|
|
27
|
+
return {"strategy": strategy, "maxPoints": maximum} if enabled else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _evenly(values, count):
|
|
31
|
+
if count <= 0 or not values:
|
|
32
|
+
return []
|
|
33
|
+
if count >= len(values):
|
|
34
|
+
return list(values)
|
|
35
|
+
if count == 1:
|
|
36
|
+
return [values[(len(values) - 1) // 2]]
|
|
37
|
+
return [values[index * (len(values) - 1) // (count - 1)] for index in range(count)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _grid_size(maximum):
|
|
41
|
+
# The grid side is the integer cube root of the source point budget.
|
|
42
|
+
side = floor(maximum ** (1 / 3))
|
|
43
|
+
while (side + 1) ** 3 <= maximum:
|
|
44
|
+
side += 1
|
|
45
|
+
while side ** 3 > maximum:
|
|
46
|
+
side -= 1
|
|
47
|
+
return max(1, side)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _morton(ratios):
|
|
51
|
+
# Source quantization uses ten bits per axis, hence 2**10 - 1.
|
|
52
|
+
coordinates = [floor(value * ((1 << 10) - 1) + .5) for value in ratios]
|
|
53
|
+
code = 0
|
|
54
|
+
for bit in range(10):
|
|
55
|
+
for axis, coordinate in enumerate(coordinates):
|
|
56
|
+
code |= ((coordinate >> bit) & 1) << (bit * 3 + axis)
|
|
57
|
+
return code
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def select_scatter_lod(parsed_by_trace: list, domains: Mapping, maximum: int):
|
|
61
|
+
candidates = []
|
|
62
|
+
for trace_index, points in enumerate(parsed_by_trace):
|
|
63
|
+
for source_index, point, _ in points:
|
|
64
|
+
if any(point[axis_index] < min(domains[axis].values())
|
|
65
|
+
or point[axis_index] > max(domains[axis].values())
|
|
66
|
+
for axis_index, axis in enumerate("xyz")):
|
|
67
|
+
continue
|
|
68
|
+
ratios = tuple(min(1, max(0, (point[axis_index] - domains[axis]["min"])
|
|
69
|
+
/ (domains[axis]["max"] - domains[axis]["min"])))
|
|
70
|
+
for axis_index, axis in enumerate("xyz"))
|
|
71
|
+
candidates.append({"trace": trace_index, "source": source_index,
|
|
72
|
+
"ratios": ratios, "morton": _morton(ratios)})
|
|
73
|
+
if len(candidates) <= maximum:
|
|
74
|
+
return {(candidate["trace"], candidate["source"]) for candidate in candidates}, len(candidates)
|
|
75
|
+
def order(candidate):
|
|
76
|
+
return candidate["morton"], candidate["trace"], candidate["source"]
|
|
77
|
+
def distance(candidate, center):
|
|
78
|
+
return sum((value - center[axis]) ** 2 for axis, value in enumerate(candidate["ratios"]))
|
|
79
|
+
by_trace = {}
|
|
80
|
+
for candidate in candidates:
|
|
81
|
+
trace = candidate["trace"]
|
|
82
|
+
current = by_trace.get(trace)
|
|
83
|
+
if current is None or (distance(candidate, (.5, .5, .5)), candidate["source"]) < (
|
|
84
|
+
distance(current, (.5, .5, .5)), current["source"]):
|
|
85
|
+
by_trace[trace] = candidate
|
|
86
|
+
selected = {id(candidate): candidate for candidate in _evenly(
|
|
87
|
+
[by_trace[key] for key in sorted(by_trace)], min(maximum, len(by_trace)))}
|
|
88
|
+
if len(selected) < maximum:
|
|
89
|
+
grid_size = _grid_size(maximum)
|
|
90
|
+
cells = {}
|
|
91
|
+
for candidate in candidates:
|
|
92
|
+
cell = tuple(min(grid_size - 1, floor(value * grid_size)) for value in candidate["ratios"])
|
|
93
|
+
center = tuple((value + .5) / grid_size for value in cell)
|
|
94
|
+
current = cells.get(cell)
|
|
95
|
+
candidate_distance = distance(candidate, center)
|
|
96
|
+
if current is None or (candidate_distance, order(candidate)) < (current[1], order(current[0])):
|
|
97
|
+
cells[cell] = (candidate, candidate_distance)
|
|
98
|
+
remaining = sorted((candidate for candidate, _ in cells.values() if id(candidate) not in selected), key=order)
|
|
99
|
+
for candidate in _evenly(remaining, min(maximum - len(selected), len(remaining))):
|
|
100
|
+
selected[id(candidate)] = candidate
|
|
101
|
+
if len(selected) < maximum:
|
|
102
|
+
remaining = sorted((candidate for candidate in candidates if id(candidate) not in selected), key=order)
|
|
103
|
+
for candidate in _evenly(remaining, min(maximum - len(selected), len(remaining))):
|
|
104
|
+
selected[id(candidate)] = candidate
|
|
105
|
+
return {(candidate["trace"], candidate["source"]) for candidate in selected.values()}, len(candidates)
|
fastplotpy/mesh3d_lod.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Largest-area stratified Mesh3D face sampling from pinned scene3d.ts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import floor
|
|
6
|
+
from typing import Mapping
|
|
7
|
+
|
|
8
|
+
from .defaults import get_defaults
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_mesh_lod(value) -> dict | None:
|
|
12
|
+
if value is ... or value is False:
|
|
13
|
+
return None
|
|
14
|
+
if value is None or value is not True and not isinstance(value, Mapping):
|
|
15
|
+
raise ValueError("mesh3d.lod must be a boolean or an options object")
|
|
16
|
+
source = {} if value is True else value
|
|
17
|
+
enabled = source.get("enabled", True)
|
|
18
|
+
if type(enabled) is not bool:
|
|
19
|
+
raise ValueError("mesh3d.lod.enabled must be a boolean")
|
|
20
|
+
defaults = get_defaults("mesh3d")["lod"]
|
|
21
|
+
strategy = source.get("strategy", defaults["strategy"])
|
|
22
|
+
if strategy != "largest-area-stratified":
|
|
23
|
+
raise ValueError("mesh3d.lod.strategy must be 'largest-area-stratified'")
|
|
24
|
+
max_faces = source.get("maxFaces", defaults["maxFaces"])
|
|
25
|
+
if type(max_faces) is not int or not 1 <= max_faces <= 2_000_000:
|
|
26
|
+
raise ValueError("mesh3d.lod.maxFaces must be an integer between 1 and 2000000")
|
|
27
|
+
return {"strategy": strategy, "maxFaces": max_faces} if enabled else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _world(point, domains):
|
|
31
|
+
return tuple((point[index] - domains[axis]["min"]) /
|
|
32
|
+
(domains[axis]["max"] - domains[axis]["min"]) - .5
|
|
33
|
+
for index, axis in enumerate("xyz"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _area_squared(points):
|
|
37
|
+
first = tuple(points[1][index] - points[0][index] for index in range(3))
|
|
38
|
+
second = tuple(points[2][index] - points[0][index] for index in range(3))
|
|
39
|
+
cross = (first[1] * second[2] - first[2] * second[1],
|
|
40
|
+
first[2] * second[0] - first[0] * second[2],
|
|
41
|
+
first[0] * second[1] - first[1] * second[0])
|
|
42
|
+
return sum(value * value for value in cross)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _allocate(candidates: list[list[dict]], maximum: int) -> list[int]:
|
|
46
|
+
allocations = [0] * len(candidates)
|
|
47
|
+
active = [(index, len(items)) for index, items in enumerate(candidates) if items]
|
|
48
|
+
count = sum(size for _, size in active)
|
|
49
|
+
if count <= maximum:
|
|
50
|
+
for index, size in active:
|
|
51
|
+
allocations[index] = size
|
|
52
|
+
return allocations
|
|
53
|
+
if maximum < len(active):
|
|
54
|
+
for index, _ in active[:maximum]:
|
|
55
|
+
allocations[index] = 1
|
|
56
|
+
return allocations
|
|
57
|
+
for index, _ in active:
|
|
58
|
+
allocations[index] = 1
|
|
59
|
+
remaining = maximum - len(active)
|
|
60
|
+
extras = sum(size - 1 for _, size in active)
|
|
61
|
+
remainders = []
|
|
62
|
+
for index, size in active:
|
|
63
|
+
exact = remaining * (size - 1) / extras
|
|
64
|
+
allocated = floor(exact)
|
|
65
|
+
allocations[index] += allocated
|
|
66
|
+
remainders.append((-(exact - allocated), index))
|
|
67
|
+
undistributed = maximum - sum(allocations)
|
|
68
|
+
for _, index in sorted(remainders):
|
|
69
|
+
if undistributed == 0:
|
|
70
|
+
break
|
|
71
|
+
if allocations[index] < len(candidates[index]):
|
|
72
|
+
allocations[index] += 1
|
|
73
|
+
undistributed -= 1
|
|
74
|
+
return allocations
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def select_mesh_lod_faces(parsed: list[dict], domains: Mapping, maximum: int) -> dict:
|
|
78
|
+
candidates = []
|
|
79
|
+
for trace in parsed:
|
|
80
|
+
items = []
|
|
81
|
+
for face_index, face in enumerate(trace["faces"]):
|
|
82
|
+
points = [_world(trace["vertices"][index], domains) for index in face]
|
|
83
|
+
area = _area_squared(points)
|
|
84
|
+
if area <= 1e-24:
|
|
85
|
+
raise ValueError(f"mesh3d face {face_index} must not be degenerate")
|
|
86
|
+
items.append({"faceIndex": face_index, "indexes": face, "areaSquared": area})
|
|
87
|
+
candidates.append(items)
|
|
88
|
+
allocations = _allocate(candidates, maximum)
|
|
89
|
+
selected = [set() for _ in candidates]
|
|
90
|
+
sampled_vertices = set()
|
|
91
|
+
for trace_index, items in enumerate(candidates):
|
|
92
|
+
allocation = allocations[trace_index]
|
|
93
|
+
if not allocation:
|
|
94
|
+
continue
|
|
95
|
+
chosen = items if allocation >= len(items) else []
|
|
96
|
+
if not chosen:
|
|
97
|
+
for stratum in range(allocation):
|
|
98
|
+
start = floor(stratum * len(items) / allocation)
|
|
99
|
+
end = floor((stratum + 1) * len(items) / allocation)
|
|
100
|
+
chosen.append(max(items[start:end], key=lambda item: (item["areaSquared"], -item["faceIndex"])))
|
|
101
|
+
for item in chosen:
|
|
102
|
+
selected[trace_index].add(item["faceIndex"])
|
|
103
|
+
sampled_vertices.update((trace_index, vertex) for vertex in item["indexes"])
|
|
104
|
+
return {"selectedFaceIndexesByTrace": selected,
|
|
105
|
+
"candidateFaceCount": sum(len(items) for items in candidates),
|
|
106
|
+
"sampledFaceCount": sum(len(items) for items in selected),
|
|
107
|
+
"sampledVertexCount": len(sampled_vertices)}
|
fastplotpy/native.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Optional direct ABI to the source-pinned fastplot-core Rust scene builders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ctypes import CDLL, POINTER, byref, c_size_t, c_ubyte, c_void_p, string_at
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from json import dumps, loads
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from sys import platform
|
|
10
|
+
from threading import RLock
|
|
11
|
+
from typing import Any, Mapping
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_lock = RLock()
|
|
15
|
+
_library = None
|
|
16
|
+
_load_error: Exception | None = None
|
|
17
|
+
_wasm_error: Exception | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _filename() -> str:
|
|
21
|
+
if platform == "win32":
|
|
22
|
+
return "fastplot_python_abi.dll"
|
|
23
|
+
if platform == "darwin":
|
|
24
|
+
return "libfastplot_python_abi.dylib"
|
|
25
|
+
return "libfastplot_python_abi.so"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _candidates():
|
|
29
|
+
yield resources.files("fastplotpy").joinpath("assets", "native", _filename())
|
|
30
|
+
# This path supports an editable checkout after `cargo build --release`.
|
|
31
|
+
yield Path(__file__).resolve().parents[2] / "rust" / "target" / "release" / _filename()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def initRustPlotEngine(path: str | Path | None = None) -> bool:
|
|
35
|
+
"""Load the optional pinned Rust core through its Python ABI.
|
|
36
|
+
|
|
37
|
+
Supplying *path* selects an explicitly built library. Failure is reported
|
|
38
|
+
through ``getLastRustInitError``; strict ``engine='rust'`` raises.
|
|
39
|
+
"""
|
|
40
|
+
global _library, _load_error
|
|
41
|
+
with _lock:
|
|
42
|
+
if _library is not None:
|
|
43
|
+
return True
|
|
44
|
+
candidates = [Path(path)] if path is not None else list(_candidates())
|
|
45
|
+
for candidate in candidates:
|
|
46
|
+
try:
|
|
47
|
+
if not candidate.is_file():
|
|
48
|
+
continue
|
|
49
|
+
library = CDLL(str(candidate))
|
|
50
|
+
library.fpyp_call.argtypes = (POINTER(c_ubyte), c_size_t, POINTER(c_size_t))
|
|
51
|
+
library.fpyp_call.restype = c_void_p
|
|
52
|
+
library.fpyp_free.argtypes = (c_void_p, c_size_t)
|
|
53
|
+
library.fpyp_free.restype = None
|
|
54
|
+
_library, _load_error = library, None
|
|
55
|
+
return True
|
|
56
|
+
except (OSError, AttributeError) as error:
|
|
57
|
+
_load_error = error
|
|
58
|
+
if _load_error is None:
|
|
59
|
+
_load_error = FileNotFoundError("fastplot-python-abi native library is not installed")
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def hasRustPlotEngine() -> bool:
|
|
64
|
+
return initRustPlotEngine()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def getLastRustInitError() -> Exception | None:
|
|
68
|
+
return _load_error
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def getRustPlotEngineStatus() -> dict[str, Any]:
|
|
72
|
+
"""Report the actual CPython native core capabilities."""
|
|
73
|
+
available = hasRustPlotEngine()
|
|
74
|
+
return {
|
|
75
|
+
"initialized": available,
|
|
76
|
+
"lineScene": available,
|
|
77
|
+
"lineSceneWithDomain": available,
|
|
78
|
+
"lineAxisLayout": False,
|
|
79
|
+
"barScene": available,
|
|
80
|
+
"heatmapScene": available,
|
|
81
|
+
"wonderplotScene": available,
|
|
82
|
+
"polarScene": available,
|
|
83
|
+
"subplotLayout": available,
|
|
84
|
+
"subplotAxisLabelVisibility": available,
|
|
85
|
+
"lastError": getLastRustInitError(),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def hasNodePlotEngine() -> bool:
|
|
90
|
+
"""Source API alias for native Rust availability in the Python host."""
|
|
91
|
+
return hasRustPlotEngine()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def initNodePlotEngine() -> bool:
|
|
95
|
+
"""Source API alias for initialization of Python's native Rust binding."""
|
|
96
|
+
return initRustPlotEngine()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def hasWasmPlotEngine() -> bool:
|
|
100
|
+
"""CPython does not initialize the browser's separate WASM engine."""
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def initWasmPlotEngine() -> bool:
|
|
105
|
+
global _wasm_error
|
|
106
|
+
_wasm_error = RuntimeError("WASM plot engine is available only in the browser frontend")
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def getLastWasmInitError() -> Exception | None:
|
|
111
|
+
return _wasm_error
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def getWasmPlotEngineStatus() -> dict[str, Any]:
|
|
115
|
+
return {
|
|
116
|
+
"initialized": False,
|
|
117
|
+
"lineScene": False,
|
|
118
|
+
"lineSceneWithDomain": False,
|
|
119
|
+
"lineAxisLayout": False,
|
|
120
|
+
"barScene": False,
|
|
121
|
+
"heatmapScene": False,
|
|
122
|
+
"wonderplotScene": False,
|
|
123
|
+
"polarScene": False,
|
|
124
|
+
"subplotLayout": False,
|
|
125
|
+
"subplotAxisLabelVisibility": False,
|
|
126
|
+
"lastError": _wasm_error,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def rustCall(method: str, **payload: Any) -> Any:
|
|
131
|
+
if not initRustPlotEngine():
|
|
132
|
+
raise RuntimeError("engine='rust' requires the optional fastplot-python-abi native library") from _load_error
|
|
133
|
+
request = dumps({"method": method, **payload}, allow_nan=False, separators=(",", ":")).encode("utf-8")
|
|
134
|
+
input_buffer = (c_ubyte * len(request)).from_buffer_copy(request)
|
|
135
|
+
size = c_size_t()
|
|
136
|
+
with _lock:
|
|
137
|
+
pointer = _library.fpyp_call(input_buffer, len(request), byref(size))
|
|
138
|
+
if not pointer:
|
|
139
|
+
raise RuntimeError("native Rust engine returned a null response")
|
|
140
|
+
try:
|
|
141
|
+
response = loads(string_at(pointer, size.value))
|
|
142
|
+
finally:
|
|
143
|
+
_library.fpyp_free(pointer, size.value)
|
|
144
|
+
if not response.get("ok"):
|
|
145
|
+
raise ValueError(response.get("error", "native Rust engine failed"))
|
|
146
|
+
return response["result"]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def buildNativeLibrary() -> Path:
|
|
150
|
+
"""Compile the bundled source-pinned Rust core into the package asset path."""
|
|
151
|
+
from shutil import copy2
|
|
152
|
+
from subprocess import run
|
|
153
|
+
|
|
154
|
+
root = Path(__file__).resolve().parents[2]
|
|
155
|
+
manifest = root / "rust" / "Cargo.toml"
|
|
156
|
+
if not manifest.is_file():
|
|
157
|
+
raise RuntimeError("native Rust source is unavailable in this installation")
|
|
158
|
+
run(["cargo", "build", "--manifest-path", str(manifest), "-p", "fastplot-python-abi", "--release"], check=True)
|
|
159
|
+
built = root / "rust" / "target" / "release" / _filename()
|
|
160
|
+
destination = Path(__file__).resolve().parent / "assets" / "native" / _filename()
|
|
161
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
copy2(built, destination)
|
|
163
|
+
return destination
|