from collections.abc import Generator from itertools import product from pathlib import Path from typing import NamedTuple class Point(NamedTuple): x: int y: int def iter_adjacent(self, wrap=False) -> Generator["Point", None, None]: for offset in product((1, 0, -1), (1, 0, -1)): adj = Point(self.x + offset[0], self.y + offset[1]) if adj == self: continue if not wrap and (adj.x < 0 or adj.y < 0): continue yield adj def add_point(self, delta: "Point") -> "Point": return Point(self.x + delta.x, self.y + delta.y) def add(self, x=0, y=0) -> "Point": return Point(self.x + x, self.y + y) def __str__(self) -> str: return f"Point({self.x}, {self.y})" class Cell(NamedTuple): coord: Point pipe: str @property def n(self) -> Point | None: if self.pipe in {"S", "|", "L", "J"}: return Point(self.coord.x, self.coord.y - 1) @property def e(self) -> Point | None: if self.pipe in {"S", "-", "L", "F"}: return Point(self.coord.x + 1, self.coord.y) @property def s(self) -> Point | None: if self.pipe in {"S", "|", "7", "F"}: return Point(self.coord.x, self.coord.y + 1) @property def w(self) -> Point | None: if self.pipe in {"S", "-", "J", "7"}: return Point(self.coord.x - 1, self.coord.y) def iter_pipes(self) -> Generator[Point, None, None]: if coord := self.n: yield coord if coord := self.e: yield coord if coord := self.s: yield coord if coord := self.w: yield coord class Grid: def __init__(self, lines: list[str]): self.lines = lines self.loop: list[Point] = [] self.start_val = "" @property def start(self) -> Point: for y, row in enumerate(self.lines): for x, cell in enumerate(row): if cell == "S": return Point(x, y) raise ValueError("No S") def get_cell(self, coord: Point) -> Cell: if coord == self.start and self.start_val: return Cell(coord, self.start_val) return Cell(coord, self.lines[coord.y][coord.x]) def replace_start(self): d = "" for neighbor in (self.get_cell(p) for p in self.start.iter_adjacent()): if neighbor.n == self.start: d += "S" if neighbor.e == self.start: d += "W" if neighbor.s == self.start: d += "N" if neighbor.w == self.start: d += "E" d = "".join(sorted(d)) if d == "NS": self.start_val = "|" if d == "EW": self.start_val = "-" if d == "EN": self.start_val = "L" if d == "ES": self.start_val = "F" if d == "SW": self.start_val = "7" if d == "NW": self.start_val = "J" print(d, "Replaced S with", self.start_val) def find_loop(self) -> None: current = self.get_cell(self.start) print(current) self.loop.append(current.coord) for neighbor_coord in current.iter_pipes(): if not all(a >= 0 for a in neighbor_coord): continue for nn_coord in self.get_cell(neighbor_coord).iter_pipes(): if nn_coord == self.start: current = self.get_cell(neighbor_coord) while True: # print("Now at", current) self.loop.append(current.coord) # Go through neighbors for neighbor_coord in current.iter_pipes(): # If we've already seen this, we skip because we only want to go forward if neighbor_coord in self.loop: continue # Take first non-backtrack current = self.get_cell(neighbor_coord) break else: print("Nothing we haven't seen here") break def cast_ray_nw(self, point: Point) -> int: # Incomplete accounting of edge cases count = 0 steps = min(point.x, point.y) for s in range(steps): p = Point(point.x - s, point.y - s) if p in self.border: if self.get_cell(p).pipe not in {"L", "7"}: count += 1 return count def cast_ray_w(self, point: Point) -> int: count = 0 # Technically we walk left to right, so this should be the order of corners tan_corners = { "L": "J", "F": "7", } last_corner = "" for s in range(point.x): p = Point(s, point.y) if p in self.border: c = self.get_cell(p).pipe if c == "S": raise ValueError("An S?") if c in tan_corners: print("Found tan corner", c, "at", p) last_corner = c elif c == "-": continue elif last_corner and c == tan_corners[last_corner]: print("Found tan corner end", c, "at", p) last_corner = "" else: print("Non-tan", c, "at", p) count += 1 last_corner = "" return count def cast_ray_s(self, point: Point) -> int: # Incomplete accounting of edge cases count = 0 for s in range(point.y): p = Point(point.x, s) if p in self.border: count += 1 return count def inside(self, point: Point) -> bool: if point in self.border: return False return all( (count % 2) == 1 for count in ( # self.cast_ray_nw(point), self.cast_ray_w(point), # self.cast_ray_s(point), ) ) def count_area(self) -> int: self.border = set(self.loop) self.replace_start() total_area = 0 for y, row in enumerate(self.lines): for x, _ in enumerate(row): point = Point(x, y) # This could be optimized by caching the counts for points to do it in O(n), but yolo if self.inside(point): print(point, "inside") total_area += 1 else: print(point, "outside") return total_area def area(coords: list[Point]) -> int: area = 0 last: Point | None = None for point in coords: if last is None: last = point else: dx = point.x - last.x dy = point.y - last.y area += 0.5 * (last.y * dx - last.x * dy) last = point return int(area) def part1(input: Path) -> int: grid = Grid(input.read_text().split("\n")) grid.find_loop() return int(len(grid.loop) / 2) def part2(input: Path) -> int: grid = Grid(input.read_text().split("\n")) grid.find_loop() return grid.count_area() if __name__ == "__main__": # input = Path("sample1.txt") # input = Path("sample2.txt") input = Path("input.txt") result1 = part1(input) print("part 1", result1) # input = Path("sample3.txt") # input = Path("sample4.txt") # input = Path("sample5.txt") result2 = part2(input) print("part 2", result2)