Spaces:
Sleeping
Sleeping
Download atrium_sim/render/renderer.py from chicelli/atrium-sim-webviz: direct link, hf CLI and curl.
- Browser
- Download file 11.4 kB
-
https://huggingface.co/spaces/chicelli/atrium-sim-webviz/resolve/main/atrium_sim/render/renderer.py
- Command line
-
hf download hf://spaces/chicelli/atrium-sim-webviz/atrium_sim/render/renderer.py
-
curl -L -o renderer.py https://huggingface.co/spaces/chicelli/atrium-sim-webviz/resolve/main/atrium_sim/render/renderer.py
11.4 kB
| """Pygame renderer: ghost blueprint, quality-coloured bricks, mm HUD. | |
| Visual language: | |
| - dashed light outlines = unfilled blueprint targets ("ghosts"); the next | |
| expected slot is highlighted; | |
| - placed bricks show a mortar-grey envelope band under a terracotta face; | |
| - once matched, the face is tinted by placement quality: green (within ±3mm), | |
| amber (close), red (poor); strays go dark; | |
| - matched bricks carry a small "+2.1" mm deviation label. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import numpy as np | |
| from atrium_sim.blueprint import BrickKind, Blueprint, brick_face | |
| from atrium_sim.constants import COURSE_MM, H_MAX, TOL_MM | |
| from atrium_sim.physics import BrickPose | |
| SCALE = 0.5 # px per mm | |
| MARGIN_MM = 150.0 | |
| HUD_H = 64 | |
| Y_TOP_MM = H_MAX + 170.0 # show sky so drops/topples are visible | |
| Y_BOT_MM = -30.0 | |
| BG = (24, 26, 32) | |
| GROUND = (70, 72, 78) | |
| GHOST = (110, 115, 125) | |
| NEXT_SLOT = (240, 200, 80) | |
| MORTAR = (105, 100, 95) | |
| TERRACOTTA = (178, 92, 62) | |
| STRAY = (90, 45, 40) | |
| HUD_TEXT = (225, 225, 220) | |
| LABEL = (200, 205, 215) | |
| ROBOT = (90, 170, 220) # mobile gantry body | |
| ROBOT_DARK = (60, 120, 165) | |
| ROBOT_TOOL = (240, 200, 80) # the descending tool / gripper | |
| CEMENT = (150, 148, 143) # curved cement heads (arches) - the "hard material" | |
| STONE = (178, 172, 160) # lintels / sills | |
| STONE_EDGE = (110, 108, 104) | |
| CENTERING = (120, 95, 60) # temporary timber former - visible only until struck | |
| VOUSSOIR_FACE = (178, 92, 62) # real structural arch voussoirs (dynamic bricks, not sensors) | |
| def _quality_color(d: float, in_tol: bool) -> tuple[int, int, int]: | |
| if in_tol: | |
| return (95, 180, 90) | |
| # amber -> red as error grows past tolerance | |
| t = min(1.0, (d - TOL_MM) / 15.0) | |
| return (int(200 + 30 * t), int(150 * (1 - t) + 30), 40) | |
| class PygameRenderer: | |
| def __init__(self, blueprint: Blueprint, render_mode: str | None): | |
| import pygame | |
| self.pygame = pygame | |
| self.blueprint = blueprint | |
| self.render_mode = render_mode | |
| # viewport top is size-aware: tall facades (a 40-course pier, a 13-course house with | |
| # arched heads) must fit, not just the old fixed H_MAX (6 courses). Leaves sky headroom. | |
| self.y_top = max(Y_TOP_MM, blueprint.n_courses * COURSE_MM + 170.0) | |
| self.w_px = int((blueprint.length + 2 * MARGIN_MM) * SCALE) | |
| self.h_px = HUD_H + int((self.y_top - Y_BOT_MM) * SCALE) | |
| pygame.font.init() | |
| self.font = pygame.font.Font(None, 16) | |
| self.hud_font = pygame.font.Font(None, 20) | |
| self.surface = pygame.Surface((self.w_px, self.h_px)) | |
| self.window = None | |
| self.clock = None | |
| if render_mode == "human": | |
| pygame.init() | |
| pygame.display.set_caption("atrium-sim") | |
| self.window = pygame.display.set_mode((self.w_px, self.h_px)) | |
| self.clock = pygame.time.Clock() | |
| # --- coordinate transform ------------------------------------------------- | |
| def _to_px(self, x: float, y: float) -> tuple[float, float]: | |
| return (x + MARGIN_MM) * SCALE, HUD_H + (self.y_top - y) * SCALE | |
| def _rect_corners(self, cx, cy, w, h, theta): | |
| c, s = math.cos(theta), math.sin(theta) | |
| pts = [] | |
| for dx, dy in ((-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2), (-w / 2, h / 2)): | |
| pts.append(self._to_px(cx + dx * c - dy * s, cy + dx * s + dy * c)) | |
| return pts | |
| def _poly_corners(self, cx, cy, theta, local_verts): | |
| """Generalizes _rect_corners to an ARBITRARY canonical polygon (e.g. a tapered arch | |
| voussoir wedge, not just a box): rotate `local_verts` by theta, translate to (cx, cy), | |
| return pixel coords - same contract as _rect_corners.""" | |
| c, s = math.cos(theta), math.sin(theta) | |
| return [self._to_px(cx + lx * c - ly * s, cy + lx * s + ly * c) for lx, ly in local_verts] | |
| def _dashed_rect(self, color, cx, cy, w, h, dash=6): | |
| pg = self.pygame | |
| corners = self._rect_corners(cx, cy, w, h, 0.0) | |
| for p1, p2 in zip(corners, corners[1:] + corners[:1]): | |
| length = math.hypot(p2[0] - p1[0], p2[1] - p1[1]) | |
| n = max(1, int(length / dash)) | |
| for i in range(0, n, 2): | |
| a = (p1[0] + (p2[0] - p1[0]) * i / n, p1[1] + (p2[1] - p1[1]) * i / n) | |
| b = ( | |
| p1[0] + (p2[0] - p1[0]) * min(i + 1, n) / n, | |
| p1[1] + (p2[1] - p1[1]) * min(i + 1, n) / n, | |
| ) | |
| pg.draw.line(self.surface, color, a, b, 1) | |
| # --- main draw -------------------------------------------------------------- | |
| def draw(self, poses: list[BrickPose], report, hud: dict, cursor: int, | |
| robot: tuple | None = None, hard_bodies: list | None = None) -> np.ndarray | None: | |
| pg = self.pygame | |
| surf = self.surface | |
| surf.fill(BG) | |
| # ground | |
| gx0, gy0 = self._to_px(-MARGIN_MM, 0) | |
| pg.draw.rect( | |
| surf, GROUND, pg.Rect(0, gy0, self.w_px, self.h_px - gy0) | |
| ) | |
| # mobile robot: shaded reach window (behind everything; the body is drawn | |
| # on top after the bricks so it reads clearly) | |
| if robot is not None: | |
| base_x, reach = robot[0], robot[1] | |
| lx, _ = self._to_px(base_x - reach, 0) | |
| rx, _ = self._to_px(base_x + reach, 0) | |
| band = pg.Surface((max(1, rx - lx), self.h_px - gy0), pg.SRCALPHA) | |
| band.fill((240, 200, 80, 20)) | |
| surf.blit(band, (lx, gy0)) | |
| # ghost blueprint + next expected slot | |
| matched_ids = {m.target_id for m in report.matches} | |
| open_in_cursor = [ | |
| t for t in self.blueprint.course_targets(cursor) if t.tid not in matched_ids | |
| ] | |
| next_tid = min((t.tid for t in sorted(open_in_cursor, key=lambda t: t.x)), default=None) | |
| for t in self.blueprint.targets: | |
| if t.tid in matched_ids: | |
| continue | |
| w, h = brick_face(t.kind) | |
| if t.tid == next_tid: | |
| pg.draw.polygon(surf, NEXT_SLOT, self._rect_corners(t.x, t.y, w, h, 0.0), 2) | |
| else: | |
| self._dashed_rect(GHOST, t.x, t.y, w, h) | |
| # static hard bodies: lintels/sills/cement heads (permanent) and the arch centering/ | |
| # skewback (centering is temporary - visible only until the ring closes and it's struck; | |
| # "voussoir" here is the older cosmetic lintel_soldier style's sensor fringe, kept | |
| # distinct from the real dynamic VOUSSOIR bricks drawn below) | |
| for _sid, kind, verts in (hard_bodies or []): | |
| pts = [self._to_px(x, y) for x, y in verts] | |
| if len(pts) < 3: | |
| continue | |
| if kind == "voussoir": | |
| fill, edge = TERRACOTTA, MORTAR | |
| elif kind == "centering": | |
| fill, edge = CENTERING, (80, 60, 35) | |
| elif kind == "skewback": | |
| fill, edge = STONE, STONE_EDGE | |
| elif kind == "cement": | |
| fill, edge = CEMENT, STONE_EDGE | |
| else: | |
| fill, edge = STONE, STONE_EDGE | |
| pg.draw.polygon(surf, fill, pts) | |
| pg.draw.polygon(surf, edge, pts, 2) | |
| # bricks | |
| match_by_brick = {m.brick_id: m for m in report.matches} | |
| stray_ids = set(report.stray_bricks) | |
| for p in poses: | |
| if p.kind == BrickKind.VOUSSOIR and p.verts: | |
| # a real structural arch wedge: arbitrary polygon, not a fixed (w, h) box - not | |
| # scored by the flat-wall audit (report never contains a VOUSSOIR match), so no | |
| # quality tint/label; rendered as its own distinct material instead. | |
| pts = self._poly_corners(p.x, p.y, p.theta, p.verts) | |
| if len(pts) >= 3: | |
| pg.draw.polygon(surf, VOUSSOIR_FACE, pts) | |
| pg.draw.polygon(surf, MORTAR, pts, 2) | |
| continue | |
| w, h = brick_face(p.kind) | |
| m = match_by_brick.get(p.brick_id) | |
| if m is not None: | |
| face = _quality_color(m.d, m.in_tol) | |
| elif p.brick_id in stray_ids: | |
| face = STRAY | |
| else: | |
| face = TERRACOTTA # mid-fall / not yet audited | |
| pg.draw.polygon(surf, MORTAR, self._rect_corners(p.x, p.y, w + 9, h + 9, p.theta)) | |
| pg.draw.polygon(surf, face, self._rect_corners(p.x, p.y, w, h, p.theta)) | |
| if m is not None: | |
| label = self.font.render(f"{m.dx:+.1f}", True, LABEL) | |
| lx, ly = self._to_px(p.x, p.y + h / 2 + 14) | |
| surf.blit(label, (lx - label.get_width() / 2, ly)) | |
| # mobile-robot body (drawn on top so it's clearly visible) | |
| if robot is not None: | |
| self._draw_robot_body(robot, poses) | |
| # HUD | |
| pg.draw.rect(surf, (18, 19, 24), pg.Rect(0, 0, self.w_px, HUD_H)) | |
| text = " ".join(f"{k}: {v}" for k, v in hud.items()) | |
| surf.blit(self.hud_font.render("atrium-sim", True, NEXT_SLOT), (10, 8)) | |
| surf.blit(self.hud_font.render(text, True, HUD_TEXT), (10, 34)) | |
| if self.render_mode == "human": | |
| self.window.blit(surf, (0, 0)) | |
| pg.event.pump() | |
| pg.display.flip() | |
| self.clock.tick(60) | |
| frame = np.transpose(pg.surfarray.array3d(surf), (1, 0, 2)) | |
| return frame | |
| def _draw_robot_body(self, robot: tuple, poses: list[BrickPose]) -> None: | |
| """A mobile gantry: wheeled chassis on the rail, a mast, a top beam spanning | |
| the reach, and a tool that descends from the beam to the brick being placed | |
| (so a high release visibly drops from up top).""" | |
| pg = self.pygame | |
| surf = self.surface | |
| base_x, reach = robot[0], robot[1] | |
| gantry_y = robot[2] if len(robot) > 2 and robot[2] else (H_MAX + 60.0) | |
| bx, gy0 = self._to_px(base_x, 0.0) | |
| _, beam_py = self._to_px(base_x, gantry_y) | |
| bx, gy0, beam_py = int(bx), int(gy0), int(beam_py) | |
| # top beam spanning the reach window | |
| half = int(reach * SCALE) | |
| pg.draw.line(surf, ROBOT, (bx - half, beam_py), (bx + half, beam_py), 5) | |
| # vertical mast up the middle | |
| pg.draw.line(surf, ROBOT, (bx, gy0 - 10), (bx, beam_py), 6) | |
| # wheeled chassis on the rail | |
| pg.draw.rect(surf, ROBOT_DARK, pg.Rect(bx - 26, gy0 - 15, 52, 15), border_radius=4) | |
| for wx in (bx - 15, bx + 15): | |
| pg.draw.circle(surf, (28, 30, 36), (wx, gy0), 6) | |
| pg.draw.circle(surf, ROBOT, (wx, gy0), 6, 2) | |
| # tool: descends from the beam to the current brick (the last-spawned pose), | |
| # clamped into the reach window — shows how high the release was | |
| if poses: | |
| p = poses[-1] | |
| tool_x = min(max(p.x, base_x - reach), base_x + reach) | |
| tpx, _ = self._to_px(tool_x, 0.0) | |
| _, tpy = self._to_px(0.0, p.y + brick_face(p.kind)[1] / 2) | |
| tpx, tpy = int(tpx), int(tpy) | |
| pg.draw.line(surf, ROBOT, (tpx, beam_py), (tpx, beam_py + 4), 8) # trolley on beam | |
| pg.draw.line(surf, ROBOT_TOOL, (tpx, beam_py), (tpx, tpy), 3) # descending tool | |
| pg.draw.circle(surf, ROBOT_TOOL, (tpx, tpy), 5) # gripper head | |
| def close(self): | |
| if self.window is not None: | |
| self.pygame.display.quit() | |
| self.window = None | |