# -*- 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' f'{defs}\n\n' + "\n".join(self.el) + "\n\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()