Add floorplan SVG/PNG renderer
Browse files- render_floorplan.py +170 -0
render_floorplan.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Render a floorplan JSON as an SVG or PNG image.
|
| 3 |
+
Supports: plot boundary, buildable boundary, rooms (color-coded by type),
|
| 4 |
+
doors, windows, dimension labels, north arrow.
|
| 5 |
+
Usage:
|
| 6 |
+
python render_floorplan.py --input floorplan.json --output floorplan.svg
|
| 7 |
+
python render_floorplan.py --input floorplan.json --output floorplan.png
|
| 8 |
+
"""
|
| 9 |
+
import json
|
| 10 |
+
import argparse
|
| 11 |
+
from typing import List, Dict, Any, Tuple
|
| 12 |
+
|
| 13 |
+
ROOM_COLORS = {
|
| 14 |
+
"living": "#E8F4FD", "bedroom": "#FFF3E0", "master_bedroom": "#FFE0B2",
|
| 15 |
+
"kitchen": "#F3E5F5", "dining": "#E0F2F1", "toilet": "#FFEBEE",
|
| 16 |
+
"bathroom": "#FFEBEE", "pooja": "#FFFDE7", "study": "#E8F5E9",
|
| 17 |
+
"balcony": "#F5F5F5", "parking": "#ECEFF1", "staircase": "#FBE9E7",
|
| 18 |
+
"corridor": "#ECEFF1", "utility": "#ECEFF1", "store": "#ECEFF1",
|
| 19 |
+
}
|
| 20 |
+
ROOM_STROKES = {
|
| 21 |
+
"living": "#1976D2", "bedroom": "#F57C00", "master_bedroom": "#E65100",
|
| 22 |
+
"kitchen": "#7B1FA2", "dining": "#00796B", "toilet": "#C62828",
|
| 23 |
+
"bathroom": "#C62828", "pooja": "#F9A825", "study": "#388E3C",
|
| 24 |
+
"balcony": "#616161", "parking": "#455A64", "staircase": "#D84315",
|
| 25 |
+
"corridor": "#78909C", "utility": "#78909C", "store": "#78909C",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
def polygon_bbox(poly):
|
| 29 |
+
xs = [p[0] for p in poly]; ys = [p[1] for p in poly]
|
| 30 |
+
return min(xs), min(ys), max(xs), max(ys)
|
| 31 |
+
|
| 32 |
+
def polygons_bbox(polys):
|
| 33 |
+
all_x, all_y = [], []
|
| 34 |
+
for poly in polys:
|
| 35 |
+
for p in poly: all_x.append(p[0]); all_y.append(p[1])
|
| 36 |
+
return min(all_x), min(all_y), max(all_x), max(all_y)
|
| 37 |
+
|
| 38 |
+
def render_floorplan_svg(floorplan, width=1200, padding=80, show_dimensions=True, show_labels=True):
|
| 39 |
+
plot = floorplan.get("plot", {})
|
| 40 |
+
rooms = floorplan.get("rooms", [])
|
| 41 |
+
doors = floorplan.get("doors", [])
|
| 42 |
+
windows = floorplan.get("windows", [])
|
| 43 |
+
|
| 44 |
+
all_polys = []
|
| 45 |
+
if plot.get("outer_boundary"): all_polys.append(plot["outer_boundary"])
|
| 46 |
+
if plot.get("buildable_boundary"): all_polys.append(plot["buildable_boundary"])
|
| 47 |
+
for room in rooms:
|
| 48 |
+
if room.get("polygon"): all_polys.append(room["polygon"])
|
| 49 |
+
|
| 50 |
+
if not all_polys:
|
| 51 |
+
return '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"></svg>'
|
| 52 |
+
|
| 53 |
+
minx, miny, maxx, maxy = polygons_bbox(all_polys)
|
| 54 |
+
plot_w, plot_h = maxx - minx, maxy - miny
|
| 55 |
+
scale = (width - 2 * padding) / max(plot_w, plot_h)
|
| 56 |
+
svg_h = int(plot_h * scale + 2 * padding)
|
| 57 |
+
svg_w = int(plot_w * scale + 2 * padding)
|
| 58 |
+
|
| 59 |
+
def tx(x): return (x - minx) * scale + padding
|
| 60 |
+
def ty(y): return svg_h - ((y - miny) * scale + padding)
|
| 61 |
+
def tpts(poly): return " ".join(f"{tx(p[0])},{ty(p[1])}" for p in poly)
|
| 62 |
+
|
| 63 |
+
lines = []
|
| 64 |
+
lines.append(f'<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" viewBox="0 0 {svg_w} {svg_h}">')
|
| 65 |
+
lines.append(f'<rect width="{svg_w}" height="{svg_h}" fill="white"/>')
|
| 66 |
+
|
| 67 |
+
project_name = floorplan.get("project_name", "Floor Plan")
|
| 68 |
+
lines.append(f'<text x="{svg_w//2}" y="30" text-anchor="middle" font-family="Arial,sans-serif" font-size="18" font-weight="bold">{project_name}</text>')
|
| 69 |
+
|
| 70 |
+
if plot.get("outer_boundary"):
|
| 71 |
+
lines.append(f'<polygon points="{tpts(plot["outer_boundary"])}" fill="none" stroke="#333" stroke-width="3"/>')
|
| 72 |
+
if plot.get("buildable_boundary"):
|
| 73 |
+
lines.append(f'<polygon points="{tpts(plot["buildable_boundary"])}" fill="none" stroke="#666" stroke-width="2" stroke-dasharray="8,4"/>')
|
| 74 |
+
|
| 75 |
+
for room in rooms:
|
| 76 |
+
poly = room.get("polygon", [])
|
| 77 |
+
if not poly: continue
|
| 78 |
+
rtype = room.get("type", "unknown")
|
| 79 |
+
fill = ROOM_COLORS.get(rtype, "#F5F5F5")
|
| 80 |
+
stroke = ROOM_STROKES.get(rtype, "#333")
|
| 81 |
+
lines.append(f'<polygon points="{tpts(poly)}" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>')
|
| 82 |
+
|
| 83 |
+
if show_labels:
|
| 84 |
+
rminx, rminy, rmaxx, rmaxy = polygon_bbox(poly)
|
| 85 |
+
cx, cy = tx((rminx + rmaxx) / 2), ty((rminy + rmaxy) / 2)
|
| 86 |
+
name = room.get("name", rtype)
|
| 87 |
+
area = room.get("area_sqm", "")
|
| 88 |
+
label = f"{name}"
|
| 89 |
+
if area: label += f" ({area}m\u00b2)"
|
| 90 |
+
lines.append(f'<text x="{cx}" y="{cy+4}" text-anchor="middle" font-family="Arial,sans-serif" font-size="10" fill="#333">{label}</text>')
|
| 91 |
+
|
| 92 |
+
for door in doors:
|
| 93 |
+
pos = door.get("position", [0, 0])
|
| 94 |
+
dw = door.get("width", 0.9) * scale
|
| 95 |
+
orient = door.get("orientation", "horizontal")
|
| 96 |
+
x, y = tx(pos[0]), ty(pos[1])
|
| 97 |
+
if orient == "horizontal":
|
| 98 |
+
lines.append(f'<line x1="{x-dw/2}" y1="{y}" x2="{x+dw/2}" y2="{y}" stroke="#2E7D32" stroke-width="4"/>')
|
| 99 |
+
else:
|
| 100 |
+
lines.append(f'<line x1="{x}" y1="{y-dw/2}" x2="{x}" y2="{y+dw/2}" stroke="#2E7D32" stroke-width="4"/>')
|
| 101 |
+
|
| 102 |
+
for win in windows:
|
| 103 |
+
pos = win.get("position", [0, 0])
|
| 104 |
+
ww = win.get("width", 1.2) * scale
|
| 105 |
+
orient = win.get("orientation", "horizontal")
|
| 106 |
+
x, y = tx(pos[0]), ty(pos[1])
|
| 107 |
+
if orient == "horizontal":
|
| 108 |
+
lines.append(f'<line x1="{x-ww/2}" y1="{y}" x2="{x+ww/2}" y2="{y}" stroke="#1565C0" stroke-width="3" stroke-dasharray="4,2"/>')
|
| 109 |
+
else:
|
| 110 |
+
lines.append(f'<line x1="{x}" y1="{y-ww/2}" x2="{x}" y2="{y+ww/2}" stroke="#1565C0" stroke-width="3" stroke-dasharray="4,2"/>')
|
| 111 |
+
|
| 112 |
+
# North arrow
|
| 113 |
+
arrow_x, arrow_y = svg_w - 60, 60
|
| 114 |
+
lines.append(f'<g transform="translate({arrow_x}, {arrow_y})">')
|
| 115 |
+
lines.append('<polygon points="0,-20 -6,10 6,10" fill="#D32F2F"/>')
|
| 116 |
+
lines.append(f'<text x="0" y="25" text-anchor="middle" font-family="Arial,sans-serif" font-size="12" font-weight="bold">N</text>')
|
| 117 |
+
lines.append('</g>')
|
| 118 |
+
|
| 119 |
+
# Legend
|
| 120 |
+
legend_x, legend_y = 20, svg_h - 20 - len(ROOM_COLORS) * 18
|
| 121 |
+
lines.append(f'<rect x="{legend_x-5}" y="{legend_y-15}" width="150" height="{len(ROOM_COLORS)*18+25}" fill="white" stroke="#ccc" stroke-width="1"/>')
|
| 122 |
+
lines.append(f'<text x="{legend_x}" y="{legend_y}" font-family="Arial,sans-serif" font-size="11" font-weight="bold">Legend</text>')
|
| 123 |
+
for i, (rtype, color) in enumerate(ROOM_COLORS.items()):
|
| 124 |
+
ly = legend_y + 15 + i * 16
|
| 125 |
+
lines.append(f'<rect x="{legend_x}" y="{ly-8}" width="12" height="12" fill="{color}" stroke="#666" stroke-width="0.5"/>')
|
| 126 |
+
lines.append(f'<text x="{legend_x+18}" y="{ly+2}" font-family="Arial,sans-serif" font-size="10">{rtype.replace("_", " ").title()}</text>')
|
| 127 |
+
|
| 128 |
+
dims = floorplan.get("dimensions", {})
|
| 129 |
+
meta_text = f"Built-up: {dims.get('total_built_up_area_sqm', 'N/A')}m\u00b2 | Carpet: {dims.get('total_carpet_area_sqm', 'N/A')}m\u00b2"
|
| 130 |
+
lines.append(f'<text x="{svg_w//2}" y="{svg_h-10}" text-anchor="middle" font-family="Arial,sans-serif" font-size="11" fill="#666">{meta_text}</text>')
|
| 131 |
+
lines.append('</svg>')
|
| 132 |
+
return "\n".join(lines)
|
| 133 |
+
|
| 134 |
+
def render_floorplan_png(floorplan, output_path, width=1200):
|
| 135 |
+
try:
|
| 136 |
+
import cairosvg
|
| 137 |
+
except ImportError:
|
| 138 |
+
raise ImportError("cairosvg required for PNG. Install: pip install cairosvg")
|
| 139 |
+
svg_content = render_floorplan_svg(floorplan, width=width)
|
| 140 |
+
cairosvg.svg2png(bytestring=svg_content.encode('utf-8'), write_to=output_path,
|
| 141 |
+
output_width=width, output_height=int(width * 0.75))
|
| 142 |
+
print(f"PNG saved to {output_path}")
|
| 143 |
+
|
| 144 |
+
def main():
|
| 145 |
+
parser = argparse.ArgumentParser(description="Render a floorplan JSON to SVG/PNG")
|
| 146 |
+
parser.add_argument("--input", type=str, required=True, help="Path to floorplan JSON")
|
| 147 |
+
parser.add_argument("--output", type=str, required=True, help="Output path (.svg or .png)")
|
| 148 |
+
parser.add_argument("--width", type=int, default=1200)
|
| 149 |
+
parser.add_argument("--no-labels", action="store_true")
|
| 150 |
+
parser.add_argument("--no-dimensions", action="store_true")
|
| 151 |
+
args = parser.parse_args()
|
| 152 |
+
|
| 153 |
+
with open(args.input) as f:
|
| 154 |
+
floorplan = json.load(f)
|
| 155 |
+
|
| 156 |
+
show_labels = not args.no_labels
|
| 157 |
+
show_dimensions = not args.no_dimensions
|
| 158 |
+
|
| 159 |
+
if args.output.lower().endswith(".svg"):
|
| 160 |
+
svg = render_floorplan_svg(floorplan, width=args.width, show_labels=show_labels, show_dimensions=show_dimensions)
|
| 161 |
+
with open(args.output, "w") as f:
|
| 162 |
+
f.write(svg)
|
| 163 |
+
print(f"SVG saved to {args.output}")
|
| 164 |
+
elif args.output.lower().endswith(".png"):
|
| 165 |
+
render_floorplan_png(floorplan, args.output, width=args.width)
|
| 166 |
+
else:
|
| 167 |
+
print("Error: output must be .svg or .png")
|
| 168 |
+
|
| 169 |
+
if __name__ == "__main__":
|
| 170 |
+
main()
|