graph-graphics 0.0.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.
- graph_graphics-0.0.1.dist-info/METADATA +81 -0
- graph_graphics-0.0.1.dist-info/RECORD +49 -0
- graph_graphics-0.0.1.dist-info/WHEEL +5 -0
- graph_graphics-0.0.1.dist-info/entry_points.txt +2 -0
- graph_graphics-0.0.1.dist-info/licenses/LICENSE +131 -0
- graph_graphics-0.0.1.dist-info/licenses/LICENSE-ARTWORK +407 -0
- graph_graphics-0.0.1.dist-info/licenses/LICENSING.md +30 -0
- graph_graphics-0.0.1.dist-info/top_level.txt +1 -0
- graph_ics/__init__.py +46 -0
- graph_ics/__main__.py +4 -0
- graph_ics/animation.py +232 -0
- graph_ics/cli.py +121 -0
- graph_ics/colonies.py +145 -0
- graph_ics/color_fields.py +29 -0
- graph_ics/colors.py +17 -0
- graph_ics/contours.py +207 -0
- graph_ics/coverage.py +151 -0
- graph_ics/data/README.md +18 -0
- graph_ics/data/__init__.py +1 -0
- graph_ics/data/spain_population.json.gz +0 -0
- graph_ics/data/spain_population_sources.json +33 -0
- graph_ics/data/uk_population.json.gz +0 -0
- graph_ics/data/uk_population_sources.json +33 -0
- graph_ics/drawing.py +30 -0
- graph_ics/edge.py +72 -0
- graph_ics/fields.py +134 -0
- graph_ics/gallery.py +118 -0
- graph_ics/gallery_page.py +95 -0
- graph_ics/graph.py +548 -0
- graph_ics/growth.py +403 -0
- graph_ics/logo.py +144 -0
- graph_ics/node.py +92 -0
- graph_ics/obj.py +5 -0
- graph_ics/option_gallery.py +178 -0
- graph_ics/py.typed +0 -0
- graph_ics/quality.py +42 -0
- graph_ics/rendering.py +159 -0
- graph_ics/replay.py +314 -0
- graph_ics/samples.py +206 -0
- graph_ics/scene.py +103 -0
- graph_ics/scene_gallery.py +86 -0
- graph_ics/site_paths.py +33 -0
- graph_ics/site_structure.py +149 -0
- graph_ics/spatial.py +86 -0
- graph_ics/styles.py +98 -0
- graph_ics/text.py +114 -0
- graph_ics/uk.py +48 -0
- graph_ics/validation.py +45 -0
- graph_ics/vector.py +28 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Licensing and commercial permissions
|
|
2
|
+
|
|
3
|
+
Required Notice: Copyright (c) 2022-2026 Johannes Pippidis Lorentzen (https://pippidis.no), for project-owned code.
|
|
4
|
+
|
|
5
|
+
## Code: PolyForm Noncommercial 1.0.0
|
|
6
|
+
|
|
7
|
+
Project-owned Python code, scripts and website implementation are licensed under [PolyForm Noncommercial 1.0.0](LICENSE). This permits use, modification and redistribution for the purposes described in that licence, with its notice requirements. The unchanged [official text](https://polyformproject.org/licenses/noncommercial/1.0.0) governs.
|
|
8
|
+
|
|
9
|
+
Permitted purposes include non-commercial use, the specified personal uses, and use by the charitable, educational, public research, public safety/health, environmental and government organisations identified in the licence. Those organisational permissions apply regardless of their funding. This is source-available software, not an unrestricted open-source licence.
|
|
10
|
+
|
|
11
|
+
## Artwork and documentation: CC BY-NC 4.0
|
|
12
|
+
|
|
13
|
+
Project-owned supplied artwork, scene illustrations and written documentation are licensed under [Creative Commons Attribution-NonCommercial 4.0 International](LICENSE-ARTWORK). Sharing and adaptation are permitted for non-commercial purposes, subject to attribution and the other licence terms. Credit Johannes Pippidis Lorentzen and graph_ics, link the licence, and indicate changes when required. The [official legal code](https://creativecommons.org/licenses/by-nc/4.0/legalcode) governs.
|
|
14
|
+
|
|
15
|
+
The artwork licence does not replace the PolyForm licence on code appearing in examples or used to generate the artwork. The two licences apply to different material; they are not alternatives from which users may choose for the same code.
|
|
16
|
+
|
|
17
|
+
## Commercial permissions
|
|
18
|
+
|
|
19
|
+
For commercial use not permitted by the applicable licence, contact [Johannes Pippidis Lorentzen](https://pippidis.no) to discuss a separate paid commercial licence. Payment alone does not grant permission; a separate agreement is required.
|
|
20
|
+
|
|
21
|
+
The copyright holder retains ownership and may use their own material commercially or license it under other terms. This does not waive other contributors' or data providers' rights. Neither licence should be described as a blanket prohibition on all uses by companies; the actual permitted-purpose clauses control.
|
|
22
|
+
|
|
23
|
+
## Third-party material and generated output
|
|
24
|
+
|
|
25
|
+
- Bundled WorldPop data remains CC BY 4.0, and Natural Earth boundaries remain public domain. Preserve the existing [source and attribution notices](src/data/README.md).
|
|
26
|
+
- Dependencies and third-party assets retain their own licences. This notice does not relicense them or claim ownership of user-supplied inputs.
|
|
27
|
+
- Running the generator does not automatically place every user-created output under the artwork licence. Rights in an output depend on the copyrighted artwork, data and other material it incorporates. The software licence governs permitted use of the generator itself.
|
|
28
|
+
- These terms apply to the current version insofar as the copyright holder has the rights to license it. They do not withdraw permissions already granted for earlier GPL-distributed versions. Third-party contributions require compatible rights or permission before relicensing.
|
|
29
|
+
|
|
30
|
+
Package metadata lists both licences because the distribution contains code and documentation; this scope statement explains which applies to each.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
graph_ics
|
graph_ics/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from .logo import make_logo, make_icon
|
|
2
|
+
from .scene import SceneConfig
|
|
3
|
+
from .color_fields import ColorField, ColorRamp
|
|
4
|
+
from .samples import Example, build_example, describe_example, example_names
|
|
5
|
+
from .validation import ValidationReport, validate_graph
|
|
6
|
+
from .replay import EventLog, EventRecorder, Playback, ReplayState
|
|
7
|
+
from .animation import ReplayRenderer, LiveGrowth
|
|
8
|
+
from .text import Text, MeasuredText
|
|
9
|
+
from .styles import GraphStyle
|
|
10
|
+
from .colonies import Colony, Interaction, ColonySettings, ColonyPopulation, ColonyReport
|
|
11
|
+
from .quality import AngleSettings, QualityReport
|
|
12
|
+
from .obj import Obj
|
|
13
|
+
from .vector import Vector
|
|
14
|
+
from .node import Node, NodeShape, NODE_SHAPES
|
|
15
|
+
from .edge import Edge, EdgeStyle, EDGE_STYLES
|
|
16
|
+
from .graph import Graph
|
|
17
|
+
from .rendering import export_graph
|
|
18
|
+
from .growth import GrowthGenerator, GrowthSettings, GrowthStatus, GrowthPolicy, RandomFrontier, NewestTip, Addition, Completion, StepResult, Join
|
|
19
|
+
from .fields import ScalarField, ConstantField, CallableField, ImageField, SpatialFields
|
|
20
|
+
|
|
21
|
+
from .contours import RegionSpec, Readability, ContourSettings, ContourReport, DistanceField, LocalWidth
|
|
22
|
+
|
|
23
|
+
from .coverage import CoverageSettings, CoverageReport
|
|
24
|
+
|
|
25
|
+
__all__ = ['Obj', 'Vector', 'Node', 'Edge', 'Graph', 'export_graph']
|
|
26
|
+
__all__ += ['GrowthGenerator', 'GrowthSettings', 'GrowthStatus', 'GrowthPolicy', 'RandomFrontier', 'NewestTip', 'Addition', 'Completion', 'StepResult']
|
|
27
|
+
__all__ += ["ScalarField", "ConstantField", "CallableField", "ImageField", "SpatialFields"]
|
|
28
|
+
|
|
29
|
+
__all__ += ["RegionSpec", "Readability", "ContourSettings", "ContourReport", "DistanceField", "LocalWidth"]
|
|
30
|
+
|
|
31
|
+
__all__ += ["CoverageSettings", "CoverageReport"]
|
|
32
|
+
|
|
33
|
+
__all__ += ["AngleSettings", "QualityReport"]
|
|
34
|
+
|
|
35
|
+
__all__ += ["Colony", "Interaction", "ColonySettings", "ColonyPopulation", "ColonyReport", "Join"]
|
|
36
|
+
|
|
37
|
+
__all__ += ["Text", "MeasuredText", "GraphStyle"]
|
|
38
|
+
|
|
39
|
+
__all__ += ["EventLog", "EventRecorder", "Playback", "ReplayState", "ReplayRenderer", "LiveGrowth"]
|
|
40
|
+
|
|
41
|
+
__all__ += ["Example", "build_example", "describe_example", "example_names", "ValidationReport", "validate_graph"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
__all__ += ["NodeShape", "NODE_SHAPES", "EdgeStyle", "EDGE_STYLES"]
|
|
45
|
+
|
|
46
|
+
__all__ += ['make_icon', 'make_logo', 'SceneConfig', 'ColorField', 'ColorRamp']
|
graph_ics/__main__.py
ADDED
graph_ics/animation.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Cached replay rendering, PNG sequences, and bounded live generation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from math import ceil, isfinite
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from time import perf_counter
|
|
6
|
+
from typing import TYPE_CHECKING, Literal
|
|
7
|
+
from shapely.geometry import Polygon, MultiPolygon, shape
|
|
8
|
+
from .colors import rgb_normalized
|
|
9
|
+
from .rendering import draw_node, draw_edge
|
|
10
|
+
from .replay import EventLog, EventRecorder, Playback, ReplayState
|
|
11
|
+
from .growth import StepResult
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
import cairo
|
|
14
|
+
from .graph import Graph
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ReplayRenderer:
|
|
18
|
+
"""Cache completed edge/node paths separately, preserving static draw order."""
|
|
19
|
+
def __init__(self, playback: Playback, *, margin: float = 5, scale: float = 1, background: str | None = None,
|
|
20
|
+
frame: Literal['region', 'artwork'] = 'region') -> None:
|
|
21
|
+
try:
|
|
22
|
+
import cairo
|
|
23
|
+
except ImportError as error:
|
|
24
|
+
raise ImportError('Install graph-ics[render] to render animation') from error
|
|
25
|
+
if not isfinite(margin) or margin < 0 or not isfinite(scale) or scale <= 0: raise ValueError('Require finite margin >= 0 and scale > 0')
|
|
26
|
+
if frame not in ('region', 'artwork'): raise ValueError('Unknown animation frame')
|
|
27
|
+
if scale < playback.log.scene.minimum_scale: raise ValueError('Animation scale is below the contour readability scale')
|
|
28
|
+
if background is not None: rgb_normalized(background)
|
|
29
|
+
self.playback, self.margin, self.scale, self.background, self.frame = playback, margin, scale, background, frame
|
|
30
|
+
region = shape(playback.log.scene.region)
|
|
31
|
+
if not isinstance(region, (Polygon, MultiPolygon)): raise ValueError('Replay region must be polygonal')
|
|
32
|
+
self.region: Polygon | MultiPolygon = region
|
|
33
|
+
final = playback.log.validate()
|
|
34
|
+
bounds = [node.bounds for node in final.nodes.values()]+[edge.footprint.bounds for edge in final.edges]+[text.bounds for text in playback.log.scene.texts]
|
|
35
|
+
if frame == 'region': bounds.append(self.region.bounds)
|
|
36
|
+
if playback.log.scene.border: bounds.append(self.region.buffer(float(playback.log.scene.configuration.get('border_width', 0.5))/2).bounds)
|
|
37
|
+
if not bounds: raise ValueError('Cannot frame empty artwork')
|
|
38
|
+
self.bounds = min(b[0] for b in bounds), min(b[1] for b in bounds), max(b[2] for b in bounds), max(b[3] for b in bounds)
|
|
39
|
+
self.width = max(1, ceil((self.bounds[2]-self.bounds[0]+2*margin)*scale))
|
|
40
|
+
self.height = max(1, ceil((self.bounds[3]-self.bounds[1]+2*margin)*scale))
|
|
41
|
+
self.cached_events = 0
|
|
42
|
+
self.cache_rebuilds = 0
|
|
43
|
+
self._reset_cache()
|
|
44
|
+
self.overlay = cairo.RecordingSurface(cairo.CONTENT_COLOR_ALPHA, None)
|
|
45
|
+
context = cairo.Context(self.overlay)
|
|
46
|
+
self._draw_overlay(context)
|
|
47
|
+
|
|
48
|
+
def _draw_overlay(self, context: cairo.Context[cairo.RecordingSurface] | cairo.Context[cairo.ImageSurface]) -> None:
|
|
49
|
+
import cairo
|
|
50
|
+
if self.playback.log.scene.border:
|
|
51
|
+
context.set_source_rgba(*rgb_normalized(self.playback.log.scene.configuration.get('border_color', '#a6a6a6')), 1)
|
|
52
|
+
context.set_line_width(self.playback.log.scene.configuration.get('border_width', 0.5))
|
|
53
|
+
context.set_line_join(cairo.LINE_JOIN_ROUND)
|
|
54
|
+
polygons = [self.region] if isinstance(self.region, Polygon) else self.region.geoms
|
|
55
|
+
for polygon in polygons:
|
|
56
|
+
for ring in [polygon.exterior, *polygon.interiors]:
|
|
57
|
+
for i, (x, y) in enumerate(ring.coords):
|
|
58
|
+
if i == 0: context.move_to(x, y)
|
|
59
|
+
else: context.line_to(x, y)
|
|
60
|
+
context.close_path()
|
|
61
|
+
context.stroke()
|
|
62
|
+
for text in self.playback.log.scene.texts:
|
|
63
|
+
context.new_path()
|
|
64
|
+
for kind, points in text.path:
|
|
65
|
+
if kind == 0: context.move_to(*points)
|
|
66
|
+
elif kind == 1: context.line_to(*points)
|
|
67
|
+
elif kind == 2: context.curve_to(*points)
|
|
68
|
+
elif kind == 3: context.close_path()
|
|
69
|
+
else: raise ValueError('Unknown text path command')
|
|
70
|
+
context.set_source_rgba(*rgb_normalized(text.color), text.opacity)
|
|
71
|
+
context.fill()
|
|
72
|
+
|
|
73
|
+
def _reset_cache(self) -> None:
|
|
74
|
+
import cairo
|
|
75
|
+
self.state = ReplayState()
|
|
76
|
+
self.edge_cache = cairo.RecordingSurface(cairo.CONTENT_COLOR_ALPHA, None)
|
|
77
|
+
self.node_cache = cairo.RecordingSurface(cairo.CONTENT_COLOR_ALPHA, None)
|
|
78
|
+
self.cached_events = 0
|
|
79
|
+
self.cache_rebuilds += 1
|
|
80
|
+
|
|
81
|
+
def update(self, log: EventLog) -> None:
|
|
82
|
+
"""Append a live recording without changing its clock or fixed viewport."""
|
|
83
|
+
if self.frame != 'region': raise ValueError('Live updates require a fixed region frame')
|
|
84
|
+
old = self.playback.log
|
|
85
|
+
if log.scene != old.scene or log.events[:len(old.events)] != old.events: raise ValueError('Live replay updates must extend the same log')
|
|
86
|
+
replacement = Playback(log, seconds_per_event=self.playback.seconds_per_event, speed=self.playback.speed)
|
|
87
|
+
replacement.seek(self.playback.position)
|
|
88
|
+
replacement.paused = self.playback.paused
|
|
89
|
+
self.playback = replacement
|
|
90
|
+
|
|
91
|
+
def _sync(self, index: int) -> None:
|
|
92
|
+
import cairo
|
|
93
|
+
if index < self.cached_events: self._reset_cache()
|
|
94
|
+
for event in self.playback.events[self.cached_events:index]:
|
|
95
|
+
self.state.apply(event)
|
|
96
|
+
if event.kind in ('branch', 'loop', 'join'): draw_edge(self.state.edges[-1], cairo.Context(self.edge_cache))
|
|
97
|
+
if event.node is not None: draw_node(self.state.nodes[event.node.id], cairo.Context(self.node_cache))
|
|
98
|
+
if event.kind == 'remove':
|
|
99
|
+
self.node_cache = cairo.RecordingSurface(cairo.CONTENT_COLOR_ALPHA, None)
|
|
100
|
+
context = cairo.Context(self.node_cache)
|
|
101
|
+
for node in self.state.nodes.values(): draw_node(node, context)
|
|
102
|
+
self.cached_events = index
|
|
103
|
+
|
|
104
|
+
def render(self, seconds: float | None = None) -> cairo.ImageSurface:
|
|
105
|
+
import cairo
|
|
106
|
+
if seconds is not None: self.playback.seek(seconds)
|
|
107
|
+
index, progress = self.playback.cursor()
|
|
108
|
+
self._sync(index)
|
|
109
|
+
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, self.width, self.height)
|
|
110
|
+
context = cairo.Context(surface)
|
|
111
|
+
if self.background is not None:
|
|
112
|
+
context.set_source_rgb(*rgb_normalized(self.background))
|
|
113
|
+
context.paint()
|
|
114
|
+
context.scale(self.scale, self.scale)
|
|
115
|
+
context.translate(self.margin-self.bounds[0], self.margin-self.bounds[1])
|
|
116
|
+
if index == len(self.playback.events):
|
|
117
|
+
# Render the completed scene directly to avoid extra compositing-rounding in the caches.
|
|
118
|
+
for edge in self.state.edges: draw_edge(edge, context)
|
|
119
|
+
for node in self.state.nodes.values(): draw_node(node, context)
|
|
120
|
+
self._draw_overlay(context)
|
|
121
|
+
surface.flush()
|
|
122
|
+
return surface
|
|
123
|
+
context.set_source_surface(self.edge_cache)
|
|
124
|
+
context.paint()
|
|
125
|
+
event = self.playback.events[index] if index < len(self.playback.events) else None
|
|
126
|
+
if event is not None and event.edge is not None and progress > 0:
|
|
127
|
+
nodes = dict(self.state.nodes)
|
|
128
|
+
if event.node is not None: nodes[event.node.id] = event.node.restore()
|
|
129
|
+
edge = event.edge.restore(nodes)
|
|
130
|
+
fraction = min(1, progress/0.75) if event.kind == 'branch' else progress
|
|
131
|
+
# Reveal the actual styled edge through a growing rectangular clip.
|
|
132
|
+
# Drawing a temporary solid segment would lose dash/dot/double styling.
|
|
133
|
+
context.save()
|
|
134
|
+
x, y = edge.start_point.x, edge.start_point.y
|
|
135
|
+
dx, dy = -edge.nx, -edge.ny
|
|
136
|
+
end_x, end_y = x+dx*edge.length*fraction, y+dy*edge.length*fraction
|
|
137
|
+
half = edge.thickness/2+1
|
|
138
|
+
context.new_path()
|
|
139
|
+
context.move_to(x-dy*half, y+dx*half)
|
|
140
|
+
context.line_to(end_x-dy*half, end_y+dx*half)
|
|
141
|
+
context.line_to(end_x+dy*half, end_y-dx*half)
|
|
142
|
+
context.line_to(x+dy*half, y-dx*half)
|
|
143
|
+
context.close_path()
|
|
144
|
+
context.clip()
|
|
145
|
+
draw_edge(edge, context)
|
|
146
|
+
context.restore()
|
|
147
|
+
context.set_source_surface(self.node_cache)
|
|
148
|
+
context.paint()
|
|
149
|
+
if event is not None and event.node is not None:
|
|
150
|
+
opacity = max(0, (progress-0.75)/0.25) if event.kind == 'branch' else progress
|
|
151
|
+
if opacity > 0:
|
|
152
|
+
node = event.node.restore()
|
|
153
|
+
node.opacity *= opacity
|
|
154
|
+
draw_node(node, context)
|
|
155
|
+
context.set_source_surface(self.overlay)
|
|
156
|
+
context.paint()
|
|
157
|
+
surface.flush()
|
|
158
|
+
return surface
|
|
159
|
+
|
|
160
|
+
def export_gif(self, path: str | Path, *, fps: float = 20, max_frames: int = 500, max_pixels: int = 100_000_000) -> Path:
|
|
161
|
+
"""Export an opaque looping GIF; reject excessive frame storage before rendering."""
|
|
162
|
+
from io import BytesIO
|
|
163
|
+
from PIL import Image
|
|
164
|
+
if not isfinite(fps) or not 0 < fps <= 100 or type(max_frames) is not int or max_frames < 1: raise ValueError('GIF requires 0 < fps <= 100 and a positive frame cap')
|
|
165
|
+
count = ceil(self.playback.duration*fps)+1
|
|
166
|
+
if count > max_frames or count*self.width*self.height > max_pixels: raise ValueError('GIF exceeds frame/pixel budget; reduce scale, fps or event duration')
|
|
167
|
+
destination = Path(path)
|
|
168
|
+
if destination.suffix.lower() != '.gif': raise ValueError('GIF output must end in .gif')
|
|
169
|
+
if destination.exists(): raise FileExistsError(destination)
|
|
170
|
+
if self.background is None: raise ValueError('GIF requires an opaque background; use PNG frames for alpha')
|
|
171
|
+
frames: list[Image.Image] = []
|
|
172
|
+
position = self.playback.position
|
|
173
|
+
try:
|
|
174
|
+
for index in range(count):
|
|
175
|
+
stream = BytesIO()
|
|
176
|
+
self.render(min(index/fps, self.playback.duration)).write_to_png(stream)
|
|
177
|
+
stream.seek(0)
|
|
178
|
+
with Image.open(stream) as image: frames.append(image.convert('RGB'))
|
|
179
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
frames[0].save(destination, save_all=True, append_images=frames[1:], duration=[max(10, round(100/fps)*10)]*(count-1)+[1500], loop=0)
|
|
181
|
+
finally:
|
|
182
|
+
self.playback.seek(position)
|
|
183
|
+
for frame in frames: frame.close()
|
|
184
|
+
return destination
|
|
185
|
+
|
|
186
|
+
def export_sequence(self, directory: str | Path, *, fps: float = 24, max_frames: int = 10000) -> tuple[Path, ...]:
|
|
187
|
+
if not isfinite(fps) or fps <= 0 or type(max_frames) is not int or max_frames < 1: raise ValueError('Require positive fps and frame limit')
|
|
188
|
+
count = ceil(self.playback.duration*fps)+1
|
|
189
|
+
if count > max_frames: raise ValueError('Sequence exceeds frame limit; reduce fps or event duration')
|
|
190
|
+
output = Path(directory)
|
|
191
|
+
paths = tuple(output/f'frame_{index:06d}.png' for index in range(count))
|
|
192
|
+
if any(path.exists() for path in paths): raise FileExistsError('Frame sequence destination already contains output frames')
|
|
193
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
position = self.playback.position
|
|
195
|
+
try:
|
|
196
|
+
for index, path in enumerate(paths): self.render(min(index/fps, self.playback.duration)).write_to_png(str(path))
|
|
197
|
+
finally:
|
|
198
|
+
self.playback.seek(position)
|
|
199
|
+
return paths
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class LiveGrowth:
|
|
203
|
+
"""Pause/resume generation in this process; optional recording is independent."""
|
|
204
|
+
def __init__(self, graph: Graph, *, record: bool = True, path: str | Path | None = None, retain: bool = True) -> None:
|
|
205
|
+
if not record and path is not None: raise ValueError('A recording destination requires recording')
|
|
206
|
+
self.graph = graph
|
|
207
|
+
self.generator = graph.growth()
|
|
208
|
+
self.recorder = EventRecorder(graph, path=path, retain=retain) if record else None
|
|
209
|
+
self.paused = False
|
|
210
|
+
|
|
211
|
+
def pause(self) -> None:
|
|
212
|
+
self.paused = True
|
|
213
|
+
|
|
214
|
+
def resume(self) -> None:
|
|
215
|
+
self.paused = False
|
|
216
|
+
|
|
217
|
+
def tick(self, max_attempts: int = 100, *, time_budget_ms: float | None = None) -> tuple[StepResult, ...]:
|
|
218
|
+
if type(max_attempts) is not int or max_attempts <= 0: raise ValueError('Attempt budget must be a positive integer')
|
|
219
|
+
if time_budget_ms is not None and (not isfinite(time_budget_ms) or time_budget_ms < 0): raise ValueError('Time budget must be finite and nonnegative')
|
|
220
|
+
if self.paused or self.generator.done: return ()
|
|
221
|
+
if time_budget_ms is None: return (self.generator.step(max_attempts),)
|
|
222
|
+
deadline = perf_counter()+time_budget_ms/1000
|
|
223
|
+
results: list[StepResult] = []
|
|
224
|
+
used = 0
|
|
225
|
+
while not self.generator.done and used < max_attempts and perf_counter() < deadline:
|
|
226
|
+
result = self.generator.step(1)
|
|
227
|
+
results.append(result)
|
|
228
|
+
used += result.attempts
|
|
229
|
+
return tuple(results)
|
|
230
|
+
|
|
231
|
+
def close(self) -> None:
|
|
232
|
+
if self.recorder is not None: self.recorder.close()
|
graph_ics/cli.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Command-line entry points using the same documented Python example factories."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import argparse
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from math import isfinite
|
|
9
|
+
from .samples import EXAMPLES, build_example, describe_example, example_names, explain_option
|
|
10
|
+
from .styles import GraphStyle
|
|
11
|
+
from .rendering import export_graph
|
|
12
|
+
from .replay import EventRecorder, EventLog, Playback
|
|
13
|
+
from .animation import ReplayRenderer
|
|
14
|
+
from .gallery import generate_gallery
|
|
15
|
+
from .validation import validate_graph
|
|
16
|
+
from .node import NODE_SHAPES
|
|
17
|
+
from .edge import EDGE_STYLES
|
|
18
|
+
from .quality import AngleSettings
|
|
19
|
+
from .scene import SceneConfig
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parser() -> argparse.ArgumentParser:
|
|
23
|
+
root = argparse.ArgumentParser(prog='graph-ics', description='Generate sprawling graph artwork, explained examples, and replay frames.')
|
|
24
|
+
commands = root.add_subparsers(dest='command', required=True)
|
|
25
|
+
commands.add_parser('list', help='List examples and palettes')
|
|
26
|
+
describe = commands.add_parser('describe', help='Explain an example and the controls it demonstrates')
|
|
27
|
+
describe.add_argument('example', choices=example_names())
|
|
28
|
+
for name in ('render', 'frames'):
|
|
29
|
+
command = commands.add_parser(name, help='Export SVG/PNG' if name == 'render' else 'Export replay PNG sequence', formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
|
30
|
+
command.add_argument('--fill-area', action='store_true', help='Keep searching underfilled space; --nodes is a safety cap, not a completion claim')
|
|
31
|
+
command.add_argument('--config', type=Path, help='Load a complete JSON/YAML scene; geometry comes from this file')
|
|
32
|
+
for option in ('node-inner-radius','node-outer-radius','node-buffer','edge-thickness','edge-node-distance','edge-length-min','edge-length-max'):
|
|
33
|
+
command.add_argument('--'+option, type=float, help='Override '+option.replace('-', ' ')+' in world units; see the visual dimensions guide')
|
|
34
|
+
command.add_argument('--example', choices=example_names(), default='typography', help='Recipe to build; describe NAME explains its settings')
|
|
35
|
+
command.add_argument('--palette', choices=GraphStyle.palette_names(), default='arctic', help='Named colour scheme for nodes, lines, text and background')
|
|
36
|
+
command.add_argument('--seed', type=int, default=7, help='Random seed; repeat it to reproduce geometry')
|
|
37
|
+
command.add_argument('--nodes', type=int, help='Requested total nodes, including seeds; packing can stop below this target')
|
|
38
|
+
command.add_argument('--output', type=Path, required=True, help='PNG/SVG file for render, or a new directory for frames')
|
|
39
|
+
command.add_argument('--scale', type=float, default=1, help='Output units per world unit; changes resolution, not growth')
|
|
40
|
+
command.add_argument('--margin', type=float, default=16, help='Empty margin around the region in world units')
|
|
41
|
+
command.add_argument('--transparent', action='store_true', help='Omit the palette background; preserve alpha')
|
|
42
|
+
command.add_argument('--node-shape', choices=NODE_SHAPES, help='Hollow shape inside the reserved node radius')
|
|
43
|
+
command.add_argument('--edge-style', choices=EDGE_STYLES, help='Stroke pattern inside the reserved connection corridor')
|
|
44
|
+
command.add_argument('--minimum-angle', type=float, help='Hard minimum between incident edges, in degrees (0 to 180)')
|
|
45
|
+
command.add_argument('--preferred-angle', type=float, help='Preferred junction angle in degrees; enables a soft target')
|
|
46
|
+
command.add_argument('--angle-penalty', type=float, help='Rejection strength below the preferred angle (0 to 1)')
|
|
47
|
+
if name == 'render':
|
|
48
|
+
command.add_argument('--record', type=Path, help='Also save replay JSONL')
|
|
49
|
+
command.add_argument('--force', action='store_true', help='Replace an existing output/recording')
|
|
50
|
+
else:
|
|
51
|
+
command.add_argument('--gif', action='store_true', help='Export one looping GIF instead of PNG frames; requires an opaque background')
|
|
52
|
+
command.add_argument('--log', type=Path, help='Replay a saved log instead of building an example')
|
|
53
|
+
command.add_argument('--fps', type=float, default=24, help='Frames per second; higher values sample the same replay more often')
|
|
54
|
+
command.add_argument('--seconds-per-event', type=float, default=0.04, help='Seconds allocated to each recorded event; larger values slow growth')
|
|
55
|
+
command.add_argument('--max-frames', type=int, default=10000, help='Safety limit: reject an export requiring more frames')
|
|
56
|
+
gallery = commands.add_parser('gallery', help='Generate the explained visual sample library', formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
|
57
|
+
gallery.add_argument('--output', type=Path, default=Path('docs'), help='Gallery directory; index.html sits above figures/ and controls.html')
|
|
58
|
+
selection = gallery.add_mutually_exclusive_group()
|
|
59
|
+
selection.add_argument('--all-palettes', action='store_true', help='Expand every regular recipe across all ten palettes')
|
|
60
|
+
selection.add_argument('--quick', action='store_true', help='Six small scenes for a smoke check')
|
|
61
|
+
return root
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main(argv: list[str] | None = None) -> int:
|
|
65
|
+
tokens = sys.argv[1:] if argv is None else argv
|
|
66
|
+
args = parser().parse_args(tokens)
|
|
67
|
+
try:
|
|
68
|
+
if args.command == 'list':
|
|
69
|
+
print('Palettes: '+', '.join(GraphStyle.palette_names()))
|
|
70
|
+
for example in EXAMPLES: print(f'{example.name}: {example.title} ({example.target_nodes} target nodes)')
|
|
71
|
+
return 0
|
|
72
|
+
if args.command == 'describe':
|
|
73
|
+
print(json.dumps(dict(asdict(describe_example(args.example)), option_notes={option: explain_option(option) for option in describe_example(args.example).options}), indent=2))
|
|
74
|
+
return 0
|
|
75
|
+
if args.command == 'gallery':
|
|
76
|
+
path = generate_gallery(args.output, quick=args.quick, all_palettes=args.all_palettes).resolve()
|
|
77
|
+
print('Gallery: '+str(path))
|
|
78
|
+
print('Open: '+path.as_uri())
|
|
79
|
+
return 0
|
|
80
|
+
if not isfinite(args.scale) or args.scale <= 0 or not isfinite(args.margin) or args.margin < 0: raise ValueError('Require finite positive scale and nonnegative margin')
|
|
81
|
+
if args.command == 'frames' and (not isfinite(args.fps) or args.fps <= 0 or not isfinite(args.seconds_per_event) or args.seconds_per_event <= 0 or args.max_frames <= 0): raise ValueError('Frame rate, event duration, and frame cap must be positive')
|
|
82
|
+
overrides = {name: getattr(args,name) for name in ('node_inner_radius','node_outer_radius','node_buffer','edge_thickness','edge_node_distance','edge_length_min','edge_length_max') if getattr(args,name) is not None}
|
|
83
|
+
if args.config and any(token.split('=',1)[0] in ('--example','--palette','--seed') for token in tokens): raise ValueError('Put example-independent colours and seed in the scene when using --config')
|
|
84
|
+
scene = SceneConfig.load(args.config) if args.config else None
|
|
85
|
+
if scene and (args.fill_area or overrides or any(value is not None for value in (args.node_shape,args.edge_style,args.minimum_angle,args.preferred_angle,args.angle_penalty,args.nodes))): raise ValueError('Put geometry overrides inside the scene when using --config')
|
|
86
|
+
background = None if args.transparent else scene.background if scene else GraphStyle.named(args.palette).background
|
|
87
|
+
if args.command == 'frames' and args.log is not None:
|
|
88
|
+
if args.config or overrides or args.fill_area: raise ValueError('Cannot rebuild a saved log with config or dimension overrides')
|
|
89
|
+
if any(value is not None for value in (args.node_shape, args.edge_style, args.minimum_angle, args.preferred_angle, args.angle_penalty)):
|
|
90
|
+
raise ValueError('Saved logs preserve shapes, lines and angles; omit geometry overrides with --log')
|
|
91
|
+
log = EventLog.load(args.log)
|
|
92
|
+
else:
|
|
93
|
+
if args.command == 'render':
|
|
94
|
+
if args.output.suffix.lower() not in ('.png', '.svg'): raise ValueError('Output must end in .png or .svg')
|
|
95
|
+
if args.record is not None and args.record.resolve() == args.output.resolve(): raise ValueError('Artwork and replay log need distinct paths')
|
|
96
|
+
for path in (args.output, args.record):
|
|
97
|
+
if path is not None and path.exists() and not args.force: raise FileExistsError(f'{path} exists; use --force to replace')
|
|
98
|
+
graph = scene.graph if scene else build_example(args.example, fill_area=args.fill_area, style_overrides=overrides, palette=args.palette, seed=args.seed, nodes=args.nodes, node_shape=args.node_shape, edge_style=args.edge_style)
|
|
99
|
+
if any(value is not None for value in (args.minimum_angle, args.preferred_angle, args.angle_penalty)):
|
|
100
|
+
graph.angles = AngleSettings(args.minimum_angle if args.minimum_angle is not None else graph.angles.minimum,
|
|
101
|
+
args.preferred_angle if args.preferred_angle is not None else graph.angles.preferred,
|
|
102
|
+
args.angle_penalty if args.angle_penalty is not None else graph.angles.penalty)
|
|
103
|
+
if args.command == 'frames' or args.record is not None:
|
|
104
|
+
with EventRecorder(graph, path=args.record if args.command == 'render' else None) as recorder: graph.build()
|
|
105
|
+
log = recorder.snapshot()
|
|
106
|
+
else: graph.build()
|
|
107
|
+
validate_graph(graph)
|
|
108
|
+
if args.command == 'render':
|
|
109
|
+
dimensions = export_graph(graph, args.output, scale=args.scale, margin=args.margin, frame='region', background=background)
|
|
110
|
+
print(json.dumps(dict(output=str(args.output), dimensions=dimensions, nodes=len(graph.nodes), attempts=graph.growth().attempts, status=graph.growth().status.value, coverage=asdict(graph.growth().coverage_report()) if graph.growth().coverage else None)))
|
|
111
|
+
return 0
|
|
112
|
+
renderer = ReplayRenderer(Playback(log, seconds_per_event=args.seconds_per_event), scale=args.scale, margin=args.margin, background=background)
|
|
113
|
+
if args.gif:
|
|
114
|
+
print(renderer.export_gif(args.output, fps=args.fps, max_frames=args.max_frames))
|
|
115
|
+
return 0
|
|
116
|
+
paths = renderer.export_sequence(args.output, fps=args.fps, max_frames=args.max_frames)
|
|
117
|
+
print(f'{len(paths)} frames: {args.output}')
|
|
118
|
+
return 0
|
|
119
|
+
except (ValueError, OSError, ImportError) as error:
|
|
120
|
+
print(f'graph-ics: {error}', file=sys.stderr)
|
|
121
|
+
return 2
|
graph_ics/colonies.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Explicit colony ownership, symmetric interactions, and spatial diagnostics."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from math import atan2, cos, sin, isfinite
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
import networkx as nx
|
|
7
|
+
from shapely.geometry import Point
|
|
8
|
+
from .colors import hex2rgb
|
|
9
|
+
from .node import Node
|
|
10
|
+
from .edge import Edge
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .graph import Graph
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Colony:
|
|
17
|
+
id: str
|
|
18
|
+
seeds: tuple[tuple[float, float], ...]
|
|
19
|
+
weight: float = 1
|
|
20
|
+
color: str = '#a6b7c8'
|
|
21
|
+
|
|
22
|
+
def __post_init__(self) -> None:
|
|
23
|
+
if not self.id or not self.seeds: raise ValueError('Colony needs an ID and at least one seed')
|
|
24
|
+
if not isfinite(self.weight) or self.weight <= 0: raise ValueError('Weight must be finite and positive')
|
|
25
|
+
if not all(isfinite(x) and isfinite(y) for x, y in self.seeds): raise ValueError('Seed positions must be finite')
|
|
26
|
+
hex2rgb(self.color)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Interaction:
|
|
31
|
+
first: str
|
|
32
|
+
second: str
|
|
33
|
+
attraction: float = 0 # [-1, 1]: avoidance through attraction; independent of joining.
|
|
34
|
+
allow_join: bool = False
|
|
35
|
+
clearance: float = 0 # Minimum distance between visible rival geometry.
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if not self.first or not self.second or self.first == self.second: raise ValueError('Interactions require two distinct colony IDs')
|
|
39
|
+
if not isfinite(self.attraction) or not -1 <= self.attraction <= 1: raise ValueError('Attraction must be between -1 and 1')
|
|
40
|
+
if type(self.allow_join) is not bool: raise ValueError('Join permission must be boolean')
|
|
41
|
+
if not isfinite(self.clearance) or self.clearance < 0: raise ValueError('Clearance must be finite and nonnegative')
|
|
42
|
+
if self.allow_join and self.clearance: raise ValueError('Joining and rival exclusion are mutually exclusive for a pair')
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ColonySettings:
|
|
47
|
+
colonies: tuple[Colony, ...]
|
|
48
|
+
interactions: tuple[Interaction, ...] = ()
|
|
49
|
+
influence_distance: float = 100
|
|
50
|
+
target_bridges: int = 0
|
|
51
|
+
bridge_attempts: int = 1000
|
|
52
|
+
bridge_distance: float = 40
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
ids = [colony.id for colony in self.colonies]
|
|
56
|
+
if not ids or len(ids) != len(set(ids)): raise ValueError('Colony IDs must be nonempty and unique')
|
|
57
|
+
if not isfinite(sum(colony.weight for colony in self.colonies)): raise ValueError('Total colony weight must be finite')
|
|
58
|
+
pairs: set[frozenset[str]] = set()
|
|
59
|
+
for rule in self.interactions:
|
|
60
|
+
pair = frozenset((rule.first, rule.second))
|
|
61
|
+
if not pair.issubset(ids) or pair in pairs: raise ValueError('Unknown or duplicate interaction pair')
|
|
62
|
+
pairs.add(pair)
|
|
63
|
+
for value in (self.influence_distance, self.bridge_distance):
|
|
64
|
+
if not isfinite(value) or value <= 0: raise ValueError('Interaction distances must be finite and positive')
|
|
65
|
+
for value in (self.target_bridges, self.bridge_attempts):
|
|
66
|
+
if type(value) is not int or value < 0: raise ValueError('Bridge limits must be nonnegative integers')
|
|
67
|
+
|
|
68
|
+
def rule(self, first: str | None, second: str | None) -> Interaction | None:
|
|
69
|
+
return next((rule for rule in self.interactions if {rule.first, rule.second} == {first, second}), None)
|
|
70
|
+
|
|
71
|
+
def bias(self, graph: Graph, parent: Node, angle: float) -> float:
|
|
72
|
+
radius = self.influence_distance
|
|
73
|
+
x, y = parent.center.x, parent.center.y
|
|
74
|
+
nearby = [graph._objects[key] for key in graph._index.query((x-radius, y-radius, x+radius, y+radius))]
|
|
75
|
+
candidates = [n for n in nearby if isinstance(n, Node) and n.colony_id != parent.colony_id and n.distance_to(parent) <= radius
|
|
76
|
+
and (rule := self.rule(parent.colony_id, n.colony_id)) is not None and rule.attraction != 0]
|
|
77
|
+
if not candidates: return angle
|
|
78
|
+
target = min(candidates, key=lambda n: (n.distance_to(parent), n.id))
|
|
79
|
+
rule = self.rule(parent.colony_id, target.colony_id)
|
|
80
|
+
assert rule is not None
|
|
81
|
+
direction = atan2(target.center.y-y, target.center.x-x)
|
|
82
|
+
strength = abs(rule.attraction)
|
|
83
|
+
dx, dy = (1-strength)*cos(angle)+rule.attraction*cos(direction), (1-strength)*sin(angle)+rule.attraction*sin(direction)
|
|
84
|
+
return atan2(dy, dx) if abs(dx)+abs(dy) > 1e-12 else angle
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def owners(obj: Node | Edge) -> set[str]:
|
|
88
|
+
ids = (obj.colony_id,) if isinstance(obj, Node) else (obj.start_node.colony_id, obj.end_node.colony_id)
|
|
89
|
+
return {key for key in ids if key is not None}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def visible_distance(first: Node | Edge, second: Node | Edge) -> float:
|
|
93
|
+
if isinstance(first, Node) and isinstance(second, Node): return first.distance_to(second)-first.outer_radius-second.outer_radius
|
|
94
|
+
if isinstance(first, Node):
|
|
95
|
+
assert isinstance(second, Edge)
|
|
96
|
+
return float(second.footprint.distance(first.center))-first.outer_radius
|
|
97
|
+
if isinstance(second, Node): return float(first.footprint.distance(second.center))-second.outer_radius
|
|
98
|
+
return float(first.footprint.distance(second.footprint))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def rival_clearance(settings: ColonySettings, first: Node | Edge, second: Node | Edge) -> float:
|
|
102
|
+
return max((rule.clearance for a in owners(first) for b in owners(second) if a != b and (rule := settings.rule(a, b)) is not None), default=0)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class ColonyPopulation:
|
|
107
|
+
colony_id: str
|
|
108
|
+
nodes: int
|
|
109
|
+
attempts: int
|
|
110
|
+
accepted: int
|
|
111
|
+
rejected: int
|
|
112
|
+
components: int
|
|
113
|
+
territory_fraction: float
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class ColonyReport:
|
|
118
|
+
populations: tuple[ColonyPopulation, ...]
|
|
119
|
+
components: int
|
|
120
|
+
bridges: int
|
|
121
|
+
bridge_attempts: int
|
|
122
|
+
bridge_shortfall: int
|
|
123
|
+
clearance_rejections: int
|
|
124
|
+
territory_samples: int
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def territory_counts(graph: Graph, resolution: int) -> tuple[dict[str, int], int]:
|
|
128
|
+
"""Nearest-center allocation of a deterministic grid of usable region samples."""
|
|
129
|
+
if type(resolution) is not int or not 1 <= resolution <= 200: raise ValueError('Territory resolution must be 1..200')
|
|
130
|
+
counts: dict[str, int] = {}
|
|
131
|
+
samples = 0
|
|
132
|
+
if not graph.nodes: return counts, samples
|
|
133
|
+
x0, y0, x1, y1 = graph.outline_polygon.bounds
|
|
134
|
+
for row in range(resolution):
|
|
135
|
+
for column in range(resolution):
|
|
136
|
+
point = Point(x0+(column+0.5)*(x1-x0)/resolution, y0+(row+0.5)*(y1-y0)/resolution)
|
|
137
|
+
if not graph.outline_polygon.covers(point) or any(area.covers(point) for area in graph._illegal_areas): continue
|
|
138
|
+
nearest = min(graph.nodes, key=lambda n: (n.center.distance(point), n.id))
|
|
139
|
+
if nearest.colony_id is not None: counts[nearest.colony_id] = counts.get(nearest.colony_id, 0)+1
|
|
140
|
+
samples += 1
|
|
141
|
+
return counts, samples
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def component_ids(graph: Graph) -> dict[str, str]:
|
|
145
|
+
return {key: min(component) for component in nx.connected_components(graph.to_networkx()) for key in component}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Spatial colour ramps sampled independently of geometry density."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
from math import isfinite
|
|
5
|
+
from .fields import ScalarField
|
|
6
|
+
from .colors import hex2rgb
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ColorField(Protocol):
|
|
10
|
+
def sample(self, x: float, y: float) -> str: ...
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ColorRamp:
|
|
15
|
+
field: ScalarField
|
|
16
|
+
colors: tuple[str, ...] = ('#284d68', '#eead64', '#a64152')
|
|
17
|
+
|
|
18
|
+
def __post_init__(self) -> None:
|
|
19
|
+
if len(self.colors) < 2: raise ValueError('Colour ramps need at least two stops')
|
|
20
|
+
for color in self.colors: hex2rgb(color)
|
|
21
|
+
|
|
22
|
+
def sample(self, x: float, y: float) -> str:
|
|
23
|
+
value = self.field.sample(x, y)
|
|
24
|
+
if not isfinite(value): raise ValueError('Colour field must return finite values')
|
|
25
|
+
position = min(1, max(0, value))*(len(self.colors)-1)
|
|
26
|
+
index = min(len(self.colors)-2, int(position))
|
|
27
|
+
fraction = position-index
|
|
28
|
+
left, right = hex2rgb(self.colors[index]), hex2rgb(self.colors[index+1])
|
|
29
|
+
return '#'+''.join(f'{round(a+(b-a)*fraction):02x}' for a,b in zip(left,right))
|
graph_ics/colors.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Minimal color parsing without importing a plotting library."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def hex2rgb(color: str) -> tuple[int, int, int]:
|
|
5
|
+
"""Parse six hexadecimal digits, optionally prefixed by '#', into byte channels.
|
|
6
|
+
|
|
7
|
+
Raises ValueError for malformed input. Use rgb_normalized for drawing.
|
|
8
|
+
"""
|
|
9
|
+
digits = color.removeprefix('#')
|
|
10
|
+
if len(digits) != 6: raise ValueError('Expected a six-digit hexadecimal color')
|
|
11
|
+
return int(digits[:2], 16), int(digits[2:4], 16), int(digits[4:], 16)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def rgb_normalized(color: str) -> tuple[float, float, float]:
|
|
15
|
+
"""Return Cairo-compatible channels in the closed interval [0, 1]."""
|
|
16
|
+
red, green, blue = hex2rgb(color)
|
|
17
|
+
return red/255, green/255, blue/255
|