One canvas loop.
Four shapes.
Zero isinstance.
A mini vector editor that renders Circle, Square, Triangle, and FreeformPath without ever asking
"what are you?". Each shape extends Shape and overrides draw(), area() and perimeter() —
the renderer just calls the methods.
This is method overriding in a working app: one call site, many object types, each answering in its own way. Scroll to the Live Renderer to watch it run.
Progress Tracker
Tick off each milestone as you complete it. Your progress is saved in this browser.
🎯 Your Journey Through the Renderer
0%What This Project Demonstrates
🎨 Method Overriding in a Real App
In the Polymorphism guide you saw a shape family
with area() overrides. This project takes that idea further:
four shape classes, each with three overridden methods
(draw(), area(), perimeter()) — and a renderer that
never checks types.
The renderer is a single function — render(scene) — that loops over a list, calls
shape.draw(svg, x, y), and accumulates shape.area(). That's it.
Adding a fifth shape means writing one new class; the renderer does not change by a single line.
This is the Open/Closed Principle in action: the system is open to extension (new shape classes) but closed to modification (the renderer never changes).
What "Overriding" Buys You Here
Instead of a renderer littered with type checks:
def bad_render(svg, shapes):
for s in shapes:
if s["kind"] == "circle":
circle = svg.circle(cx=..., r=s["r"])
elif s["kind"] == "square":
rect = svg.rect(x=..., width=s["w"])
elif s["kind"] == "triangle":
poly = svg.polygon(points=...)
# …and on and on, one branch per shape…you write the renderer once:
def render(svg, shapes):
for shape in shapes:
shape.draw(svg, shape.x, shape.y) # polymorphic callThat's the whole project. The rest of this page is spent showing you how to build it well, then letting you play with it.
The Shape Hierarchy
Four concrete shapes, one abstract base. The base defines what a shape can do; each subclass overrides how.
Inheritance Tree
Each subclass must override three methods. Forgetting any raises NotImplementedError at call time — visible immediately, not silently.
The Override Table
| Method | Defined on Shape as | Overridden by |
|---|---|---|
draw(svg, cx, cy) | raise NotImplementedError | All four shapes |
area() | raise NotImplementedError | All four shapes |
perimeter() | raise NotImplementedError | All four shapes |
describe() | Implemented once — calls area() and perimeter() | Never (inherited by all) |
Notice describe(): it lives in the base class and never gets overridden. Yet it prints a different string for every shape, because the area() and perimeter() calls inside it dispatch to the subclass. That's the Template Method pattern.
Two More Techniques Used
Shared data via super()
Each subclass calls super().__init__(x, y, color) so position and color are stored once, in Shape.
__repr__ for debugging
repr(circle) prints Circle(x=100, y=80, r=40) — not <object at 0x…>. Essential when you print a list of shapes.
Shoelace formula
FreeformPath uses the shoelace formula for polygon area — the one non-obvious math in the project.
Read-only __init__ contract
Shape.__init__ takes position and color. Subclasses must pass them up; forgetting breaks describe() with AttributeError — visible and easy to fix.
Full Source (Runnable)
The complete project fits in one file. Press Run to execute it in your browser with Pyodide — the output below is a text-mode preview (SVG needs a browser, which the Live Renderer handles).
shape_renderer.py
Notice how render() never mentions Circle, Square, Triangle or FreeformPath. It only knows Shape.
import math
from abc import ABC, abstractmethod
class Shape(ABC):
def __init__(self, x, y, color="#4B8BBE"):
self.x = x
self.y = y
self.color = color
@abstractmethod
def draw(self, svg, cx, cy):
...
@abstractmethod
def area(self):
...
@abstractmethod
def perimeter(self):
...
def describe(self):
return (f"{type(self).__name__:<12} "
f"area={self.area():7.1f} perimeter={self.perimeter():6.1f}")
def __repr__(self):
return f"{type(self).__name__}(x={self.x}, y={self.y})"
class Circle(Shape):
def __init__(self, x, y, r, color="#4B8BBE"):
super().__init__(x, y, color)
self.r = r
def draw(self, svg, cx, cy):
return f"<circle cx='{cx}' cy='{cy}' r='{self.r}' fill='{self.color}'/>"
def area(self):
return math.pi * self.r ** 2
def perimeter(self):
return 2 * math.pi * self.r
class Square(Shape):
def __init__(self, x, y, side, color="#FFD966"):
super().__init__(x, y, color)
self.side = side
def draw(self, svg, cx, cy):
half = self.side / 2
return f"<rect x='{cx-half}' y='{cy-half}' width='{self.side}' height='{self.side}' fill='{self.color}'/>"
def area(self):
return self.side ** 2
def perimeter(self):
return 4 * self.side
class Triangle(Shape):
def __init__(self, x, y, base, height, color="#10B981"):
super().__init__(x, y, color)
self.base = base
self.height = height
def draw(self, svg, cx, cy):
b2, h2 = self.base / 2, self.height / 2
pts = f"{cx},{cy-h2} {cx+b2},{cy+h2} {cx-b2},{cy+h2}"
return f"<polygon points='{pts}' fill='{self.color}'/>"
def area(self):
return 0.5 * self.base * self.height
def perimeter(self):
return self.base + 2 * math.sqrt((self.base / 2) ** 2 + self.height ** 2)
class FreeformPath(Shape):
def __init__(self, x, y, points, color="#c084fc"):
super().__init__(x, y, color)
self.points = list(points)
def draw(self, svg, cx, cy):
pts = " ".join(f"{cx+px},{cy+py}" for px, py in self.points)
return f"<polygon points='{pts}' fill='{self.color}'/>"
def area(self):
n = len(self.points)
if n < 3:
return 0.0
s = 0.0
for i in range(n):
x1, y1 = self.points[i]
x2, y2 = self.points[(i + 1) % n]
s += x1 * y2 - x2 * y1
return abs(s) / 2
def perimeter(self):
n = len(self.points)
if n < 2:
return 0.0
p = 0.0
for i in range(n):
x1, y1 = self.points[i]
x2, y2 = self.points[(i + 1) % n]
p += math.hypot(x2 - x1, y2 - y1)
return p
def render(scene):
print(f"Rendering {len(scene)} shapes:")
total_area = 0.0
for shape in scene:
svg = shape.draw(None, shape.x, shape.y)
print(" ", shape.describe())
total_area += shape.area()
print(f"Total area: {total_area:.1f}")
scene = [
Circle(80, 80, 40),
Square(200, 80, 60),
Triangle(320, 80, 70, 55),
FreeformPath(430, 80, [(0, -30), (30, 20), (10, 30), (-25, 15), (-20, -25)]),
Circle(150, 220, 25, color="#78B9E6"),
Square(300, 220, 45, color="#F59E0B"),
]
render(scene)- The loop in
render()is one line.shape.describe()— but the output has six distinct rows becausedescribe()callsarea()andperimeter(), and those dispatch to each subclass. - The renderer has no
isinstance. It callsshape.draw()and lets the object answer. Compare to the "bad_render" ladder in the intro — same job, one tenth the code. - Adding a fifth shape is one class. Nothing in
render()changes. That's the whole point of the Open/Closed Principle.
Line-by-Line Deep Dive
The Base Class
📖 What each line does
class Shape(ABC):— inherits fromabc.ABC, making it abstract. InstantiatingShape()directly raisesTypeError.__init__(self, x, y, color="#4B8BBE")— position and color shared by all shapes. Subclasses callsuper().__init__()to get them.@abstractmethod def draw(self, svg, cx, cy): ...— declares the drawing contract. Any subclass that doesn't override it can't be instantiated.@abstractmethod def area(self) / perimeter(self)— the math contract. Same enforcement.def describe(self)— not abstract. Written once, usesself.area()andself.perimeter()polymorphically. This is the Template Method.def __repr__(self)— good debugging output. Tryprint(scene)with and without it.
The Subclasses
Circle and Square follow the same pattern:
__init__callssuper().__init__(x, y, color)— reuses the base.- Stores its own dimension (
self.r/self.side). draw()returns an SVG string — same signature(svg, cx, cy). In the browser,svgis the real SVG element; in Pyodide it'sNone, and we just build the string.area()andperimeter()— pure math, no shared state.
Triangle adds a small geometric twist:
perimeter()uses Pythagoras — the slanted sides are√((b/2)² + h²). So a triangle's perimeter isb + 2·√((b/2)² + h²).
FreeformPath is the interesting one — it stores a list of points and uses two classic algorithms:
- Shoelace formula for area:
½|Σ(xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)|. Correct for any simple polygon, in either winding direction. - Perimeter via
math.hypot: sum of distances between consecutive points, wrapping to the first.
The Renderer
The renderer is the whole point of polymorphism:
def render(scene):
print(f"Rendering {len(scene)} shapes:")
total_area = 0.0
for shape in scene:
svg = shape.draw(None, shape.x, shape.y) # ← polymorphic
print(" ", shape.describe()) # ← polymorphic inside
total_area += shape.area() # ← polymorphic
print(f"Total area: {total_area:.1f}")- One loop. No
isinstance, notype(), noif kind == .... - Three separate calls to
shapemethods —draw,describe,area. Each one dispatches by the shape's real class. shape.describe()internally callsarea()again — so eacharea()override fires twice per shape. Fine here (pure math); would matter ifarea()were expensive.- Total area accumulates from the same polymorphic call — 5026.5 + 3600 + 1925 + 2975 + 1963.5 + 2025 = 17515.0.
Add a new shape? Write the class, append an instance to scene. render() doesn't change. That's the test for whether your design is right.
Where the SVG Rendering Lives
The Python code returns a string for draw() so it runs identically in a terminal and in the browser. The browser renderer below does the same thing with real DOM nodes:
// Circle.prototype.draw — real DOM version
Circle.prototype.draw = function (svg, cx, cy) {
const circle = svgEl('circle', {
cx: cx, cy: cy, r: this.r * PX,
fill: this.color, 'fill-opacity': '.85',
stroke: '#E2E8F0', 'stroke-width': '1.5'
});
svg.appendChild(circle);
};Same contract as the Python version — (svg, cx, cy) — so the render loop is identical. Only the medium changed (SVG strings in Python, real <circle> nodes in JS).
🧪 Try it yourself
Change one thing at a time:
- Remove
@abstractmethodfromareaonShape. Now tryShape(0, 0). What changed? - Add a
RegularPolygonwithnsides and a side length. Register it in the scene — the renderer keeps working. - Override
describe()inFreeformPathto append the number of vertices. Callsuper().describe()inside — what's reused? - Print
Circle.__mro__. Compare toShape.__mro__.
Watch the Loop Dispatch
This is the same scene as the code above, rendered to an actual SVG. Press any button to add a shape; the trace panel below the canvas shows which class answered draw(), area() and perimeter() — from one loop, one call site.
What to notice: the render function only calls shape.draw(), shape.area() and shape.perimeter(). It never asks what kind of shape it holds. Load the sample, then add your own — the trace shows exactly which class answered each call.
Python Playground
Write your own shape classes or experiment with the ones above. The runtime loads once and stays cached.
Challenges: (1) add a Rectangle(width, height); (2) add a RegularPolygon(n, side) using area = (n·s²)/(4·tan(π/n)); (3) add a FreeformPath(points) using the shoelace formula; (4) refactor the scene to accept any Shape subclass and confirm the loop still works.
Add a Fifth Shape
The whole lesson of polymorphism is that adding a type shouldn't touch existing code. Prove it.
Exercise — RegularPolygon
Task: write RegularPolygon(x, y, n, side, color) that overrides draw(), area() and perimeter(). Area formula:
area = (n * side**2) / (4 * math.tan(math.pi / n))
Then add one instance to scene and re-run. How many existing lines did you edit?
Answer: two — the class RegularPolygon definition and the one RegularPolygon(...) in the scene list. render() and describe() were untouched. That's the Open/Closed Principle.
Exercise — Colored Drawing
Task: change draw() on every shape to use self.color instead of a fixed color. Then add a recolor() method on Shape and test circle.recolor("#c084fc") before render(scene).
Hint: recolor is one line: self.color = new_color. Because color is stored in Shape.__init__, one method covers every subclass.
Exercise — A Regression Test
Task: write a function test_areas() that asserts every shape returns a positive float from area() and perimeter(). Run it on the sample scene. If a future subclass forgets an override, the test catches it.
Why this matters: polymorphism means a caller trusts every shape to answer correctly. A test that iterates the scene is the cheapest way to enforce that trust.
Next Project
You've seen method overriding at work. Next, see duck typing — unrelated classes plugged into one pipeline, with no shared parent.
Image Filter Plugin →
Blur, Grayscale, Sharpen, Sepia — four unrelated classes, one apply(pixel). The pipeline never imports them by name.
Media Player →
Songs, podcasts, audiobooks and video clips all extend MediaItem — each decides what play() means.
Polymorphism Guide →
The complete reference — four flavors, 5 programs, 6 exercises, a live lab and 10 projects.