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,183 @@
|
|
|
1
|
+
"""Source-shaped accessibility summaries for renderer-neutral 3D scenes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
from .defaults import get_defaults
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_MISSING = object()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _validate(value: Any, path: str) -> None:
|
|
15
|
+
if value is _MISSING or isinstance(value, bool):
|
|
16
|
+
return
|
|
17
|
+
if not isinstance(value, Mapping):
|
|
18
|
+
raise ValueError(f"{path} must be a boolean or an object")
|
|
19
|
+
for field in ("enabled", "includeTable"):
|
|
20
|
+
if field in value and not isinstance(value[field], bool):
|
|
21
|
+
raise ValueError(f"{path}.{field} must be a boolean")
|
|
22
|
+
for field in ("label", "description"):
|
|
23
|
+
if field in value and (not isinstance(value[field], str) or not value[field].strip()):
|
|
24
|
+
raise ValueError(f"{path}.{field} must be a non-empty string")
|
|
25
|
+
if "maxRows" in value and (type(value["maxRows"]) is not int or not 1 <= value["maxRows"] <= 1000):
|
|
26
|
+
raise ValueError(f"{path}.maxRows must be an integer between 1 and 1000")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve(root: Any, scoped: Any) -> dict | None:
|
|
30
|
+
if scoped is not None:
|
|
31
|
+
root = {**root, **scoped} if isinstance(root, Mapping) and isinstance(scoped, Mapping) else scoped
|
|
32
|
+
if root is None or root is False:
|
|
33
|
+
return None
|
|
34
|
+
if root is True:
|
|
35
|
+
root = {}
|
|
36
|
+
if root.get("enabled") is False:
|
|
37
|
+
return None
|
|
38
|
+
defaults = get_defaults("scene3d")
|
|
39
|
+
return {"label": root.get("label", "").strip(),
|
|
40
|
+
"description": root.get("description", "").strip(),
|
|
41
|
+
"maxRows": root.get("maxRows", defaults["accessibilityMaxRows"]),
|
|
42
|
+
"includeTable": root.get("includeTable", defaults["accessibilityIncludeTable"])}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _source_traces(options: Mapping) -> list[dict]:
|
|
46
|
+
return list(options.get("traces") or ([{"data": options["data"]}] if options.get("data") is not None else []))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _scalar(value: Any) -> Any:
|
|
50
|
+
if isinstance(value, datetime):
|
|
51
|
+
return value.isoformat().replace("+00:00", "Z")
|
|
52
|
+
if isinstance(value, date):
|
|
53
|
+
return datetime(value.year, value.month, value.day).isoformat() + "Z"
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _number(value: float) -> str:
|
|
58
|
+
if type(value) is int or isinstance(value, float) and value.is_integer():
|
|
59
|
+
return str(int(value))
|
|
60
|
+
return format(value, ".6g")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _domain_text(domains: Mapping) -> str:
|
|
64
|
+
return ", ".join(f"{axis} {_number(domains[axis]['min'])} to {_number(domains[axis]['max'])}"
|
|
65
|
+
for axis in "xyz")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _label(options: Mapping, resolved: Mapping) -> str:
|
|
69
|
+
defaults = get_defaults("scene3d")
|
|
70
|
+
title = options.get("title")
|
|
71
|
+
if resolved["label"]:
|
|
72
|
+
return resolved["label"]
|
|
73
|
+
if isinstance(title, str) and title.strip():
|
|
74
|
+
return title.strip()
|
|
75
|
+
key = {"scatter3d": "accessibilityScatter3dLabel", "line3d": "accessibilityLine3dLabel",
|
|
76
|
+
"surface": "accessibilitySurfaceLabel", "mesh3d": "accessibilityMesh3dLabel"}
|
|
77
|
+
return defaults[key[options["type"]]] if options["type"] in key else "Pie3D chart"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _pie_summary(metadata: Mapping, options: Mapping, resolved: Mapping) -> dict:
|
|
81
|
+
from .scene import _pie_scene
|
|
82
|
+
pie = options.get("pie") or {}
|
|
83
|
+
sectors = (_pie_scene(options, options["width"], options["height"])["primitives"] or [{}])[0].get("pieSectors", [])
|
|
84
|
+
trace_inputs = pie.get("traces") or options.get("traces") or []
|
|
85
|
+
rows = []
|
|
86
|
+
for sector in sectors[:resolved["maxRows"]]:
|
|
87
|
+
index = sector["traceIndex"]
|
|
88
|
+
row = {"traceIndex": index, "sourceIndex": sector["sourceIndex"],
|
|
89
|
+
"x": sector["label"], "y": sector["value"], "z": None}
|
|
90
|
+
if index < len(trace_inputs) and trace_inputs[index].get("name"):
|
|
91
|
+
row["traceName"] = trace_inputs[index]["name"]
|
|
92
|
+
rows.append(row)
|
|
93
|
+
label = _label(options, resolved)
|
|
94
|
+
indexes = list(dict.fromkeys(sector["traceIndex"] for sector in sectors))
|
|
95
|
+
summaries = []
|
|
96
|
+
for index in indexes:
|
|
97
|
+
item = {"traceIndex": index, "rowCount": sum(sector["traceIndex"] == index for sector in sectors)}
|
|
98
|
+
if index < len(trace_inputs) and trace_inputs[index].get("name"):
|
|
99
|
+
item["name"] = trace_inputs[index]["name"]
|
|
100
|
+
summaries.append(item)
|
|
101
|
+
return {"label": label,
|
|
102
|
+
"description": resolved["description"] or f"{label}. Experimental pie3d scene with {len(sectors)} visible sectors.",
|
|
103
|
+
"domains": metadata["domains"], "maxRows": resolved["maxRows"],
|
|
104
|
+
"totalRowCount": len(sectors), "rows": rows, "truncated": len(sectors) > len(rows),
|
|
105
|
+
"includeTable": resolved["includeTable"], "traces": summaries}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validateScene3dAccessibility(options: Mapping) -> None:
|
|
109
|
+
scene_options = options.get("scene3d") or {}
|
|
110
|
+
root = scene_options.get("accessibility", _MISSING)
|
|
111
|
+
_validate(root, "scene3d.accessibility")
|
|
112
|
+
scoped_values = scene_options.get("subplots") or {}
|
|
113
|
+
for subplot_id, scoped in scoped_values.items():
|
|
114
|
+
_validate((scoped or {}).get("accessibility", _MISSING),
|
|
115
|
+
f"scene3d.subplots.{subplot_id}.accessibility")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def applyScene3dAccessibilitySummary(scene: dict, options: Mapping) -> dict:
|
|
119
|
+
"""Decorate scene metadata after geometry construction, as in source plot()."""
|
|
120
|
+
validateScene3dAccessibility(options)
|
|
121
|
+
root = (options.get("scene3d") or {}).get("accessibility")
|
|
122
|
+
scoped_values = (options.get("scene3d") or {}).get("subplots") or {}
|
|
123
|
+
metadata_items = scene.get("scenes3d") or ([scene["scene3d"]] if scene.get("scene3d") else [])
|
|
124
|
+
if not metadata_items:
|
|
125
|
+
return scene
|
|
126
|
+
traces = _source_traces(options)
|
|
127
|
+
for metadata in metadata_items:
|
|
128
|
+
subplot_id = metadata["subplotId"]
|
|
129
|
+
scoped = (scoped_values.get(subplot_id) or {}).get("accessibility")
|
|
130
|
+
resolved = _resolve(root, scoped)
|
|
131
|
+
if resolved is None:
|
|
132
|
+
metadata.pop("accessibility", None)
|
|
133
|
+
continue
|
|
134
|
+
if options["type"] == "pie3d":
|
|
135
|
+
metadata["accessibility"] = _pie_summary(metadata, options, resolved)
|
|
136
|
+
continue
|
|
137
|
+
if options.get("subplots") is not None:
|
|
138
|
+
from .subplots import resolveTraceSubplotBindings
|
|
139
|
+
bindings = resolveTraceSubplotBindings(options)
|
|
140
|
+
indexes = [index for index, binding in enumerate(bindings) if binding["subplotId"] == subplot_id]
|
|
141
|
+
else:
|
|
142
|
+
indexes = list(range(len(traces)))
|
|
143
|
+
summaries = []
|
|
144
|
+
rows = []
|
|
145
|
+
for index in indexes:
|
|
146
|
+
trace = traces[index]
|
|
147
|
+
if trace.get("visible") is False:
|
|
148
|
+
continue
|
|
149
|
+
item = {"traceIndex": index, "rowCount": len(trace.get("data") or [])}
|
|
150
|
+
if trace.get("name"):
|
|
151
|
+
item["name"] = trace["name"]
|
|
152
|
+
if options["type"] == "mesh3d":
|
|
153
|
+
item["faceCount"] = len((trace.get("mesh3d") or {}).get("faces",
|
|
154
|
+
(options.get("mesh3d") or {}).get("faces") or []))
|
|
155
|
+
summaries.append(item)
|
|
156
|
+
for source_index, point in enumerate(trace.get("data") or []):
|
|
157
|
+
if len(rows) >= resolved["maxRows"]:
|
|
158
|
+
break
|
|
159
|
+
row = {"traceIndex": index, "sourceIndex": source_index,
|
|
160
|
+
**{axis: _scalar(point.get(axis)) for axis in "xyz"}}
|
|
161
|
+
if trace.get("name"):
|
|
162
|
+
row["traceName"] = trace["name"]
|
|
163
|
+
rows.append(row)
|
|
164
|
+
total = sum(item["rowCount"] for item in summaries)
|
|
165
|
+
label = _label(options, resolved)
|
|
166
|
+
truncated = total > len(rows)
|
|
167
|
+
if resolved["description"]:
|
|
168
|
+
description = resolved["description"]
|
|
169
|
+
else:
|
|
170
|
+
description = (f"{label}. Experimental {options['type']} scene {subplot_id} with "
|
|
171
|
+
f"{len(summaries)} visible {'trace' if len(summaries) == 1 else 'traces'} and "
|
|
172
|
+
f"{total} source {'row' if total == 1 else 'rows'}.")
|
|
173
|
+
if options["type"] == "mesh3d":
|
|
174
|
+
count = sum(item.get("faceCount", 0) for item in summaries)
|
|
175
|
+
description += f" {count} source {'face' if count == 1 else 'faces'}."
|
|
176
|
+
description += f" Camera-independent domains: {_domain_text(metadata['domains'])}."
|
|
177
|
+
if truncated:
|
|
178
|
+
description += f" The data table shows the first {len(rows)} rows."
|
|
179
|
+
metadata["accessibility"] = {"label": label, "description": description,
|
|
180
|
+
"domains": metadata["domains"], "maxRows": resolved["maxRows"],
|
|
181
|
+
"totalRowCount": total, "rows": rows, "truncated": truncated,
|
|
182
|
+
"includeTable": resolved["includeTable"], "traces": summaries}
|
|
183
|
+
return scene
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Named 3D subplot composition with source trace identity and local geometry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from typing import Callable, Mapping
|
|
7
|
+
|
|
8
|
+
from .defaults import DEFAULT_TRACE_COLORS
|
|
9
|
+
from .defaults import get_defaults
|
|
10
|
+
from .scene3d_accessibility import applyScene3dAccessibilitySummary, validateScene3dAccessibility
|
|
11
|
+
from .subplots import resolveSubplots, resolveTraceSubplotBindings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _traces(options: Mapping) -> list[dict]:
|
|
15
|
+
return list(options.get("traces") or ([{"data": options["data"]}] if options.get("data") is not None else []))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _renderer_mode(options: Mapping) -> str:
|
|
19
|
+
requested = (options.get("scene3d") or {}).get("renderer")
|
|
20
|
+
if requested is None:
|
|
21
|
+
requested = get_defaults("scene3d")["renderer"]
|
|
22
|
+
if requested not in ("canvas", "webgl2", "auto"):
|
|
23
|
+
raise ValueError("scene3d.renderer must be 'canvas', 'webgl2', or 'auto'")
|
|
24
|
+
return requested
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _attach_renderer(scene: dict, requested: str) -> dict:
|
|
28
|
+
metadata = scene.get("scenes3d") or ([scene["scene3d"]] if scene.get("scene3d") else [])
|
|
29
|
+
for item in metadata:
|
|
30
|
+
item["renderer"] = {"requested": requested, "resolved": "renderer-neutral"}
|
|
31
|
+
return scene
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _merge_policy(root, scoped):
|
|
35
|
+
if scoped is None:
|
|
36
|
+
return root
|
|
37
|
+
if isinstance(root, Mapping) and isinstance(scoped, Mapping):
|
|
38
|
+
return {**root, **scoped}
|
|
39
|
+
return scoped
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _overlap(left, right) -> bool:
|
|
43
|
+
# The epsilon is the pinned source resolveScene3dLayout predicate.
|
|
44
|
+
return (min(left["right"], right["right"]) - max(left["left"], right["left"]) > 1e-12
|
|
45
|
+
and min(left["bottom"], right["bottom"]) - max(left["top"], right["top"]) > 1e-12)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _local_options(options: Mapping, traces: list[dict], scoped: Mapping | None,
|
|
49
|
+
local_width: float, local_height: float) -> dict:
|
|
50
|
+
scene = options.get("scene3d") or {}
|
|
51
|
+
scoped = scoped or {}
|
|
52
|
+
merged = {**scene, **scoped}
|
|
53
|
+
lighting = _merge_policy(scene.get("lighting"), scoped.get("lighting"))
|
|
54
|
+
if lighting is not None:
|
|
55
|
+
merged["lighting"] = lighting
|
|
56
|
+
else:
|
|
57
|
+
merged.pop("lighting", None)
|
|
58
|
+
merged.pop("subplots", None)
|
|
59
|
+
local = {**options, "width": local_width, "height": local_height,
|
|
60
|
+
"scene3d": merged, "traces": traces, "data": None}
|
|
61
|
+
local.pop("subplots", None)
|
|
62
|
+
return local
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _remap_primitive(primitive: dict, trace: Mapping, original_index: int,
|
|
66
|
+
family: str, subplot_id: str, bounds: Mapping,
|
|
67
|
+
options: Mapping) -> None:
|
|
68
|
+
primitive["traceIndex"] = original_index
|
|
69
|
+
primitive["subplot"] = subplot_id
|
|
70
|
+
primitive["bounds"] = dict(bounds)
|
|
71
|
+
default_color = DEFAULT_TRACE_COLORS[original_index % len(DEFAULT_TRACE_COLORS)]
|
|
72
|
+
if family in ("scatter3d", "line3d"):
|
|
73
|
+
default_applies = ((trace.get("lineStyle") or {}).get("color") is None
|
|
74
|
+
and trace.get("color") is None
|
|
75
|
+
and (options.get("lineStyle") or {}).get("color") is None
|
|
76
|
+
and ((((options.get("style") or {}).get("trace") or {}).get("line") or {}).get("color")) is None)
|
|
77
|
+
else:
|
|
78
|
+
default_applies = trace.get("color") is None
|
|
79
|
+
if not default_applies:
|
|
80
|
+
return
|
|
81
|
+
primitive["color"] = default_color
|
|
82
|
+
if family == "scatter3d":
|
|
83
|
+
for point in primitive.get("scatter3dPoints") or []:
|
|
84
|
+
if "colorValue" not in point:
|
|
85
|
+
point["fillColor"] = default_color
|
|
86
|
+
if family == "mesh3d":
|
|
87
|
+
mesh = trace.get("mesh3d") or {}
|
|
88
|
+
plot_mesh = options.get("mesh3d") or {}
|
|
89
|
+
if (mesh.get("faceColors") is None and mesh.get("vertexColors") is None
|
|
90
|
+
and plot_mesh.get("faceColors") is None and plot_mesh.get("vertexColors") is None):
|
|
91
|
+
for triangle in primitive.get("mesh3dTriangles") or []:
|
|
92
|
+
if triangle.get("lighting"):
|
|
93
|
+
# Shading is recalculated only when the source has no
|
|
94
|
+
# explicit trace or plot mesh color policy.
|
|
95
|
+
from .lighting3d import shade_scene3d_color
|
|
96
|
+
triangle["lighting"]["baseColor"] = default_color
|
|
97
|
+
triangle["fillColor"] = shade_scene3d_color(
|
|
98
|
+
default_color, triangle["lighting"]["intensity"],
|
|
99
|
+
triangle["lighting"]["specularIntensity"])
|
|
100
|
+
else:
|
|
101
|
+
triangle["fillColor"] = default_color
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def buildScene3dCollection(options: Mapping, builder: Callable[[Mapping, float, float], dict]) -> dict:
|
|
105
|
+
"""Build each bound scene locally, then restore global source trace indexes."""
|
|
106
|
+
renderer = _renderer_mode(options)
|
|
107
|
+
validateScene3dAccessibility(options)
|
|
108
|
+
width, height = options["width"], options["height"]
|
|
109
|
+
if options.get("subplots") is None:
|
|
110
|
+
if options["type"] != "pie3d" and any("subplot" in trace for trace in _traces(options)):
|
|
111
|
+
raise ValueError(f"experimental {options['type']} subplot bindings require a subplots layout")
|
|
112
|
+
return _attach_renderer(applyScene3dAccessibilitySummary(builder(options, width, height), options), renderer)
|
|
113
|
+
family = options["type"]
|
|
114
|
+
if family == "pie3d":
|
|
115
|
+
raise ValueError("experimental pie3d currently requires one full-chart 3D scene")
|
|
116
|
+
subplots = resolveSubplots(options)
|
|
117
|
+
bindings = resolveTraceSubplotBindings(options)
|
|
118
|
+
traces = _traces(options)
|
|
119
|
+
target_ids = {binding["subplotId"] for binding in bindings}
|
|
120
|
+
if not target_ids and len(subplots) == 1:
|
|
121
|
+
target_ids.add(subplots[0]["id"])
|
|
122
|
+
if not target_ids:
|
|
123
|
+
raise ValueError(f"experimental {family} requires every trace to bind to exactly one shared 3D subplot")
|
|
124
|
+
targets = [subplot for subplot in subplots if subplot["id"] in target_ids]
|
|
125
|
+
if len(targets) == 1 and any(binding["subplotId"] != targets[0]["id"] for binding in bindings):
|
|
126
|
+
raise ValueError(f"experimental {family} requires every trace to bind to exactly one shared 3D subplot")
|
|
127
|
+
scenes = []
|
|
128
|
+
scene_scopes = (options.get("scene3d") or {}).get("subplots") or {}
|
|
129
|
+
for target in targets:
|
|
130
|
+
subplot_id, bounds = target["id"], target["bounds"]
|
|
131
|
+
overlap = next((other for other in subplots if other["id"] != subplot_id
|
|
132
|
+
and _overlap(bounds, other["bounds"])), None)
|
|
133
|
+
if overlap is not None:
|
|
134
|
+
raise ValueError(f"experimental {family} subplot '{subplot_id}' overlaps subplot '{overlap['id']}'")
|
|
135
|
+
indexes = [index for index, binding in enumerate(bindings)
|
|
136
|
+
if binding["subplotId"] == subplot_id]
|
|
137
|
+
local_width = max(1, bounds["right"] - bounds["left"])
|
|
138
|
+
local_height = max(1, bounds["bottom"] - bounds["top"])
|
|
139
|
+
local_traces = [deepcopy(traces[index]) for index in indexes]
|
|
140
|
+
for trace in local_traces:
|
|
141
|
+
trace.pop("subplot", None)
|
|
142
|
+
local = _local_options(options, local_traces, scene_scopes.get(subplot_id),
|
|
143
|
+
local_width, local_height)
|
|
144
|
+
scene = builder(local, local_width, local_height)
|
|
145
|
+
scene["scene3d"]["subplotId"] = subplot_id
|
|
146
|
+
scene["scene3d"]["bounds"] = dict(bounds)
|
|
147
|
+
for primitive in scene["primitives"]:
|
|
148
|
+
original_index = indexes[primitive.get("traceIndex", 0)]
|
|
149
|
+
_remap_primitive(primitive, traces[original_index], original_index,
|
|
150
|
+
family, subplot_id, bounds, options)
|
|
151
|
+
scenes.append(scene)
|
|
152
|
+
metadata = [scene["scene3d"] for scene in scenes]
|
|
153
|
+
result = {"width": width, "height": height,
|
|
154
|
+
"primitives": [primitive for scene in scenes for primitive in scene["primitives"]],
|
|
155
|
+
"scene3d": metadata[0]}
|
|
156
|
+
if len(scenes) > 1:
|
|
157
|
+
result["scenes3d"] = metadata
|
|
158
|
+
return _attach_renderer(applyScene3dAccessibilitySummary(result, options), renderer)
|
fastplotpy/subplots.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Subplot layout and trace bindings from the source Rust/TypeScript contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import isfinite
|
|
6
|
+
from typing import Mapping
|
|
7
|
+
|
|
8
|
+
from .defaults import get_defaults
|
|
9
|
+
from .native import hasRustPlotEngine, rustCall
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _tracks(raw, fallback):
|
|
13
|
+
if isinstance(raw, list):
|
|
14
|
+
if not raw:
|
|
15
|
+
raise ValueError("Subplot track arrays must not be empty")
|
|
16
|
+
return [track.get("weight", 1) if isinstance(track, Mapping)
|
|
17
|
+
and isinstance(track.get("weight"), (int, float)) and not isinstance(track["weight"], bool)
|
|
18
|
+
and isfinite(track["weight"]) and track["weight"] > 0 else 1 for track in raw]
|
|
19
|
+
count = fallback if raw is None else raw
|
|
20
|
+
if isinstance(count, bool) or not isinstance(count, int) or count <= 0:
|
|
21
|
+
raise ValueError("Subplot layout rows and columns must be positive integers")
|
|
22
|
+
return [1] * count
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _track_bounds(start, end, weights, gap):
|
|
26
|
+
available = max(1, end - start - gap * max(0, len(weights) - 1))
|
|
27
|
+
total = sum(weights)
|
|
28
|
+
cursor = start
|
|
29
|
+
bounds = []
|
|
30
|
+
for index, weight in enumerate(weights):
|
|
31
|
+
size = end - cursor if index == len(weights) - 1 else available * weight / total
|
|
32
|
+
bounds.append((cursor, max(cursor + 1, cursor + size)))
|
|
33
|
+
cursor += size + gap
|
|
34
|
+
return bounds
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _python_layout(options, bounds):
|
|
38
|
+
items = ((options.get("subplots") or {}).get("items") or {})
|
|
39
|
+
if not items:
|
|
40
|
+
return [{"id": "main", "row": 0, "column": 0, "rowSpan": 1, "columnSpan": 1,
|
|
41
|
+
"xAxisId": "x", "yAxisId": "y", "bounds": bounds}]
|
|
42
|
+
layout = (options.get("subplots") or {}).get("layout") or {}
|
|
43
|
+
fallback_rows = max(item["row"] + item.get("rowSpan", 1) for item in items.values())
|
|
44
|
+
fallback_columns = max(item["column"] + item.get("columnSpan", 1) for item in items.values())
|
|
45
|
+
rows = _tracks(layout.get("rows"), fallback_rows)
|
|
46
|
+
columns = _tracks(layout.get("columns"), fallback_columns)
|
|
47
|
+
defaults = get_defaults("layout")["subplots"]
|
|
48
|
+
def gap(key):
|
|
49
|
+
value = layout.get(key)
|
|
50
|
+
return max(0, value) if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) else defaults[key]
|
|
51
|
+
row_bounds = _track_bounds(bounds["top"], bounds["bottom"], rows, gap("rowGap"))
|
|
52
|
+
column_bounds = _track_bounds(bounds["left"], bounds["right"], columns, gap("columnGap"))
|
|
53
|
+
result = []
|
|
54
|
+
for name, item in items.items():
|
|
55
|
+
row, column = item["row"], item["column"]
|
|
56
|
+
row_span, column_span = item.get("rowSpan", 1), item.get("columnSpan", 1)
|
|
57
|
+
if any(isinstance(value, bool) or not isinstance(value, int) or value < 0 for value in (row, column)):
|
|
58
|
+
raise ValueError(f"Invalid position for subplot '{name}'")
|
|
59
|
+
if any(isinstance(value, bool) or not isinstance(value, int) or value <= 0 for value in (row_span, column_span)):
|
|
60
|
+
raise ValueError(f"Invalid span for subplot '{name}'")
|
|
61
|
+
if row + row_span > len(rows) or column + column_span > len(columns):
|
|
62
|
+
raise ValueError(f"Subplot '{name}' is outside the configured layout")
|
|
63
|
+
result.append({"id": name, "row": row, "column": column,
|
|
64
|
+
"rowSpan": row_span, "columnSpan": column_span,
|
|
65
|
+
"xAxisId": item.get("xAxis", "x"), "yAxisId": item.get("yAxis", "y"),
|
|
66
|
+
"bounds": {"left": column_bounds[column][0], "top": row_bounds[row][0],
|
|
67
|
+
"right": column_bounds[column + column_span - 1][1],
|
|
68
|
+
"bottom": row_bounds[row + row_span - 1][1]}})
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolveSubplots(options, outerBounds=None):
|
|
73
|
+
"""Resolve source-named subplot layout, using the pinned Rust builder when available."""
|
|
74
|
+
bounds = outerBounds or {"left": 0, "top": 0, "right": options["width"], "bottom": options["height"]}
|
|
75
|
+
subplot = options.get("subplots") or {}
|
|
76
|
+
layout = subplot.get("layout") or {}
|
|
77
|
+
items = subplot.get("items") or {}
|
|
78
|
+
defaults = get_defaults("layout")["subplots"]
|
|
79
|
+
def tracks(raw):
|
|
80
|
+
if isinstance(raw, list):
|
|
81
|
+
return [{"weight": item.get("weight") if isinstance(item, Mapping) else None} for item in raw]
|
|
82
|
+
if isinstance(raw, int) and not isinstance(raw, bool):
|
|
83
|
+
return [{} for _ in range(raw)]
|
|
84
|
+
return None
|
|
85
|
+
payload = {"bounds": bounds, "rows": tracks(layout.get("rows")), "columns": tracks(layout.get("columns")),
|
|
86
|
+
"row_gap": max(0, layout["rowGap"]) if isinstance(layout.get("rowGap"), (int, float)) and isfinite(layout["rowGap"]) else defaults["rowGap"],
|
|
87
|
+
"column_gap": max(0, layout["columnGap"]) if isinstance(layout.get("columnGap"), (int, float)) and isfinite(layout["columnGap"]) else defaults["columnGap"],
|
|
88
|
+
"items": [{"id": name, "row": item["row"], "column": item["column"],
|
|
89
|
+
"row_span": item.get("rowSpan"), "column_span": item.get("columnSpan"),
|
|
90
|
+
"x_axis_id": item.get("xAxis"), "y_axis_id": item.get("yAxis")}
|
|
91
|
+
for name, item in items.items()]}
|
|
92
|
+
if hasRustPlotEngine():
|
|
93
|
+
native = rustCall("subplot_layout", input=payload)
|
|
94
|
+
return [{"id": item["id"], "row": item["row"], "column": item["column"],
|
|
95
|
+
"rowSpan": item["row_span"], "columnSpan": item["column_span"],
|
|
96
|
+
"xAxisId": item["x_axis_id"], "yAxisId": item["y_axis_id"],
|
|
97
|
+
"bounds": item["bounds"]} for item in native]
|
|
98
|
+
return _python_layout(options, bounds)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolveTraceSubplotBindings(options):
|
|
102
|
+
traces = options.get("traces") or ([{"data": options["data"]}] if options.get("data") is not None else [])
|
|
103
|
+
subplots = resolveSubplots(options)
|
|
104
|
+
by_id = {item["id"]: item for item in subplots}
|
|
105
|
+
default_id = "main" if "main" in by_id else subplots[0]["id"] if len(subplots) == 1 else "main"
|
|
106
|
+
result = []
|
|
107
|
+
for trace in traces:
|
|
108
|
+
name = trace.get("subplot", default_id)
|
|
109
|
+
subplot = by_id.get(name)
|
|
110
|
+
if subplot is None:
|
|
111
|
+
raise ValueError(f"Unknown subplot id '{name}' for trace")
|
|
112
|
+
result.append({"trace": trace, "subplotId": name,
|
|
113
|
+
"xAxisId": trace.get("xAxis", subplot["xAxisId"]),
|
|
114
|
+
"yAxisId": trace.get("yAxis", subplot["yAxisId"])})
|
|
115
|
+
return result
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Source Surface3D contour levels and triangle intersections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import isfinite
|
|
6
|
+
from typing import Mapping
|
|
7
|
+
|
|
8
|
+
from .defaults import get_defaults
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _validate(value, label):
|
|
12
|
+
if value is ... or type(value) is bool:
|
|
13
|
+
return None
|
|
14
|
+
if not isinstance(value, Mapping):
|
|
15
|
+
raise ValueError(f"{label} must be boolean or an object")
|
|
16
|
+
if "show" in value and type(value["show"]) is not bool:
|
|
17
|
+
raise ValueError(f"{label}.show must be boolean")
|
|
18
|
+
if "color" in value:
|
|
19
|
+
from .scene3d import _hex_channels
|
|
20
|
+
_hex_channels(value["color"], f"{label}.color")
|
|
21
|
+
if "width" in value and (type(value["width"]) not in (int, float)
|
|
22
|
+
or not isfinite(value["width"]) or value["width"] <= 0):
|
|
23
|
+
raise ValueError(f"{label}.width must be a positive finite number")
|
|
24
|
+
if "opacity" in value and (type(value["opacity"]) not in (int, float)
|
|
25
|
+
or not isfinite(value["opacity"]) or not 0 <= value["opacity"] <= 1):
|
|
26
|
+
raise ValueError(f"{label}.opacity must be a finite number between 0 and 1")
|
|
27
|
+
defaults = get_defaults("surface")
|
|
28
|
+
if "count" in value and (type(value["count"]) is not int or not 1 <= value["count"] <= defaults["maxContourLevels"]):
|
|
29
|
+
raise ValueError(f"{label}.count must be an integer between 1 and {defaults['maxContourLevels']}")
|
|
30
|
+
if "values" in value:
|
|
31
|
+
values = value["values"]
|
|
32
|
+
if not isinstance(values, list) or not 1 <= len(values) <= defaults["maxContourLevels"]:
|
|
33
|
+
raise ValueError(f"{label}.values must contain between 1 and {defaults['maxContourLevels']} values")
|
|
34
|
+
if any(type(item) not in (int, float) or not isfinite(item) for item in values):
|
|
35
|
+
raise ValueError(f"{label}.values must be a dense array of finite numbers")
|
|
36
|
+
if "values" in value and "count" in value:
|
|
37
|
+
raise ValueError(f"{label} cannot configure both values and count")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_surface_contours(plot_value, trace_value, color_domain):
|
|
42
|
+
plot_options = _validate(plot_value, "surface.contours")
|
|
43
|
+
trace_options = _validate(trace_value, "trace.surface.contours")
|
|
44
|
+
defaults = get_defaults("surface")
|
|
45
|
+
selected = trace_value if trace_value is not ... else plot_value
|
|
46
|
+
if selected is ...:
|
|
47
|
+
show = defaults["contourShow"]
|
|
48
|
+
elif type(selected) is bool:
|
|
49
|
+
show = selected
|
|
50
|
+
else:
|
|
51
|
+
show = selected.get("show", True)
|
|
52
|
+
if not show:
|
|
53
|
+
return None
|
|
54
|
+
level_source = (trace_options if trace_options and ("values" in trace_options or "count" in trace_options)
|
|
55
|
+
else plot_options)
|
|
56
|
+
if level_source and "values" in level_source:
|
|
57
|
+
levels = sorted(set(level_source["values"]))
|
|
58
|
+
elif color_domain[0] == color_domain[1]:
|
|
59
|
+
levels = []
|
|
60
|
+
else:
|
|
61
|
+
count = level_source.get("count", defaults["contourCount"]) if level_source else defaults["contourCount"]
|
|
62
|
+
levels = [color_domain[0] + (color_domain[1] - color_domain[0]) * ((index + 1) / (count + 1))
|
|
63
|
+
for index in range(count)]
|
|
64
|
+
color = (trace_options or {}).get("color", (plot_options or {}).get("color", defaults["contourColor"]))
|
|
65
|
+
from .scene3d import _hex_channels
|
|
66
|
+
_hex_channels(color, "surface.contours.color")
|
|
67
|
+
return {"levels": levels, "style": {"color": color.strip(),
|
|
68
|
+
"width": (trace_options or {}).get("width", (plot_options or {}).get("width", defaults["contourWidth"])),
|
|
69
|
+
"opacity": (trace_options or {}).get("opacity", (plot_options or {}).get("opacity", defaults["contourOpacity"]))}}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _interpolate(first, second, ratio):
|
|
73
|
+
return {key: first[key] + (second[key] - first[key]) * ratio
|
|
74
|
+
for key in ("sourceX", "sourceY", "sourceZ")}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def contour_source_segment(vertices, level):
|
|
78
|
+
intersections = []
|
|
79
|
+
def append(vertex):
|
|
80
|
+
if not any(all(abs(current["world"][axis] - vertex["world"][axis]) <= 1e-12
|
|
81
|
+
for axis in range(3)) for current in intersections):
|
|
82
|
+
intersections.append(vertex)
|
|
83
|
+
for index, first in enumerate(vertices):
|
|
84
|
+
second = vertices[(index + 1) % 3]
|
|
85
|
+
first_delta, second_delta = first["sourceZ"] - level, second["sourceZ"] - level
|
|
86
|
+
if first_delta == 0 and second_delta == 0:
|
|
87
|
+
append(first)
|
|
88
|
+
append(second)
|
|
89
|
+
elif first_delta == 0:
|
|
90
|
+
append(first)
|
|
91
|
+
elif second_delta == 0:
|
|
92
|
+
append(second)
|
|
93
|
+
elif first_delta < 0 < second_delta or first_delta > 0 > second_delta:
|
|
94
|
+
ratio = -first_delta / (second_delta - first_delta)
|
|
95
|
+
interpolated = _interpolate(first, second, ratio)
|
|
96
|
+
interpolated["world"] = tuple(first["world"][axis] + (second["world"][axis] - first["world"][axis]) * ratio
|
|
97
|
+
for axis in range(3))
|
|
98
|
+
append(interpolated)
|
|
99
|
+
return intersections if len(intersections) == 2 else None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def contour_segment_key(level, segment):
|
|
103
|
+
keys = [tuple(vertex[name] for name in ("sourceX", "sourceY", "sourceZ")) for vertex in segment]
|
|
104
|
+
return level, *sorted(keys)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def project_contour_segment(segment, projector, clip_domain_segment):
|
|
108
|
+
first, second = segment
|
|
109
|
+
clipped = clip_domain_segment(first["world"], second["world"])
|
|
110
|
+
if clipped is None:
|
|
111
|
+
return None
|
|
112
|
+
projected = projector.project_segment(clipped["from"], clipped["to"])
|
|
113
|
+
if projected is None:
|
|
114
|
+
return None
|
|
115
|
+
start_ratio = clipped["startRatio"] + (clipped["endRatio"] - clipped["startRatio"]) * projected["startRatio"]
|
|
116
|
+
end_ratio = clipped["startRatio"] + (clipped["endRatio"] - clipped["startRatio"]) * projected["endRatio"]
|
|
117
|
+
source_from = _interpolate(first, second, start_ratio)
|
|
118
|
+
source_to = _interpolate(first, second, end_ratio)
|
|
119
|
+
return {"x1": projected["from"]["x"], "y1": projected["from"]["y"], "depth1": projected["from"]["depth"],
|
|
120
|
+
"x2": projected["to"]["x"], "y2": projected["to"]["y"], "depth2": projected["to"]["depth"],
|
|
121
|
+
"sourceX1": source_from["sourceX"], "sourceY1": source_from["sourceY"], "sourceZ1": source_from["sourceZ"],
|
|
122
|
+
"sourceX2": source_to["sourceX"], "sourceY2": source_to["sourceY"], "sourceZ2": source_to["sourceZ"]}
|