How PyFreeform Works¶
Understanding four concepts unlocks everything PyFreeform can do.
The Mental Model¶
A Scene is your canvas. It contains a Grid of Cells. You add Entities (dots, lines, shapes, text) to each cell.
Every cell inherits data from the image (if you used from_image()) — brightness, color, RGB values. Without an image, cells have neutral defaults. Either way, the pattern is always the same:
scene = Scene.from_image("photo.jpg") # or Scene.with_grid(...)
for cell in scene.grid:
# Read data from the cell
# Add entities based on that data
cell.add_dot(radius=cell.brightness * 0.45, color=cell.color)
scene.save("output.svg")
Named Positions¶
Every surface (cell, scene, merged region) has 9 named positions. Use them anywhere you'd specify a location:
cell.add_dot(at="center") # Default
cell.add_dot(at="top_left") # Corner
cell.add_line(start="left", end="right") # Edge to edge
You can also use relative coordinates — (0.0, 0.0) is top-left, (1.0, 1.0) is bottom-right:
The Surface Protocol¶
Cell, Scene, and CellGroup all share the same builder methods. This means you learn one set of methods and use them everywhere:
| Method | Creates |
|---|---|
add_dot() |
Filled circle |
add_line() |
Line segment |
add_curve() |
Bezier curve |
add_ellipse() |
Ellipse (also a path!) |
add_polygon() |
Any polygon shape |
add_rect() |
Rectangle |
add_text() |
Text label |
add_path() |
Smooth path from any parametric equation |
add_fill() |
Solid background fill |
add_border() |
Stroke-only border |
Everything is Relative¶
All add_*() builder methods use fractions of the cell for sizes and positions -- not pixels. This makes your art resolution-independent.
| Parameter | Meaning | Example |
|---|---|---|
at=(0.5, 0.5) |
Position: 50% across, 50% down | center of cell |
radius=0.25 |
Size: 25% of cell's smaller dimension | medium dot |
width=0.6 |
Size: 60% of cell width | default rect width |
rx=0.4 |
Size: 40% of cell width | default ellipse radius |
font_size=0.25 |
Size: 25% of cell height | default text size |
fit=True |
Shrink text to fit cell width | prevents overflow |
cell.add_dot(radius=0.40) # 40% of cell size
cell.add_rect(width=0.80, height=0.50) # 80% wide, 50% tall
cell.add_ellipse(rx=0.35, ry=0.20) # 35% x 20% radii
cell.add_text("Hi", font_size=0.30) # 30% of cell height
cell.add_text("Long label", font_size=0.30, fit=True) # shrinks if needed
This means the same code produces proportional output regardless of cell size -- whether your cells are 10px or 100px.
Every entity created by a builder method stores its position as a RelCoord(rx, ry) -- a NamedTuple with rx and ry fields. You can read or write this through the .at property:
dot = cell.add_dot(at=(0.25, 0.75), color="red")
print(dot.at) # RelCoord(rx=0.25, ry=0.75)
dot.at = (0.5, 0.5) # Reposition to center
Pixel escape hatch
If you need absolute pixel positioning, use place() with direct constructors:
Layering with z_index¶
When entities overlap, z_index controls which appears on top. Higher values render later (on top):
cell.add_fill(color="navy", z_index=0) # Background
cell.add_ellipse(fill="coral", z_index=1) # Middle layer
cell.add_dot(color="white", z_index=2) # Foreground
Same z_index?
Entities with the same z_index render in the order they were added.
What's Next?¶
You now understand the core architecture. The Guide takes you through every feature in depth: