Back to Home
Live Project · Shape Renderer

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.

📅 October 20, 2026 ⏱ 18 min read 🎨 Method Overriding 🐍 Python 3.11+
Back to Home Open Live Renderer Polymorphism Guide Full Code

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).

    Full source with runnable code Line-by-line walkthrough Live SVG renderer Extend-it exercises Progress tracker

    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 call

    That'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

    Shape (abstract base) ├── Circle(radius) area = π·r² perimeter = 2πr ├── Square(side) area = s² perimeter = 4s ├── Triangle(base, h) area = ½·b·h perimeter = b + 2·√((b/2)² + h²) └── FreeformPath(points) area = shoelace formula perimeter = sum of segment lengths

    Each subclass must override three methods. Forgetting any raises NotImplementedError at call time — visible immediately, not silently.

    The Override Table

    MethodDefined on Shape asOverridden by
    draw(svg, cx, cy)raise NotImplementedErrorAll four shapes
    area()raise NotImplementedErrorAll four shapes
    perimeter()raise NotImplementedErrorAll 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)
    Expected output
    Rendering 6 shapes: Circle area= 5026.5 perimeter= 251.3 Square area= 3600.0 perimeter= 240.0 Triangle area= 1925.0 perimeter= 209.0 FreeformPath area= 2975.0 perimeter= 219.7 Circle area= 1963.5 perimeter= 157.1 Square area= 2025.0 perimeter= 180.0 Total area: 17515.0
    📝 Try to spot it
    1. The loop in render() is one line. shape.describe() — but the output has six distinct rows because describe() calls area() and perimeter(), and those dispatch to each subclass.
    2. The renderer has no isinstance. It calls shape.draw() and lets the object answer. Compare to the "bad_render" ladder in the intro — same job, one tenth the code.
    3. 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

    1. class Shape(ABC): — inherits from abc.ABC, making it abstract. Instantiating Shape() directly raises TypeError.
    2. __init__(self, x, y, color="#4B8BBE") — position and color shared by all shapes. Subclasses call super().__init__() to get them.
    3. @abstractmethod def draw(self, svg, cx, cy): ... — declares the drawing contract. Any subclass that doesn't override it can't be instantiated.
    4. @abstractmethod def area(self) / perimeter(self) — the math contract. Same enforcement.
    5. def describe(self) — not abstract. Written once, uses self.area() and self.perimeter() polymorphically. This is the Template Method.
    6. def __repr__(self) — good debugging output. Try print(scene) with and without it.

    The Subclasses

    Circle and Square follow the same pattern:

    1. __init__ calls super().__init__(x, y, color) — reuses the base.
    2. Stores its own dimension (self.r / self.side).
    3. draw() returns an SVG string — same signature (svg, cx, cy). In the browser, svg is the real SVG element; in Pyodide it's None, and we just build the string.
    4. area() and perimeter() — pure math, no shared state.

    Triangle adds a small geometric twist:

    1. perimeter() uses Pythagoras — the slanted sides are √((b/2)² + h²). So a triangle's perimeter is b + 2·√((b/2)² + h²).

    FreeformPath is the interesting one — it stores a list of points and uses two classic algorithms:

    1. Shoelace formula for area: ½|Σ(xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)|. Correct for any simple polygon, in either winding direction.
    2. 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}")
    1. One loop. No isinstance, no type(), no if kind == ....
    2. Three separate calls to shape methods — draw, describe, area. Each one dispatches by the shape's real class.
    3. shape.describe() internally calls area() again — so each area() override fires twice per shape. Fine here (pure math); would matter if area() were expensive.
    4. 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 @abstractmethod from area on Shape. Now try Shape(0, 0). What changed?
    • Add a RegularPolygon with n sides and a side length. Register it in the scene — the renderer keeps working.
    • Override describe() in FreeformPath to append the number of vertices. Call super().describe() inside — what's reused?
    • Print Circle.__mro__. Compare to Shape.__mro__.
    Advertisement

    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.

    🔷 Shape Renderer · 0 / 12 shapes Total area: 0.0
    Add a shape to begin.

    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.

    🐍 shape_lab.py
    Press Run to execute your code.

    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.

    Share This Project

    Advertisement