fastplotpy 0.3.0a1__py3-none-win_amd64.whl

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