plot3 0.4.2__py3-none-any.whl → 0.5.1__py3-none-any.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.
plot3/__version__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.4.2"
1
+ __version__ = "0.5.1"
plot3/build.py CHANGED
@@ -440,6 +440,22 @@ def _apply_fill(mapping: dict, kind: str) -> dict:
440
440
  return out
441
441
 
442
442
 
443
+ def _layer_mapping(base_mapping, geom) -> dict:
444
+ """The plot's mapping with the layer's own on top, fill resolved per side.
445
+
446
+ A set colour (``geom_point(colour="black")`` under ``aes(colour=g)``)
447
+ replaces the inherited colour mapping for that layer, as in ggplot2, so
448
+ the layer neither recolours nor needs the column. Filled layers keep
449
+ it: there ``colour=`` is the outline.
450
+ """
451
+ kind = getattr(geom, "kind", None)
452
+ mapping = _apply_fill(dict(base_mapping), kind)
453
+ if getattr(geom, "const_color", None) is not None and kind not in _FILL_KINDS:
454
+ mapping.pop("color", None)
455
+ mapping.update(_apply_fill(dict(geom.mapping), kind))
456
+ return mapping
457
+
458
+
443
459
  _REF_KINDS = frozenset({"hline", "vline", "abline"})
444
460
  _MM_TO_PX = 96.0 / 25.4
445
461
 
@@ -571,8 +587,7 @@ def expand_stat_geom(
571
587
  layers = expand_vector_field(geom, transition, slider)
572
588
  return layers[0] if len(layers) == 1 else layers
573
589
  # Resolve fill per mapping, so a layer's own colour beats a base fill.
574
- mapping = _apply_fill(dict(base_mapping), geom.kind)
575
- mapping.update(_apply_fill(dict(geom.mapping), geom.kind))
590
+ mapping = _layer_mapping(base_mapping, geom)
576
591
  if geom.kind == "box3d":
577
592
  from plot3.stats3d import box3d_layers
578
593
 
@@ -976,8 +991,8 @@ def expand_stat_geom(
976
991
  indices = np.zeros((0, 3), dtype=np.int32)
977
992
  colour_by = getattr(geom, "colour_by", "level")
978
993
  map_kwargs: dict = {"x": "x", "y": "y", "z": "z"}
979
- if colour_by == "level" and "colour" in vertices.columns:
980
- map_kwargs["colour"] = "colour"
994
+ if colour_by == "level" and "level" in vertices.columns:
995
+ map_kwargs["colour"] = "level"
981
996
  out = _Geom(
982
997
  aes(**map_kwargs),
983
998
  color=geom.const_color,
@@ -990,6 +1005,7 @@ def expand_stat_geom(
990
1005
  out.wireframe = bool(getattr(geom, "wireframe", False))
991
1006
  out._indices = indices
992
1007
  out._iso_levels = used
1008
+ out._colour_title = "level"
993
1009
  return out
994
1010
  return geom
995
1011
 
@@ -1963,8 +1979,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
1963
1979
  if getattr(geom, "_replace_mapping", False):
1964
1980
  m = dict(geom.mapping)
1965
1981
  else:
1966
- m = _apply_fill(dict(g.mapping), geom.kind)
1967
- m.update(_apply_fill(dict(geom.mapping), geom.kind))
1982
+ m = _layer_mapping(g.mapping, geom)
1968
1983
  if "x" not in m or "y" not in m:
1969
1984
  raise ValueError("aes(x=, y=) are required (bar/histogram/density supply y)")
1970
1985
  if geom.kind == "surface" and "z" not in m:
plot3/calculus.py CHANGED
@@ -884,37 +884,115 @@ def _arrow(x, y, dx, dy, length: float):
884
884
 
885
885
 
886
886
  def _streamlines(formula, xs, ys, cell, xlim, ylim):
887
- stride = max(1, int(len(xs) / 6))
888
- seeds = [
889
- (float(xs[i]), float(ys[j]))
890
- for j in range(0, len(ys), stride)
891
- for i in range(0, len(xs), stride)
892
- ]
893
- step = 0.45 * cell
887
+ """Evenly spaced streamlines (Jobard and Lefer), one arrowhead each.
888
+
889
+ Seeds sit on the grid, centre first. Each line is traced both ways with
890
+ RK4 at unit speed and stops at the edge, at a zero of the field, within
891
+ ``0.5 * cell`` of another line, or where it meets itself (a closed
892
+ orbit), so lines never pile up on one another.
893
+ """
894
+ sep = cell
895
+ near = 0.5 * sep
896
+ step = 0.1 * sep
897
+ lag = 3.0 * sep # arc length before a line may end on itself
898
+ max_steps = int(4 * ((xlim[1] - xlim[0]) + (ylim[1] - ylim[0])) / step)
899
+ taken: dict[tuple[int, int], list[tuple[float, float]]] = {}
900
+
901
+ def key(x, y):
902
+ return int(np.floor(x / near)), int(np.floor(y / near))
903
+
904
+ def crowded(grid, x, y, radius, s=None):
905
+ kx, ky = key(x, y)
906
+ for ix in (kx - 1, kx, kx + 1):
907
+ for iy in (ky - 1, ky, ky + 1):
908
+ for item in grid.get((ix, iy), ()):
909
+ if (item[0] - x) ** 2 + (item[1] - y) ** 2 >= radius * radius:
910
+ continue
911
+ if s is None or abs(item[2] - s) > lag:
912
+ return True
913
+ return False
914
+
915
+ def unit(x, y):
916
+ vx, vy = _field_at(formula, x, y)
917
+ mag = float(np.hypot(vx, vy))
918
+ if not np.isfinite(mag) or mag < 1e-12:
919
+ return None
920
+ return vx / mag, vy / mag
921
+
922
+ def trace(sx, sy, direction, own):
923
+ points = []
924
+ x, y, s = sx, sy, 0.0
925
+ for _ in range(max_steps):
926
+ k1 = unit(x, y)
927
+ if k1 is None:
928
+ break
929
+ k2 = unit(x + 0.5 * step * direction * k1[0], y + 0.5 * step * direction * k1[1])
930
+ k3 = None if k2 is None else unit(
931
+ x + 0.5 * step * direction * k2[0], y + 0.5 * step * direction * k2[1]
932
+ )
933
+ k4 = None if k3 is None else unit(
934
+ x + step * direction * k3[0], y + step * direction * k3[1]
935
+ )
936
+ if k4 is None:
937
+ break
938
+ x += step * direction * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]) / 6
939
+ y += step * direction * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]) / 6
940
+ s += direction * step
941
+ if not (xlim[0] <= x <= xlim[1] and ylim[0] <= y <= ylim[1]):
942
+ break
943
+ if crowded(taken, x, y, near):
944
+ break
945
+ closed = crowded(own, x, y, 0.5 * step + 1e-12, s)
946
+ points.append((x, y, s))
947
+ own.setdefault(key(x, y), []).append((x, y, s))
948
+ if closed:
949
+ break
950
+ return points
951
+
952
+ cx, cy = 0.5 * (xlim[0] + xlim[1]), 0.5 * (ylim[0] + ylim[1])
953
+ seeds = sorted(
954
+ ((float(x), float(y)) for y in ys for x in xs),
955
+ key=lambda p: (p[0] - cx) ** 2 + (p[1] - cy) ** 2,
956
+ )
894
957
  rows: list[tuple[float, float]] = []
895
958
  groups: list[list[int]] = []
896
959
  for sx, sy in seeds:
897
- for direction in (1.0, -1.0):
898
- points = [(sx, sy)]
899
- x, y = sx, sy
900
- for _ in range(48):
901
- vx, vy = _field_at(formula, x, y)
902
- mag = float(np.hypot(vx, vy))
903
- if not np.isfinite(mag) or mag < 1e-12:
904
- break
905
- x += direction * step * vx / mag
906
- y += direction * step * vy / mag
907
- if x < xlim[0] or x > xlim[1] or y < ylim[0] or y > ylim[1]:
908
- break
909
- points.append((x, y))
910
- if len(points) < 2:
911
- continue
960
+ if crowded(taken, sx, sy, sep) or unit(sx, sy) is None:
961
+ continue
962
+ own: dict = {key(sx, sy): [(sx, sy, 0.0)]}
963
+ forward = trace(sx, sy, 1.0, own)
964
+ backward = trace(sx, sy, -1.0, own)
965
+ line = [p for p in reversed(backward)] + [(sx, sy, 0.0)] + forward
966
+ if len(line) * step < sep:
967
+ continue
968
+ for p in line:
969
+ taken.setdefault(key(p[0], p[1]), []).append(p)
970
+ start = len(rows)
971
+ rows.extend((p[0], p[1]) for p in line)
972
+ groups.append([start, len(line)])
973
+ head = _line_arrowhead(line, 0.7 * sep)
974
+ if head is not None:
912
975
  start = len(rows)
913
- rows.extend(points)
914
- groups.append([start, len(points)])
976
+ rows.extend(head)
977
+ groups.append([start, len(head)])
915
978
  return rows, groups
916
979
 
917
980
 
981
+ def _line_arrowhead(line, size: float):
982
+ """A barb pair at the middle of ``line``, pointing along it."""
983
+ mid = len(line) // 2
984
+ a, b = line[max(0, mid - 1)], line[min(len(line) - 1, mid + 1)]
985
+ dx, dy = b[0] - a[0], b[1] - a[1]
986
+ mag = float(np.hypot(dx, dy))
987
+ if not np.isfinite(mag) or mag == 0.0:
988
+ return None
989
+ ux, uy = dx / mag, dy / mag
990
+ hx, hy = line[mid][0], line[mid][1]
991
+ bx, by = hx - 0.4 * size * ux, hy - 0.4 * size * uy
992
+ wing = 0.25 * size
993
+ return [(bx - wing * uy, by + wing * ux), (hx, hy), (bx + wing * uy, by - wing * ux)]
994
+
995
+
918
996
  def _field_at(formula, x: float, y: float) -> tuple[float, float]:
919
997
  env = {"x": np.array([x]), "y": np.array([y])}
920
998
  dx = float(np.asarray(_component(formula, "dx", env)).reshape(-1)[0])
plot3/contour.py CHANGED
@@ -42,6 +42,7 @@ _EDGE_LUT = np.array(
42
42
  # it cannot refine a small loop that shares the window with a long curve.
43
43
  _REFINE_SUB = 8
44
44
  _REFINE_NODE_BUDGET = 400_000
45
+ _REFINE_GROW = 4 # rounds of refining around a curve's stray ends
45
46
 
46
47
  _contourpy_mod = None
47
48
  _contourpy_loaded = False
@@ -130,6 +131,51 @@ def _interp_t(a: np.ndarray, b: np.ndarray, level: float) -> np.ndarray:
130
131
  return np.clip(t, 0.0, 1.0)
131
132
 
132
133
 
134
+ _ROOT_STEPS = 14 # bisection steps per crossing: 1/16384 of a fine cell
135
+
136
+
137
+ def _edge_root(sample, fa, fb, xa, ya, xb, yb, level: float) -> np.ndarray:
138
+ """Where the curve crosses each edge, as a fraction from a to b.
139
+
140
+ Linear interpolation of the corner values is exact only where the
141
+ function crosses zero like a line. Near a double or triple root (the
142
+ heart ``(x^2 + y^2 - 1)^3 = x^2 y^3``) it can miss by half a cell, which
143
+ shows as kinks. With ``sample``, the crossing is bisected on the
144
+ function itself, then interpolated inside the final bracket.
145
+ """
146
+ t = _interp_t(fa, fb, level)
147
+ if sample is None:
148
+ return t
149
+ fa = np.asarray(fa, dtype=np.float64)
150
+ fb = np.asarray(fb, dtype=np.float64)
151
+ cross = np.isfinite(fa) & np.isfinite(fb) & ((fa >= level) != (fb >= level))
152
+ if not np.any(cross):
153
+ return t
154
+ xa, ya, xb, yb = (np.broadcast_to(v, fa.shape)[cross] for v in (xa, ya, xb, yb))
155
+ lo = np.zeros(xa.shape)
156
+ hi = np.ones(xa.shape)
157
+ flo = fa[cross] - level
158
+ fhi = fb[cross] - level
159
+ for _ in range(_ROOT_STEPS):
160
+ mid = 0.5 * (lo + hi)
161
+ fm = np.asarray(sample(xa + mid * (xb - xa), ya + mid * (yb - ya)), dtype=np.float64)
162
+ fm = np.broadcast_to(fm, mid.shape) - level
163
+ ok = np.isfinite(fm)
164
+ low_side = ok & ((fm >= 0) == (flo >= 0))
165
+ high_side = ok & ~low_side
166
+ lo = np.where(low_side, mid, lo)
167
+ flo = np.where(low_side, fm, flo)
168
+ hi = np.where(high_side, mid, hi)
169
+ fhi = np.where(high_side, fm, fhi)
170
+ if not ok.all():
171
+ break
172
+ with np.errstate(divide="ignore", invalid="ignore"):
173
+ inner = np.where(fhi != flo, -flo / (fhi - flo), 0.5)
174
+ out = np.array(t, dtype=np.float64, copy=True)
175
+ out[cross] = lo + np.clip(inner, 0.0, 1.0) * (hi - lo)
176
+ return out
177
+
178
+
133
179
  def _pair_segments(
134
180
  local_ids: np.ndarray,
135
181
  cases: np.ndarray,
@@ -367,6 +413,83 @@ def _refine_active_cells(
367
413
  _case, active, _center = _cell_cases(field, level)
368
414
  if not np.any(active):
369
415
  return []
416
+ lines = _refine_cells(xs, ys, active, level, sample, sub)
417
+ # A curve that stops inside the window left the refined cells through
418
+ # a cell whose corners all share a sign: a cusp, or a wedge thinner
419
+ # than one cell. Refine around each stray end until the curve goes on.
420
+ for _ in range(_REFINE_GROW):
421
+ if lines is None:
422
+ return None
423
+ ends = _stray_ends(lines, xs, ys)
424
+ if not ends:
425
+ break
426
+ grown = active.copy()
427
+ for x_val, y_val in ends:
428
+ i = int(np.clip(np.searchsorted(xs, x_val) - 1, 0, len(xs) - 2))
429
+ j = int(np.clip(np.searchsorted(ys, y_val) - 1, 0, len(ys) - 2))
430
+ grown[max(0, j - 1) : j + 2, max(0, i - 1) : i + 2] = True
431
+ if grown.sum() == active.sum():
432
+ break
433
+ active = grown
434
+ lines = _refine_cells(xs, ys, active, level, sample, sub)
435
+ if lines is None:
436
+ return None
437
+ gap = 2.0 * max(float(np.max(np.diff(xs))), float(np.max(np.diff(ys)))) / max(1, int(sub))
438
+ return _join_close_ends(lines, xs, ys, gap)
439
+
440
+
441
+ def _stray_ends(lines, xs, ys) -> list[tuple[float, float]]:
442
+ """End points of open polylines that are not on the window's edge."""
443
+ tol_x = 1e-9 * max(1.0, abs(float(xs[-1] - xs[0])))
444
+ tol_y = 1e-9 * max(1.0, abs(float(ys[-1] - ys[0])))
445
+ out = []
446
+ for line in lines:
447
+ if len(line) < 2 or line[0] == line[-1]:
448
+ continue
449
+ for x_val, y_val in (line[0], line[-1]):
450
+ on_edge = (
451
+ abs(x_val - xs[0]) <= tol_x or abs(x_val - xs[-1]) <= tol_x
452
+ or abs(y_val - ys[0]) <= tol_y or abs(y_val - ys[-1]) <= tol_y
453
+ )
454
+ if not on_edge:
455
+ out.append((x_val, y_val))
456
+ return out
457
+
458
+
459
+ def _join_close_ends(lines, xs, ys, gap: float):
460
+ """Join open polylines whose stray ends are within ``gap`` (a cusp tip)."""
461
+ lines = [list(line) for line in lines if len(line) >= 2]
462
+ while True:
463
+ stray = set(_stray_ends(lines, xs, ys))
464
+ ends = [
465
+ (a, at_end, line[-1] if at_end else line[0])
466
+ for a, line in enumerate(lines)
467
+ for at_end in (False, True)
468
+ if line[0] != line[-1] and (line[-1] if at_end else line[0]) in stray
469
+ ]
470
+ pair = next(
471
+ (
472
+ (p, q)
473
+ for k, p in enumerate(ends)
474
+ for q in ends[k + 1 :]
475
+ if (p[0], p[1]) != (q[0], q[1])
476
+ and np.hypot(p[2][0] - q[2][0], p[2][1] - q[2][1]) <= gap
477
+ ),
478
+ None,
479
+ )
480
+ if pair is None:
481
+ return lines
482
+ (a, a_end, _pa), (b, b_end, _pb) = pair
483
+ if a == b:
484
+ lines[a].append(lines[a][0]) # both ends of one line: close it
485
+ continue
486
+ first = lines[a] if a_end else lines[a][::-1] # ends at the joint
487
+ second = lines[b] if not b_end else lines[b][::-1] # starts at it
488
+ lines[a] = first + second
489
+ del lines[b]
490
+
491
+
492
+ def _refine_cells(xs, ys, active, level, sample, sub):
370
493
  jj, ii = np.nonzero(active)
371
494
  sub = int(sub)
372
495
  nodes = sub + 1
@@ -389,7 +512,7 @@ def _refine_active_cells(
389
512
  fine = np.asarray(sample(xx, yy), dtype=np.float64)
390
513
  if fine.shape != xx.shape:
391
514
  fine = np.broadcast_to(fine, xx.shape).astype(np.float64, copy=False)
392
- return _contour_refined(ii, jj, xs, ys, fine, xf, yf, level, sub)
515
+ return _contour_refined(ii, jj, xs, ys, fine, xf, yf, level, sub, sample)
393
516
 
394
517
 
395
518
  def _contour_refined(
@@ -402,6 +525,7 @@ def _contour_refined(
402
525
  yf: np.ndarray,
403
526
  level: float,
404
527
  sub: int,
528
+ sample=None,
405
529
  ) -> list[list[tuple[float, float]]]:
406
530
  """Marching squares on a batch of refined cells, chained by global edge id."""
407
531
  f00 = fine[:, :-1, :-1]
@@ -429,7 +553,7 @@ def _contour_refined(
429
553
  ny = int(ys.shape[0])
430
554
  h_stride = (nx - 1) * sub
431
555
  v_stride = (nx - 1) * sub + 1
432
- n_horizontal = h_stride * ((ny - 1) * sub)
556
+ n_horizontal = h_stride * ((ny - 1) * sub + 1) # rows of cells + 1 rows of edges
433
557
  bottom = gj * h_stride + gi
434
558
  top = (gj + 1) * h_stride + gi
435
559
  left = n_horizontal + gj * v_stride + gi
@@ -445,30 +569,18 @@ def _contour_refined(
445
569
  for edge, x_val, y_val in zip(edge_ids.tolist(), x_vals.tolist(), y_vals.tolist()):
446
570
  points[int(edge)] = (float(x_val), float(y_val))
447
571
 
448
- t = _interp_t(fine[aj, lj, li], fine[aj, lj, li + 1], level)
449
- put(
450
- bottom,
451
- xf[aj, li] + t * (xf[aj, li + 1] - xf[aj, li]),
452
- yf[aj, lj],
453
- )
454
- t = _interp_t(fine[aj, lj, li + 1], fine[aj, lj + 1, li + 1], level)
455
- put(
456
- right,
457
- xf[aj, li + 1],
458
- yf[aj, lj] + t * (yf[aj, lj + 1] - yf[aj, lj]),
459
- )
460
- t = _interp_t(fine[aj, lj + 1, li], fine[aj, lj + 1, li + 1], level)
461
- put(
462
- top,
463
- xf[aj, li] + t * (xf[aj, li + 1] - xf[aj, li]),
464
- yf[aj, lj + 1],
465
- )
466
- t = _interp_t(fine[aj, lj, li], fine[aj, lj + 1, li], level)
467
- put(
468
- left,
469
- xf[aj, li],
470
- yf[aj, lj] + t * (yf[aj, lj + 1] - yf[aj, lj]),
471
- )
572
+ x0, x1 = xf[aj, li], xf[aj, li + 1]
573
+ y0, y1 = yf[aj, lj], yf[aj, lj + 1]
574
+ f00, f10 = fine[aj, lj, li], fine[aj, lj, li + 1]
575
+ f01, f11 = fine[aj, lj + 1, li], fine[aj, lj + 1, li + 1]
576
+ t = _edge_root(sample, f00, f10, x0, y0, x1, y0, level)
577
+ put(bottom, x0 + t * (x1 - x0), y0)
578
+ t = _edge_root(sample, f10, f11, x1, y0, x1, y1, level)
579
+ put(right, x1, y0 + t * (y1 - y0))
580
+ t = _edge_root(sample, f01, f11, x0, y1, x1, y1, level)
581
+ put(top, x0 + t * (x1 - x0), y1)
582
+ t = _edge_root(sample, f00, f01, x0, y0, x0, y1, level)
583
+ put(left, x0, y0 + t * (y1 - y0))
472
584
 
473
585
  def point_of(edge: int) -> tuple[float, float]:
474
586
  return points[edge]
plot3/expr.py CHANGED
@@ -434,6 +434,14 @@ def _parse_math(
434
434
  code_tree = ast.BinOp(left=lhs_tree, op=ast.Sub(), right=rhs_tree)
435
435
  # Names only on the left still count (already in ``free``).
436
436
 
437
+ if len(free) > 2 and defer_missing and "x" in free and "y" in free:
438
+ # ``z = sin(r - t)`` or ``x^2 + y^2 = r^2``: x and y are the axes, so
439
+ # any other letter is a coefficient a slider or transition will
440
+ # sweep. Unswept ones fail at build time like any missing coefficient.
441
+ pending.extend(name for name in free if name not in ("x", "y"))
442
+ free = [name for name in free if name in ("x", "y")]
443
+ if mode == "explicit" and lhs_tree is None:
444
+ dependent = "z"
437
445
  if len(free) > 2:
438
446
  listed = ", ".join(free)
439
447
  raise ExprError(
plot3/geoms.py CHANGED
@@ -417,7 +417,8 @@ class geom_isosurface(_Geom):
417
417
  Density grid bins per axis (8–64). Overridden by a preceding
418
418
  :class:`stat_density_3d` if present on the figure.
419
419
  colour_by:
420
- ``"level"`` colours mesh vertices by isolevel index (default).
420
+ ``"level"`` colours each surface by its level (default), on the
421
+ same 0–1 scale as ``levels``.
421
422
  wireframe, alpha:
422
423
  Same idea as :class:`geom_surface`.
423
424
  """
@@ -2066,12 +2067,17 @@ class scale_x_log10:
2066
2067
  Positions are encoded in log10 space, so a decade is a constant distance
2067
2068
  and a tween along the axis moves in log space. Non-positive values are
2068
2069
  omitted. Tick labels stay in the original units (1, 10, 100, …).
2070
+ ``name``, ``limits``, ``breaks``, and ``labels`` work as in
2071
+ ``scale_x_continuous``.
2069
2072
  """
2070
2073
 
2071
2074
  axis = "x"
2072
2075
 
2076
+ def __init__(self, name=None, *, limits=None, breaks=None, labels=None):
2077
+ self.name, self.limits, self.breaks, self.labels = name, limits, breaks, labels
2073
2078
 
2074
- class scale_y_log10:
2079
+
2080
+ class scale_y_log10(scale_x_log10):
2075
2081
  """Base-10 logarithmic scale for y. See :class:`scale_x_log10`."""
2076
2082
 
2077
2083
  axis = "y"
plot3/ggplot.py CHANGED
@@ -355,22 +355,23 @@ class ggplot:
355
355
  g.coord = other
356
356
  elif isinstance(other, stat_density_3d):
357
357
  g.stat_density_3d = other
358
- elif isinstance(other, scale_x_log10):
359
- g.scale_x = other
360
- elif isinstance(other, scale_y_log10):
361
- g.scale_y = other
358
+ elif isinstance(other, scale_x_log10): # also scale_y_log10
359
+ g = g + _scaling.PositionScale(
360
+ other.axis, "continuous", name=other.name, limits=other.limits,
361
+ breaks=other.breaks, labels=other.labels, trans="log10",
362
+ )
362
363
  elif isinstance(other, _scaling._Lims):
363
364
  for part in other:
364
365
  g = g + part
365
366
  elif isinstance(other, _scaling.PositionScale):
366
367
  if other.axis == "x":
367
368
  g.xscale = other
368
- g.scale_x = scale_x_log10() if other.trans == "log10" else (
369
+ g.scale_x = scale_x_log10(other.name) if other.trans == "log10" else (
369
370
  None if other.kind == "continuous" else g.scale_x
370
371
  )
371
372
  else:
372
373
  g.yscale = other
373
- g.scale_y = scale_y_log10() if other.trans == "log10" else (
374
+ g.scale_y = scale_y_log10(other.name) if other.trans == "log10" else (
374
375
  None if other.kind == "continuous" else g.scale_y
375
376
  )
376
377
  elif isinstance(other, _scaling.ColourScale):
plot3/static.py CHANGED
@@ -1065,6 +1065,10 @@ def _draw_2d(
1065
1065
  box = _box_2d(spec, x, y, w, h, labs, fonts, extra_right, extra_bottom)
1066
1066
  scales = spec.get("scales") or {}
1067
1067
  window = _view_window(spec, box[2], box[3])
1068
+ scales = dict(scales)
1069
+ for axis, u_lo, u_hi in (("x", window[0], window[1]), ("y", window[2], window[3])):
1070
+ if axis in scales:
1071
+ scales[axis] = _window_ticks(scales[axis], u_lo, u_hi)
1068
1072
  surface = theme.get("surface") or "#0b1020"
1069
1073
  grid = theme.get("grid") or "#1c2742"
1070
1074
  axis = theme.get("axis") or "#2e3a5c"
@@ -1429,6 +1433,15 @@ def _draw_3d(
1429
1433
  # labels the same room as the others.
1430
1434
  inner = _inset_box(box, layout["pad"])
1431
1435
  project, centre = _fit_projector(base, ext, inner, zoom)
1436
+ if not void and centre is not None:
1437
+ # A long axis title ("elevation") beside a short edge can reach past
1438
+ # the canvas: narrow the cube on that side to make room for it.
1439
+ spots = _axis_title_spots(ext, project, centre, labs, fonts, layout)
1440
+ left, right = _title_overflow(spots, fonts, (x, y, w, h))
1441
+ if left > 0.5 or right > 0.5:
1442
+ ix, iy, iw, ih = inner
1443
+ inner = (ix + left, iy, max(4.0, iw - left - right), ih)
1444
+ project, centre = _fit_projector(base, ext, inner, zoom)
1432
1445
  ink = theme.get("ink") or "#ffffff"
1433
1446
  gz = bool(spec.get("gz"))
1434
1447
  min_dim = min(box[2], box[3])
@@ -2449,6 +2462,16 @@ def _clamp_point(px: float, py: float, bounds) -> tuple[float, float]:
2449
2462
  )
2450
2463
 
2451
2464
 
2465
+ def _clamp_label(px, py, text, size, anchor, baseline, bounds) -> tuple[float, float]:
2466
+ """``(px, py)`` moved so the whole label, not only its anchor, stays in ``bounds``."""
2467
+ px, py = _clamp_point(px, py, bounds)
2468
+ left, top, width, height = _label_box(px, py, text, size, anchor, baseline)
2469
+ x, y, w, h = bounds
2470
+ dx = max(0.0, x + 3.0 - left) - max(0.0, left + width - (x + w - 3.0))
2471
+ dy = max(0.0, y + 3.0 - top) - max(0.0, top + height - (y + h - 3.0))
2472
+ return px + dx, py + dy
2473
+
2474
+
2452
2475
  def _thin_marks(marks, air: float) -> list:
2453
2476
  """Every k-th tick, the smallest k whose labels do not touch, keeping
2454
2477
  round values (0, 5, 10 rather than 1, 4, 7) when the ticks allow."""
@@ -2472,6 +2495,41 @@ def _thin_marks(marks, air: float) -> list:
2472
2495
  return marks[:1]
2473
2496
 
2474
2497
 
2498
+ def _axis_title_spots(ext, project, centre, labs, fonts, layout) -> list:
2499
+ """``(text, x, y, anchor, baseline)`` for each 3D axis title, unclamped."""
2500
+ if centre is None:
2501
+ return []
2502
+ corners = _cube_corners(project, ext)
2503
+ hull = _hull_edge_set(corners)
2504
+ spots = []
2505
+ for axis in ("x", "y", "z"):
2506
+ text = labs.get(axis) or ""
2507
+ if not text:
2508
+ continue
2509
+ pair = _visible_axis_edge(axis, hull, corners, centre)
2510
+ if corners[pair[0]] is None or corners[pair[1]] is None:
2511
+ continue
2512
+ nx, ny = _outward_normal(corners[pair[0]], corners[pair[1]], centre)
2513
+ anchor, baseline = _outside_anchor(nx, ny)
2514
+ mid = project(_corner_xyz(pair[0], ext) * 0.5 + _corner_xyz(pair[1], ext) * 0.5)
2515
+ if mid is None:
2516
+ continue
2517
+ gap = layout["title_gap"]
2518
+ spots.append((text, mid[0] + nx * gap, mid[1] + ny * gap, anchor, baseline))
2519
+ return spots
2520
+
2521
+
2522
+ def _title_overflow(spots, fonts, bounds) -> tuple[float, float]:
2523
+ """How far the axis titles reach past the left and right of ``bounds``."""
2524
+ x, _y, w, _h = bounds
2525
+ left = right = 0.0
2526
+ for text, lx, ly, anchor, baseline in spots:
2527
+ bx, _by, bw, _bh = _label_box(lx, ly, text, fonts[0], anchor, baseline)
2528
+ left = max(left, x + 3.0 - bx)
2529
+ right = max(right, bx + bw - (x + w - 3.0))
2530
+ return left, right
2531
+
2532
+
2475
2533
  def _draw_3d_axes(spec, ext, project, centre, commands, theme, fonts, labs, bounds, layout) -> None:
2476
2534
  """Tick numbers and axis names just outside the three visible edges."""
2477
2535
  if centre is None:
@@ -2482,7 +2540,6 @@ def _draw_3d_axes(spec, ext, project, centre, commands, theme, fonts, labs, boun
2482
2540
  hull = _hull_edge_set(corners)
2483
2541
  scales = spec.get("scales") or {}
2484
2542
  tick_gap = layout["tick_gap"]
2485
- title_gap = layout["title_gap"]
2486
2543
  placed: list[tuple[float, float, float, float]] = [] # label boxes, all axes
2487
2544
  for axis in ("x", "y", "z"):
2488
2545
  pair = _visible_axis_edge(axis, hull, corners, centre)
@@ -2504,7 +2561,10 @@ def _draw_3d_axes(spec, ext, project, centre, commands, theme, fonts, labs, boun
2504
2561
  hit = project(_corner_xyz(pair[0], ext) * (1.0 - u) + _corner_xyz(pair[1], ext) * u)
2505
2562
  if hit is None:
2506
2563
  continue
2507
- tx, ty = _clamp_point(hit[0] + nx * tick_gap, hit[1] + ny * tick_gap, bounds)
2564
+ tx, ty = _clamp_label(
2565
+ hit[0] + nx * tick_gap, hit[1] + ny * tick_gap, str(lab), fonts[0],
2566
+ anchor, baseline, bounds,
2567
+ )
2508
2568
  box = _label_box(tx, ty, str(lab), fonts[0], anchor, baseline)
2509
2569
  marks.append((value, str(lab), hit, tx, ty, box))
2510
2570
  reach = max(3.0, tick_gap - 2.0)
@@ -2522,13 +2582,8 @@ def _draw_3d_axes(spec, ext, project, centre, commands, theme, fonts, labs, boun
2522
2582
  "text", tx, ty, lab, fonts[0], muted,
2523
2583
  anchor, baseline, 0, 400,
2524
2584
  ))
2525
- text = labs.get(axis) or ""
2526
- if not text:
2527
- continue
2528
- mid = project(_corner_xyz(pair[0], ext) * 0.5 + _corner_xyz(pair[1], ext) * 0.5)
2529
- if mid is None:
2530
- continue
2531
- lx, ly = _clamp_point(mid[0] + nx * title_gap, mid[1] + ny * title_gap, bounds)
2585
+ for text, lx, ly, anchor, baseline in _axis_title_spots(ext, project, centre, labs, fonts, layout):
2586
+ lx, ly = _clamp_label(lx, ly, text, fonts[0], anchor, baseline, bounds)
2532
2587
  commands.append((
2533
2588
  "text", lx, ly, text, fonts[0], ink2, anchor, baseline, 0, 400,
2534
2589
  ))
@@ -2609,6 +2664,30 @@ def _view_window(spec, plot_w, plot_h):
2609
2664
  return (0.5 - nx / 2, 0.5 + nx / 2, 0.5 - ny / 2, 0.5 + ny / 2)
2610
2665
 
2611
2666
 
2667
+ def _window_ticks(scale: dict, u_lo: float, u_hi: float) -> dict:
2668
+ """Ticks across the visible window when coord_equal widens an axis.
2669
+
2670
+ The scale's ticks span the data. Equal aspect can show far more than
2671
+ that (``y^2 = x^3`` is 4.6 wide and 20 tall), and the data's ticks then
2672
+ bunch in a strip. The viewer re-ticks whatever is visible; so does this.
2673
+ Breaks the user chose (``fixed``) are kept.
2674
+ """
2675
+ if (
2676
+ scale.get("kind") != "num" or scale.get("fixed") or scale.get("trans") == "log10"
2677
+ or (u_hi - u_lo) <= 1.2 * (1 + 2 * _PAD)
2678
+ ):
2679
+ return scale
2680
+ from plot3.scales import fmt_ticks, nice_ticks
2681
+
2682
+ lo = float(scale.get("lo", 0.0))
2683
+ span = float(scale.get("hi", 1.0)) - lo or 1.0
2684
+ v_lo, v_hi = lo + u_lo * span, lo + u_hi * span
2685
+ values = [v for v in nice_ticks(v_lo, v_hi, 5) if v_lo <= v <= v_hi]
2686
+ if not values:
2687
+ return scale
2688
+ return dict(scale, ticks=[[v, label] for v, label in zip(values, fmt_ticks(values))])
2689
+
2690
+
2612
2691
  def _ticks(scale: dict) -> list:
2613
2692
  if not scale:
2614
2693
  return []