CH NEO-ZÜRICH EDITION
WEATHER · ACID RAIN 11°C
BLEND OF THE DAY · 07/ROGUE
EST. 2027
THE AEC CYBER MORNING NEWS

PAZ Kaffi

DESIGN · DEMOLITION · CAFFEINE · DISPATCH
EDITION 1110 · 10 November 2026
BROADCAST 04:42 CET
2,400 BROADSHEETS PRINTED
READ TIME · 47 MIN
Every Roof Face, Tagged: Pitch, Percent and Run-Off in 69 Lines of Rhino Python
ACADEMY
FRAME · 07:00
10-11-2026

Every Roof Face, Tagged: Pitch, Percent and Run-Off in 69 Lines of Rhino Python

A 69-line Rhino 8 ScriptEditor script reads each roof face normal and drops a text dot with pitch in degrees, percent and run-off direction, flat ones red.

The roof plan is due on Thursday, and somebody in the office has the job nobody puts on a CV. They click a roof face, measure it, work out the angle, type “30°” next to a little arrow, and move to the next face. On a hip roof with two dormers that is a dozen careful clicks. On a housing block with six roof levels and a few terraces it is an afternoon. Then the model changes and the labels quietly stop being true.

The roof plan does not care how tired your mouse hand is.

This tutorial replaces that afternoon with one idea and 69 lines of Python. Every flat roof face has one normal vector, the arrow that stands perpendicular to it. Split that arrow into a horizontal part and a vertical part. The horizontal part divided by the vertical part is the slope in percent. The arctangent of the same ratio is the pitch in degrees. The direction the horizontal part points in plan is the way the water runs. One vector, three numbers, every face at once.

←TODAY: Rhino 8 ships a ScriptEditor that runs native CPython 3 next to RhinoCommon, so an office script is one file and one Run button.
→3012: The roofs that stay repairable are the ones whose drawings explain themselves in plain text a stranger can still open.
Fulcrum: A 69-line script you can read is both today’s time-saver and tomorrow’s documentation of how the plan was made.

The Tool: Rhino 8’s ScriptEditor, with RhinoCommon (the .NET geometry library inside Rhino) and rhinoscriptsyntax (the friendly command-style layer on top of it), all made by Robert McNeel & Associates. RhinoPython began as Steve Baer’s work at McNeel, and Rhino 8 added CPython 3, the same Python engineers use everywhere else, next to the older IronPython 2. The packaging is the gift here: McNeel’s developer docs describe every call this script uses, so you are learning from the people who built it. PAZ Academy is a Rhino centre in Switzerland and a reseller, so if your office still needs Rhino 8 seats, we can help with that too.

Setup:

# Rhino 8 command line
ScriptEditor

# In the ScriptEditor window:
#   New file  -> choose Python 3
#   Paste the 69-line script below
#   Save as   -> roof_pitch_tags.py
#   Press Run, select the roof, press Enter

First steps:

  1. Open any model with a roof made of surfaces, polysurfaces or extrusions. A copy of the current project is ideal.
  2. Run the script. It asks you to pick objects; preselected roofs count too.
  3. Look at the new layer A-ROOF-PITCH. Each face should carry a dot like 30.0° | 57.7 % | S, coloured green, orange or red.
  4. Change the model, delete the layer’s dots, run again. That is the whole re-issue workflow.

One honest note: the pure-maths function pitch() was executed for this piece in plain Python 3.10. The Rhino half was checked line by line against McNeel’s documentation but not run inside Rhino. Do your first test run on a copy.

The code, explained:

#! python 3
"""Roof pitch tags - Rhino 8 ScriptEditor (Python 3).
Select roof surfaces/polysurfaces. Every face gets a text dot with its
pitch in degrees and percent and the direction water runs off."""
import math
import Rhino
import rhinoscriptsyntax as rs
import scriptcontext as sc
from System.Drawing import Color

MIN_FALL = 1.5      # % - flatter than this is flagged (set to your spec)
WALL = 89.0         # deg - steeper than this is a wall, skip it
LAYER = "A-ROOF-PITCH"
COMPASS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]

def pitch(nx, ny, nz):
    """Unit normal -> (degrees, percent, run-off direction)."""
    if nz < 0:                      # face points down: flip it, same plane
        nx, ny, nz = -nx, -ny, -nz
    run = math.hypot(nx, ny)        # horizontal part of the normal
    deg = math.degrees(math.atan2(run, nz))
    pct = 100.0 * run / nz if nz > 1e-9 else float("inf")
    if run < 1e-9:
        return deg, pct, "-"
    az = math.degrees(math.atan2(nx, ny)) % 360.0   # 0 = north (+Y), clockwise
    return deg, pct, COMPASS[int((az + 22.5) // 45) % 8]

def colour_for(deg, pct):
    if pct < MIN_FALL:
        return Color.FromArgb(220, 40, 40)      # ponding risk
    if deg < 10.0:
        return Color.FromArgb(240, 160, 0)      # low pitch: check build-up
    return Color.FromArgb(40, 150, 60)          # pitched

def main():
    ids = rs.GetObjects("Select roof surfaces or polysurfaces", 8 | 16, preselect=True)
    if not ids:
        return
    if not rs.IsLayer(LAYER):
        rs.AddLayer(LAYER, Color.FromArgb(90, 90, 90))
    tol = sc.doc.ModelAbsoluteTolerance
    rs.EnableRedraw(False)
    count = 0
    for gid in ids:
        brep = Rhino.Geometry.Brep.TryConvertBrep(sc.doc.Objects.FindId(gid).Geometry)
        if brep is None:
            continue
        for face in brep.Faces:
            mp = Rhino.Geometry.AreaMassProperties.Compute(face.DuplicateFace(False))
            if mp is None:
                continue
            ok, u, v = face.ClosestPoint(mp.Centroid)
            if not ok:
                continue
            n = face.NormalAt(u, v)
            n.Unitize()
            deg, pct, falls = pitch(n.X, n.Y, n.Z)
            if deg > WALL:
                continue
            curved = "" if face.IsPlanar(tol) else "~"
            text = "{}{:.1f}° | {:.1f} % | {}".format(curved, deg, pct, falls)
            dot = rs.AddTextDot(text, face.PointAt(u, v))
            rs.ObjectLayer(dot, LAYER)
            rs.ObjectColor(dot, colour_for(deg, pct))
            count += 1
    rs.EnableRedraw(True)
    print("{} roof faces tagged on layer {}".format(count, LAYER))

main()

Lines 1–14 — the settings you own: the imports bring in maths, RhinoCommon (Rhino), rhinoscriptsyntax (rs), the live document (scriptcontext) and .NET colours. Then four constants. MIN_FALL is the fall below which a face is flagged; 1.5 % is a placeholder, so set it to your project’s spec, not to a number from memory. WALL skips anything steeper than 89°, so fascias and parapets stay quiet. A constant is just a named value you set once at the top, so nobody has to hunt for it inside the logic.

Lines 16–26 — the number that does all the work: pitch() takes the three components of the normal. math.hypot(nx, ny) is the horizontal length, the “run”. atan2(run, nz) turns run against rise into degrees, and 100 * run / nz is the percent. The azimuth line measures the plan angle clockwise from +Y, then rounds it into one of eight compass sectors of 45°. A dead-level face has no horizontal part, so it returns “-” instead of inventing a direction. The first two lines flip a downward-pointing normal, because a face and its flipped twin are the same plane.

Run in plain Python, the function prints:

30 deg roof falling south         30.0 deg |   57.7 % | S
same face, normal flipped         30.0 deg |   57.7 % | S
flat roof, 2 % fall to east        1.1 deg |    2.0 % | E
flat roof, 1 % fall to west        0.6 deg |    1.0 % | W
dead-level slab                    0.0 deg |    0.0 % | -
45 deg hip falling north-east     45.0 deg |  100.0 % | NE

Lines 28–33 — three colour classes: red below your minimum fall, orange under 10° as a prompt to check the build-up, green for pitched. Color.FromArgb builds a colour from red, green and blue values between 0 and 255.

Lines 35–43 — selection and housekeeping: per McNeel’s RhinoScriptSyntax reference, rs.GetObjects takes a filter where surface is 8 and polysurface is 16, so 8 | 16 accepts both. The layer is created only if rs.IsLayer says it is missing. rs.EnableRedraw(False) stops the viewport repainting after every dot, which is where the speed comes from. One thing to know before the first run: on a closed roof solid the loop also visits the downward faces, so a flat slab’s underside gets its own red 0.0 % | - dot. Pick the roof surfaces, or delete the dots on the soffit side.

Lines 44–56 — face by face, finding the middle: Brep.TryConvertBrep turns whatever you picked, extrusions included, into a Brep (boundary representation: a solid or shell made of trimmed faces). For each face, AreaMassProperties.Compute on face.DuplicateFace(False) gives the area centroid. ClosestPoint pulls that centroid back onto the face as surface parameters u, v, and NormalAt(u, v) reads the normal there. The beginner mistake lives on line 52: according to the RhinoCommon documentation, Surface.ClosestPoint returns true on success and hands u and v back as out parameters, and in Rhino 8 CPython those arrive as one tuple (ok, u, v). Write u, v = ... and Python stops with an unpacking error.

Lines 57–67 — the dot: IsPlanar(tol) checks flatness at the model tolerance and prefixes “~” on curved faces. rs.AddTextDot places the label, then rs.ObjectLayer and rs.ObjectColor file and colour it. Redraw comes back on, and the command line reports how many faces were tagged.

Two limits to tell the team. Plan north is +Y, so a rotated site needs its model rotated or the compass adjusted. A curved face is read at its centroid only, and on an L-shaped face the dot can sit just outside the trimmed edge. The trade-off is plain: one dot per face is fast and readable, but it is a single sample, not a fall map of a warped roof.

Atelier: For a 12-person studio in Zürich issuing roof plans for every Bauleitung round, the labels stop being a chore someone does last and become a check anyone runs first, which also means the red faces surface before the roofer finds them. Monday move: put roof_pitch_tags.py in the office’s shared script folder and write the project’s agreed minimum fall into MIN_FALL at the start of each job.

Hack: Give the site team the number they actually set out with: millimetres of fall per metre. Percent times ten is mm per metre, so the 2 % east-falling roof from the test should read 20 mm/m and the 30° face about 577 mm/m. Replace line 61 with this and predict before you run:

mm = 10 * pct   # 1 % fall = 10 mm per metre of run
text = "{}{:.1f}° | {:.1f} % | {:.0f} mm/m | {}".format(
    curved, deg, pct, mm, falls)

PAZ argued in its PAZ-Rhino piece that the best office scripts start with the task you repeat every week. Roof pitches are that task. Save the script, run it on your current roof, and issue the next plan with labels the model wrote itself.

Learn-it:

FILED FROM
CO-SIGNERS
PAZ Academy
CONFIDENCE
HIGH
REPRINTS
© PAZ - PARAMETRIC ACADEMY ZURICH · ALL RIGHTS RESERVED

PAZ Kaffi · multidisciplinary editorial, led by PAZ Academy

⚑ REPORT AN ERROR · SUBMIT A CORRECTION
◂ BACK TO FRONT PAGE · PAZ KAFFI

© 2026 PAZ Academy.