Draw the Rooms Once, Let Rhino Count: An SIA 416 Area Schedule in Python 3
Draw rooms as closed curves on HNF, NNF, VF and FF layers, and 30 lines of rhinoscriptsyntax tag each room, sum NF and NGF, and write a Swiss-ready CSV.
In a Rhino 8 model, a room is three things: a closed curve, a name and a layer. That is everything an SIA 416 area schedule needs, and Rhino already measures the curve exactly with CurveArea. Draw the rooms once, and about 30 lines of Python 3 print the table. On a 3.5-room test flat that comes to HNF 69.15 m2, VF 11.06 m2 and NGF 82.75 m2, written into a CSV that Swiss Excel opens in columns.
Every Wettbewerb, every Baueingabe and every cost estimate asks for areas by category. Many offices still retype those areas into a spreadsheet, which starts drifting the first time someone moves a wall. Here the model is the source and the table is printed from it: move the wall, rerun, and the numbers follow the drawing. One honest note before we start. The Rhino half below was written against the McNeel developer docs and was not run in Rhino. The pure-Python half was run, on Python 3.10.11, and its output is quoted exactly.
The Tool: rhinoscriptsyntax, the plain-language Python layer McNeel ships inside Rhino, run from the Rhino 8 ScriptEditor. It goes back to RhinoPython, developed at McNeel by Steve Baer; Rhino 8 then added native CPython 3, the same Python scientists use. The quiet heroes here are the people who wrote the developer reference. Every call in this tutorial is documented on one page, arguments and return values spelled out, and that page is why a beginner can write a working script in an afternoon. The script uses ten of those calls.
←TODAY: In 2026 many Swiss area tables are still typed by hand next to a model that already knows every square metre.
→3012: From my side of the century, the plans that survived were closed curves plus a plain CSV — files a 25-year-old can still open.
Fulcrum: The schedule outlives the software only when it is printed from geometry, not retyped beside it.
Setup:
# Rhino 8 · any document units (mm or m, the script converts)
# 1 · Layers panel: create HNF, NNF, VF, FF
# (sublayers such as "Geschoss 1::HNF" work too)
# 2 · Open the editor from the command line:
_ScriptEditor
# 3 · New Python file. The first line must be:
#! python 3
# 4 · Paste the two blocks below into ONE file, save it as
# C:\paz\room_schedule.py, then run it from a toolbar button:
_-ScriptEditor _R "C:\paz\room_schedule.py"According to McNeel’s guide to advanced ScriptEditor macros, the language specifier is “necessary so the command can determine which language to run the script with”; the valid ones are #! python 3, #! python 2 and // #! csharp.
First steps:
- Make the four layers. The layer is the classification: the script reads the category, it never guesses one.
- Draw each room as a closed curve on its layer. Polylines, arcs and splines all work, because
CurveAreameasures any closed planar curve. Type the room name in Properties → Name, for example “Zimmer 1”. - Run the script, select the room curves, press Enter. Every room gets a text dot with its name and area; the command line prints how many rooms were tagged and how many curves were turned red.
- Open
room_schedule.csvfrom your home folder in Excel. Semicolons mean it lands in columns, not in one long line.
A word on the categories. According to the Ginesta glossary, which cites SIA 416, net floor space NGF is “the part of the overall floor space” “between the perimeter walls and the interior construction elements”, and it splits into usable space NF, access space VF and functional space FF. NNF is the part of NF “supplementary to the main usable space” (HNF). FF covers rooms “set aside for technical maintenance and building infrastructure”: shafts, installation rooms, boiler rooms. So NF = HNF + NNF, and NGF = NF + VF + FF. We read the norm through that glossary, not the SIA document itself, so check your project’s own SIA 416 assignment before a submission.
The code, explained:
# schedule_core.py - the pure-Python half (no Rhino needed)
import csv, io
ORDER = ["HNF", "NNF", "VF", "FF"] # SIA 416 categories, one layer each
def shoelace(pts):
"""Area of a closed polygon from its corner points (x, y). Test helper; in Rhino, CurveArea does this."""
s = 0.0
for (x1, y1), (x2, y2) in zip(pts, pts[1:] + pts[:1]):
s += x1 * y2 - x2 * y1
return abs(s) / 2.0
def schedule(rows):
"""rows: list of (room name, category, area in m2). Returns CSV text and prints a summary."""
rows = sorted(rows, key=lambda r: (ORDER.index(r[1]) if r[1] in ORDER else 99, r[0]))
totals = {}
for name, cat, a in rows:
totals[cat] = totals.get(cat, 0.0) + a
nf = totals.get("HNF", 0) + totals.get("NNF", 0)
ngf = sum(totals.values())
buf = io.StringIO()
w = csv.writer(buf, delimiter=";") # semicolon: Swiss Excel opens it in columns
w.writerow(["Raum", "Kategorie", "Flaeche m2"])
for name, cat, a in rows:
w.writerow([name, cat, f"{a:.2f}"])
for cat in ORDER:
if cat in totals:
print(f"{cat:>4} {totals[cat]:8.2f} m2 {100 * totals[cat] / ngf:5.1f} % of NGF")
print(f" NF {nf:8.2f} m2 (HNF + NNF)")
print(f" NGF {ngf:8.2f} m2 (NF + VF + FF, every drawn room)")
return buf.getvalue()#! python 3
# room_schedule.py - Rhino 8 ScriptEditor (Python 3)
# Select closed room curves on layers HNF / NNF / VF / FF (SIA 416 categories), name each curve
# in Properties (e.g. "Zimmer 1"). The script tags every room with its area and writes a CSV.
import os
import rhinoscriptsyntax as rs
from schedule_core import schedule # in Rhino: paste the schedule() function and ORDER above this line
ids = rs.GetObjects("Select closed room curves", filter=4, preselect=True) # 4 = curves
if ids:
k = rs.UnitScale(4) ** 2 # model units -> metres, squared for areas
rows, skipped = [], []
for cid in ids:
props = rs.CurveArea(cid) if rs.IsCurveClosed(cid) and rs.IsCurvePlanar(cid) else None
if not props: # open, non-planar or self-intersecting: flag it, do not guess
skipped.append(cid)
rs.ObjectColor(cid, (255, 0, 0))
continue
area = props[0] * k
cat = rs.ObjectLayer(cid).split("::")[-1].upper() # "Geschoss 1::HNF" -> "HNF"
name = rs.ObjectName(cid) or "Raum ?"
rows.append((name, cat, area))
rs.AddTextDot(f"{name} {area:.1f} m2", rs.CurveAreaCentroid(cid)[0])
text = schedule(rows)
path = os.path.join(os.path.expanduser("~"), "room_schedule.csv")
with open(path, "w", encoding="utf-8-sig", newline="") as f: # BOM so Excel reads umlauts
f.write(text)
print(f"{len(rows)} rooms tagged, {len(skipped)} curves turned red. CSV: {path}")Core, lines 1–4 — the norm’s vocabulary as a list: ORDER is a list, an ordered row of values. It fixes the sequence the table prints in. The from schedule_core import line only works if that file sits on the ScriptEditor’s module path, so the safe move is the one in the comment: paste ORDER and schedule() above the Rhino part, in one file.
Core, lines 6–11 — the shoelace, a test helper: a function is a named, reusable block. This one walks a polygon’s corners and sums cross products, the classic formula for polygon area. In Rhino, CurveArea does the job, arcs and splines included; the helper exists so you can test the table logic on a laptop with no Rhino at all.
Core, lines 13–31 — sort, sum, write: the sort key puts known categories in ORDER and anything unknown at position 99, then sorts by name. A loop (code that repeats once per item) adds each area into a dictionary of totals. The CSV writer uses a semicolon delimiter, the character between columns, because Swiss Excel expects it. Fed with the 3.5-room test flat drawn in mm, the printed summary was exactly:
HNF 69.15 m2 83.6 % of NGF
NNF 2.24 m2 2.7 % of NGF
VF 11.06 m2 13.4 % of NGF
FF 0.30 m2 0.4 % of NGF
NF 71.39 m2 (HNF + NNF)
NGF 82.75 m2 (NF + VF + FF, every drawn room)The CSV rows include Wohnen/Essen;HNF;29.76. Check it by hand: 6.2 × 4.8 = 29.76 m2. The flat is an example, not a project, and putting Bad and Küche under HNF was the editor’s call, not the norm’s.
Rhino, lines 9–11 — select and scale: GetObjects with filter 4 accepts curves only. McNeel’s API reference states that UnitScale(to_system) uses the document’s current units when from_system is omitted; code 4 means metres, and the docs’ own example is rs.UnitScale(3, 4) # 100.0. The beginner’s mistake: forgetting ** 2. A length factor applied to an area leaves a mm file 1,000 times too large. Squared, a mm file and an m file give the same table.
Rhino, lines 12–18 — measure or refuse: per the same reference, CurveArea returns “area of closed planar curves”, as a list where [0] is the area and [1] the absolute error bound. The script only asks after IsCurveClosed and IsCurvePlanar pass. Anything it cannot measure goes into skipped and gets coloured red. Whether that red is visible when the curve’s colour is set “By Layer” was not verified, so trust the skipped count on the command line.
Rhino, lines 19–28 — read, tag, write: the layer path is split at :: and uppercased, so a lowercase “hnf” still counts. An unnamed curve becomes “Raum ?”. CurveAreaCentroid returns a tuple (a fixed pair) of point and vector; [0], the centroid, is where the dot goes. utf-8-sig adds a BOM, a short marker that tells Excel to read umlauts correctly. In a dry run against a stub rs module, with one open “Balkon” curve and one unnamed room on layer “hnf”, it printed 3 rooms tagged, 1 curves turned red. and the dots read Wohnen/Essen 29.8 m2, Korridor 11.1 m2, Raum ? 2.2 m2. The script does not track its dots, so delete the old ones before a rerun. Otherwise you get two labels per room, a very Swiss form of thoroughness.
The trade-off, plainly: this schedule is only as honest as the layers. A room drawn on the wrong layer is summed into the wrong category, without complaint. It measures net floor space only; walls are not drawn, so there is no GF here.
Atelier: For a small Zürich or Bern studio, the shift is from checking a spreadsheet to checking a drawing, which is where the drafter’s eye already is. The Monday move: add the four layers HNF, NNF, VF and FF to the office Rhino template and agree that every room is a named closed curve on exactly one of them.
Hack: Check the L-shaped corridor yourself before you trust the script. Predict first: 1.4 × 2.6 + 5.3 × 1.4 should give 11.06 m2. Then feed its six corners in metres to the test helper:
from schedule_core import shoelace
korridor = [(0, 0), (5.3, 0), (5.3, 1.4), (1.4, 1.4), (1.4, 4.0), (0, 4.0)]
print(round(shoelace(korridor), 2)) # expect 11.06Then change 5.3 to 6.0 and predict again before you run it: 1.4 × 2.6 + 6.0 × 1.4 = 12.04.
Next steps: add a Geschoss column from the parent layer name, then move the same logic into a Grasshopper Python 3 component so the table updates live — the path PAZ Academy teaches in its Rhino and Grasshopper courses. PAZ has walked this ground from the IFC side, in a Python door schedule in 30 lines. Same idea, different file: the model knows, so let the script ask it.
Learn-it:
- McNeel — RhinoScriptSyntax API reference (every call used here)
- McNeel — Advanced ScriptEditor macros (the
#! python 3rule and the toolbar macro) - Ginesta glossary — Nettogeschossfläche (NGF)
- Ginesta glossary — Nebennutzfläche (NNF)
- PAZ Kaffi — IfcOpenShell: a Python door schedule in 30 lines
Open your current project, put one floor’s rooms on the four layers, and print its schedule before Friday.
PAZ Kaffi · multidisciplinary editorial, led by PAZ Academy