meowplotlib 0.1.0__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.
- meowplotlib/__init__.py +9 -0
- meowplotlib/api.py +74 -0
- meowplotlib/assets/__init__.py +0 -0
- meowplotlib/assets/_toml_fallback.py +46 -0
- meowplotlib/assets/images/chonk/chonk_01.png +0 -0
- meowplotlib/assets/images/classic/classic_01.png +0 -0
- meowplotlib/assets/images/derp/derp_01.png +0 -0
- meowplotlib/assets/registry.py +103 -0
- meowplotlib/assets/styles.toml +14 -0
- meowplotlib/core/__init__.py +0 -0
- meowplotlib/core/config.py +36 -0
- meowplotlib/core/placement.py +183 -0
- meowplotlib/core/rng.py +10 -0
- meowplotlib/render/__init__.py +0 -0
- meowplotlib/render/artist.py +88 -0
- meowplotlib/render/bboxes.py +76 -0
- meowplotlib/render/hook.py +69 -0
- meowplotlib-0.1.0.dist-info/METADATA +66 -0
- meowplotlib-0.1.0.dist-info/RECORD +20 -0
- meowplotlib-0.1.0.dist-info/WHEEL +4 -0
meowplotlib/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""meowplotlib: whimsical cat decorations for matplotlib figures."""
|
|
2
|
+
|
|
3
|
+
from meowplotlib.api import config, disable, enable, set_density, set_seed, set_style
|
|
4
|
+
from meowplotlib.render.hook import install as _install
|
|
5
|
+
|
|
6
|
+
__all__ = ["config", "disable", "enable", "set_density", "set_seed", "set_style"]
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
_install()
|
meowplotlib/api.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""User-facing public API: enable/disable/set_style/set_density/set_seed/config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
|
|
8
|
+
from meowplotlib.assets.registry import resolve_style_names
|
|
9
|
+
from meowplotlib.core.config import get_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def enable() -> None:
|
|
13
|
+
"""Re-enable cat decoration for the current session."""
|
|
14
|
+
get_config().enabled = True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def disable() -> None:
|
|
18
|
+
"""Disable cat decoration for the current session."""
|
|
19
|
+
get_config().enabled = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_VALID_DENSITIES = ("sparse", "normal", "chaotic")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _validate_style(style: object) -> None:
|
|
26
|
+
if not isinstance(style, str | list):
|
|
27
|
+
raise TypeError(f"style must be a str or list[str], got {type(style).__name__}")
|
|
28
|
+
resolve_style_names(style) # fail-fast; "mix" always validates, resolved fresh at render time
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _validate_density(density: object) -> None:
|
|
32
|
+
if density not in _VALID_DENSITIES:
|
|
33
|
+
raise ValueError(f"Unknown density: {density!r}. Valid options: {_VALID_DENSITIES}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set_style(style: str | list[str]) -> None:
|
|
37
|
+
"""Select a single style, a mix of styles, or "mix" for all styles.
|
|
38
|
+
|
|
39
|
+
Validates eagerly: an unregistered style name raises immediately, rather than
|
|
40
|
+
surfacing as a silent absence of cats at the next render.
|
|
41
|
+
"""
|
|
42
|
+
_validate_style(style)
|
|
43
|
+
get_config().style = style
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def set_density(density: str) -> None:
|
|
47
|
+
"""Set cat density: "sparse", "normal", or "chaotic"."""
|
|
48
|
+
_validate_density(density)
|
|
49
|
+
get_config().density = density
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def set_seed(seed: int | None) -> None:
|
|
53
|
+
"""Set the RNG seed for reproducible placements, or None for randomized."""
|
|
54
|
+
get_config().seed = seed
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextmanager
|
|
58
|
+
def config(**overrides: object) -> Iterator[None]:
|
|
59
|
+
"""Temporarily override config values within a `with` block.
|
|
60
|
+
|
|
61
|
+
Applies the same validation as `set_style`/`set_density` to any overridden values.
|
|
62
|
+
"""
|
|
63
|
+
if "style" in overrides:
|
|
64
|
+
_validate_style(overrides["style"])
|
|
65
|
+
if "density" in overrides:
|
|
66
|
+
_validate_density(overrides["density"])
|
|
67
|
+
|
|
68
|
+
cfg = get_config()
|
|
69
|
+
saved = cfg.snapshot()
|
|
70
|
+
try:
|
|
71
|
+
cfg.update(**overrides)
|
|
72
|
+
yield
|
|
73
|
+
finally:
|
|
74
|
+
cfg.restore(saved)
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Minimal parser for styles.toml on Python < 3.11 (no stdlib tomllib).
|
|
2
|
+
|
|
3
|
+
NOT a general-purpose TOML parser. Covers exactly the subset styles.toml uses: flat
|
|
4
|
+
`[styles.<name>]` sections, each with a quoted `display_name` string and an optional numeric
|
|
5
|
+
`scale`. See specs/003-style-system/research.md for why this exists instead of a dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
_SECTION_RE = re.compile(r"^\[styles\.([A-Za-z0-9_-]+)\]\s*$")
|
|
13
|
+
_STRING_KV_RE = re.compile(r'^display_name\s*=\s*"([^"]*)"\s*$')
|
|
14
|
+
_FLOAT_KV_RE = re.compile(r"^scale\s*=\s*([0-9.]+)\s*$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def loads(text: str) -> dict[str, object]:
|
|
18
|
+
"""Parse styles.toml's specific shape into {"styles": {name: {...}}}."""
|
|
19
|
+
styles: dict[str, dict[str, object]] = {}
|
|
20
|
+
current: str | None = None
|
|
21
|
+
|
|
22
|
+
for raw_line in text.splitlines():
|
|
23
|
+
line = raw_line.split("#", 1)[0].strip()
|
|
24
|
+
if not line:
|
|
25
|
+
continue
|
|
26
|
+
|
|
27
|
+
section_match = _SECTION_RE.match(line)
|
|
28
|
+
if section_match:
|
|
29
|
+
current = section_match.group(1)
|
|
30
|
+
styles[current] = {}
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
if current is None:
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
string_match = _STRING_KV_RE.match(line)
|
|
37
|
+
if string_match:
|
|
38
|
+
styles[current]["display_name"] = string_match.group(1)
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
float_match = _FLOAT_KV_RE.match(line)
|
|
42
|
+
if float_match:
|
|
43
|
+
styles[current]["scale"] = float(float_match.group(1))
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
return {"styles": styles}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Discovers styles from the assets dir + manifest. Zero-code-change style extension.
|
|
2
|
+
|
|
3
|
+
Re-scans the filesystem on every query rather than caching (see research.md) — style counts are
|
|
4
|
+
tiny, and this keeps "drop a file, it just works" true even mid-process.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.resources
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
if sys.version_info >= (3, 11):
|
|
15
|
+
import tomllib
|
|
16
|
+
else:
|
|
17
|
+
from meowplotlib.assets import _toml_fallback as tomllib # type: ignore[no-redef]
|
|
18
|
+
|
|
19
|
+
IMAGE_SUFFIXES = (".png",)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class StyleInfo:
|
|
24
|
+
"""A discovered, valid style: has both an image directory and a manifest entry."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
display_name: str
|
|
28
|
+
scale: float = 1.0
|
|
29
|
+
image_paths: list[Path] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_manifest(manifest_path: Path) -> dict[str, dict[str, object]]:
|
|
33
|
+
if not manifest_path.is_file():
|
|
34
|
+
return {}
|
|
35
|
+
if sys.version_info >= (3, 11):
|
|
36
|
+
with manifest_path.open("rb") as f:
|
|
37
|
+
data = tomllib.load(f)
|
|
38
|
+
else:
|
|
39
|
+
data = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
|
|
40
|
+
styles = data.get("styles", {})
|
|
41
|
+
return styles if isinstance(styles, dict) else {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def discover_styles(images_dir: Path, manifest_path: Path) -> dict[str, StyleInfo]:
|
|
45
|
+
"""Scan `images_dir` + parse `manifest_path`; return styles with both present."""
|
|
46
|
+
manifest_styles = _parse_manifest(manifest_path)
|
|
47
|
+
result: dict[str, StyleInfo] = {}
|
|
48
|
+
|
|
49
|
+
if not images_dir.is_dir():
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
for style_dir in sorted(images_dir.iterdir()):
|
|
53
|
+
if not style_dir.is_dir():
|
|
54
|
+
continue
|
|
55
|
+
name = style_dir.name
|
|
56
|
+
manifest_entry = manifest_styles.get(name)
|
|
57
|
+
if not isinstance(manifest_entry, dict):
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
image_paths = sorted(p for p in style_dir.iterdir() if p.suffix.lower() in IMAGE_SUFFIXES)
|
|
61
|
+
if not image_paths:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
display_name = manifest_entry.get("display_name")
|
|
65
|
+
if not isinstance(display_name, str):
|
|
66
|
+
continue
|
|
67
|
+
scale = manifest_entry.get("scale", 1.0)
|
|
68
|
+
if not isinstance(scale, int | float):
|
|
69
|
+
scale = 1.0
|
|
70
|
+
|
|
71
|
+
result[name] = StyleInfo(
|
|
72
|
+
name=name,
|
|
73
|
+
display_name=display_name,
|
|
74
|
+
scale=float(scale),
|
|
75
|
+
image_paths=image_paths,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def available_styles() -> dict[str, StyleInfo]:
|
|
82
|
+
"""Discover styles from the package's own bundled assets tree."""
|
|
83
|
+
images_root = importlib.resources.files("meowplotlib.assets") / "images"
|
|
84
|
+
manifest = importlib.resources.files("meowplotlib.assets") / "styles.toml"
|
|
85
|
+
with (
|
|
86
|
+
importlib.resources.as_file(images_root) as images_dir,
|
|
87
|
+
importlib.resources.as_file(manifest) as manifest_path,
|
|
88
|
+
):
|
|
89
|
+
return discover_styles(images_dir, manifest_path)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve_style_names(selection: str | list[str]) -> list[str]:
|
|
93
|
+
"""Resolve set_style()'s raw value to a flat, validated list of style names."""
|
|
94
|
+
styles = available_styles()
|
|
95
|
+
|
|
96
|
+
if selection == "mix":
|
|
97
|
+
return list(styles)
|
|
98
|
+
|
|
99
|
+
names = [selection] if isinstance(selection, str) else selection
|
|
100
|
+
for name in names:
|
|
101
|
+
if name not in styles:
|
|
102
|
+
raise ValueError(f"Unknown style: {name!r}. Available styles: {sorted(styles)}")
|
|
103
|
+
return list(names)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Style manifest. Adding a style = a new [styles.<name>] section + a directory
|
|
2
|
+
# of PNGs under assets/images/<name>/. No code changes required.
|
|
3
|
+
|
|
4
|
+
[styles.classic]
|
|
5
|
+
display_name = "Classic"
|
|
6
|
+
scale = 1.0
|
|
7
|
+
|
|
8
|
+
[styles.derp]
|
|
9
|
+
display_name = "Derp"
|
|
10
|
+
scale = 1.0
|
|
11
|
+
|
|
12
|
+
[styles.chonk]
|
|
13
|
+
display_name = "Chonk"
|
|
14
|
+
scale = 1.15
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Module-level config dataclass. No matplotlib imports, no I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, fields
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Config:
|
|
10
|
+
"""Global meowplotlib configuration, mutated by api.py and read by render/hook.py."""
|
|
11
|
+
|
|
12
|
+
enabled: bool = True
|
|
13
|
+
style: str | list[str] = "mix"
|
|
14
|
+
density: str = "normal"
|
|
15
|
+
seed: int | None = None
|
|
16
|
+
|
|
17
|
+
def snapshot(self) -> dict[str, object]:
|
|
18
|
+
return {f.name: getattr(self, f.name) for f in fields(self)}
|
|
19
|
+
|
|
20
|
+
def restore(self, snapshot: dict[str, object]) -> None:
|
|
21
|
+
for name, value in snapshot.items():
|
|
22
|
+
setattr(self, name, value)
|
|
23
|
+
|
|
24
|
+
def update(self, **overrides: object) -> None:
|
|
25
|
+
valid = {f.name for f in fields(self)}
|
|
26
|
+
for name, value in overrides.items():
|
|
27
|
+
if name not in valid:
|
|
28
|
+
raise TypeError(f"Unknown config option: {name}")
|
|
29
|
+
setattr(self, name, value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_CONFIG = Config()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_config() -> Config:
|
|
36
|
+
return _CONFIG
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Placement geometry: figure dims + exclusion bboxes -> cat placements.
|
|
2
|
+
|
|
3
|
+
Pure logic — no matplotlib imports, no I/O (constitution #1).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from meowplotlib.core.rng import new_rng
|
|
13
|
+
|
|
14
|
+
Density = Literal["sparse", "normal", "chaotic"]
|
|
15
|
+
|
|
16
|
+
# Per-tier area divisor: target count = available_border_area / divisor. Chosen so a reference
|
|
17
|
+
# canvas (unit square, ~70% centered exclusion, default size_range) yields roughly 6/12/24 cats
|
|
18
|
+
# for sparse/normal/chaotic respectively — strictly increasing on any fixed canvas. (Halved from
|
|
19
|
+
# the original 3/6/12 tuning per user request for a denser default look, 2026-07-02.)
|
|
20
|
+
_DENSITY_DIVISORS: dict[Density, float] = {
|
|
21
|
+
"sparse": 0.0655,
|
|
22
|
+
"normal": 0.0328,
|
|
23
|
+
"chaotic": 0.0164,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Fixed edge margin as a fraction of the smaller canvas dimension.
|
|
27
|
+
_EDGE_MARGIN_FRACTION = 0.03
|
|
28
|
+
|
|
29
|
+
_MAX_ATTEMPTS_PER_CANDIDATE = 30
|
|
30
|
+
_SHRINK_FACTOR = 0.85
|
|
31
|
+
|
|
32
|
+
# Defensive upper bound on target count regardless of canvas size, so a pathologically large
|
|
33
|
+
# canvas can't make placement (O(n^2) collision checks against prior placements) unbounded.
|
|
34
|
+
_MAX_TARGET_COUNT = 200
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Rect:
|
|
39
|
+
"""Axis-aligned bounding rectangle in figure-fraction coordinates."""
|
|
40
|
+
|
|
41
|
+
x: float
|
|
42
|
+
y: float
|
|
43
|
+
width: float
|
|
44
|
+
height: float
|
|
45
|
+
|
|
46
|
+
def intersects(self, other: Rect) -> bool:
|
|
47
|
+
return (
|
|
48
|
+
self.x < other.x + other.width
|
|
49
|
+
and other.x < self.x + self.width
|
|
50
|
+
and self.y < other.y + other.height
|
|
51
|
+
and other.y < self.y + self.height
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class PlacementConfig:
|
|
57
|
+
"""Input configuration for one `place_cats(...)` call."""
|
|
58
|
+
|
|
59
|
+
density: Density
|
|
60
|
+
size_range: tuple[float, float]
|
|
61
|
+
seed: int | None
|
|
62
|
+
styles: list[str]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Placement:
|
|
67
|
+
"""One engine output entry."""
|
|
68
|
+
|
|
69
|
+
x: float
|
|
70
|
+
y: float
|
|
71
|
+
size: float
|
|
72
|
+
rotation: float
|
|
73
|
+
style: str
|
|
74
|
+
|
|
75
|
+
def bbox(self) -> Rect:
|
|
76
|
+
"""Axis-aligned bounding box of this cat's square footprint AFTER rotation.
|
|
77
|
+
|
|
78
|
+
Reserves the true rotated extent (side * (|cos theta| + |sin theta|), up to ~1.41x at
|
|
79
|
+
45 degrees) rather than the unrotated square, so a rendered, actually-rotated image
|
|
80
|
+
never exceeds the space collision-checked against exclusions and other placements. See
|
|
81
|
+
specs/001-core-placement-engine/research.md's 2026-07-02 addendum.
|
|
82
|
+
"""
|
|
83
|
+
theta = math.radians(self.rotation)
|
|
84
|
+
side = self.size * (abs(math.cos(theta)) + abs(math.sin(theta)))
|
|
85
|
+
half = side / 2
|
|
86
|
+
return Rect(x=self.x - half, y=self.y - half, width=side, height=side)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _margin_rects(canvas_width: float, canvas_height: float, margin: float) -> list[Rect]:
|
|
90
|
+
"""Four thin rectangles covering the edge-margin band, so it composes with exclusions."""
|
|
91
|
+
return [
|
|
92
|
+
Rect(x=0, y=0, width=canvas_width, height=margin), # bottom
|
|
93
|
+
Rect(x=0, y=canvas_height - margin, width=canvas_width, height=margin), # top
|
|
94
|
+
Rect(x=0, y=0, width=margin, height=canvas_height), # left
|
|
95
|
+
Rect(x=canvas_width - margin, y=0, width=margin, height=canvas_height), # right
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _available_border_area(
|
|
100
|
+
canvas_width: float, canvas_height: float, exclusions: list[Rect], margin: float
|
|
101
|
+
) -> float:
|
|
102
|
+
"""Coarse Monte Carlo estimate of canvas area outside exclusions and the edge margin."""
|
|
103
|
+
inner = Rect(
|
|
104
|
+
x=margin,
|
|
105
|
+
y=margin,
|
|
106
|
+
width=max(0.0, canvas_width - 2 * margin),
|
|
107
|
+
height=max(0.0, canvas_height - 2 * margin),
|
|
108
|
+
)
|
|
109
|
+
if inner.width <= 0 or inner.height <= 0:
|
|
110
|
+
return 0.0
|
|
111
|
+
total = inner.width * inner.height
|
|
112
|
+
excluded = 0.0
|
|
113
|
+
for rect in exclusions:
|
|
114
|
+
overlap_w = min(inner.x + inner.width, rect.x + rect.width) - max(inner.x, rect.x)
|
|
115
|
+
overlap_h = min(inner.y + inner.height, rect.y + rect.height) - max(inner.y, rect.y)
|
|
116
|
+
if overlap_w > 0 and overlap_h > 0:
|
|
117
|
+
excluded += overlap_w * overlap_h
|
|
118
|
+
return max(0.0, total - excluded)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _target_count(available_area: float, density: Density) -> int:
|
|
122
|
+
if available_area <= 0:
|
|
123
|
+
return 0
|
|
124
|
+
return min(_MAX_TARGET_COUNT, int(available_area / _DENSITY_DIVISORS[density]))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def place_cats(
|
|
128
|
+
canvas_width: float,
|
|
129
|
+
canvas_height: float,
|
|
130
|
+
exclusions: list[Rect],
|
|
131
|
+
config: PlacementConfig,
|
|
132
|
+
) -> list[Placement]:
|
|
133
|
+
"""Return non-overlapping cat placements confined to the canvas border region.
|
|
134
|
+
|
|
135
|
+
See specs/001-core-placement-engine/contracts/placement-api.md for the full contract.
|
|
136
|
+
"""
|
|
137
|
+
if canvas_width <= 0 or canvas_height <= 0 or not config.styles:
|
|
138
|
+
return []
|
|
139
|
+
|
|
140
|
+
margin = _EDGE_MARGIN_FRACTION * min(canvas_width, canvas_height)
|
|
141
|
+
blocked = list(exclusions) + _margin_rects(canvas_width, canvas_height, margin)
|
|
142
|
+
available_area = _available_border_area(canvas_width, canvas_height, exclusions, margin)
|
|
143
|
+
target = _target_count(available_area, config.density)
|
|
144
|
+
if target <= 0:
|
|
145
|
+
return []
|
|
146
|
+
|
|
147
|
+
rng = new_rng(config.seed)
|
|
148
|
+
min_size, max_size = config.size_range
|
|
149
|
+
placements: list[Placement] = []
|
|
150
|
+
|
|
151
|
+
for _ in range(target):
|
|
152
|
+
size = max_size
|
|
153
|
+
placed = False
|
|
154
|
+
while not placed:
|
|
155
|
+
for _ in range(_MAX_ATTEMPTS_PER_CANDIDATE):
|
|
156
|
+
rotation = rng.uniform(0, 360)
|
|
157
|
+
theta = math.radians(rotation)
|
|
158
|
+
rotated_side = size * (abs(math.cos(theta)) + abs(math.sin(theta)))
|
|
159
|
+
half = rotated_side / 2
|
|
160
|
+
x = rng.uniform(half, canvas_width - half)
|
|
161
|
+
y = rng.uniform(half, canvas_height - half)
|
|
162
|
+
candidate = Placement(
|
|
163
|
+
x=x,
|
|
164
|
+
y=y,
|
|
165
|
+
size=size,
|
|
166
|
+
rotation=rotation,
|
|
167
|
+
style=rng.choice(config.styles),
|
|
168
|
+
)
|
|
169
|
+
bbox = candidate.bbox()
|
|
170
|
+
if any(bbox.intersects(r) for r in blocked):
|
|
171
|
+
continue
|
|
172
|
+
if any(bbox.intersects(p.bbox()) for p in placements):
|
|
173
|
+
continue
|
|
174
|
+
placements.append(candidate)
|
|
175
|
+
placed = True
|
|
176
|
+
break
|
|
177
|
+
if placed:
|
|
178
|
+
break
|
|
179
|
+
size *= _SHRINK_FACTOR
|
|
180
|
+
if size < min_size:
|
|
181
|
+
break
|
|
182
|
+
|
|
183
|
+
return placements
|
meowplotlib/core/rng.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Seeded RNG wrapper for reproducible placements. No matplotlib imports, no I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def new_rng(seed: int | None) -> random.Random:
|
|
9
|
+
"""Return a fresh `random.Random` instance seeded from `seed`, or unseeded if `None`."""
|
|
10
|
+
return random.Random(seed)
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Draws placed cats onto the figure.
|
|
2
|
+
|
|
3
|
+
Each cat is drawn in its own tiny inset Axes positioned at the placement's exact figure-fraction
|
|
4
|
+
bounding box (`Placement.bbox()`), rather than via OffsetImage/AnnotationBbox's point-space
|
|
5
|
+
`zoom`. This makes the actual rendered footprint match the bbox M1's collision math assumed
|
|
6
|
+
exactly, instead of approximating it through a DPI-dependent zoom factor. Cat axes are tagged
|
|
7
|
+
with `_meowplotlib_cat = True` so `render/bboxes.py` can exclude them from exclusion extraction.
|
|
8
|
+
|
|
9
|
+
Since M1's `Placement.bbox()` reserves the true rotated footprint (see
|
|
10
|
+
specs/001-core-placement-engine/research.md's 2026-07-02 addendum), the cat axes here is always
|
|
11
|
+
exactly that (larger, square) bbox; the image itself is drawn at its true unrotated size,
|
|
12
|
+
centered, and rotated in data-space via an affine transform. Because the reserved bbox is by
|
|
13
|
+
construction the axis-aligned bounding box of a `placement.size`-side square rotated by
|
|
14
|
+
`placement.rotation`, the rotated image's true footprint always stays within the axes — no
|
|
15
|
+
overflow into neighboring exclusions or other cats regardless of the sampled rotation angle.
|
|
16
|
+
|
|
17
|
+
Style resolution goes through `assets.registry` (M3): `placement.style` selects a style, and a
|
|
18
|
+
deterministic (not RNG-based) hash of the placement's own fields picks one image from that
|
|
19
|
+
style's pool, so seeded reproducibility survives multi-image pools — see
|
|
20
|
+
specs/003-style-system/research.md.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import importlib.resources
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
import matplotlib.image as mpimg
|
|
29
|
+
import matplotlib.transforms as mtransforms
|
|
30
|
+
|
|
31
|
+
from meowplotlib.assets.registry import available_styles
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
from matplotlib.figure import Figure
|
|
38
|
+
|
|
39
|
+
from meowplotlib.core.placement import Placement
|
|
40
|
+
|
|
41
|
+
_IMAGE_CACHE: dict[Path, np.ndarray] = {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load_image(path: Path) -> np.ndarray:
|
|
45
|
+
if path not in _IMAGE_CACHE:
|
|
46
|
+
with importlib.resources.as_file(path) as local_path:
|
|
47
|
+
_IMAGE_CACHE[path] = mpimg.imread(local_path)
|
|
48
|
+
return _IMAGE_CACHE[path]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _resolve_image(placement: Placement) -> tuple[np.ndarray, float]:
|
|
52
|
+
"""Return (image array, clamped scale) for `placement`, deterministic given its fields.
|
|
53
|
+
|
|
54
|
+
Scale is clamped to <= 1.0: a style's manifest `scale` may only ever shrink a cat's
|
|
55
|
+
rendered footprint within its reserved bbox, never grow it beyond the box M1's collision
|
|
56
|
+
math reserved (constitution #2 takes priority over exact visual scale fidelity — see
|
|
57
|
+
PROGRESS.md for the follow-up needed to let scale genuinely grow a cat's footprint).
|
|
58
|
+
"""
|
|
59
|
+
styles = available_styles()
|
|
60
|
+
style_info = styles[placement.style]
|
|
61
|
+
pool = style_info.image_paths # already filename-sorted by registry.discover_styles
|
|
62
|
+
index = hash((placement.x, placement.y, placement.size, placement.rotation)) % len(pool)
|
|
63
|
+
image = _load_image(pool[index])
|
|
64
|
+
return image, min(style_info.scale, 1.0)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def draw_placements(figure: Figure, placements: list[Placement]) -> None:
|
|
68
|
+
"""Add one rotated cat image per placement, each in its own figure-fraction inset axes."""
|
|
69
|
+
for placement in placements:
|
|
70
|
+
image, scale = _resolve_image(placement)
|
|
71
|
+
bbox = placement.bbox() # the rotation-inclusive reserved square
|
|
72
|
+
cat_axes = figure.add_axes((bbox.x, bbox.y, bbox.width, bbox.height))
|
|
73
|
+
cat_axes._meowplotlib_cat = True # type: ignore[attr-defined]
|
|
74
|
+
cat_axes.set_axis_off()
|
|
75
|
+
cat_axes.patch.set_alpha(0.0)
|
|
76
|
+
|
|
77
|
+
half_span = bbox.width / 2
|
|
78
|
+
cat_axes.set_xlim(-half_span, half_span)
|
|
79
|
+
cat_axes.set_ylim(-half_span, half_span)
|
|
80
|
+
|
|
81
|
+
visual_half = (placement.size * scale) / 2
|
|
82
|
+
cat_image = cat_axes.imshow(
|
|
83
|
+
image,
|
|
84
|
+
extent=(-visual_half, visual_half, -visual_half, visual_half),
|
|
85
|
+
)
|
|
86
|
+
cat_image.set_transform(
|
|
87
|
+
mtransforms.Affine2D().rotate_deg(placement.rotation) + cat_axes.transData
|
|
88
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Extracts exclusion zones (axes, labels, ticks, legend) from a live figure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from meowplotlib.core.placement import Rect
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from matplotlib.figure import Figure
|
|
11
|
+
from matplotlib.transforms import Bbox, Transform
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def extract_exclusions(figure: Figure) -> list[Rect]:
|
|
15
|
+
"""Return one exclusion Rect per axes (tight bbox incl. ticks/labels), plus legends.
|
|
16
|
+
|
|
17
|
+
Coordinates are converted to figure-fraction space via `figure.transFigure`.
|
|
18
|
+
"""
|
|
19
|
+
renderer = figure._get_renderer() # type: ignore[attr-defined] # noqa: SLF001
|
|
20
|
+
exclusions: list[Rect] = []
|
|
21
|
+
inv = figure.transFigure.inverted()
|
|
22
|
+
|
|
23
|
+
for axes in figure.axes:
|
|
24
|
+
if getattr(axes, "_meowplotlib_cat", False):
|
|
25
|
+
continue # a previously-placed cat's own inset axes, not a protected element
|
|
26
|
+
bbox = axes.get_tightbbox(renderer)
|
|
27
|
+
if bbox is None:
|
|
28
|
+
continue
|
|
29
|
+
exclusions.append(_bbox_to_rect(bbox, inv))
|
|
30
|
+
|
|
31
|
+
legend = axes.get_legend()
|
|
32
|
+
if legend is not None:
|
|
33
|
+
legend_bbox = legend.get_window_extent(renderer)
|
|
34
|
+
exclusions.append(_bbox_to_rect(legend_bbox, inv))
|
|
35
|
+
|
|
36
|
+
return exclusions
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ensure_minimum_margin(figure: Figure, margin: float) -> None:
|
|
40
|
+
"""Shrink each axes so at least `margin` (figure-fraction) of clear space remains between
|
|
41
|
+
its tight bbox and every figure edge, so cats have room to land on all four sides.
|
|
42
|
+
|
|
43
|
+
This actively resizes the user's plot area — a deliberate, opted-in departure from pure
|
|
44
|
+
"decorate around the existing layout" behavior. See PROGRESS.md for the tradeoff record.
|
|
45
|
+
Only shrinks when needed: axes with an already-generous margin on a given side are left
|
|
46
|
+
untouched on that side.
|
|
47
|
+
"""
|
|
48
|
+
renderer = figure._get_renderer() # type: ignore[attr-defined] # noqa: SLF001
|
|
49
|
+
inv = figure.transFigure.inverted()
|
|
50
|
+
|
|
51
|
+
for axes in figure.axes:
|
|
52
|
+
if getattr(axes, "_meowplotlib_cat", False):
|
|
53
|
+
continue
|
|
54
|
+
tight = axes.get_tightbbox(renderer)
|
|
55
|
+
if tight is None:
|
|
56
|
+
continue
|
|
57
|
+
fig_tight = tight.transformed(inv)
|
|
58
|
+
|
|
59
|
+
left_deficit = max(0.0, margin - fig_tight.x0)
|
|
60
|
+
bottom_deficit = max(0.0, margin - fig_tight.y0)
|
|
61
|
+
right_deficit = max(0.0, margin - (1.0 - fig_tight.x1))
|
|
62
|
+
top_deficit = max(0.0, margin - (1.0 - fig_tight.y1))
|
|
63
|
+
if not (left_deficit or bottom_deficit or right_deficit or top_deficit):
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
pos = axes.get_position()
|
|
67
|
+
new_width = pos.width - left_deficit - right_deficit
|
|
68
|
+
new_height = pos.height - bottom_deficit - top_deficit
|
|
69
|
+
if new_width <= 0 or new_height <= 0:
|
|
70
|
+
continue # figure too small to guarantee this margin; leave axes as-is
|
|
71
|
+
axes.set_position((pos.x0 + left_deficit, pos.y0 + bottom_deficit, new_width, new_height))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _bbox_to_rect(bbox: Bbox, inv: Transform) -> Rect:
|
|
75
|
+
fig_bbox = bbox.transformed(inv)
|
|
76
|
+
return Rect(x=fig_bbox.x0, y=fig_bbox.y0, width=fig_bbox.width, height=fig_bbox.height)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Figure draw interception: the single hook point for both display and savefig.
|
|
2
|
+
|
|
3
|
+
See specs/002-matplotlib-integration/research.md for why `Figure.draw` is the shared
|
|
4
|
+
interception point, and why the patch is permanent (enable/disable only flips a flag).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from matplotlib.figure import Figure
|
|
13
|
+
|
|
14
|
+
from meowplotlib.assets.registry import resolve_style_names
|
|
15
|
+
from meowplotlib.core.config import get_config
|
|
16
|
+
from meowplotlib.core.placement import PlacementConfig, place_cats
|
|
17
|
+
from meowplotlib.render import artist, bboxes
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
|
|
22
|
+
_installed = False
|
|
23
|
+
|
|
24
|
+
# Guaranteed clear space (figure-fraction) between each axes' tight bbox and every figure edge,
|
|
25
|
+
# so cats can land on all four sides rather than only wherever the chart's default layout
|
|
26
|
+
# happens to leave room. Sized comfortably above the largest rotated cat footprint (max size
|
|
27
|
+
# 0.16, up to ~1.41x when rotated ~= 0.226) so a typical cat fits without shrinking.
|
|
28
|
+
_GUARANTEED_MARGIN = 0.14
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def install() -> None:
|
|
32
|
+
"""Wrap `Figure.draw` once, process-wide. Safe to call more than once (no-op after first)."""
|
|
33
|
+
global _installed
|
|
34
|
+
if _installed:
|
|
35
|
+
return
|
|
36
|
+
_installed = True
|
|
37
|
+
|
|
38
|
+
original_draw: Callable[..., Any] = Figure.draw
|
|
39
|
+
|
|
40
|
+
@functools.wraps(original_draw)
|
|
41
|
+
def wrapped_draw(self: Figure, renderer: Any, *args: Any, **kwargs: Any) -> Any:
|
|
42
|
+
config = get_config()
|
|
43
|
+
if config.enabled and not getattr(self, "_meowplotlib_decorated", False):
|
|
44
|
+
_decorate(self, config.density, config.seed, config.style)
|
|
45
|
+
self._meowplotlib_decorated = True # type: ignore[attr-defined]
|
|
46
|
+
return original_draw(self, renderer, *args, **kwargs)
|
|
47
|
+
|
|
48
|
+
Figure.draw = wrapped_draw # type: ignore[assignment]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _decorate(figure: Figure, density: str, seed: int | None, style: str | list[str]) -> None:
|
|
52
|
+
# bboxes.extract_exclusions() returns rects in figure-fraction space (transFigure-normalized
|
|
53
|
+
# to 0..1), so the canvas itself is always the unit square in that same coordinate system —
|
|
54
|
+
# NOT figure.get_size_inches(). This also gives small-figure degradation "for free": at a
|
|
55
|
+
# fixed font size, a small figure's tick/axis labels occupy a larger fraction of the unit
|
|
56
|
+
# square, shrinking available border area exactly as intended.
|
|
57
|
+
bboxes.ensure_minimum_margin(figure, _GUARANTEED_MARGIN)
|
|
58
|
+
exclusions = bboxes.extract_exclusions(figure)
|
|
59
|
+
styles = resolve_style_names(style)
|
|
60
|
+
if not styles:
|
|
61
|
+
return
|
|
62
|
+
config = PlacementConfig(
|
|
63
|
+
density=density, # type: ignore[arg-type]
|
|
64
|
+
size_range=(0.06, 0.16),
|
|
65
|
+
seed=seed,
|
|
66
|
+
styles=styles,
|
|
67
|
+
)
|
|
68
|
+
placements = place_cats(1.0, 1.0, exclusions, config)
|
|
69
|
+
artist.draw_placements(figure, placements)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: meowplotlib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Whimsical cat decorations for matplotlib figures.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: matplotlib>=3.7
|
|
8
|
+
Requires-Dist: numpy>=1.24
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: hypothesis>=6.100; extra == 'dev'
|
|
12
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# meowplotlib
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
Whimsical cat decorations for `matplotlib` figures. One import, zero required config.
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import matplotlib.pyplot as plt
|
|
25
|
+
import meowplotlib # cats now appear on every figure you show or save
|
|
26
|
+
|
|
27
|
+
plt.plot([1, 2, 3], [1, 4, 9])
|
|
28
|
+
plt.savefig("chart.png")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install meowplotlib
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import meowplotlib
|
|
39
|
+
|
|
40
|
+
meowplotlib.set_style("chonk") # or a list, or "mix"
|
|
41
|
+
meowplotlib.set_density("chaotic") # "sparse" | "normal" | "chaotic"
|
|
42
|
+
meowplotlib.set_seed(42) # reproducible layouts
|
|
43
|
+
meowplotlib.disable() # turn it off for the rest of the session
|
|
44
|
+
meowplotlib.enable() # turn it back on
|
|
45
|
+
|
|
46
|
+
with meowplotlib.config(enabled=False):
|
|
47
|
+
... # one chart rendered without cats
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Config
|
|
51
|
+
|
|
52
|
+
| Function | Values | Effect |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `set_style` | style name, list of names, or `"mix"` | which cat art pool(s) to draw from |
|
|
55
|
+
| `set_density` | `"sparse"` \| `"normal"` \| `"chaotic"` | how many cats appear |
|
|
56
|
+
| `set_seed` | `int` \| `None` | lock or randomize placement |
|
|
57
|
+
| `enable()` / `disable()` | — | session on/off switch |
|
|
58
|
+
| `config(**overrides)` | context manager | temporary overrides for one block |
|
|
59
|
+
|
|
60
|
+
## Art
|
|
61
|
+
|
|
62
|
+
v1 ships with three real cat art styles: `classic`, `derp`, and `chonk`. Adding or swapping a
|
|
63
|
+
style is a pure file-drop under `src/meowplotlib/assets/images/<style>/` plus one `styles.toml`
|
|
64
|
+
entry — no code changes required. See [ATTRIBUTION.md](ATTRIBUTION.md) for notes.
|
|
65
|
+
|
|
66
|
+
See `STANDUP_PLAN.md` and `constitution.md` for the full project contract.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
meowplotlib/__init__.py,sha256=9-QZnxDNUC2z1xNIcai33KfmyzpM6a63SkaW9oG4Jco,329
|
|
2
|
+
meowplotlib/api.py,sha256=WFy_2bESoUuvfFAZP0dHuwzdb4EbL9fMh7AC0DWx6Q8,2227
|
|
3
|
+
meowplotlib/assets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
meowplotlib/assets/_toml_fallback.py,sha256=w6maFX6mbrYgHc8npn1lXeBFHBNGjyZGD8LXUEpFj-g,1489
|
|
5
|
+
meowplotlib/assets/registry.py,sha256=YJ3h1BzmlfkIAHMZOr4usO4CwgzJXcFetxbEudepnbE,3368
|
|
6
|
+
meowplotlib/assets/styles.toml,sha256=2Ji9AU1kDulnxBpYTTgXtAmEIhDOAeMoAYSa2Ktv21c,300
|
|
7
|
+
meowplotlib/assets/images/chonk/chonk_01.png,sha256=mplF5TjfjRYyfIeCBYwCNNLPb99krr39aemDzPeTWg0,326322
|
|
8
|
+
meowplotlib/assets/images/classic/classic_01.png,sha256=F70_HOuKUn30uEi6BEicVrUsvbw1hsKSx5YHJUTwpFM,538877
|
|
9
|
+
meowplotlib/assets/images/derp/derp_01.png,sha256=mwXEPS0jorHD4HthwUAMW5hRG90Rk9bFFla2kY1hywM,204446
|
|
10
|
+
meowplotlib/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
meowplotlib/core/config.py,sha256=m43IrPzKXynM6Zxs7IaCJey8lDkMXKEOHJ1ckL6gpzM,995
|
|
12
|
+
meowplotlib/core/placement.py,sha256=4Wkr17wS5MivFqda5Z3qr31NwuvV2AwH1vnxOKO_VtU,6348
|
|
13
|
+
meowplotlib/core/rng.py,sha256=WLUgHVCMjruZrZAUIFxmZMK4yEzZqtPT-fuf_gqGk2U,310
|
|
14
|
+
meowplotlib/render/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
meowplotlib/render/artist.py,sha256=DQRKs1qjc6SSZIL1v19xBTgf1W0BAmooArxHhpZRLrU,3983
|
|
16
|
+
meowplotlib/render/bboxes.py,sha256=KdNTsS7xBfaNCyxbKtS1pk-ANir6xJoqFp4csBFZhXI,3073
|
|
17
|
+
meowplotlib/render/hook.py,sha256=hb8TkkTqnbfLVKW-pEtI29pSx-fEAKK03hzZsZQT8-M,2838
|
|
18
|
+
meowplotlib-0.1.0.dist-info/METADATA,sha256=_jLV72fd_bPyr9MqYPSmDT-rHm0n1-xtWGO75qF3U1s,2132
|
|
19
|
+
meowplotlib-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
20
|
+
meowplotlib-0.1.0.dist-info/RECORD,,
|