# -*- coding: utf-8 -*-
"""
Room Reading v0.1 (draft) - AI for Architects, Assignment 3.
Photographs of an existing room in; one keep / strip / alter diagram out, drawn in your interior style card.
python room_reading.py room.json [--card room_card.toml] [--register register_room.csv] [--out room_reading_out]
room.json is written by your AI from your photographs, following room_reading_instructions.md. It is an
existing.json (the Existing Space format), so the same file can go on to Existing Space and Revit later.
It adds two things per element: `evidence` (seen | inferred | unknown) and `photo_refs` (which photos show it).
Writes, into the output folder:
keep_strip_alter.svg the diagram (line drawing; opens in a browser, Illustrator, imports to Rhino)
keep_strip_alter.png the same drawing as a picture, if cairosvg is installed
register_room.csv one row per claim; you fill keep_strip_alter, verdict and note
room_checked.json room.json with your decisions merged in (Existing Space reads it)
run_report.txt everything the script noticed
Standard library only (Python 3.11+). No network. Fails per element, never per run.
Not a survey, not a hazardous-materials assessment, not structural advice.
"""
import argparse, csv, json, math, os, re, sys, tomllib
from datetime import date
from pathlib import Path
from xml.sax.saxutils import escape
VERSION = "0.1"
KSA = ("keep", "strip", "alter")
EVIDENCE = ("seen", "inferred", "unknown")
REG_COLS = ["id", "element", "location", "category", "assertion", "source", "confidence", "material",
"keep_strip_alter", "verdict", "note"]
FINISH_SLOTS = ("floor", "walls", "ceiling")
DEFAULT_CARD = {
"meta": {"name": "Placeholder style (no card given)"},
"paper": {"size": "A3", "background": "#FFFFFF", "ink": "#1A1A1A"},
"verdict": {"keep": "#4D7C66", "strip": "#B8322A", "alter": "#D69A2D", "undecided": "#CCCCCC"},
"evidence": {"seen": "solid", "inferred": "dashed", "unknown": "dotted", "unknown_hatch": "////"},
"section": {"convention": "fill", "wall_alpha": 1.0, "fixed_alpha": 0.35},
"lines": {"wall_outline": 0.35, "opening": 0.25, "fixed": 0.25, "room": 0.13, "photo": 0.18},
"type": {"family": "Helvetica", "size": 7.0, "title_size": 14.0, "case": "sentence"},
"materials": {},
"show": {"tags": True, "photos": True, "north": True, "scale_bar": True},
}
PAPER = {"A3": (420.0, 297.0), "A4": (297.0, 210.0)}
PT = 0.3528 # mm per point
def log(msg):
print(msg, flush=True)
# ── load ──────────────────────────────────────────────────────────────────────────────────────────────────
def load_json(path):
t = Path(path).read_text(encoding="utf-8-sig")
m = re.search(r"```(?:json)?\s*(\{.*\})\s*```", t, re.S) # pasted straight from a chat
if m:
t = m.group(1)
t = re.sub(r"^\s*//.*$", "", t, flags=re.M) # stray comment lines
t = re.sub(r",(\s*[}\]])", r"\1", t) # trailing commas
return json.loads(t)
def load_card(path, issues):
card = json.loads(json.dumps(DEFAULT_CARD))
if not path:
issues.append(("warn", "card", "No style card given: placeholder style used. Fill in room_card.toml."))
return card
try:
user = tomllib.loads(Path(path).read_text(encoding="utf-8"))
except Exception as e:
issues.append(("warn", "card", f"Could not read the style card ({e}). Placeholder style used."))
return card
for sec, vals in user.items():
if isinstance(vals, dict):
card.setdefault(sec, {}).update(vals)
else:
card[sec] = vals
for k in ("keep", "strip", "alter", "undecided"):
if not re.fullmatch(r"#[0-9A-Fa-f]{6}", str(card["verdict"].get(k, ""))):
issues.append(("warn", "card", f"verdict.{k} is not a #RRGGBB colour; default used."))
card["verdict"][k] = DEFAULT_CARD["verdict"][k]
return card
def num(v, d=None):
try:
return float(v)
except (TypeError, ValueError):
return d
def pt(p):
try:
return [float(p[0]), float(p[1])]
except Exception:
return None
# ── check ─────────────────────────────────────────────────────────────────────────────────────────────────
def check(d, issues):
for k in ("walls", "openings", "fixed", "rooms", "photos", "register"):
d.setdefault(k, [])
if not isinstance(d[k], list):
issues.append(("error", k, f"'{k}' should be a list; ignored."))
d[k] = []
d.setdefault("project", {})
if d.get("schema") != "existing.json":
issues.append(("warn", "file", "schema should be \"existing.json\" (the Existing Space format)."))
sb = d.get("scale_basis") or {}
kd = (sb.get("known_dimension") or {})
if not num(kd.get("mm")):
issues.append(("warn", "scale", "No measured dimension in scale_basis.known_dimension. The drawing's size is a guess."))
ids = set()
def ok_id(e, kind):
i = str(e.get("id", ""))
if not i:
issues.append(("error", kind, "an element has no id; left out."))
return False
if i in ids:
issues.append(("error", i, "id used twice; the second is left out."))
return False
ids.add(i)
if kind == "rooms":
return True
ev = e.get("evidence")
if ev not in EVIDENCE:
if ev is not None:
issues.append(("warn", i, f"evidence '{ev}' should be seen, inferred or unknown; drawn as unknown."))
else:
issues.append(("warn", i, "no evidence given; drawn as unknown."))
e["evidence"] = "unknown"
return True
walls = []
for w in d["walls"]:
if not ok_id(w, "wall"):
continue
a, b, t = pt(w.get("start")), pt(w.get("end")), num(w.get("thickness"))
if not a or not b or math.dist(a, b) < 1:
issues.append(("error", w["id"], "wall has no usable start and end; left out."))
continue
if not t or t <= 0:
issues.append(("warn", w["id"], "no thickness; drawn at 110 mm."))
t = 110.0
w.update(start=a, end=b, thickness=t)
walls.append(w)
d["walls"] = walls
wid = {w["id"]: w for w in walls}
ops = []
for o in d["openings"]:
if not ok_id(o, "opening"):
continue
w = wid.get(o.get("wall_id"))
off, wd = num(o.get("offset")), num(o.get("width"))
if not w or off is None or not wd:
issues.append(("error", o["id"], "opening needs a wall_id that exists, an offset and a width; left out."))
continue
L = math.dist(w["start"], w["end"])
if off < -1 or off + wd > L + 1:
issues.append(("warn", o["id"], f"runs past the end of {w['id']} ({off:.0f}+{wd:.0f} > {L:.0f} mm); clipped."))
off = max(0.0, off); wd = min(wd, L - off)
if o.get("type") not in ("door", "window", "opening"):
o["type"] = "opening"
o.update(offset=off, width=wd)
ops.append(o)
d["openings"] = ops
for k in ("fixed", "rooms"):
keep = []
for e in d[k]:
if not ok_id(e, k):
continue
poly = [pt(p) for p in (e.get("polygon") or [])]
if len(poly) < 3 or any(p is None for p in poly):
issues.append(("error", e["id"], "needs a polygon of at least three points; left out."))
continue
e["polygon"] = poly
keep.append(e)
d[k] = keep
return ids
# ── register ──────────────────────────────────────────────────────────────────────────────────────────────
def build_register(d, csv_path, issues, known):
rows = []
for r in d["register"]:
row = {c: str(r.get(c, "") if r.get(c) is not None else "") for c in REG_COLS}
rows.append(row)
byid = {r["id"]: r for r in rows if r["id"]}
if csv_path and Path(csv_path).exists():
with open(csv_path, encoding="utf-8-sig", newline="") as f:
for r in csv.DictReader(f):
rid = (r.get("id") or "").strip()
if rid in byid:
for c in ("keep_strip_alter", "verdict", "note"):
if (r.get(c) or "").strip():
byid[rid][c] = r[c].strip()
elif rid:
new = {c: (r.get(c) or "").strip() for c in REG_COLS}
rows.append(new); byid[rid] = new
issues.append(("info", rid, "row added from your CSV (it wasn't in room.json)."))
log(f" merged your decisions from {Path(csv_path).name}")
for r in rows:
r["keep_strip_alter"] = r["keep_strip_alter"].strip().lower()
if r["keep_strip_alter"] not in ("",) + KSA:
issues.append(("warn", r["id"], f"keep_strip_alter '{r['keep_strip_alter']}' must be keep, strip, alter or blank; treated as blank."))
r["keep_strip_alter"] = ""
el = r["element"]
base = el.split("-")[0] if el else ""
if el and el not in known and base not in known and el not in ("site", "scale", "ceiling", "floor"):
issues.append(("warn", r["id"], f"refers to element '{el}', which isn't in the drawing."))
return rows
def decisions(rows, issues):
"""element -> keep|strip|alter|'' (undecided) | 'conflict'. Only the student's column counts."""
out = {}
for r in rows:
v, el = r["keep_strip_alter"], r["element"]
if not el or not v:
continue
if el in out and out[el] != v:
issues.append(("warn", el, f"has two different decisions ({out[el]} and {v}); drawn as undecided until you pick one."))
out[el] = "conflict"
elif out.get(el) != "conflict":
out[el] = v
return out
def hazards(rows):
return {r["element"] for r in rows if r["category"] == "hazard" and r["element"]}
# ── geometry ──────────────────────────────────────────────────────────────────────────────────────────────
def frame(w):
a, b = w["start"], w["end"]
L = math.dist(a, b)
u = ((b[0] - a[0]) / L, (b[1] - a[1]) / L)
return a, L, u, (-u[1], u[0])
def at(a, u, n, t, s):
return (a[0] + u[0] * t + n[0] * s, a[1] + u[1] * t + n[1] * s)
def end_ext(w, walls, k):
ext = 0.0
for v in walls:
if v is w:
continue
for kk in ("start", "end"):
if math.dist(w[k], v[kk]) < 1.0:
ext = max(ext, v["thickness"] / 2)
return ext
def wall_pieces(w, walls, ops):
_, L, _, _ = frame(w)
cuts = sorted((o["offset"], o["offset"] + o["width"]) for o in ops if o["wall_id"] == w["id"])
t, pieces = -end_ext(w, walls, "start"), []
for c0, c1 in cuts:
if c0 > t + 1:
pieces.append((t, c0))
t = max(t, c1)
e1 = L + end_ext(w, walls, "end")
if e1 > t + 1:
pieces.append((t, e1))
return pieces
def extent(d):
xs, ys = [], []
for w in d["walls"]:
for p in (w["start"], w["end"]):
xs.append(p[0]); ys.append(p[1])
for k in ("fixed", "rooms"):
for e in d[k]:
for p in e["polygon"]:
xs.append(p[0]); ys.append(p[1])
for p in d["photos"]:
q = pt(p.get("position"))
if q:
xs.append(q[0]); ys.append(q[1])
if not xs:
return 0, 0, 4000, 4000
return min(xs), min(ys), max(xs), max(ys)
def poly_area(p):
return abs(sum(p[i][0] * p[(i + 1) % len(p)][1] - p[(i + 1) % len(p)][0] * p[i][1] for i in range(len(p)))) / 2
def centroid(p):
A = 0; cx = cy = 0
for i in range(len(p)):
x0, y0 = p[i]; x1, y1 = p[(i + 1) % len(p)]
c = x0 * y1 - x1 * y0
A += c; cx += (x0 + x1) * c; cy += (y0 + y1) * c
if abs(A) < 1e-9:
return sum(q[0] for q in p) / len(p), sum(q[1] for q in p) / len(p)
return cx / (3 * A), cy / (3 * A)
# ── drawing ───────────────────────────────────────────────────────────────────────────────────────────────
class Sheet:
def __init__(self, card):
self.c = card
self.W, self.H = PAPER.get(str(card["paper"].get("size", "A3")).upper(), PAPER["A3"])
self.el = []
self.ink = card["paper"]["ink"]
self.font = card["type"].get("family", "Helvetica")
self.fs = float(card["type"].get("size", 7.0)) * PT
self.upper = str(card["type"].get("case", "sentence")).lower() == "upper"
def txt(self, s):
return s.upper() if self.upper else s
def add(self, s):
self.el.append(s)
def text(self, x, y, s, size=None, weight="normal", anchor="start", fill=None, cls=""):
self.add(f'{escape(self.txt(s))}')
def svg(self, defs=""):
bg = self.c["paper"]["background"]
return (f'\n")
DASH = {"solid": "", "dashed": "1.6 0.9", "dotted": "0.35 0.7"}
def dash_attr(card, ev):
style = str(card["evidence"].get(ev, {"seen": "solid", "inferred": "dashed", "unknown": "dotted"}[ev]))
d = DASH.get(style, "")
return f' stroke-dasharray="{d}"' if d else ""
def hatch_def(card):
h = str(card["evidence"].get("unknown_hatch", "////"))
if h in ("none", ""):
return "", False
ink = card["paper"]["ink"]
g = max(1.2, 6.0 / max(1, len(h))) # more marks = denser
marks = {"/": f'',
"\\": f'',
"x": f'',
"-": f'',
".": f''}
key = h[0] if h[0] in marks else "/"
return (f''
f'{marks[key]}'), True
def nice_scale(need_mm_per_mm):
for s in (10, 20, 25, 50, 75, 100, 125, 200, 250, 500):
if s >= need_mm_per_mm:
return s
return 1000
def draw(d, rows, card, out_svg, issues):
S = Sheet(card)
V = card["verdict"]; L = card["lines"]; show = card["show"]
dec = decisions(rows, issues)
haz = hazards(rows)
hatch, has_hatch = hatch_def(card)
hs = str(card["evidence"].get("unknown_hatch", "////"))
hatch_gap = max(0.8, 4.0 / max(1, len(hs)))
hatch_dir = 1 if hs.startswith("\\") else -1
conv = str(card["section"].get("convention", "fill")).lower()
wall_alpha = float(card["section"].get("wall_alpha", 1.0))
fixed_alpha = float(card["section"].get("fixed_alpha", 0.35))
def col(el):
v = dec.get(el, "")
return V[v] if v in KSA else V["undecided"]
# layout: plan on the left, key on the right
M = 14.0
key_w = 108.0 if S.W > 300 else 88.0
px0, py0, pw, ph = M, M + 16, S.W - 2 * M - key_w - 8, S.H - 2 * M - 16 - 10
x0, y0, x1, y1 = extent(d)
pad = 600
x0 -= pad; y0 -= pad; x1 += pad; y1 += pad
scale = nice_scale(max((x1 - x0) / pw, (y1 - y0) / ph))
k = 1.0 / scale
cx, cy = px0 + pw / 2, py0 + ph / 2
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
def P(p):
return cx + (p[0] - mx) * k, cy - (p[1] - my) * k
def poly(pts, fill="none", stroke=None, sw=0.2, extra=""):
s = " ".join(f"{P(q)[0]:.2f},{P(q)[1]:.2f}" for q in pts)
S.add(f'')
hatch_n = [0]
def hatch_poly(pts, paper=False):
"""Unknown: hatch lines clipped to the shape (explicit lines, so every renderer and Rhino shows them)."""
if not has_hatch:
return
hatch_n[0] += 1
cid = f"hc{hatch_n[0]}"
P2 = list(pts) if paper else [P(q) for q in pts]
s = " ".join(f"{x:.2f},{y:.2f}" for x, y in P2)
xs, ys = [q[0] for q in P2], [q[1] for q in P2]
g = hatch_gap
segs = []
c = min(xs) - (max(ys) - min(ys)) - g
dirs = (-1, 1) if hs.startswith("x") else (hatch_dir,)
while c < max(xs) + g:
for hd in dirs:
if hd < 0:
segs.append(f"M{c:.2f},{max(ys):.2f} L{c + (max(ys) - min(ys)):.2f},{min(ys):.2f}")
else:
segs.append(f"M{c:.2f},{min(ys):.2f} L{c + (max(ys) - min(ys)):.2f},{max(ys):.2f}")
c += g
S.add(f''
f'')
def line(a, b, sw, stroke=None, extra=""):
A, B = P(a), P(b)
S.add(f'')
def tag(p, s, dx=0.0, dy=0.0):
if show.get("tags", True):
X, Y = P(p)
S.text(X + dx, Y + dy, s, size=S.fs * 0.8, fill=S.ink)
def hazard_mark(p):
X, Y = P(p)
r = 1.6
S.add(f''
f'!')
walls, ops = d["walls"], d["openings"]
S.add('')
for r in d["rooms"]:
poly(r["polygon"], "none", S.ink, L["room"], ' stroke-dasharray="0.6 0.6" opacity="0.6"')
S.add('')
for f in d["fixed"]:
c = col(f["id"])
poly(f["polygon"], c, S.ink, L["fixed"], f' fill-opacity="{fixed_alpha}"{dash_attr(card, f["evidence"])}')
if f["evidence"] == "unknown":
hatch_poly(f["polygon"])
c0 = centroid(f["polygon"])
tag(c0, f["id"], -2.0, 1.0)
if f["id"] in haz:
hazard_mark((c0[0] + 250 * scale / 50, c0[1]))
S.add('')
for w in walls:
a, Lw, u, n = frame(w)
h = w["thickness"] / 2
c = col(w["id"])
for t0, t1 in wall_pieces(w, walls, ops):
pts = [at(a, u, n, t0, -h), at(a, u, n, t1, -h), at(a, u, n, t1, h), at(a, u, n, t0, h)]
if conv == "outline":
poly(pts, "none", c, max(L["wall_outline"] * 2, 0.5), dash_attr(card, w["evidence"]))
else:
poly(pts, c, S.ink, L["wall_outline"], f' fill-opacity="{wall_alpha}"{dash_attr(card, w["evidence"])}')
if w["evidence"] == "unknown":
hatch_poly(pts)
tag(at(a, u, n, Lw * 0.22, h + 3.0 / k), w["id"], -2.0, 1.0)
if w["id"] in haz:
hazard_mark(at(a, u, n, Lw / 2 + 350, h + 350))
S.add('')
wid = {w["id"]: w for w in walls}
for o in ops:
w = wid[o["wall_id"]]
a, Lw, u, n = frame(w)
h = w["thickness"] / 2
t0, t1 = o["offset"], o["offset"] + o["width"]
c = col(o["id"])
sw = L["opening"]
ev = dash_attr(card, o["evidence"])
line(at(a, u, n, t0, -h), at(a, u, n, t0, h), sw)
line(at(a, u, n, t1, -h), at(a, u, n, t1, h), sw)
if o["type"] == "window":
for s in (-h, 0, h):
line(at(a, u, n, t0, s), at(a, u, n, t1, s), sw * (1.6 if s == 0 else 1), c if s == 0 else None, ev)
elif o["type"] == "door":
# swing into the room the door belongs to (nearest room centroid), unless opens = "out"
mid = at(a, u, n, (t0 + t1) / 2, 0)
cents = [centroid(r["polygon"]) for r in d["rooms"]]
side = 1
if cents:
cc = min(cents, key=lambda q: math.dist(q, mid))
side = 1 if (cc[0] - mid[0]) * n[0] + (cc[1] - mid[1]) * n[1] >= 0 else -1
if str(o.get("opens") or "").lower() == "out":
side = -side
hinge_end = str(o.get("hinge") or "").lower() == "end"
leaves = [(t0, t1)] if o["width"] <= 1300 else [(t0, (t0 + t1) / 2), (t1, (t0 + t1) / 2)]
if len(leaves) == 1 and hinge_end:
leaves = [(t1, t0)]
for th, tf in leaves:
R0 = abs(tf - th)
hp = at(a, u, n, th, side * h)
along = 1 if tf > th else -1
pts = []
for i in range(0, 19):
ang = math.radians(i * 5)
q = (hp[0] + (u[0] * along * math.cos(ang) + n[0] * side * math.sin(ang)) * R0,
hp[1] + (u[1] * along * math.cos(ang) + n[1] * side * math.sin(ang)) * R0)
pts.append(P(q))
line(hp, (hp[0] + n[0] * side * R0, hp[1] + n[1] * side * R0), sw * 1.8, c, ev)
S.add('')
else:
line(at(a, u, n, t0, 0), at(a, u, n, t1, 0), sw * 1.8, c, ' stroke-dasharray="1.2 0.8"')
tag(at(a, u, n, (t0 + t1) / 2, -h - 3.5 / k), o["id"], -2.0, 1.0)
if o["id"] in haz:
hazard_mark(at(a, u, n, (t0 + t1) / 2, -h - 700))
S.add('')
# rooms: name, area, finish chips (floor / walls / ceiling)
for r in d["rooms"]:
c0 = centroid(r["polygon"])
X, Y = P(c0)
S.text(X, Y - 1.5, r.get("name", r["id"]), size=S.fs * 1.25, weight="bold", anchor="middle")
S.text(X, Y + 2.2, f'{poly_area(r["polygon"]) / 1e6:.1f} m² (sketch)', size=S.fs * 0.85, anchor="middle")
for i, slot in enumerate(FINISH_SLOTS):
el = f'{r["id"]}-{slot}'
cx0 = X - 7.5 + i * 5.5
S.add(f'')
S.text(cx0 + 2.1, Y + 10.6, slot[0].upper(), size=S.fs * 0.75, anchor="middle")
if el in haz:
hazard_mark((c0[0] + (i - 1) * 5.5 / k, c0[1] - 9.5 / k))
# photographs: position and direction faced
north = num(d["project"].get("north_deg"), 0.0) or 0.0
if show.get("photos", True):
for i, p in enumerate(d["photos"], 1):
q = pt(p.get("position"))
hd = num(p.get("heading"))
if not q:
continue
X, Y = P(q)
S.add(f'')
S.text(X, Y + 0.8, str(i), size=2.1, anchor="middle")
if hd is not None:
b = math.radians(north + hd)
ux, uy = math.sin(b), -math.cos(b)
S.add(f'')
for sgn in (-1, 1):
bb = b + sgn * math.radians(24)
S.add(f'')
# north point and scale bar, bottom left of the plan area
bx, by = px0 + 2, py0 + ph - 2
if show.get("north", True):
nx, ny = px0 + 8, py0 + 10
b = math.radians(north)
ux, uy = math.sin(b), -math.cos(b)
S.add(f'')
S.add(f'')
S.text(nx + ux * 7.5, ny + uy * 7.5 + 1.0, "N", size=S.fs, weight="bold", anchor="middle")
if d["project"].get("north_deg") is None:
S.text(nx, ny + 9, "north assumed up", size=S.fs * 0.75, anchor="middle")
if show.get("scale_bar", True):
step = 1000 if scale <= 50 else 2000
for i in range(4):
S.add(f'')
S.text(bx, by - 2.6, f"0", size=S.fs * 0.8)
S.text(bx + 4 * step * k, by - 2.6, f"{4 * step / 1000:.0f} m", size=S.fs * 0.8, anchor="middle")
S.text(bx + 4 * step * k + 6, by - 0.4, f"1:{scale} at {S.c['paper'].get('size', 'A3')}", size=S.fs * 0.8)
# title
proj = d["project"]
title = proj.get("name") or "Existing room"
S.text(M, M + 5, "Keep · strip · alter", size=float(card["type"].get("title_size", 14)) * PT, weight="bold")
S.text(M, M + 11, f"{title}", size=S.fs * 1.2)
# key panel
kx, ky = S.W - M - key_w, M + 16
def head(y, s):
S.text(kx, y, s, size=S.fs * 0.9, weight="bold")
S.add(f'')
return y + 5.5
counts = {v: 0 for v in KSA + ("undecided",)}
elements = [w["id"] for w in walls] + [o["id"] for o in ops] + [f["id"] for f in d["fixed"]] + \
[f'{r["id"]}-{s}' for r in d["rooms"] for s in FINISH_SLOTS]
for e in elements:
v = dec.get(e, "")
counts[v if v in KSA else "undecided"] += 1
y = head(ky, "Decision")
for v in KSA + ("undecided",):
S.add(f'')
label = {"keep": "Keep", "strip": "Strip", "alter": "Alter", "undecided": "Not decided yet"}[v]
S.text(kx + 8.5, y, label)
S.text(kx + key_w, y, str(counts[v]), anchor="end")
y += 5.2
y += 2
y = head(y, "How sure the reading is")
for ev, label in (("seen", "Seen in a photograph"), ("inferred", "Inferred from what is visible"), ("unknown", "Not visible: hidden, never guessed")):
S.add(f'')
S.text(kx + 8.5, y, label)
y += 5.0
if has_hatch:
S.add(f'')
hatch_poly([(kx, y - 3), (kx + 6, y - 3), (kx + 6, y + 0.6), (kx, y + 0.6)], paper=True)
S.text(kx + 8.5, y, "Unknown: the decision waits on an inspection")
y += 5.0
if haz:
hazard_mark_key = (kx + 3, y - 1.4)
X, Y = hazard_mark_key
S.add(f'')
S.text(X, Y + 0.9, "!", size=2.2, weight="bold", anchor="middle")
S.text(kx + 8.5, y, "Possible hazardous material: test before any strip")
y += 5.0
y += 2
if d["rooms"]:
y = head(y, "Finishes (F floor · W walls · C ceiling)")
mats = card.get("materials", {})
byel = {}
for r in rows:
if r["element"]:
byel.setdefault(r["element"], []).append(r)
for r in d["rooms"]:
for slot in FINISH_SLOTS:
el = f'{r["id"]}-{slot}'
rr = byel.get(el, [])
if not rr:
continue
mat = next((x["material"] for x in rr if x["material"]), "")
what = rr[0]["assertion"]
S.add(f'')
if mat and mat in mats:
S.add(f'')
short = what if len(what) <= 58 else what[:56] + "…"
S.text(kx + 9.5, y, f'{slot[0].upper()} · {short}', size=S.fs * 0.85)
y += 4.6
if y > S.H - 60:
break
y += 2
y = min(y, S.H - 52)
y = head(y, "Read this first")
kd = (d.get("scale_basis") or {}).get("known_dimension") or {}
notes = [
"A sketch plan from photographs. Not a measured survey.",
f"Sized from one measurement: {kd.get('what', 'none given')}"
+ (f" = {num(kd.get('mm'), 0):.0f} mm." if num(kd.get('mm')) else "."),
"Every tag (W01, D01, F01, R01-floor) is a row in the register.",
"Only you fill keep, strip and alter. Undecided is never keep.",
"Structure and services behind finishes are not visible here.",
]
maxc = int(key_w / (S.fs * 0.85 * 0.5)) # rough characters per line
for s in notes:
words, cur = s.split(), ""
for wd in words:
if len(cur) + len(wd) + 1 > maxc and cur:
S.text(kx, y, cur, size=S.fs * 0.85); y += 3.6; cur = wd
else:
cur = (cur + " " + wd).strip()
S.text(kx, y, cur, size=S.fs * 0.85)
y += 4.4
# footer
S.add(f'')
photos = len(d["photos"])
S.text(M, S.H - M + 6, f"Room Reading {VERSION} · {date.today().isoformat()} · {photos} photograph{'s' if photos != 1 else ''} · "
f"style: {card['meta'].get('name', '')}", size=S.fs * 0.8)
S.text(S.W - M, S.H - M + 6, "Not a survey, not a hazardous-materials assessment, not structural advice.",
size=S.fs * 0.8, anchor="end")
Path(out_svg).write_text(S.svg(hatch), encoding="utf-8")
return counts, dec
def write_register(rows, path):
with open(path, "w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=REG_COLS)
w.writeheader()
for r in rows:
w.writerow({c: r.get(c, "") for c in REG_COLS})
def run(args):
issues = []
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
log(f"Room Reading {VERSION} | {args.room}")
d = load_json(args.room)
card = load_card(args.card, issues)
known = check(d, issues)
csv_path = args.register or (out / "register_room.csv" if (out / "register_room.csv").exists() else None)
rows = build_register(d, csv_path, issues, known)
log(f" {len(d['walls'])} walls, {len(d['openings'])} openings, {len(d['fixed'])} fixed, {len(d['rooms'])} rooms, "
f"{len(d['photos'])} photos, {len(rows)} register rows")
svg = out / "keep_strip_alter.svg"
counts, dec = draw(d, rows, card, svg, issues)
log(f" wrote {svg.name} (keep {counts['keep']}, strip {counts['strip']}, alter {counts['alter']}, not decided {counts['undecided']})")
try:
import cairosvg
cairosvg.svg2png(url=str(svg), write_to=str(out / "keep_strip_alter.png"), dpi=150)
log(" wrote keep_strip_alter.png")
except Exception:
log(" (no PNG: install cairosvg for one, or open the SVG in a browser)")
haz = hazards(rows)
for el in sorted(haz):
if dec.get(el) == "strip":
issues.append(("warn", el, "marked strip but has a possible-hazard row: it must be tested by a licensed assessor first."))
unknown_keep = [e for e, v in dec.items() if v == "keep"
and any(x.get("id") == e and x.get("evidence") == "unknown" for x in d["walls"] + d["openings"] + d["fixed"])]
for e in unknown_keep:
issues.append(("info", e, "marked keep, but it isn't visible in any photo. Say in the note how you know it can stay."))
write_register(rows, out / "register_room.csv")
log(" wrote register_room.csv")
d["register"] = rows
(out / "room_checked.json").write_text(json.dumps(d, indent=1, ensure_ascii=False), encoding="utf-8")
log(" wrote room_checked.json")
lines = [f"Room Reading {VERSION} - run report - {date.today().isoformat()}", ""]
for lvl in ("error", "warn", "info"):
for l, el, msg in issues:
if l == lvl:
lines.append(f"{lvl.upper():5} {el:10} {msg}")
if len(lines) == 2:
lines.append("Nothing to report.")
(out / "run_report.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
log(f" wrote run_report.txt ({sum(1 for i in issues if i[0] == 'error')} errors, {sum(1 for i in issues if i[0] == 'warn')} warnings)")
return out, issues
def main(argv=None):
ap = argparse.ArgumentParser(description="Room Reading: photographs of a room -> one keep/strip/alter diagram")
ap.add_argument("room", help="room.json written by your AI (an existing.json)")
ap.add_argument("--card", help="your interior style card (room_card.toml)")
ap.add_argument("--register", help="register_room.csv with your keep/strip/alter decisions filled in")
ap.add_argument("--out", default="room_reading_out")
a = ap.parse_args(argv)
if not a.card and Path("room_card.toml").exists():
a.card = "room_card.toml"
run(a)
if __name__ == "__main__":
main()