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.
Files changed (49) hide show
  1. graph_graphics-0.0.1.dist-info/METADATA +81 -0
  2. graph_graphics-0.0.1.dist-info/RECORD +49 -0
  3. graph_graphics-0.0.1.dist-info/WHEEL +5 -0
  4. graph_graphics-0.0.1.dist-info/entry_points.txt +2 -0
  5. graph_graphics-0.0.1.dist-info/licenses/LICENSE +131 -0
  6. graph_graphics-0.0.1.dist-info/licenses/LICENSE-ARTWORK +407 -0
  7. graph_graphics-0.0.1.dist-info/licenses/LICENSING.md +30 -0
  8. graph_graphics-0.0.1.dist-info/top_level.txt +1 -0
  9. graph_ics/__init__.py +46 -0
  10. graph_ics/__main__.py +4 -0
  11. graph_ics/animation.py +232 -0
  12. graph_ics/cli.py +121 -0
  13. graph_ics/colonies.py +145 -0
  14. graph_ics/color_fields.py +29 -0
  15. graph_ics/colors.py +17 -0
  16. graph_ics/contours.py +207 -0
  17. graph_ics/coverage.py +151 -0
  18. graph_ics/data/README.md +18 -0
  19. graph_ics/data/__init__.py +1 -0
  20. graph_ics/data/spain_population.json.gz +0 -0
  21. graph_ics/data/spain_population_sources.json +33 -0
  22. graph_ics/data/uk_population.json.gz +0 -0
  23. graph_ics/data/uk_population_sources.json +33 -0
  24. graph_ics/drawing.py +30 -0
  25. graph_ics/edge.py +72 -0
  26. graph_ics/fields.py +134 -0
  27. graph_ics/gallery.py +118 -0
  28. graph_ics/gallery_page.py +95 -0
  29. graph_ics/graph.py +548 -0
  30. graph_ics/growth.py +403 -0
  31. graph_ics/logo.py +144 -0
  32. graph_ics/node.py +92 -0
  33. graph_ics/obj.py +5 -0
  34. graph_ics/option_gallery.py +178 -0
  35. graph_ics/py.typed +0 -0
  36. graph_ics/quality.py +42 -0
  37. graph_ics/rendering.py +159 -0
  38. graph_ics/replay.py +314 -0
  39. graph_ics/samples.py +206 -0
  40. graph_ics/scene.py +103 -0
  41. graph_ics/scene_gallery.py +86 -0
  42. graph_ics/site_paths.py +33 -0
  43. graph_ics/site_structure.py +149 -0
  44. graph_ics/spatial.py +86 -0
  45. graph_ics/styles.py +98 -0
  46. graph_ics/text.py +114 -0
  47. graph_ics/uk.py +48 -0
  48. graph_ics/validation.py +45 -0
  49. 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
@@ -0,0 +1,4 @@
1
+ """Support python -m graph_ics."""
2
+ from .cli import main
3
+
4
+ raise SystemExit(main())
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