| from __future__ import annotations |
|
|
| import math |
| import tempfile |
| from pathlib import Path |
| from typing import Any, Iterable |
|
|
| from PIL import Image, ImageDraw, ImageFont |
|
|
| from .models import EvidenceItem, SiteSelection |
|
|
|
|
| INK = "#111817" |
| INK_2 = "#33423f" |
| MUTED = "#687572" |
| PAPER = "#f6f1e7" |
| PANEL = "#ffffff" |
| LINE = "#c8d0cc" |
| CHARCOAL = "#0f1c1b" |
| TEAL = "#006b62" |
| TEAL_LIGHT = "#d9efeb" |
| OCHRE = "#d9972b" |
| CLAY = "#b55a3d" |
|
|
|
|
| def create_board_artifacts( |
| *, |
| project_name: str, |
| site_name: str, |
| project_type: str, |
| boundary_source: str, |
| culture_notes: str, |
| selection: SiteSelection, |
| climate: dict[str, Any], |
| osm_context: dict[str, Any], |
| sun_summary: dict[str, str], |
| site_identity: dict[str, Any] | None, |
| evidence_rows: Iterable[EvidenceItem], |
| diagram_paths: list[str], |
| warnings: list[str], |
| topography: dict[str, Any] | None = None, |
| soil: dict[str, Any] | None = None, |
| ) -> dict[str, str]: |
| out_dir = Path(tempfile.mkdtemp(prefix="sis_board_")) |
| png_path = out_dir / "site-intelligence-presentation-board.png" |
| pdf_path = out_dir / "site-intelligence-presentation-board.pdf" |
|
|
| image = Image.new("RGB", (2400, 1350), PAPER) |
| draw = ImageDraw.Draw(image) |
| fonts = _fonts() |
| evidence = list(evidence_rows) |
|
|
| _draw_title_band( |
| draw, |
| fonts, |
| project_name=project_name, |
| site_name=site_name, |
| project_type=project_type, |
| selection=selection, |
| site_identity=site_identity, |
| ) |
| _draw_meta_strip(draw, fonts, selection, boundary_source, evidence) |
|
|
| context_path = _path_at(diagram_paths, 3) or _path_at(diagram_paths, 2) |
| climate_path = _path_at(diagram_paths, 0) |
| sun_path = _path_at(diagram_paths, 1) |
|
|
| _panel(draw, (60, 382, 920, 1158), "01 Geographic context", fonts) |
| _paste_image(image, context_path, (92, 472, 888, 890)) |
| _draw_wrapped_text( |
| draw, |
| _context_caption(osm_context, selection), |
| (92, 922, 888, 1108), |
| fonts["body"], |
| INK_2, |
| line_spacing=8, |
| ) |
|
|
| _panel(draw, (965, 382, 1575, 748), "02 Climate read", fonts) |
| _paste_image(image, climate_path, (995, 445, 1545, 652)) |
| _draw_wrapped_text( |
| draw, |
| _climate_caption(climate), |
| (995, 666, 1545, 724), |
| fonts["small"], |
| INK_2, |
| line_spacing=5, |
| ) |
|
|
| _panel(draw, (1605, 382, 2340, 748), "03 Sun and wind orientation", fonts) |
| _paste_image(image, sun_path, (1635, 445, 1915, 700)) |
| _draw_wrapped_text( |
| draw, |
| _sun_caption(sun_summary), |
| (1945, 452, 2312, 700), |
| fonts["body"], |
| INK_2, |
| line_spacing=8, |
| ) |
|
|
| _panel(draw, (965, 778, 1575, 1158), "04 Terrain, ground and Earth checks", fonts) |
| _draw_bullets( |
| draw, |
| _design_cues(selection, osm_context, culture_notes, topography, soil), |
| (995, 850, 1545, 1112), |
| fonts["body"], |
| INK_2, |
| ) |
|
|
| _panel(draw, (1605, 778, 2340, 1158), "05 Site visit verification", fonts) |
| _draw_bullets( |
| draw, |
| _verification_items(evidence, warnings), |
| (1635, 850, 2312, 1112), |
| fonts["body"], |
| INK_2, |
| ) |
|
|
| _draw_footer(draw, fonts, evidence, warnings) |
|
|
| image.save(png_path, quality=95) |
| image.save(pdf_path, "PDF", resolution=160.0) |
| return {"png": str(png_path), "pdf": str(pdf_path)} |
|
|
|
|
| def _draw_title_band( |
| draw: ImageDraw.ImageDraw, |
| fonts: dict[str, ImageFont.ImageFont], |
| *, |
| project_name: str, |
| site_name: str, |
| project_type: str, |
| selection: SiteSelection, |
| site_identity: dict[str, Any] | None, |
| ) -> None: |
| draw.rectangle((0, 0, 2400, 210), fill=CHARCOAL) |
| draw.rectangle((0, 205, 2400, 218), fill=OCHRE) |
| draw.text((60, 42), "SITE INTELLIGENCE BOARD", font=fonts["kicker"], fill="#8fd5cd") |
| title = site_name or project_name or "Untitled site" |
| draw.text((60, 78), _clip(title, 48), font=fonts["title"], fill="#ffffff") |
| subtitle = project_name if site_name and project_name else "Preliminary architecture site-analysis assistant" |
| if project_type: |
| subtitle = f"{subtitle} / {project_type}" |
| draw.text((64, 160), _clip(subtitle, 110), font=fonts["subtitle"], fill="#dce5e2") |
|
|
| identity = _site_identity(site_identity) |
| coord = _coordinate_label(selection) |
| right_lines = [ |
| ("Location", identity), |
| ("Anchor", coord), |
| ("Mode", selection.selection_type.replace("_", " ")), |
| ] |
| x = 1685 |
| y = 42 |
| for label, value in right_lines: |
| draw.text((x, y), label.upper(), font=fonts["micro_bold"], fill="#8fd5cd") |
| draw.text((x + 150, y - 3), _clip(value, 42), font=fonts["meta"], fill="#ffffff") |
| y += 48 |
|
|
|
|
| def _draw_meta_strip( |
| draw: ImageDraw.ImageDraw, |
| fonts: dict[str, ImageFont.ImageFont], |
| selection: SiteSelection, |
| boundary_source: str, |
| evidence_rows: list[EvidenceItem], |
| ) -> None: |
| cards = [ |
| ("Area", _fmt(selection.area_sqm, "sqm"), "approximate" if selection.selection_type != "dxf_boundary" else "from selected CAD candidate"), |
| ("Perimeter", _fmt(selection.perimeter_m, "m"), selection.unit_source or "computed geometry"), |
| ("Accuracy", selection.accuracy_label, "verify with CAD/KML/GeoJSON or survey"), |
| ("Evidence", f"{len(evidence_rows)} rows", "source, confidence, limitation tracked"), |
| ] |
| if boundary_source: |
| cards[-1] = ("Boundary source", _clip(boundary_source, 38), "user-provided") |
| x = 60 |
| y = 238 |
| width = 545 |
| for label, value, note in cards: |
| draw.rounded_rectangle((x, y, x + width, y + 110), radius=8, fill=PANEL, outline=LINE, width=2) |
| draw.text((x + 24, y + 18), label.upper(), font=fonts["micro_bold"], fill=TEAL) |
| draw.text((x + 24, y + 42), _clip(value, 34), font=fonts["card_value"], fill=INK) |
| draw.text((x + 24, y + 82), _clip(note, 56), font=fonts["micro"], fill=MUTED) |
| x += width + 28 |
|
|
|
|
| def _panel(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], title: str, fonts: dict[str, ImageFont.ImageFont]) -> None: |
| x1, y1, x2, y2 = box |
| draw.rounded_rectangle(box, radius=10, fill=PANEL, outline=LINE, width=2) |
| draw.rectangle((x1, y1, x2, y1 + 54), fill="#eef4f2") |
| draw.rectangle((x1, y1, x1 + 12, y1 + 54), fill=TEAL) |
| draw.text((x1 + 28, y1 + 15), title, font=fonts["panel"], fill=INK) |
|
|
|
|
| def _paste_image(image: Image.Image, path: str | None, box: tuple[int, int, int, int]) -> None: |
| x1, y1, x2, y2 = box |
| ImageDraw.Draw(image).rounded_rectangle(box, radius=4, fill="#f8f8f5", outline="#d8dedb", width=1) |
| if not path or not Path(path).exists(): |
| draw = ImageDraw.Draw(image) |
| draw.text((x1 + 24, y1 + 24), "Diagram unavailable", fill=MUTED, font=_fonts()["body"]) |
| return |
| with Image.open(path) as source: |
| source = source.convert("RGB") |
| source.thumbnail((x2 - x1 - 10, y2 - y1 - 10), Image.Resampling.LANCZOS) |
| px = x1 + (x2 - x1 - source.width) // 2 |
| py = y1 + (y2 - y1 - source.height) // 2 |
| image.paste(source, (px, py)) |
|
|
|
|
| def _draw_footer( |
| draw: ImageDraw.ImageDraw, |
| fonts: dict[str, ImageFont.ImageFont], |
| evidence_rows: list[EvidenceItem], |
| warnings: list[str], |
| ) -> None: |
| draw.rectangle((0, 1178, 2400, 1350), fill=CHARCOAL) |
| draw.text((60, 1216), "Scope and source notes", font=fonts["panel"], fill="#ffffff") |
| source_names = [] |
| for item in evidence_rows: |
| if item.source_name and item.source_name not in source_names: |
| source_names.append(item.source_name) |
| source_text = "Sources: " + (", ".join(source_names[:8]) if source_names else "user input and deterministic calculations") |
| note = ( |
| "Preliminary site-analysis assistant. Verify on site. Public map and climate data may be coarse or incomplete. " |
| "Soil, foundation, legal boundary, and final design decisions require professional verification." |
| ) |
| if warnings: |
| note += " Warnings: " + " ".join(warnings[:2]) |
| _draw_wrapped_text(draw, source_text, (60, 1260, 1040, 1320), fonts["small"], "#cbd6d4", line_spacing=5) |
| _draw_wrapped_text(draw, note, (1110, 1218, 2320, 1320), fonts["small"], "#cbd6d4", line_spacing=5) |
|
|
|
|
| def _draw_bullets( |
| draw: ImageDraw.ImageDraw, |
| bullets: list[str], |
| box: tuple[int, int, int, int], |
| font: ImageFont.ImageFont, |
| fill: str, |
| ) -> None: |
| x1, y1, x2, y2 = box |
| y = y1 |
| for item in bullets[:7]: |
| if y > y2 - 78: |
| break |
| draw.ellipse((x1, y + 9, x1 + 9, y + 18), fill=OCHRE) |
| y = _draw_wrapped_text(draw, item, (x1 + 24, y, x2, y2), font, fill, line_spacing=7, max_lines=2) + 10 |
|
|
|
|
| def _draw_wrapped_text( |
| draw: ImageDraw.ImageDraw, |
| text: str, |
| box: tuple[int, int, int, int], |
| font: ImageFont.ImageFont, |
| fill: str, |
| *, |
| line_spacing: int = 6, |
| max_lines: int | None = None, |
| ) -> int: |
| x1, y1, x2, y2 = box |
| words = str(text).replace("\n", " ").split() |
| lines: list[str] = [] |
| line = "" |
| for word in words: |
| test = f"{line} {word}".strip() |
| if draw.textbbox((0, 0), test, font=font)[2] <= x2 - x1: |
| line = test |
| else: |
| if line: |
| lines.append(line) |
| line = word |
| if line: |
| lines.append(line) |
| if max_lines is not None and len(lines) > max_lines: |
| lines = lines[:max_lines] |
| lines[-1] = _ellipsize_to_width(draw, lines[-1], font, x2 - x1) |
| y = y1 |
| line_height = draw.textbbox((0, 0), "Ag", font=font)[3] + line_spacing |
| for line in lines: |
| if y + line_height > y2: |
| break |
| draw.text((x1, y), line, font=font, fill=fill) |
| y += line_height |
| return y |
|
|
|
|
| def _fonts() -> dict[str, ImageFont.ImageFont]: |
| def load(size: int, bold: bool = False) -> ImageFont.ImageFont: |
| names = ( |
| ["segoeuib.ttf", "arialbd.ttf", "DejaVuSans-Bold.ttf"] if bold else ["segoeui.ttf", "arial.ttf", "DejaVuSans.ttf"] |
| ) |
| for name in names: |
| try: |
| return ImageFont.truetype(name, size) |
| except OSError: |
| continue |
| return ImageFont.load_default() |
|
|
| return { |
| "title": load(60, True), |
| "subtitle": load(26), |
| "kicker": load(20, True), |
| "panel": load(26, True), |
| "body": load(25), |
| "small": load(20), |
| "micro": load(17), |
| "micro_bold": load(17, True), |
| "meta": load(24, True), |
| "card_value": load(30, True), |
| } |
|
|
|
|
| def _path_at(paths: list[str], index: int) -> str | None: |
| return paths[index] if len(paths) > index else None |
|
|
|
|
| def _fmt(value: float | None, suffix: str) -> str: |
| if value is None: |
| return "not available" |
| if suffix == "sqm" and value >= 10_000: |
| return f"{value:,.0f} sqm / {value / 10_000:,.2f} ha" |
| return f"{value:,.0f} {suffix}" |
|
|
|
|
| def _coordinate_label(selection: SiteSelection) -> str: |
| if selection.anchor_lat is not None and selection.anchor_lon is not None: |
| return f"{selection.anchor_lat:.5f}, {selection.anchor_lon:.5f}" |
| return "not georeferenced" |
|
|
|
|
| def _site_identity(site_identity: dict[str, Any] | None) -> str: |
| if not site_identity: |
| return "address unavailable" |
| for key in ("city", "town", "village", "district", "state", "country"): |
| if site_identity.get(key): |
| return str(site_identity[key]) |
| if site_identity.get("display_name"): |
| return str(site_identity["display_name"]).split(",")[0] |
| return "address unavailable" |
|
|
|
|
| def _climate_caption(climate: dict[str, Any]) -> str: |
| current = climate.get("forecast") or {} |
| normal = climate.get("climate_normal") or climate.get("recent_historical") or {} |
| temp = current.get("current_temperature_c", "n/a") |
| humidity = current.get("current_humidity_pct", "n/a") |
| wind = current.get("current_wind_speed_kmh", "n/a") |
| rain = normal.get("total_precipitation_mm", "n/a") |
| if temp == "n/a" and humidity == "n/a" and wind == "n/a" and rain == "n/a": |
| return "Climate unavailable. Verify separately before design claims." |
| return ( |
| f"Forecast/current: {temp} C, {humidity}% humidity, {wind} km/h wind. " |
| f"Climate-normal style annual precipitation: {rain} mm. Use as design context, not on-site measurement." |
| ) |
|
|
|
|
| def _context_caption(osm_context: dict[str, Any], selection: SiteSelection) -> str: |
| counts = osm_context.get("counts") or {} |
| summary = ", ".join(f"{key}: {value}" for key, value in list(counts.items())[:5]) |
| if not summary: |
| summary = "OSM context is sparse or unavailable for this selection." |
| accuracy = selection.accuracy_label.replace("_", " ") |
| return f"{summary}. Boundary mode: {selection.selection_type.replace('_', ' ')}. Accuracy: {accuracy}. Verify roads, water edges, vegetation, and access on site." |
|
|
|
|
| def _sun_caption(sun_summary: dict[str, str]) -> str: |
| orientation = sun_summary.get("orientation_note", "") |
| if "southern" in orientation.lower(): |
| orientation = "Sun cue: generally southern sky at this latitude." |
| elif orientation: |
| orientation = "Sun cue: use latitude-based solar orientation." |
| else: |
| orientation = "Sun cue unavailable; verify with sun-path study." |
| wind = sun_summary.get("wind_note", "") |
| if wind: |
| wind = wind.replace("Available climate data suggests dominant wind around", "Wind cue:") |
| wind = wind.replace("Treat this as regional context and verify on site.", "Verify on site.") |
| wind = _clip(wind, 62) |
| else: |
| wind = "Wind: regional/modelled cue only; verify comfort and ventilation on site." |
| return ( |
| f"{orientation} West/east edges need glare and heat checks. " |
| f"{wind} No shadow sim." |
| ) |
|
|
|
|
| def _design_cues( |
| selection: SiteSelection, |
| osm_context: dict[str, Any], |
| culture_notes: str, |
| topography: dict[str, Any] | None, |
| soil: dict[str, Any] | None, |
| ) -> list[str]: |
| counts = osm_context.get("counts") or {} |
| cues = [ |
| "Use the report's Google Earth/Maps links as visual references only; confirm visible trees, water, roads, and structures on site.", |
| "Treat west and afternoon exposure as a shading and glare item to test in massing.", |
| "Use mapped roads and access only as a first-pass circulation clue; verify road width and entry points.", |
| "Check drainage, waterlogging, runoff, and low points during the site visit.", |
| "Keep soil and foundation language as professional-verification prompts, not recommendations.", |
| ] |
| terrain_cue = _terrain_cue(topography) |
| if terrain_cue: |
| cues.insert(0, terrain_cue) |
| soil_cue = _soil_cue(soil) |
| if soil_cue: |
| cues.insert(1, soil_cue) |
| if counts.get("natural/water") or counts.get("water"): |
| cues.insert(1, "Mapped water context should trigger edge, flood, drainage, humidity, and view checks.") |
| if counts.get("leisure/green") or counts.get("natural/wood"): |
| cues.insert(1, "Mapped green or vegetation context should trigger shade, ecology, and tree-retention checks.") |
| if selection.selection_type == "pin_radius": |
| cues.insert(0, "Pin-radius output is neighborhood context; add an exact boundary before using plot-level conclusions.") |
| if culture_notes: |
| cues.append("User culture/local-activity notes should be verified through observation or local interviews.") |
| return cues |
|
|
|
|
| def _terrain_cue(topography: dict[str, Any] | None) -> str | None: |
| if not topography: |
| return "Terrain data unavailable; use CAD contours, site levels, and drainage observation." |
| relief = topography.get("relief_m") |
| slope = topography.get("approx_slope_pct") |
| if relief is None and slope is None: |
| return "Public terrain sampling is incomplete; verify slope and drainage manually." |
| return f"Terrain: relief {relief} m, sampled slope {slope}%; verify contours and drainage." |
|
|
|
|
| def _soil_cue(soil: dict[str, Any] | None) -> str | None: |
| if not soil: |
| return "Soil data unavailable; request geotechnical/professional ground information." |
| texture = soil.get("texture_signal", "soil texture signal") |
| return f"SoilGrids: {texture}; professional ground verification only." |
|
|
|
|
| def _verification_items(evidence_rows: list[EvidenceItem], warnings: list[str]) -> list[str]: |
| items = [ |
| "Confirm actual plot edges with CAD, KML, GeoJSON, faculty drawing, or site survey.", |
| "Photograph all edges, corners, access points, road conditions, and adjacent building heights.", |
| "Record trees, shade, water bodies, utilities, noise, dust, traffic, and local activity peaks.", |
| "Verify drainage and waterlogging with site observation, especially after rain.", |
| "Request soil/geotechnical information before any foundation decision.", |
| ] |
| for row in evidence_rows: |
| verify = row.verification_needed.strip() |
| if verify and verify not in items: |
| items.append(verify) |
| if len(items) >= 7: |
| break |
| for warning in warnings: |
| if len(items) >= 7: |
| break |
| items.append(warning) |
| return items |
|
|
|
|
| def _clip(text: object, limit: int) -> str: |
| value = "" if text is None else str(text) |
| return value if len(value) <= limit else value[: max(0, limit - 3)].rstrip() + "..." |
|
|
|
|
| def _ellipsize_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, width: int) -> str: |
| ellipsis = "..." |
| value = text.rstrip() |
| if draw.textbbox((0, 0), value, font=font)[2] <= width: |
| return value |
| while value and draw.textbbox((0, 0), value + ellipsis, font=font)[2] > width: |
| value = value[:-1].rstrip() |
| return (value or text[:1]) + ellipsis |
|
|