diff --git a/README.md b/README.md index 599fa85..ec2f882 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,21 @@ ├── test_epsilon_nfa.py # 测试 epsilon_nfa 数据结构的单元测试 └── test_nfa.py # 测试 nfa 数据结构的单元测试 ``` + +## 使用说明 + +使用时请确保切换为英文输入模式。拖拽代表状态的圆圈,将它们放在屏幕上的任意位置。使用时按下空格键来输入 ε。 + +在图形化界面中单击按钮按下即生效,随时点击下方切换按钮,即可切换进对应功能;再次点击可以退出状态: + +| 按钮 | 按钮类型 | 使用说明 | +| --- | --- | --- | +| New Delta | 切换 | 点击两个对应状态,再按下字符,添加转换关系 | +| Remove Delta | 切换 | 点击两个对应状态,再按下字符,移除指定转换关系 | +| Reassign Char | 切换 | 点击两个对应状态,按下字符 `a`,再按下字符 `b` 即可完成修改 | +| Rename State | 切换 | 点击状态,输入新名字(数字),再按回车,将对应状态改名 | +| Set as Start | 切换 | 点击状态,将其设为起始状态 | +| Set as Final | 切换 | 点击状态,将其设为终止状态 | +| Remove State | 切换 | 删除点击的状态 | +| New State | 单击 | 添加一个新状态 | +| Conver to DFA | 单击 | 将输入的自动机转化为最简 DFA 并输出。并顺便输出中途产生的 ε-NFA、NFA 和非最简 DFA | diff --git a/src/appModel.py b/src/appModel.py new file mode 100644 index 0000000..07ba983 --- /dev/null +++ b/src/appModel.py @@ -0,0 +1,261 @@ +import pygame +from pygame.locals import * +import math +from enum import Enum +from pygame import gfxdraw + + +class AppState(Enum): + idle = 0 + pairing = 1 + depairing = 2 + renamingLabels = 3 + removing = 4 + setAsStart = 5 + setAsEnd = 6 + renamingStates = 7 + + +class AppModel: + def __init__(self) -> None: + self.state = AppState.idle + self.selected_tiles: list[Tile] = [] + self.pressedKeys = [] + + def setState(self, state: int): + self.state = state + self.selected_tiles.clear() + self.pressedKeys.clear() + + +class TileType(Enum): + start = 0 + final = 1 + intermediate = 2 + start_final = 3 + + +class Tile(pygame.sprite.Sprite): + def __init__(self, position, id): + super().__init__() + self.name = "q" + str(id) + self.rect = pygame.Rect(position[0], position[1], 100, 100) + self.rect.topleft = position + self.clicked = False + self.id = id + self.type = TileType.intermediate + + def rename(self, id): + self.id = id + self.name = "q" + str(id) + + def isClicked(self, event): + if event.type == MOUSEBUTTONDOWN: + if self.rect.collidepoint(event.pos): + distance = math.sqrt( + (event.pos[0] - self.rect.centerx) ** 2 + + (event.pos[1] - self.rect.centery) ** 2 + ) + if distance <= 50: + return True + + def update(self, event, appModel: AppModel): + if self.isClicked(event): + self.clicked = True + if appModel.state != AppState.idle: + appModel.selected_tiles.append(self) + return True + elif event.type == MOUSEBUTTONUP: + self.clicked = False + elif event.type == MOUSEMOTION: + if self.clicked: + self.rect.move_ip(event.rel) + return False + + def draw(self, screen): + backgroundColor = (125, 125, 125) + + gfxdraw.aacircle( + screen, + self.rect.centerx, + self.rect.centery, + 50, + (0, 0, 0), + ) + + if self.clicked: + gfxdraw.filled_circle( + screen, + self.rect.centerx, + self.rect.centery, + 50, + (0, 0, 255), + ) + gfxdraw.aacircle( + screen, + self.rect.centerx, + self.rect.centery, + 50, + (0, 0, 255), + ) + gfxdraw.filled_circle( + screen, + self.rect.centerx, + self.rect.centery, + 45, + (255, 255, 255), + ) + gfxdraw.aacircle( + screen, + self.rect.centerx, + self.rect.centery, + 45, + (255, 255, 255), + ) + + font = pygame.font.Font(None, 36) + text = font.render(self.name, True, (0, 0, 0)) + text_rect = text.get_rect(center=self.rect.center) + screen.blit(text, text_rect) + if self.type == TileType.final or self.type == TileType.start_final: + pygame.draw.circle(screen, "black", self.rect.center, 40, 5) + if self.type == TileType.start or self.type == TileType.start_final: + arrow_x = self.rect.left + arrow_y = self.rect.centery + arrow_x1 = arrow_x + 30 * math.cos(0.9 * math.pi) + arrow_y1 = arrow_y + 30 * math.sin(0.9 * math.pi) + arrow_x2 = arrow_x + 30 * math.cos(1.1 * math.pi) + arrow_y2 = arrow_y + 30 * math.sin(1.1 * math.pi) + gfxdraw.filled_polygon( + screen, + [(arrow_x, arrow_y), (arrow_x1, arrow_y1), (arrow_x2, arrow_y2)], + (0, 0, 0), + ) + gfxdraw.aapolygon( + screen, + [(arrow_x, arrow_y), (arrow_x1, arrow_y1), (arrow_x2, arrow_y2)], + (0, 0, 0), + ) + pygame.draw.aaline( + screen, "black", (arrow_x, arrow_y), (arrow_x - 100, arrow_y), 5 + ) + + def __eq__(self, other): + if isinstance(other, Tile): + return self.id == other.id + return False + + +class Edge: + def __init__(self, fromTile: Tile, toTile: Tile, label=""): + self.fromTile = fromTile + self.toTile = toTile + self.labels: set[str] = {label} + + def addLabel(self, label): + self.labels.add(label) + + def renameLabel(self, old, new): + if old in self.labels: + self.labels.remove(old) + self.labels.add(new) + + def isEmpty(self): + return len(self.labels) == 0 + + def removeLabel(self, label): + if label in self.labels: + self.labels.remove(label) + + def __eq__(self, other): + if isinstance(other, Edge): + return self.fromTile == other.fromTile and self.toTile == other.toTile + return False + + def draw(self, screen): + arrow_length = 30 + radius = self.fromTile.rect.width // 2 + arrow_angle = math.pi / 6 + + displayedStr = "" + for label in self.labels: + displayedStr += (label if label != "" else "ε") + ", " + displayedStr = displayedStr[:-2] + + if self.fromTile == self.toTile: + offset = self.fromTile.rect.width + rect = pygame.Rect( + self.fromTile.rect.centerx, + self.fromTile.rect.centery - offset, + offset, + offset, + ) + pygame.draw.arc(screen, (255, 0, 0), rect, -0.5 * math.pi, math.pi, 2) + angle = 0.9 * math.pi + arrow_x = self.fromTile.rect.centerx + radius + arrow_y = self.fromTile.rect.centery + arrow_x1 = arrow_x - arrow_length * math.cos(angle + arrow_angle / 2) + arrow_y1 = arrow_y - arrow_length * math.sin(angle + arrow_angle / 2) + arrow_x2 = arrow_x - arrow_length * math.cos(angle - arrow_angle / 2) + arrow_y2 = arrow_y - arrow_length * math.sin(angle - arrow_angle / 2) + font = pygame.font.Font(None, 50) + text = font.render(displayedStr, True, (0, 0, 0)) + text_rect = text.get_rect(center=rect.topright) + screen.blit(text, text_rect) + gfxdraw.filled_polygon( + screen, + [(arrow_x, arrow_y), (arrow_x1, arrow_y1), (arrow_x2, arrow_y2)], + (255, 0, 0), + ) + gfxdraw.aapolygon( + screen, + [(arrow_x, arrow_y), (arrow_x1, arrow_y1), (arrow_x2, arrow_y2)], + (255, 0, 0), + ) + else: + start_x, start_y = self.fromTile.rect.centerx, self.fromTile.rect.centery + end_x, end_y = self.toTile.rect.centerx, self.toTile.rect.centery + angle = math.atan2(end_y - start_y, end_x - start_x) + tailDeviateAngle = math.pi * 0.1 + tailArcHeight = 20 + tail_x, tail_y = start_x + radius * math.cos( + angle + tailDeviateAngle + ), start_y + radius * math.sin(angle + tailDeviateAngle) + head_x, head_y = end_x + radius * math.cos( + angle - tailDeviateAngle + math.pi + ), end_y + radius * math.sin(angle - tailDeviateAngle + math.pi) + distance = math.sqrt((tail_x - head_x) ** 2 + (tail_y - head_y) ** 2) + mid_x, mid_y = (tail_x + head_x) / 2, (tail_y + head_y) / 2 + angle_offset = math.atan(tailArcHeight * 2 / distance) + r = distance / (2 * math.sin(2 * angle_offset)) + center_x, center_y = mid_x + (r - tailArcHeight) * math.sin( + angle + ), mid_y - (r - tailArcHeight) * math.cos(angle) + from_angle, to_angle = ( + math.pi * 0.5 + angle - 2 * angle_offset, + math.pi * 0.5 + angle + 2 * angle_offset, + ) + arcRect = pygame.Rect(center_x - r, center_y - r, 2 * r, 2 * r) + pygame.draw.arc(screen, (255, 0, 0), arcRect, -to_angle, -from_angle, 2) + finalAngle = angle - tailDeviateAngle + arrow_x1 = head_x - arrow_length * math.cos(finalAngle + arrow_angle / 2) + arrow_y1 = head_y - arrow_length * math.sin(finalAngle + arrow_angle / 2) + arrow_x2 = head_x - arrow_length * math.cos(finalAngle - arrow_angle / 2) + arrow_y2 = head_y - arrow_length * math.sin(finalAngle - arrow_angle / 2) + gfxdraw.filled_polygon( + screen, + [(head_x, head_y), (arrow_x1, arrow_y1), (arrow_x2, arrow_y2)], + (255, 0, 0), + ) + gfxdraw.aapolygon( + screen, + [(head_x, head_y), (arrow_x1, arrow_y1), (arrow_x2, arrow_y2)], + (255, 0, 0), + ) + font = pygame.font.Font(None, 50) + text = font.render(displayedStr, True, (0, 0, 0)) + yOffset = 20 if self.fromTile.id < self.toTile.id else -20 + text_rect = text.get_rect( + center=((tail_x + head_x) // 2, (tail_y + head_y) // 2 + yOffset) + ) + screen.blit(text, text_rect) diff --git a/src/buttons.py b/src/buttons.py new file mode 100644 index 0000000..1d85d9b --- /dev/null +++ b/src/buttons.py @@ -0,0 +1,66 @@ +import pygame +from pygame.locals import * +from appModel import AppState, AppModel + + +class Button(pygame.Rect): + def __init__(self, left, top, width, height, text, color): + super().__init__(left, top, width, height) + self.color = color + self.text = text + self.corner_radius = 5 + + def isClicked(self, event): + if event.type == pygame.MOUSEBUTTONDOWN: + if self.collidepoint(event.pos): + return True + + def draw(self, screen): + pygame.draw.rect(screen, self.color, self, border_radius=self.corner_radius) + font = pygame.font.Font(None, 36) + text = font.render(self.text, True, (255, 255, 255)) + text_rect = text.get_rect(center=self.center) + screen.blit(text, text_rect) + + +class ToggleButton(Button): + def __init__(self, left, top, width, height, text, color, toState): + super().__init__(left, top, width, height, text, color) + self.selfState = toState + + def isClicked(self, event): + if event.type == MOUSEBUTTONDOWN: + if self.collidepoint(event.pos): + return True + return False + + def update(self, event, appModel: AppModel): + if self.isClicked(event): + if appModel.state != self.selfState: + appModel.setState(self.selfState) + else: + appModel.setState(AppState.idle) + + def setState(self, pressed: bool): + self.pressedDown = pressed + + def draw(self, screen, appModel: AppModel): + r = self.corner_radius + brightnessIncrement = 100 + if appModel.state == self.selfState: + pygame.draw.rect( + screen, + ( + self.color[0] + brightnessIncrement, + self.color[1] + brightnessIncrement, + self.color[2] + brightnessIncrement, + ), + self, + border_radius=r, + ) + else: + pygame.draw.rect(screen, self.color, self, border_radius=r) + font = pygame.font.Font(None, 36) + text = font.render(self.text, True, (255, 255, 255)) + text_rect = text.get_rect(center=self.center) + screen.blit(text, text_rect) diff --git a/src/main.py b/src/main.py index 2ae2839..92ed230 100644 --- a/src/main.py +++ b/src/main.py @@ -1 +1,325 @@ -pass +import math +import pygame +from pygame.locals import * +from pygame import draw +from buttons import Button, ToggleButton +from appModel import Tile, Edge, AppModel, AppState, TileType +from algorithms import enfa2nfa, nfa2dfa, minimize_dfa +from data_structures.epsilon_nfa import EpsilonNFA +from data_structures.nfa import NFA +from data_structures.dfa import DFA + +pygame.init() +screen = pygame.display.set_mode((1800, 900), pygame.RESIZABLE) + +tiles = [Tile((i * 110, i * 110), i) for i in range(5)] +tilePairs: list[Edge] = [] +occupiedIds: set[int] = {tile.id for tile in tiles} +model = AppModel() + +buttonWidth, buttonHeight = 180, 50 + +pairButton = ToggleButton( + left=0, + top=screen.get_height() - buttonHeight, + width=buttonWidth, + height=buttonHeight, + text="New Delta", + color=(0, 128, 0), + toState=AppState.pairing, +) +depairButton = ToggleButton( + left=0, + top=screen.get_height() - buttonHeight, + width=buttonWidth, + height=buttonHeight, + text="Remove Delta", + color=(128, 0, 0), + toState=AppState.depairing, +) +renameButton = ToggleButton( + left=0, + top=screen.get_height() - buttonHeight, + width=buttonWidth, + height=buttonHeight, + text="Reassign Char", + color=(0, 0, 128), + toState=AppState.renamingLabels, +) +setAsStartButton = ToggleButton( + left=0, + top=screen.get_height() - buttonHeight, + width=buttonWidth, + height=buttonHeight, + text="Set as Start", + color=(0, 0, 128), + toState=AppState.setAsStart, +) +setAsFinalButton = ToggleButton( + left=0, + top=screen.get_height() - buttonHeight, + width=buttonWidth, + height=buttonHeight, + text="Set as Final", + color=(0, 0, 128), + toState=AppState.setAsEnd, +) + +reanmeStateButton = ToggleButton( + left=0, + top=screen.get_height() - buttonHeight, + width=buttonWidth, + height=buttonHeight, + text="Rename State", + color=(0, 0, 128), + toState=AppState.renamingStates, +) + +removeStateButton = ToggleButton( + left=0, + top=screen.get_height() - buttonHeight, + width=buttonWidth, + height=buttonHeight, + text="Remove State", + color=(128, 0, 0), + toState=AppState.removing, +) + +appendButton = Button( + left=0, + top=screen.get_height() - buttonHeight, + width=buttonWidth, + height=buttonHeight, + text="New State", + color=(0, 128, 0), +) +convertButton = Button( + left=0, + top=screen.get_height() - buttonHeight, + width=buttonWidth, + height=buttonHeight, + text="Convert to DFA", + color=(245, 202, 35), +) + +toggleButtons: list[Button] = [ + pairButton, + depairButton, + renameButton, + reanmeStateButton, + setAsStartButton, + setAsFinalButton, + removeStateButton, +] + +clickButtons: list[Button] = [appendButton, convertButton] + + +def arrangeButtons(): + buttonCnt = len(toggleButtons) + len(clickButtons) + buttonSpacing = (screen.get_width() - (buttonWidth * buttonCnt)) / (buttonCnt - 1) + for i, button in enumerate(toggleButtons): + button.left = buttonSpacing * (i) + buttonWidth * i + button.bottom = screen.get_height() + for i, button in enumerate(clickButtons): + button.left = buttonSpacing * (i + len(toggleButtons)) + buttonWidth * ( + i + len(toggleButtons) + ) + button.bottom = screen.get_height() + + +arrangeButtons() + + +def appendNewTile(): + new_tile_id = len(tiles) + while new_tile_id in occupiedIds: + new_tile_id += 1 + new_tile = Tile( + (screen.get_width() // 2 - 50, screen.get_height() // 2 - 50), + new_tile_id, + ) + tiles.append(new_tile) + occupiedIds.add(new_tile_id) + + +def constructEpsilonNFA() -> EpsilonNFA: + Q: set[str] = {tile.name for tile in tiles} + + T: set[str] = set() + + for pair in tilePairs: + for label in pair.labels: + if label: + T.add(label) + + delta: dict[tuple[str, str], set[str]] = {} + for edge in tilePairs: + for label in edge.labels: + if (edge.fromTile.name, label) not in delta: + delta[(edge.fromTile.name, label)] = set() + delta[(edge.fromTile.name, label)].add(edge.toTile.name) + + q0: str = next( + ( + tile.name + for tile in tiles + if tile.type == TileType.start or tile.type == TileType.start_final + ), + "", + ) + + qf: set[str] = { + tile.name + for tile in tiles + if tile.type == TileType.final or tile.type == TileType.start_final + } + return EpsilonNFA(Q, T, delta, q0, qf) + + +def makeId(digKeys: list[str]) -> int: + strId = "".join(digKeys) + return int(strId) + + +clock = pygame.time.Clock() + +while True: + for event in pygame.event.get(): + for tile in tiles: + if tile.update(event, model): + break + for button in toggleButtons: + button.update(event, model) + if event.type == QUIT: + quit() + elif event.type == VIDEORESIZE: + screen = pygame.display.set_mode((event.w, event.h), pygame.RESIZABLE) + arrangeButtons() + elif event.type == MOUSEBUTTONDOWN: + if appendButton.isClicked(event): + appendNewTile() + if convertButton.isClicked(event): + try: + e_nfa = constructEpsilonNFA() + thenfa = enfa2nfa.transfer_epsilon_nfa_to_nfa(e_nfa) + dogwaterdfa = nfa2dfa.transfer_nfa_to_dfa(thenfa) + minimized_dfa = minimize_dfa.minimize_dfa(dogwaterdfa) + + e_nfa.draw("tmp/enfa") + thenfa.draw("tmp/nfa") + dogwaterdfa.draw("tmp/ddfa") + minimized_dfa.draw("tmp/dfa") + except ValueError: + print("Invalid Epsilon NFA definition") + elif event.type == KEYDOWN: + if event.unicode == " ": + model.pressedKeys.append("") + else: + model.pressedKeys.append(event.unicode) + match model.state: + case AppState.pairing: + if len(model.selected_tiles) >= 2 and len(model.pressedKeys) >= 1: + selected_tiles = model.selected_tiles + selectedPair = Edge( + selected_tiles[0], selected_tiles[1], model.pressedKeys[0] + ) + if tilePairs.count(selectedPair) == 0: + tilePairs.append(selectedPair) + else: + selectedPair = tilePairs[tilePairs.index(selectedPair)] + selectedPair.addLabel(model.pressedKeys[0]) + model.selected_tiles.clear() + model.pressedKeys.clear() + case AppState.depairing: + if len(model.selected_tiles) >= 2 and len(model.pressedKeys) >= 1: + selected_tiles = model.selected_tiles + selectedPair = Edge(selected_tiles[0], selected_tiles[1]) + if tilePairs.count(selectedPair) != 0: + selectedPair = tilePairs[tilePairs.index(selectedPair)] + selectedPair.removeLabel(model.pressedKeys[0]) + if selectedPair.isEmpty(): + tilePairs.remove(selectedPair) + model.selected_tiles.clear() + model.pressedKeys.clear() + case AppState.renamingLabels: + if len(model.selected_tiles) >= 2 and len(model.pressedKeys) >= 2: + selected_tiles = model.selected_tiles + selectedPair = Edge(selected_tiles[0], selected_tiles[1]) + if tilePairs.count(selectedPair) != 0: + selectedPair = tilePairs[tilePairs.index(selectedPair)] + selectedPair.renameLabel(model.pressedKeys[0], model.pressedKeys[1]) + model.selected_tiles.clear() + model.pressedKeys.clear() + case AppState.renamingStates: + if len(model.selected_tiles) >= 1 and len(model.pressedKeys) >= 2: + if model.pressedKeys[-1] == "\r": + id = makeId(model.pressedKeys[:-1]) + selected_tile = tiles[tiles.index(model.selected_tiles[0])] + if id not in occupiedIds: + selected_tile.rename(id) + occupiedIds.add(id) + else: + print("Duplicate ID") + model.selected_tiles.clear() + model.pressedKeys.clear() + case AppState.removing: + if len(model.selected_tiles) >= 1: + selected_tile = model.selected_tiles[0] + tiles.remove(selected_tile) + occupiedIds.remove(selected_tile.id) + for pair in tilePairs: + if pair.fromTile == selected_tile or pair.toTile == selected_tile: + tilePairs.remove(pair) + model.selected_tiles.clear() + case AppState.setAsStart: + if len(model.selected_tiles) >= 1: + for tile in tiles: + if tile == model.selected_tiles[0]: + continue + if tile.type == TileType.start: + tile.type = TileType.intermediate + elif tile.type == TileType.start_final: + tile.type = TileType.final + selected_tile: Tile = model.selected_tiles[0] + tile_type = selected_tile.type + match tile_type: + case TileType.final: + selected_tile.type = TileType.start_final + case TileType.intermediate: + selected_tile.type = TileType.start + case TileType.start_final: + selected_tile.type = TileType.final + case TileType.start: + selected_tile.type = TileType.intermediate + model.selected_tiles.clear() + case AppState.setAsEnd: + if len(model.selected_tiles) >= 1: + selected_tile = model.selected_tiles[0] + tile_type = selected_tile.type + match tile_type: + case TileType.final: + selected_tile.type = TileType.intermediate + case TileType.start_final: + selected_tile.type = TileType.start + case TileType.start: + selected_tile.type = TileType.start_final + case TileType.intermediate: + selected_tile.type = TileType.final + model.selected_tiles.clear() + + screen.fill((255, 255, 255)) + for pair in tilePairs: + pair.draw(screen=screen) + for tile in tiles: + tile.draw(screen) + + for tile in model.selected_tiles: + pygame.draw.circle(screen, (0, 255, 255), tile.rect.center, 50, 5) + + for button in toggleButtons: + button.draw(screen, model) + for button in clickButtons: + button.draw(screen) + pygame.display.flip() + clock.tick(30)