Coverage for maze_dataset/plotting/plot_svg_fancy.py: 0%
57 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-09 12:48 -0600
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-09 12:48 -0600
1"""Plot a maze as SVG with rounded corners."""
3from xml.dom.minidom import parseString
4from xml.etree.ElementTree import Element, SubElement, tostring
6import numpy as np
8# Known color map (excluding walls).
9COLOR_MAP: dict[tuple[int, int, int], str] = {
10 (255, 255, 255): "#f0f0f0",
11 (0, 255, 0): "#4caf50",
12 (255, 0, 0): "#f44336",
13 (0, 0, 255): "#2196f3",
14}
16WALL_COLOR_HEX: str = "#222" # (0,0,0) in hex
17WALL_RGB: tuple[int, int, int] = (0, 0, 0)
19# Offsets in the order [top, right, bottom, left]
20_NEIGHBORS: np.ndarray = np.array(
21 [
22 [-1, 0], # top
23 [0, +1], # right
24 [+1, 0], # bottom
25 [0, -1], # left
26 ],
27 dtype=int,
28)
31def is_wall(y: int, x: int, grid: np.ndarray) -> bool:
32 """True if (y, x) is out of bounds or has the wall color."""
33 h, w, _ = grid.shape
34 if not (0 <= y < h and 0 <= x < w):
35 return True
36 return bool((grid[y, x] == WALL_RGB).all())
39def create_tile_path(
40 origin: tuple[float, float],
41 tile_size: float,
42 corner_radius: float,
43 edges: tuple[bool, bool, bool, bool],
44) -> str:
45 """Generate an SVG path for a tile at `origin` with side length `tile_size`.
47 `edges` is (top, right, bottom, left) booleans, where True means that edge
48 borders a wall/outside. If both edges meeting at a corner are True and
49 corner_radius>0, we draw a rounded corner; else it's a sharp corner.
51 Corner order (clockwise):
52 c0 = top-left
53 c1 = top-right
54 c2 = bottom-right
55 c3 = bottom-left
57 edges = (top, right, bottom, left).
58 corner c0 is formed by edges top + left => edges[0] & edges[3]
59 corner c1 => top + right => edges[0] & edges[1]
60 corner c2 => right + bottom => edges[1] & edges[2]
61 corner c3 => bottom + left => edges[2] & edges[3]
62 """
63 x0, y0 = origin
64 top, right, bottom, left = edges
66 # A corner is "exposed" if both adjoining edges are True
67 c0_exposed: bool = top and left # top-left
68 c1_exposed: bool = top and right # top-right
69 c2_exposed: bool = right and bottom # bottom-right
70 c3_exposed: bool = bottom and left # bottom-left
72 # If corner_radius=0, arcs become straight lines.
73 r: float = corner_radius
75 # We'll construct the path in a standard top-left -> top-right -> bottom-right -> bottom-left order.
76 path_cmds = []
77 # Move to top-left corner, possibly offset if c0 is exposed
78 # (meaning both top and left edges are external).
79 start_x = x0 + (r if c0_exposed else 0)
80 start_y = y0
81 path_cmds.append(f"M {start_x},{start_y}")
83 # === TOP edge to top-right corner
84 end_x = x0 + tile_size - (r if c1_exposed else 0)
85 end_y = y0
86 path_cmds.append(f"L {end_x},{end_y}")
87 # Arc if c1_exposed
88 if c1_exposed and r > 0:
89 path_cmds.append(f"A {r} {r} 0 0 1 {x0 + tile_size},{y0 + r}")
91 # === RIGHT edge to bottom-right corner
92 path_cmds.append(f"L {x0 + tile_size},{y0 + tile_size - (r if c2_exposed else 0)}")
93 if c2_exposed and r > 0:
94 path_cmds.append(f"A {r} {r} 0 0 1 {x0 + tile_size - r},{y0 + tile_size}")
96 # === BOTTOM edge to bottom-left corner
97 path_cmds.append(f"L {x0 + (r if c3_exposed else 0)},{y0 + tile_size}")
98 if c3_exposed and r > 0:
99 path_cmds.append(f"A {r} {r} 0 0 1 {x0},{y0 + tile_size - r}")
101 # === LEFT edge back up to top-left corner
102 path_cmds.append(f"L {x0},{y0 + (r if c0_exposed else 0)}")
103 if c0_exposed and r > 0:
104 path_cmds.append(f"A {r} {r} 0 0 1 {x0 + r},{y0}")
106 path_cmds.append("Z")
107 return " ".join(path_cmds)
110def plot_svg_fancy(
111 pixel_grid: np.ndarray,
112 size: int = 40,
113 corner_radius: float = 8.0,
114 bounding_corner_radius: float = 20.0,
115) -> str:
116 """plot the output of SolvedMaze(...).as_pixels() as a nice svg
118 Create an SVG with:
119 - A single rounded-square background (walls).
120 - Each non-wall cell is drawn via create_tile_path, with corner_radius controlling
121 whether corners are rounded. (Set corner_radius=0 for squares.)
123 # Parameters:
124 - `pixel_grid : np.ndarray`
125 3D array of shape (h, w, 3) with RGB values
126 - `size : int`
127 Size (in px) of each grid cell
128 - `corner_radius : float`
129 Radius for rounding corners of each tile (0 => squares)
130 - `bounding_corner_radius : float`
131 Radius for rounding the outer bounding rectangle
133 # Returns:
134 `str`: A pretty-printed SVG string
135 """
136 h, w, _ = pixel_grid.shape
138 # Create the root <svg>
139 svg = Element(
140 "svg",
141 xmlns="http://www.w3.org/2000/svg",
142 width=str(w * size),
143 height=str(h * size),
144 viewBox=f"0 0 {w * size} {h * size}",
145 )
147 # Single rounded-square background for the walls
148 SubElement(
149 svg,
150 "rect",
151 {
152 "x": "0",
153 "y": "0",
154 "width": str(w * size),
155 "height": str(h * size),
156 "fill": WALL_COLOR_HEX,
157 "rx": str(bounding_corner_radius),
158 "ry": str(bounding_corner_radius),
159 },
160 )
162 for yy in range(h):
163 for xx in range(w):
164 rgb_tuple = tuple(pixel_grid[yy, xx])
165 if rgb_tuple == WALL_RGB:
166 # It's a wall => skip (already covered by background)
167 continue
169 fill_color: str | None = COLOR_MAP.get(rgb_tuple, None) # noqa: SIM910
170 if fill_color is None:
171 # Unknown color => skip or handle differently
172 continue
174 # Check which edges are "external" => next cell is wall
175 # edges in the order (top, right, bottom, left)
176 edges_bool = [
177 is_wall(yy + dy, xx + dx, pixel_grid) for (dy, dx) in _NEIGHBORS
178 ]
180 d_path = create_tile_path(
181 origin=(xx * size, yy * size),
182 tile_size=size,
183 corner_radius=corner_radius,
184 edges=tuple(edges_bool), # type: ignore[arg-type]
185 )
187 SubElement(
188 svg,
189 "path",
190 {
191 "d": d_path,
192 "fill": fill_color,
193 "stroke": "none",
194 },
195 )
197 raw_svg = tostring(svg, encoding="unicode")
198 # we are in charge of the svg so it's safe to decode
199 return parseString(raw_svg).toprettyxml(indent=" ") # noqa: S318