From f3b69f9a0b491fb054d0a040f6b9390a1409bcb9 Mon Sep 17 00:00:00 2001 From: daehyupsohn Date: Mon, 28 Sep 2026 22:18:47 +0900 Subject: [PATCH 001/108] Connect a browser presentation adapter to existing editing services --- CHANGELOG.md | 10 + README.ko.md | 4 + README.md | 4 + app/chemvas/adapters/qt/renderer.py | 53 +- app/chemvas/bootstrap/application.py | 13 + app/chemvas/bootstrap/web_adapter.py | 1091 +++++++++++++++++ app/chemvas/features/graph/__init__.py | 48 +- app/chemvas/features/rendering/__init__.py | 7 +- .../features/rendering/acs1996_style.py | 37 +- .../features/rendering/bond_geometry.py | 18 + .../features/rendering/line_geometry.py | 37 + app/chemvas/features/selection/__init__.py | 6 + app/chemvas/features/selection/hit.py | 78 +- app/chemvas/shell/icon_design.py | 60 +- app/chemvas/shell/icon_factory.py | 81 +- app/chemvas/ui/annotations/arrows.py | 50 +- .../ui/canvas/canvas_hit_testing_service.py | 94 +- .../ui/canvas/canvas_move_controller.py | 31 +- .../canvas/canvas_ring_fill_scene_service.py | 26 +- app/chemvas/ui/insert/ring_occupancy.py | 5 +- app/chemvas/ui/molecule/atom_coords_access.py | 7 +- .../ui/molecule/atom_label_merge_service.py | 47 +- .../ui/molecule/atom_label_renderer.py | 22 +- app/chemvas/ui/molecule/atom_label_service.py | 59 +- .../ui/molecule/bond_graphics_draw_service.py | 32 +- .../ui/molecule/bond_line_geometry_service.py | 14 +- .../structure_benzene_build_service.py | 31 +- .../molecule/structure_bond_build_service.py | 72 +- .../ui/molecule/structure_build_committer.py | 21 +- .../ui/molecule/structure_geometry_access.py | 45 +- .../ui/molecule/structure_growth_geometry.py | 10 +- app/chemvas/ui/molecule/template_geometry.py | 5 +- app/chemvas/ui/scene/scene_delete_plan.py | 22 +- app/chemvas/ui/scene/scene_geometry.py | 32 +- app/chemvas/ui/tools/bond_tool.py | 82 +- app/chemvas/ui/tools/bond_tool_logic.py | 80 +- app/chemvas/ui/tools/text_tool.py | 29 +- app/chemvas/ui/tools/text_tool_logic.py | 25 +- app/chemvas/ui/window/main_window_config.py | 46 + .../main_window_context_bar_page_factories.py | 17 +- .../window/main_window_context_bar_widgets.py | 13 +- .../ui/window/main_window_status_service.py | 18 +- app/chemvas/web/app.mjs | 394 ++++++ app/chemvas/web/index.html | 52 + app/chemvas/web/scene.mjs | 60 + app/chemvas/web/style.css | 59 + app/chemvas/web/transport.mjs | 35 + docs/ARCHITECTURE.ko.md | 7 + docs/ARCHITECTURE.md | 6 + docs/WEB_ADAPTER.ko.md | 95 ++ docs/WEB_ADAPTER.md | 98 ++ docs/adr/0029-browser-adapter.md | 70 ++ pyproject.toml | 2 +- tests/test_atom_label_service.py | 2 +- tests/test_ring_label_clearance.py | 6 +- tests/test_verify_dist.py | 4 + tests/test_web_adapter.py | 1039 ++++++++++++++++ tests/web_adapter.test.mjs | 69 ++ 58 files changed, 3989 insertions(+), 491 deletions(-) create mode 100644 app/chemvas/bootstrap/web_adapter.py create mode 100644 app/chemvas/web/app.mjs create mode 100644 app/chemvas/web/index.html create mode 100644 app/chemvas/web/scene.mjs create mode 100644 app/chemvas/web/style.css create mode 100644 app/chemvas/web/transport.mjs create mode 100644 docs/WEB_ADAPTER.ko.md create mode 100644 docs/WEB_ADAPTER.md create mode 100644 docs/adr/0029-browser-adapter.md create mode 100644 tests/test_web_adapter.py create mode 100644 tests/web_adapter.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2069d4a8..cf2265c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Browser presentation adapter selected with `chemvas --ui web`, alongside + `chemvas --ui qt`. Existing Chemvas services own bond and benzene construction, + attachment/fusion, deletion, bond styles, atom labels/merging, selected-atom/bond movement and Undo/Redo. + Shift-click and Select All support multiple selection and single-command deletion. Browser controls reuse + desktop declarations and artwork; `.chemvas` copies reopen in Qt. Unconnected + actions remain disabled and unsupported content opens read-only. + See [adapter usage and current connections](docs/WEB_ADAPTER.md). + ### Changed - Rewrite both READMEs around reproducible publication schemes and checked diff --git a/README.ko.md b/README.ko.md index adc805a2..b537ed17 100644 --- a/README.ko.md +++ b/README.ko.md @@ -81,6 +81,10 @@ chemvas render-document revised.chemvas --output scheme.svg --width-mm 174 ## 문서 및 가이드 +- [브라우저 어댑터](https://github.com/dhsohn/Chemvas/blob/main/docs/WEB_ADAPTER.ko.md): `chemvas --ui web`으로 Qt 앱과 함께 실행한다. + 기본 편집과 사본 저장을 제공하며, 논문용 출력과 전체 데스크톱 사용성은 아직 + 동등하지 않다. + - [헤드리스 & 에이전트 CLI](https://github.com/dhsohn/Chemvas/blob/main/docs/AGENT_CLI.ko.md) · [논문 그림 작성 예제](https://github.com/dhsohn/Chemvas/blob/main/docs/PUBLICATION_SCHEMES.ko.md) · [반응 도식 배치](https://github.com/dhsohn/Chemvas/blob/main/docs/SCHEME_LAYOUT.ko.md) - [그리기 도구 및 단축키](https://github.com/dhsohn/Chemvas/blob/main/docs/REFERENCE.ko.md) · [화학 입출력](https://github.com/dhsohn/Chemvas/blob/main/docs/REFERENCE.ko.md#화학-입출력) · [이미지 객체](https://github.com/dhsohn/Chemvas/blob/main/docs/IMAGE_OBJECTS.ko.md) · [문서 호환성 정책](https://github.com/dhsohn/Chemvas/blob/main/docs/DOCUMENT_COMPATIBILITY.ko.md) - [Reaction Mapping](https://github.com/dhsohn/Chemvas/blob/main/docs/AGENT_CLI.ko.md#데스크톱-반응-매핑): 2D 캔버스에서 반응물·생성물 원자를 대응시키고 결합 변화를 검토합니다. `.chemvas`에 저장해 AI나 협업자에게 반응을 설명할 수 있으며, 선택적인 RDKit 계산 구조 내보내기는 `machine.json`과 XYZ를 생성합니다. diff --git a/README.md b/README.md index ffadb619..b7e4e0de 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,10 @@ For detailed instructions and example files, see the [step-by-step guide](https: ## Documentation +- [Browser adapter](https://github.com/dhsohn/Chemvas/blob/main/docs/WEB_ADAPTER.md): run `chemvas --ui web` alongside + the Qt app. Basic editing and saved copies are available; publication rendering + and full desktop workflow parity are still in development. + - [Headless & Agent CLI](https://github.com/dhsohn/Chemvas/blob/main/docs/AGENT_CLI.md) · [Publication Schemes](https://github.com/dhsohn/Chemvas/blob/main/docs/PUBLICATION_SCHEMES.md) · [Scheme Layout](https://github.com/dhsohn/Chemvas/blob/main/docs/SCHEME_LAYOUT.md) - [Drawing Tools & Shortcuts](https://github.com/dhsohn/Chemvas/blob/main/docs/REFERENCE.md) · [Chemistry I/O](https://github.com/dhsohn/Chemvas/blob/main/docs/REFERENCE.md#chemistry-io) · [Image Objects](https://github.com/dhsohn/Chemvas/blob/main/docs/IMAGE_OBJECTS.md) · [Document compatibility](https://github.com/dhsohn/Chemvas/blob/main/docs/DOCUMENT_COMPATIBILITY.md) - [Reaction Mapping](https://github.com/dhsohn/Chemvas/blob/main/docs/AGENT_CLI.md#desktop-reaction-mapping): Map reactant and product atoms on the 2D canvas, review bond changes, and save the correspondence in `.chemvas` for an AI assistant or collaborator to interpret. Optional RDKit geometry handoff exports `machine.json` and XYZ. diff --git a/app/chemvas/adapters/qt/renderer.py b/app/chemvas/adapters/qt/renderer.py index 859953d4..c54d58f1 100644 --- a/app/chemvas/adapters/qt/renderer.py +++ b/app/chemvas/adapters/qt/renderer.py @@ -1,46 +1,18 @@ from __future__ import annotations -from dataclasses import replace +from typing import TYPE_CHECKING -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QBrush, QColor, QFont, QPen +if TYPE_CHECKING: + from PyQt6.QtGui import QBrush, QFont, QPen -from chemvas.features.rendering import ACS1996Style +from chemvas.features.rendering import RenderMetrics -class Renderer: - BASE_BOND_LENGTH_PX = 20.0 - - def __init__(self, style: ACS1996Style | None = None) -> None: - self.style = style or ACS1996Style() - - def set_bond_length(self, length_px: float) -> None: - self.style = replace(self.style, bond_length_px=length_px) - - def metric_scale(self) -> float: - if self.style.bond_length_px <= 0: - return 1.0 - return self.style.bond_length_px / self.BASE_BOND_LENGTH_PX - - def scaled_style_metric(self, value: float) -> float: - return value * self.metric_scale() - - def bond_line_width(self) -> float: - return self.scaled_style_metric(self.style.bond_line_width) - - def bold_bond_width(self) -> float: - return self.scaled_style_metric(self.style.bold_bond_width) - - def bond_spacing(self) -> float: - return self.scaled_style_metric(self.style.bond_spacing_px) - - def hash_spacing(self) -> float: - return self.scaled_style_metric(self.style.hash_spacing_px) - - def atom_font_size_pt(self) -> int: - return max(1, round(self.scaled_style_metric(float(self.style.font_size_pt)))) - +class Renderer(RenderMetrics): def bond_pen(self) -> QPen: + from PyQt6.QtCore import Qt + from PyQt6.QtGui import QColor, QPen + pen = QPen(QColor(self.style.bond_color)) pen.setWidthF(self.bond_line_width()) # Round caps let the ends of two bonds meeting at an atom overlap into a @@ -50,6 +22,8 @@ def bond_pen(self) -> QPen: return pen def dotted_bond_pen(self) -> QPen: + from PyQt6.QtCore import Qt + pen = self.bond_pen() pen.setStyle(Qt.PenStyle.DotLine) pen.setCapStyle(Qt.PenCapStyle.RoundCap) @@ -57,6 +31,9 @@ def dotted_bond_pen(self) -> QPen: return pen def bold_bond_pen(self) -> QPen: + from PyQt6.QtCore import Qt + from PyQt6.QtGui import QColor, QPen + pen = QPen(QColor(self.style.bond_color)) pen.setWidthF(self.bold_bond_width()) pen.setCapStyle(Qt.PenCapStyle.FlatCap) @@ -64,10 +41,14 @@ def bold_bond_pen(self) -> QPen: return pen def atom_font(self) -> QFont: + from PyQt6.QtGui import QFont + font = QFont(self.style.font_family, self.atom_font_size_pt()) return font def ring_fill_brush(self, color: str | None = None) -> QBrush: + from PyQt6.QtGui import QBrush, QColor + fill = QColor(color or self.style.ring_fill_color) fill.setAlphaF(self.style.ring_fill_alpha) return QBrush(fill) diff --git a/app/chemvas/bootstrap/application.py b/app/chemvas/bootstrap/application.py index 7db86638..218a5782 100644 --- a/app/chemvas/bootstrap/application.py +++ b/app/chemvas/bootstrap/application.py @@ -77,6 +77,7 @@ def _root_help() -> str: " chemvas [document]\n" " chemvas [options]\n\n" "Run with no arguments to launch the desktop app.\n" + "Select an adapter with --ui qt or --ui web (experimental).\n" "Pass a .chemvas, .svg, or .mol document to open it at startup.\n\n" "Options:\n" " -h, --help show this help message and exit\n" @@ -210,6 +211,18 @@ def _filtered_stderr(stderr_fd: int = 2, platform: str | None = None) -> Iterato def main() -> None: + # Select the presentation adapter before importing Qt or starting its event loop. + if len(sys.argv) > 2 and sys.argv[1] == "--ui": + adapter = sys.argv[2] + if adapter == "web": + from chemvas.bootstrap.web_adapter import main as web_main + + web_main(sys.argv[3:]) + return + if adapter == "qt": + sys.argv = [sys.argv[0], *sys.argv[3:]] + else: + _reject_startup_argument(adapter) # The Windows GUI bootloader has no standard streams. Keep command-line # operations on the console companion rather than losing their reports. if ( diff --git a/app/chemvas/bootstrap/web_adapter.py b/app/chemvas/bootstrap/web_adapter.py new file mode 100644 index 00000000..f7710b5c --- /dev/null +++ b/app/chemvas/bootstrap/web_adapter.py @@ -0,0 +1,1091 @@ +"""Local browser adapter over existing Chemvas UI definitions and editing services.""" + +from __future__ import annotations + +import argparse +import json +import math +import secrets +import webbrowser +from contextlib import nullcontext, suppress +from copy import deepcopy +from dataclasses import asdict, dataclass +from functools import partial +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from threading import RLock +from types import SimpleNamespace +from typing import Any, cast, override + +from chemvas.core.history import HistoryCommand +from chemvas.domain.document import ( + CANVAS_FILE_VERSION, + Bond, + atom_shows_itself, + broken_ring_fill_indices, + build_normalized_document_payload, + deserialize_model_state, + extract_document_state, + model_bond_pairs, + normalize_json_numbers, + serialize_model_state_with_warnings, +) +from chemvas.domain.document.ring_fills import RingFill, ring_fill_to_state +from chemvas.domain.document.sheet import SHEET_SIZES_MM +from chemvas.domain.json_io import strict_json_loads +from chemvas.features.document_composition import compose_document_state +from chemvas.features.graph import ( + CanvasGraphState, + add_bond_to_atom_index, + build_ring_edge_index, +) +from chemvas.features.rendering import ( + ACS1996Style, + RenderMetrics, + arrow_head_polylines, + cycle_plain_bond_style, + line_normal, +) +from chemvas.features.selection import ( + nearest_atom_id, + nearest_bond_id, + selected_atom_ids_with_bond_endpoints, +) +from chemvas.shell import toolbar_styles +from chemvas.shell.icon_design import DESIGN_ICON_NAMES, design_icon_svg +from chemvas.shell.palette import PALETTE +from chemvas.ui.canvas.canvas_history_service import CanvasHistoryService +from chemvas.ui.canvas.canvas_history_state import CanvasHistoryState +from chemvas.ui.canvas.canvas_mark_registry import CanvasMarkRegistry +from chemvas.ui.canvas.canvas_move_controller import CanvasMoveController +from chemvas.ui.canvas.canvas_ring_fill_scene_service import rebuild_ring_fill_polygons +from chemvas.ui.canvas.canvas_tool_settings_state import CanvasToolSettingsState +from chemvas.ui.molecule.atom_label_merge_service import AtomLabelMergeService +from chemvas.ui.molecule.atom_label_service import AtomLabelService +from chemvas.ui.molecule.bond_geometry_plan_service import ( + BondGeometryPlanService, + BondLinePrimitive, + BondPolygonPrimitive, +) +from chemvas.ui.molecule.bond_graphics_draw_service import BondGraphicsDrawService +from chemvas.ui.molecule.bond_line_geometry_service import BondLineGeometryService +from chemvas.ui.molecule.bond_ring_double_geometry_service import ( + BondRingDoubleGeometryService, +) +from chemvas.ui.molecule.structure_benzene_build_service import ( + StructureBenzeneBuildService, +) +from chemvas.ui.molecule.structure_bond_build_service import StructureBondBuildService +from chemvas.ui.molecule.structure_build_committer import StructureBuildCommitter +from chemvas.ui.molecule.structure_geometry_access import ( + default_bond_endpoint_for, + regular_ring_points_for_atom_for, + regular_ring_points_for_bond_for, +) +from chemvas.ui.molecule.template_geometry import polygon_contains_point +from chemvas.ui.scene.scene_delete_plan import ( + DeleteSelectionBuckets, + build_delete_selection_plan, +) +from chemvas.ui.tools.bond_tool_logic import ( + apply_active_bond_style, + is_short_bond_gesture, + resolve_bond_endpoint_target, +) +from chemvas.ui.tools.text_tool_logic import ( + TextToolTarget, + apply_text_input, + normalize_text_symbol, + plan_text_input, + resolve_text_tool_target, +) +from chemvas.ui.window.main_window_config import ( + ATOM_INPUT_SPEC, + BOND_MODIFIERS, + BOND_ORDER_SEGMENTS, + RING_FILL_TOOL_ACTION_SPEC, + TOOL_ACTION_SPECS, + TOOL_HINTS, + TOOLBAR_TOOL_GROUPS, +) +from chemvas.ui.window.main_window_toolbar_logic import bond_style_from_label + +MAX_REQUEST_BYTES = 2 * 1024 * 1024 +ASSETS = Path(__file__).resolve().parents[1] / "web" +STATIC_FILES = { + "/": ("index.html", "text/html; charset=utf-8"), + "/style.css": ("style.css", "text/css; charset=utf-8"), + "/app.mjs": ("app.mjs", "text/javascript; charset=utf-8"), + "/transport.mjs": ("transport.mjs", "text/javascript; charset=utf-8"), + "/scene.mjs": ("scene.mjs", "text/javascript; charset=utf-8"), +} +SUPPORTED_BONDS = { + "single", + "double", + "double_center", + "triple", + "wedge", + "hash", + "bold_in", + "bold_center", + "bold_out", +} + + +def ui_spec() -> dict[str, Any]: + """Serialize the desktop's existing declarations, rather than copy its design.""" + tools = { + key: { + "key": key, + "label": label, + "icon": design_icon_svg(DESIGN_ICON_NAMES[icon]), + "tip": tip, + } + for key, label, _tool, icon, tip in TOOL_ACTION_SPECS + } + key, label, icon, tip = RING_FILL_TOOL_ACTION_SPEC + tools[key] = { + "key": key, + "label": label, + "icon": design_icon_svg(DESIGN_ICON_NAMES[icon]), + "tip": tip, + } + return { + "groups": [[tools[key] for key in group] for group in TOOLBAR_TOOL_GROUPS], + "bond_groups": [ + [ + { + "key": bond_style_from_label(label)[0], + "label": label, + "tip": tip, + "icon": design_icon_svg(DESIGN_ICON_NAMES[icon]), + } + for label, icon, tip in group + ] + for group in (BOND_ORDER_SEGMENTS, BOND_MODIFIERS) + ], + "hints": TOOL_HINTS, + "atom_input": { + **ATOM_INPUT_SPEC, + "value": CanvasToolSettingsState().atom_symbol, + }, + "panels": [ + {"key": key, "label": label, "icon": design_icon_svg(key)} + for key, label in ( + ("molecule_info", "Molecule Info"), + ("reaction_mapping", "Reaction Mapping"), + ) + ], + } + + +def ui_css() -> str: + values = { + **PALETTE, + **{ + key.lower(): str(getattr(toolbar_styles, key)) + "px" + for key in ( + "TOOLBAR_THICKNESS", + "TOOLBAR_BUTTON_SIZE", + "TOOLBAR_ICON_SIZE", + "CONTEXT_BAR_CONTENT_HEIGHT", + "CONTEXT_BAR_BUTTON_HEIGHT", + "CONTEXT_BAR_ICON_SIZE", + ) + }, + } + return ( + ":root{" + + ";".join( + f"--{key.replace('_', '-')}: {value}" for key, value in values.items() + ) + + "}" + ) + + +def new_document() -> dict[str, Any]: + state = compose_document_state( + { + "format": "chemvas-document-composition", + "version": 2, + "atoms": [], + "bonds": [], + } + ) + return build_normalized_document_payload(state, CANVAS_FILE_VERSION) + + +def document_info(payload: object) -> dict[str, Any]: + """Validate without dropping data; unsupported drawings remain read-only.""" + state = extract_document_state(normalize_json_numbers(payload)) + if ( + len(json.dumps(normalize_json_numbers(payload), ensure_ascii=False).encode()) + > MAX_REQUEST_BYTES + ): + raise ValueError("The browser adapter supports documents up to 2 MiB.") + model = state["model"] + if len(model["atoms"]) > 2000 or len(model["bonds"]) > 3000: + raise ValueError( + "The browser adapter supports up to 2,000 atoms and 3,000 bonds." + ) + reasons = [ + key.replace("_", " ") + for key in ( + "marks", + "shapes", + "images", + "orbitals", + "ts_brackets", + "groups", + "perspective", + "calculation_plan", + ) + if state.get(key) + ] + if model.get("atom_annotations"): + reasons.append("atom charges, isotopes or radicals") + if any(bond["style"] not in SUPPORTED_BONDS for bond in model["bonds"] if bond): + reasons.append("additional bond styles") + if state["notes"]: + reasons.append("text annotations") + if state["arrows"]: + reasons.append("arrow annotations") + settings = state["settings"] + if settings.get("note_box_enabled") or settings.get("note_border_enabled"): + reasons.append("note backgrounds or borders") + size = settings["sheet_size"] + width, height = ( + (595.0, 842.0) + if size == "A4" + else tuple( + float(value) * 72 / 25.4 + for value in ( + settings["sheet_custom_size_mm"] + if size == "Custom" + else SHEET_SIZES_MM[size] + ) + ) + ) + if settings["sheet_orientation"] == "landscape": + width, height = height, width + return { + "document": normalize_json_numbers(payload), + "unsupported": reasons, + "sheet": [width, height], + "style": asdict(ACS1996Style()), + "drawing": drawing_geometry(state), + } + + +def drawing_geometry(state: dict[str, Any]) -> dict[str, Any]: + """Connect the actual desktop geometry planner to SVG-compatible primitives.""" + model = deserialize_model_state(state["model"]) + graph = CanvasGraphState() + for index, bond in enumerate(model.bonds): + if bond: + add_bond_to_atom_index(graph.atom_bond_ids, index, bond.a, bond.b) + edges = build_ring_edge_index( + model.atoms, + model.bonds, + preferred_rings=(ring["atom_ids"] for ring in state.get("ring_fills", [])), + ) + + point = BrowserPoint + + def ring_center(bond: Bond) -> Any: + ring = edges.get((min(bond.a, bond.b), max(bond.a, bond.b))) + return ( + None + if ring is None + else point( + sum(model.atoms[i].x for i in ring) / len(ring), + sum(model.atoms[i].y for i in ring) / len(ring), + ) + ) + + def label_rect(atom_id: int | None) -> bool | None: + if atom_id is None: + return None + atom = model.atoms.get(atom_id) + return True if atom and atom_shows_itself(atom) else None + + def trim_labels( + a_id: int | None, + b_id: int | None, + x1: float, + y1: float, + x2: float, + y2: float, + offsets: object = (), + ) -> tuple[float, float]: + # Browser glyph bounds still require a separate presentation adapter. + length = math.hypot(x2 - x1, y2 - y1) or 1.0 + return ( + min(5 / length, 1 / 3) if label_rect(a_id) else 0.0, + 1 - (min(5 / length, 1 / 3) if label_rect(b_id) else 0.0), + ) + + metrics = RenderMetrics() + metrics.set_bond_length(state["settings"]["bond_length_px"]) + geometry: Any = SimpleNamespace( + trim_line_for_labels=trim_labels, + label_rect_for_atom=label_rect, + line_normal=line_normal, + bond_offset_unit_3d=lambda *args, **kwargs: None, + ring_center_for_bond=ring_center, + ring_center_3d_for_bond=lambda bond: None, + ) + rendering = SimpleNamespace( + bond_spacing=metrics.bond_spacing, + bond_line_width=metrics.bond_line_width, + bold_bond_width=metrics.bold_bond_width, + hash_spacing=metrics.hash_spacing, + bold_bond_pen=lambda: SimpleNamespace(widthF=metrics.bold_bond_width), + ) + context: Any = SimpleNamespace( + model=model, + state=SimpleNamespace(graph_state=graph), + geometry=geometry, + renderer=rendering, + ) + lines = BondLineGeometryService(context, point_factory=point) + ring_lines = BondRingDoubleGeometryService(context, renderer=geometry) + for name in ( + "parallel_bond_segments", + "plain_double_segments", + "hash_segments", + "hash_topology_count", + ): + setattr(geometry, name, getattr(lines, name)) + geometry.wedge_polygon = lines.wedge_triangle + geometry.ring_double_segments = ring_lines.ring_double_segments + geometry.graphics_drawer = BondGraphicsDrawService( + context, + renderer=geometry, + point_factory=point, + polygon_factory=lambda points: [(p.x(), p.y()) for p in points], + ) + planner = BondGeometryPlanService(context, renderer=geometry) + result = {} + for index, bond in enumerate(model.bonds): + if not bond or bond.style not in SUPPORTED_BONDS: + continue + primitives = planner.primitives_for_bond( + bond, model.atoms[bond.a], model.atoms[bond.b] + ) + result[str(index)] = [ + ( + {"line": item.segment} + if isinstance(item, BondLinePrimitive) + else { + "polygon": list(cast("Any", item.polygon)), + "outlined": item.outlined, + } + ) + for item in primitives + if isinstance(item, (BondLinePrimitive, BondPolygonPrimitive)) + ] + return { + "bonds": result, + "atom_labels": { + str(i): atom.element + for i, atom in model.atoms.items() + if atom_shows_itself(atom) + }, + "line_width": metrics.bond_line_width(), + "font_size": metrics.atom_font_size_pt(), + "arrows": [ + [ + [arrow["start"], arrow["end"]], + *arrow_head_polylines( + tuple(arrow["start"]), + tuple(arrow["end"]), + head_len=state["settings"]["bond_length_px"] + * state["settings"]["arrow_head_scale"], + line_width=state["settings"]["arrow_line_width"], + double=False, + ), + ] + for arrow in state["arrows"] + ], + } + + +@dataclass(frozen=True) +class BrowserPoint: + """Coordinate boundary for existing services that consume x()/y() points.""" + + px: float + py: float + + def x(self) -> float: + return self.px + + def y(self) -> float: + return self.py + + +@dataclass +class BrowserRingItem: + """Ring graphics port: the shared fitter writes a candidate's polygon.""" + + record: dict[str, Any] + + def data(self, role: int) -> Any: + return self.record["atom_ids"] if role == 2 else None + + def setPolygon(self, points: tuple[BrowserPoint, ...]) -> None: # noqa: N802 - graphics port + self.record["points"] = [(point.x(), point.y()) for point in points] + + +class BrowserStructureAdapter: + """Materialize existing structure builders into a candidate document and SVG. + + The existing committer owns atom merging, bond order and ring construction. + document_info renders the accepted candidate once, instead of creating + QGraphicsItems during each mutation. + """ + + def __init__(self, state: dict[str, Any]) -> None: + self.document_state = state + self.model = deserialize_model_state(state["model"]) + self.renderer = RenderMetrics() + self.renderer.set_bond_length(state["settings"]["bond_length_px"]) + # document_info materializes the complete SVG after the build commits. + self.bond_renderer = SimpleNamespace(add_bond_graphics=lambda _bond_id: None) + self.runtime_state = SimpleNamespace( + mark_registry=CanvasMarkRegistry(), + atom_graphics_state=SimpleNamespace(atom_items={}, atom_dots={}), + bond_graphics_state=SimpleNamespace(bond_items={}), + atom_coords_3d_state=SimpleNamespace(atom_coords_3d={}), + hover_preview_state=SimpleNamespace(atom_id=None), + ring_items=lambda: (), + callback_state=SimpleNamespace(error=self.reject_edit), + ) + self.services = SimpleNamespace( + canvas_atom_mutation_service=self.model, + canvas_bond_mutation_service=self.model, + scene_item_controller=SimpleNamespace(attach_scene_item=self.attach_ring), + ) + # The browser materializes labels once, after candidate validation. + self.render_context = SimpleNamespace( + atom_labels=SimpleNamespace( + atom_item_for_id=lambda _id: None, + draw_atom=lambda _id, **kwargs: None, + relayout_atom_label=lambda _id: False, + ) + ) + graph = SimpleNamespace(rebuild_bond_adjacency=lambda: None) + self.labels = AtomLabelService( + cast("Any", self), + graph_service=graph, + merge_service=AtomLabelMergeService( + self, graph_service=graph, capture_history=False + ), + connection_allowed=lambda _ids: True, + ) + self.committer = StructureBuildCommitter(cast("Any", self)) + self.bond_builder = StructureBondBuildService( + self, + self.committer, + hit_testing_service=self, + graph_service=self.committer, + move_controller=SimpleNamespace( + redraw_bond=lambda _id: None, + redraw_connected_bonds=lambda _id, **kwargs: None, + ), + # Grouped documents are read-only until group adapters are connected. + connection_allowed=lambda _anchors: True, + ) + self.builder = StructureBenzeneBuildService( + self, + self.committer, + point_factory=BrowserPoint, + center_inside_existing_ring=self.center_inside_ring, + ) + + @staticmethod + def reject_edit(message: str) -> None: + raise ValueError(message) + + def find_atom_near(self, x: float, y: float, max_dist: float) -> int | None: + return nearest_atom_id( + self.model.atoms, self.model.atoms, x=x, y=y, max_dist=max_dist + ) + + def atom_target(self, edit: dict[str, Any]) -> TextToolTarget: + x, y = float(edit["x"]), float(edit["y"]) + if not math.isfinite(x) or not math.isfinite(y): + raise ValueError("Atom coordinates must be finite.") + atom_id, bond_id = edit.get("atom_id"), edit.get("bond_id") + for target, record in ( + (atom_id, self.model.atom_for_id), + (bond_id, self.model.bond_for_id), + ): + if target is not None and ( + type(target) is not int or record(target) is None + ): + raise ValueError("Unknown atom tool target.") + radius = self.renderer.style.bond_length_px + return resolve_text_tool_target( + self.model, + pos=(x, y), + item_atom_id=atom_id, + hover_bond_id=bond_id, + nearby_atom_id=self.find_atom_near(x, y, radius * 0.9), + nearby_bond_id=nearest_bond_id( + self.model, + range(len(self.model.bonds)), + BrowserPoint(x, y), + radius * 0.6, + point_factory=BrowserPoint, + ) + if atom_id is None + else None, + ) + + def apply_atom_input(self, edit: dict[str, Any]) -> None: + text = edit["text"] + if not isinstance(text, str) or len(text) > int(ATOM_INPUT_SPEC["max_length"]): + raise ValueError("Atom labels must contain at most 255 characters.") + target = self.atom_target(edit) + atom = self.model.atom_for_id(target.atom_id) + apply_text_input( + target, + normalize_text_symbol(text), + atom.element if atom is not None else "", + add_atom=lambda text, x, y: self.labels.add_labelled_atom( + text, x, y, record=False + ), + update_label=lambda atom_id, text, **kwargs: ( + self.labels.add_or_update_atom_label( + atom_id, text, record=False, **kwargs + ) + ), + notify_error=self.reject_edit, + ) + self.publish_model() + + def insert_bond(self, start: list[float], end: list[float], style: str) -> None: + if len(start) != 2 or len(end) != 2: + raise ValueError("A point needs two coordinates.") + start_point = BrowserPoint(*(float(value) for value in start)) + end_point = BrowserPoint(*(float(value) for value in end)) + radius = self.renderer.style.bond_length_px * 0.35 + start_id = self.find_atom_near(start_point.x(), start_point.y(), radius) + short_click = is_short_bond_gesture( + (start_point.x(), start_point.y()), + (end_point.x(), end_point.y()), + self.renderer.style.bond_length_px, + ) + if start_id is not None: + atom = self.model.atoms[start_id] + start_point = BrowserPoint(atom.x, atom.y) + end_id = self.find_atom_near(end_point.x(), end_point.y(), radius) + endpoint = resolve_bond_endpoint_target( + self.model, + start=(start_point.x(), start_point.y()), + end=(end_point.x(), end_point.y()), + atom_id=end_id, + start_atom_id=start_id, + snap_angle_step=30, + bond_length=self.renderer.style.bond_length_px, + ) + if short_click: + end_point = cast( + "BrowserPoint", + default_bond_endpoint_for( + self, cast("Any", start_point), start_id, point_factory=BrowserPoint + ), + ) + else: + end_point = BrowserPoint(*endpoint) + self.bond_builder.add_bond_between_points( + cast("Any", start_point), + cast("Any", end_point), + style, + bond_style_from_label(style.capitalize())[1], + record=False, + ) + self.publish_model() + + def apply_bond_style(self, bond_id: int, style: str) -> None: + bond = self.model.bond_for_id(bond_id) + if type(bond_id) is not int or bond is None: + raise ValueError("Unknown bond.") + + def apply_style(style: str, order: int) -> None: + bond.style, bond.order = style, order + + apply_active_bond_style( + bond, + style, + bond_style_from_label(style.capitalize())[1], + apply_style=apply_style, + cycle_style=lambda: apply_style( + *cycle_plain_bond_style( + bond.style, bond.order, allow_double_variants=False + ) + ), + notify_error=self.reject_edit, + ) + self.publish_model() + + def center_inside_ring(self, center: BrowserPoint) -> bool: + return any( + polygon_contains_point((center.x(), center.y()), ring["points"]) + for ring in self.document_state.get("ring_fills", []) + ) + + def attach_ring(self, ring: RingFill) -> None: + record = ring_fill_to_state(ring, self.model.atoms) + record.pop("kind") + self.document_state.setdefault("ring_fills", []).append(record) + + def ring_points( + self, center: Any, atom_id: int | None = None, bond_id: int | None = None + ) -> Any: + return self.builder.benzene_ring_points( + center, + atom_id, + bond_id, + regular_ring_points_for_atom=lambda n, atom_id: ( + regular_ring_points_for_atom_for( + self, + n, + atom_id, + point_factory=BrowserPoint, + ) + ), + regular_ring_points_for_bond=lambda n, bond_id, hint: ( + regular_ring_points_for_bond_for( + self, + n, + bond_id, + hint, + point_factory=BrowserPoint, + ) + ), + ) + + def insert_benzene( + self, x: float, y: float, atom_id: int | None = None, bond_id: int | None = None + ) -> None: + for target, records in ( + (atom_id, self.model.atoms), + (bond_id, dict(enumerate(self.model.bonds))), + ): + if target is not None and ( + type(target) is not int + or target not in records + or records[target] is None + ): + raise ValueError("Unknown ring attachment.") + self.builder.build_benzene_ring( + cast("Any", BrowserPoint(x, y)), + atom_id, + bond_id, + benzene_ring_points=self.ring_points, + add_atom_with_merge=self.committer.add_atom_with_merge, + bond_exists=lambda a, b: self.committer.bond_id_between(a, b) is not None, + create_ring_fill_item=lambda points, ids: RingFill( + tuple(ids), + self.renderer.style.ring_fill_color, + self.renderer.style.ring_fill_alpha, + ), + ) + self.publish_model() + + def publish_model(self) -> None: + state, warnings = serialize_model_state_with_warnings( + self.model, + {key for key, atom in self.model.atoms.items() if atom.explicit_label}, + ) + if warnings: + raise ValueError( + "The edit produced invalid model data: " + "; ".join(warnings) + ) + self.document_state["model"] = state + + def graph_selection(self, items: object) -> DeleteSelectionBuckets: + if not isinstance(items, list) or len(items) > 5000: + raise ValueError("Expected a bounded list of selected atoms and bonds.") + buckets = DeleteSelectionBuckets() + for item in items: + if not isinstance(item, dict) or set(item) != {"target", "id"}: + raise ValueError("Expected target and id for each selected item.") + kind, item_id = item["target"], item["id"] + if kind not in {"atom", "bond"} or type(item_id) is not int or item_id < 0: + raise ValueError("Invalid selection target.") + if kind == "atom": + if self.model.atom_for_id(item_id) is None: + raise ValueError("The atom no longer exists.") + buckets.atom_ids.add(item_id) + else: + if self.model.bond_for_id(item_id) is None: + raise ValueError("The bond no longer exists.") + buckets.bond_ids.add(item_id) + return buckets + + def move_selection(self, items: object, dx: float, dy: float) -> None: + if not math.isfinite(dx) or not math.isfinite(dy): + raise ValueError("Movement must be finite.") + buckets = self.graph_selection(items) + atoms = selected_atom_ids_with_bond_endpoints( + buckets.atom_ids, buckets.bond_ids, bonds=self.model.bonds + ) + controller = CanvasMoveController( + cast("Any", self), + hit_testing_service=cast( + "Any", SimpleNamespace(mark_spatial_index_dirty=lambda: None) + ), + ring_polygon_rebuilder=partial( + rebuild_ring_fill_polygons, + point_factory=BrowserPoint, + polygon_factory=tuple, + ), + ) + controller.move_atoms( + atoms, + dx, + dy, + bond_ids=set(), + redraw_bond_ids=set(), + update_selection=False, + affected_ring_items=tuple( + BrowserRingItem(ring) + for ring in self.document_state.get("ring_fills", []) + ), + ) + self.publish_model() + + def delete_selection(self, items: object) -> None: + buckets = self.graph_selection(items) + plan = build_delete_selection_plan( + buckets, + bonds=self.model.bonds, + marks_by_atom={}, + atom_has_visible_label=lambda atom_id: atom_shows_itself( + self.model.atoms[atom_id] + ), + ) + for bond_id in plan.bond_ids_to_remove: + self.model.clear_bond(bond_id) + for atom_id in plan.atom_ids: + self.model.pop_atom(atom_id) + rings = self.document_state.get("ring_fills", []) + broken = broken_ring_fill_indices( + [ring["atom_ids"] for ring in rings], + atom_ids=set(self.model.atoms), + bond_pairs=model_bond_pairs(self.model), + ) + self.document_state["ring_fills"] = [ + ring for index, ring in enumerate(rings) if index not in broken + ] + self.publish_model() + + +def edit_document(request: object) -> dict[str, Any]: + """Only connected Chemvas operations can publish a validated candidate.""" + if not isinstance(request, dict) or set(request) != {"document", "edit"}: + raise ValueError("Expected document and edit.") + info = document_info(request["document"]) + if info["unsupported"]: + raise ValueError( + "This document is read-only in the browser. Use Qt to edit it." + ) + payload = info["document"] + edit = request["edit"] + if not isinstance(edit, dict): + raise ValueError("edit must be an object.") + kind = edit.get("kind") + candidate = deepcopy(extract_document_state(payload)) + adapter = BrowserStructureAdapter(candidate) + if kind in {"bond", "bond_style"}: + if edit.get("style") not in SUPPORTED_BONDS: + raise ValueError("Unsupported bond style.") + if kind == "bond" and set(edit) == {"kind", "start", "end", "style"}: + adapter.insert_bond(edit["start"], edit["end"], edit["style"]) + elif kind == "bond_style" and set(edit) == {"kind", "id", "style"}: + adapter.apply_bond_style(edit["id"], edit["style"]) + elif kind == "ring" and {"kind", "x", "y"} <= set(edit) <= { + "kind", + "x", + "y", + "atom_id", + "bond_id", + }: + adapter.insert_benzene( + float(edit["x"]), float(edit["y"]), edit.get("atom_id"), edit.get("bond_id") + ) + elif kind == "move" and set(edit) == {"kind", "selection", "dx", "dy"}: + adapter.move_selection(edit["selection"], float(edit["dx"]), float(edit["dy"])) + elif kind == "delete_selection" and set(edit) == {"kind", "selection"}: + adapter.delete_selection(edit["selection"]) + elif kind == "atom" and {"kind", "x", "y", "text"} <= set(edit) <= { + "kind", + "x", + "y", + "text", + "atom_id", + "bond_id", + }: + adapter.apply_atom_input(edit) + elif kind == "bond_length" and set(edit) == {"kind", "value"}: + candidate["settings"]["bond_length_px"] = edit["value"] + else: + raise ValueError("Unsupported edit or unexpected fields.") + return document_info( + build_normalized_document_payload(candidate, payload["version"]) + ) + + +def atom_input_plan(request: object) -> dict[str, Any]: + if ( + not isinstance(request, dict) + or set(request) != {"document", "edit", "symbol"} + or not isinstance(request["edit"], dict) + ): + raise ValueError("Expected document, atom input target and symbol.") + info = document_info(request["document"]) + if info["unsupported"]: + raise ValueError("This document is read-only in the browser.") + adapter = BrowserStructureAdapter(extract_document_state(info["document"])) + target = adapter.atom_target(request["edit"]) + atom = adapter.model.atom_for_id(target.atom_id) + symbol = request["symbol"] + if not isinstance(symbol, str) or len(symbol) > int(ATOM_INPUT_SPEC["max_length"]): + raise ValueError("Atom labels must contain at most 255 characters.") + return asdict(plan_text_input(symbol, atom.element if atom is not None else "")) + + +class DocumentChange(HistoryCommand): + """Adapt a validated document value to Chemvas's existing history service.""" + + def __init__(self, before: dict[str, Any], after: dict[str, Any]) -> None: + self.before, self.after = before, after + + @override + def undo(self, operations: Any) -> None: + operations.info = self.before + + @override + def redo(self, operations: Any) -> None: + operations.info = self.after + + +class BrowserSession: + """One document owner; the browser only mirrors accepted state.""" + + def __init__(self) -> None: + self.info = document_info(new_document()) + self.saved = json.dumps(self.info["document"], sort_keys=True) + self.revision = 0 + self.state = CanvasHistoryState() + operations: Any = self + self.history = CanvasHistoryService( + operations, self.state, replay_context=nullcontext + ) + + def dispatch(self, request: dict[str, Any]) -> dict[str, Any]: + if request.get("revision") != self.revision: + raise ValueError("This window has stale state. Reopen it before editing.") + action = request.get("action") + if action == "load": + candidate = document_info(request["document"]) + self.history.clear() + self.info = candidate + self.saved = json.dumps(candidate["document"], sort_keys=True) + elif action == "edit": + candidate = edit_document( + {"document": self.info["document"], "edit": request["edit"]} + ) + if candidate != self.info: + # Push first: a failed record cannot publish the candidate. + self.history.push(DocumentChange(self.info, candidate)) + self.info = candidate + elif action in {"undo", "redo"}: + getattr(self.history, action)() + elif action != "read": + raise ValueError("Unknown browser action.") + if action != "read": + self.revision += 1 + return { + **self.info, + "revision": self.revision, + "can_undo": bool(self.state.history), + "can_redo": bool(self.state.redo_stack), + "dirty": json.dumps(self.info["document"], sort_keys=True) != self.saved, + } + + +class BrowserServer(ThreadingHTTPServer): + """One loopback server with isolated in-memory document sessions and no file writes.""" + + def __init__(self, port: int = 0) -> None: + self.token = secrets.token_urlsafe(32) + self.sessions: dict[str, BrowserSession] = {} + self.session_lock = RLock() + super().__init__(("127.0.0.1", port), BrowserHandler) + self.origin = f"http://127.0.0.1:{self.server_port}" + + +class BrowserHandler(BaseHTTPRequestHandler): + server: BrowserServer + + @override + def setup(self) -> None: + super().setup() + self.connection.settimeout(10) + + @override + def log_message(self, format: str, *args: Any) -> None: + """Request bodies and launch credentials are never logged.""" + + def _reply(self, status: int, body: bytes, content_type: str) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header( + "Content-Security-Policy", + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", + ) + self.end_headers() + self.wfile.write(body) + + def _json(self, status: int, value: object) -> None: + self._reply( + status, + json.dumps(value, ensure_ascii=False, allow_nan=False).encode(), + "application/json; charset=utf-8", + ) + + def _allowed(self, *, api: bool) -> bool: + host = self.server.origin.removeprefix("http://") + if ( + self.headers.get("Host") != host + or self.headers.get("Origin", self.server.origin) != self.server.origin + ): + self._json( + 403, + {"error": "This browser adapter accepts its own local window only."}, + ) + return False + if api and not secrets.compare_digest( + self.headers.get("Authorization", "").encode(), + ("Bearer " + self.server.token).encode(), + ): + self._json( + 401, {"error": "Launch Chemvas again to open an authorized window."} + ) + return False + return True + + def do_GET(self) -> None: + if not self._allowed(api=self.path.startswith("/api/")): + return + if self.path == "/api/new": + self._json(200, document_info(new_document())) + elif self.path == "/api/ui": + self._json(200, ui_spec()) + elif self.path == "/ui.css": + self._reply(200, ui_css().encode(), "text/css; charset=utf-8") + elif self.path in STATIC_FILES: + filename, mime = STATIC_FILES[self.path] + self._reply(200, (ASSETS / filename).read_bytes(), mime) + else: + self._json(404, {"error": "Not found."}) + + def do_POST(self) -> None: + if not self._allowed(api=True): + return + if self.path not in { + "/api/open", + "/api/session", + "/api/preview", + "/api/atom-input", + }: + self._json(404, {"error": "Not found."}) + return + try: + length = int(self.headers.get("Content-Length", "0")) + if not 0 < length <= MAX_REQUEST_BYTES: + self._json( + 413, + {"error": "The browser adapter accepts JSON files up to 2 MiB."}, + ) + return + if self.headers.get( + "Content-Type" + ) != "application/json" or self.headers.get("Transfer-Encoding"): + self._json( + 415, {"error": "Expected a JSON request with Content-Length."} + ) + return + body = self.rfile.read(length) + if len(body) != length: + raise ValueError("Incomplete request.") + request = strict_json_loads(body) + if self.path == "/api/atom-input": + self._json(200, atom_input_plan(request)) + return + if self.path == "/api/preview": + result = edit_document(request) + self._json(200, result) + return + if self.path == "/api/session": + if not isinstance(request, dict): + raise ValueError("Expected a session request.") + with self.server.session_lock: + session_id = request.get("session") + if not session_id: + if len(self.server.sessions) >= 16: + raise ValueError( + "Close a browser window before opening another." + ) + session_id = secrets.token_urlsafe(24) + self.server.sessions[session_id] = BrowserSession() + session = self.server.sessions.get(session_id) + if session is None: + raise ValueError("This browser adapter session has ended.") + if request.get("action") == "close": + del self.server.sessions[session_id] + self._json(200, {}) + return + result = {**session.dispatch(request), "session": session_id} + self._json(200, result) + return + result = document_info(request) + except (ValueError, TypeError, KeyError, OverflowError, RecursionError) as exc: + self._json(400, {"error": str(exc) or "Invalid document."}) + else: + self._json(200, result) + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser( + description="Open the Chemvas browser adapter. Stop with Ctrl+C." + ) + parser.add_argument( + "--port", type=int, default=0, help="Local port (default: choose a free port)" + ) + parser.add_argument( + "--no-browser", + action="store_true", + help="Print the launch URL without opening a browser", + ) + args = parser.parse_args(argv) + with BrowserServer(args.port) as server: + url = f"{server.origin}/#token={server.token}" + print(f"Chemvas browser adapter: {url}", flush=True) + if not args.no_browser: + webbrowser.open(url) + with suppress(KeyboardInterrupt): + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/app/chemvas/features/graph/__init__.py b/app/chemvas/features/graph/__init__.py index daf89212..a58ef4d0 100644 --- a/app/chemvas/features/graph/__init__.py +++ b/app/chemvas/features/graph/__init__.py @@ -28,9 +28,11 @@ from __future__ import annotations +import math from dataclasses import dataclass, field from typing import TYPE_CHECKING +from chemvas.domain.document import Bond from chemvas.features.graph.algorithms import ( adjacency_for_bonds, axis_from_rotation_hint_policy, @@ -43,8 +45,9 @@ ) if TYPE_CHECKING: - from collections.abc import Callable, Iterable, MutableMapping, Sequence + from collections.abc import Callable, Collection, Iterable, MutableMapping, Sequence + from chemvas.domain.document import MoleculeModel from chemvas.features.graph.algorithms import BondLike @@ -70,6 +73,47 @@ def reset(self) -> None: self.bond_cycle_cache = {} +def connected_atom_unit_vectors( + model: MoleculeModel, atom_id: int +) -> list[tuple[float, float]]: + atom = model.atoms.get(atom_id) + if atom is None: + return [] + vectors = [] + for bond in model.bonds: + if bond is None or (bond.a != atom_id and bond.b != atom_id): + continue + other = model.atoms.get(bond.b if bond.a == atom_id else bond.a) + if other is None: + continue + dx, dy = other.x - atom.x, other.y - atom.y + length = math.hypot(dx, dy) + if length > 1e-9: + vectors.append((dx / length, dy / length)) + return vectors + + +def build_ring_edge_index( + atom_ids: Collection[int], + bonds: Iterable[BondLike | None], + *, + preferred_rings: Iterable[Sequence[int]] = (), +) -> dict[tuple[int, int], list[int]]: + """The scene's canonical topology order, after document-owned ring records.""" + topology = { + (min(edge.a, edge.b), max(edge.a, edge.b)) + for edge in bonds + if edge is not None and edge.a in atom_ids and edge.b in atom_ids + } + rings = find_rings(Bond(a, b) for a, b in sorted(topology)) + by_edge: dict[tuple[int, int], list[int]] = {} + for ring in [*preferred_rings, *rings]: + for index, a in enumerate(ring): + b = ring[(index + 1) % len(ring)] + by_edge.setdefault((min(a, b), max(a, b)), list(ring)) + return by_edge + + def ensure_neighbor_entry( atom_neighbors: MutableMapping[int, set[int]], atom_id: int, @@ -304,7 +348,9 @@ def cached_bond_in_cycle( "bond_matches_atoms", "bond_sets_for_atom_ids", "build_bond_adjacency_index", + "build_ring_edge_index", "cached_bond_in_cycle", + "connected_atom_unit_vectors", "connected_components_for_nodes", "edge_has_reachable_alternative_path", "ensure_bond_index_entry", diff --git a/app/chemvas/features/rendering/__init__.py b/app/chemvas/features/rendering/__init__.py index 170af0cb..874e52b0 100644 --- a/app/chemvas/features/rendering/__init__.py +++ b/app/chemvas/features/rendering/__init__.py @@ -1,11 +1,12 @@ """Bond rendering policy and Qt geometry primitives.""" -from .acs1996_style import ACS1996Style +from .acs1996_style import ACS1996Style, RenderMetrics from .bond_dotted import dotted_bond_dot_centers from .bond_geometry import ( LineSegment, bold_double_strip_geometry, line_intersection, + line_normal, normal_away_from_parallel_segment, normalize_3d, offset_segment, @@ -49,6 +50,7 @@ from .line_geometry import ( arc_midpoint, arc_points, + arrow_head_polylines, hex_grid_cells, nearest_endpoint, snapped_endpoint, @@ -73,9 +75,11 @@ "STANDARD_BOND_STYLES", "ACS1996Style", "LineSegment", + "RenderMetrics", "ValenceWarningCache", "arc_midpoint", "arc_points", + "arrow_head_polylines", "base_plain_double_style_for_dotted_variant", "bold_double_strip_geometry", "bold_double_style_for_position", @@ -91,6 +95,7 @@ "is_plain_double_bond_style", "is_positionable_double_bond_style", "line_intersection", + "line_normal", "nearest_endpoint", "normal_away_from_parallel_segment", "normalize_3d", diff --git a/app/chemvas/features/rendering/acs1996_style.py b/app/chemvas/features/rendering/acs1996_style.py index 033871ce..8f50b3ec 100644 --- a/app/chemvas/features/rendering/acs1996_style.py +++ b/app/chemvas/features/rendering/acs1996_style.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace @dataclass(frozen=True, kw_only=True) @@ -28,3 +28,38 @@ class ACS1996Style: orbital_positive_color: str = "#2f6ed3" orbital_negative_color: str = "#d84a3a" orbital_alpha: float = 0.25 + + +class RenderMetrics: + """Existing desktop metric calculations, shared by rendering adapters.""" + + BASE_BOND_LENGTH_PX = 20.0 + + def __init__(self, style: ACS1996Style | None = None) -> None: + self.style = style or ACS1996Style() + + def set_bond_length(self, length_px: float) -> None: + self.style = replace(self.style, bond_length_px=length_px) + + def metric_scale(self) -> float: + if self.style.bond_length_px <= 0: + return 1.0 + return self.style.bond_length_px / self.BASE_BOND_LENGTH_PX + + def scaled_style_metric(self, value: float) -> float: + return value * self.metric_scale() + + def bond_line_width(self) -> float: + return self.scaled_style_metric(self.style.bond_line_width) + + def bold_bond_width(self) -> float: + return self.scaled_style_metric(self.style.bold_bond_width) + + def bond_spacing(self) -> float: + return self.scaled_style_metric(self.style.bond_spacing_px) + + def hash_spacing(self) -> float: + return self.scaled_style_metric(self.style.hash_spacing_px) + + def atom_font_size_pt(self) -> int: + return max(1, round(self.scaled_style_metric(float(self.style.font_size_pt)))) diff --git a/app/chemvas/features/rendering/bond_geometry.py b/app/chemvas/features/rendering/bond_geometry.py index 4222ca3a..d5503f0c 100644 --- a/app/chemvas/features/rendering/bond_geometry.py +++ b/app/chemvas/features/rendering/bond_geometry.py @@ -1,6 +1,7 @@ from __future__ import annotations import math +from typing import Any from .bond_style import DOUBLE_STYLE_OUTER @@ -145,3 +146,20 @@ def strip_corners( "strip_corners", "trim_segment", ] + + +def line_normal( + x1: float, y1: float, x2: float, y2: float, target: Any = None +) -> tuple[float, float]: + dx, dy = x2 - x1, y2 - y1 + length = math.hypot(dx, dy) + if length < 1e-9: + return 0.0, 0.0 + nx, ny = -dy / length, dx / length + if ( + target is not None + and nx * (target.x() - (x1 + x2) * 0.5) + ny * (target.y() - (y1 + y2) * 0.5) + < 0 + ): + return -nx, -ny + return nx, ny diff --git a/app/chemvas/features/rendering/line_geometry.py b/app/chemvas/features/rendering/line_geometry.py index 51fb9228..382a169d 100644 --- a/app/chemvas/features/rendering/line_geometry.py +++ b/app/chemvas/features/rendering/line_geometry.py @@ -220,3 +220,40 @@ def arc_midpoint( "snapped_to_hex_grid", "wavy_line_points", ] + + +def arrow_head_polylines( + start: Point2D, + end: Point2D, + *, + head_len: float, + line_width: float, + double: bool, + half: bool = False, + mirrored: bool = False, +) -> list[list[Point2D]]: + """ArrowRenderer's existing head construction, with tuple drawing outputs.""" + angle = math.atan2(end[1] - start[1], end[0] - start[0]) + head_angle = math.radians(-25 if mirrored else 25) + offsets = [0.0] + if double: + offset_mag = max(1.4, line_width * 1.2) + offsets = [-offset_mag, offset_mag] + polylines = [] + for offset in offsets: + dx = math.cos(angle + math.pi / 2) * offset + dy = math.sin(angle + math.pi / 2) * offset + tip = (end[0] + dx, end[1] + dy) if double else end + right = ( + tip[0] - head_len * math.cos(angle + head_angle), + tip[1] - head_len * math.sin(angle + head_angle), + ) + if half: + polylines.append([right, tip]) + continue + left = ( + tip[0] - head_len * math.cos(angle - head_angle), + tip[1] - head_len * math.sin(angle - head_angle), + ) + polylines.append([left, tip, right]) + return polylines diff --git a/app/chemvas/features/selection/__init__.py b/app/chemvas/features/selection/__init__.py index aa5d2fbd..599863d1 100644 --- a/app/chemvas/features/selection/__init__.py +++ b/app/chemvas/features/selection/__init__.py @@ -10,6 +10,9 @@ StructureHit, build_selection_snapshot, choose_preferred_structure_hit, + distance_point_to_segment, + nearest_atom_id, + nearest_bond_id, nearest_ring_atom_id, padded_rect_contains_point, selected_atom_ids_with_bond_endpoints, @@ -49,9 +52,12 @@ "build_selection_snapshot", "center_for_coords_3d", "choose_preferred_structure_hit", + "distance_point_to_segment", "dominant_axis_angle_from_drag", "flatten_coords_to_plane", "fragment_plane_normal_for", + "nearest_atom_id", + "nearest_bond_id", "nearest_ring_atom_id", "normalize_3d", "padded_rect_contains_point", diff --git a/app/chemvas/features/selection/hit.py b/app/chemvas/features/selection/hit.py index 668111a6..109ad349 100644 --- a/app/chemvas/features/selection/hit.py +++ b/app/chemvas/features/selection/hit.py @@ -1,12 +1,13 @@ from __future__ import annotations +import math from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Any, Literal if TYPE_CHECKING: - from collections.abc import Iterable, Sequence + from collections.abc import Iterable, Mapping, Sequence - from chemvas.domain.document import Bond + from chemvas.domain.document import Atom, Bond StructureKind = Literal["atom", "bond", "ring", "other"] Point2D = tuple[float, float] @@ -132,6 +133,32 @@ def choose_preferred_structure_hit( return None +def nearest_atom_id( + atoms: Mapping[int, Atom], + candidates: Iterable[int], + *, + x: float, + y: float, + max_dist: float, +) -> int | None: + """The canvas pick rule; distance ties belong to the lowest atom ID.""" + nearest_id = None + nearest_dist_sq = max_dist * max_dist + for atom_id in candidates: + atom = atoms.get(atom_id) + if atom is None: + continue + dx = atom.x - x + dy = atom.y - y + dist_sq = dx * dx + dy * dy + if dist_sq < nearest_dist_sq or ( + dist_sq == nearest_dist_sq and (nearest_id is None or atom_id < nearest_id) + ): + nearest_id = atom_id + nearest_dist_sq = dist_sq + return nearest_id + + def nearest_ring_atom_id( atom_distances: Sequence[tuple[int, float]], *, @@ -219,3 +246,48 @@ def selection_hit_matches(request: SelectionHitRequest) -> bool: "selection_hit_matches", "structure_hit_is_selected", ] + + +def distance_point_to_segment(p: Any, a: Any, b: Any) -> float: + abx = b.x() - a.x() + aby = b.y() - a.y() + apx = p.x() - a.x() + apy = p.y() - a.y() + ab_len_sq = abx * abx + aby * aby + if ab_len_sq == 0: + return math.hypot(apx, apy) + t = max(0.0, min(1.0, (apx * abx + apy * aby) / ab_len_sq)) + cx = a.x() + abx * t + cy = a.y() + aby * t + return math.hypot(p.x() - cx, p.y() - cy) + + +def nearest_bond_id( + model: Any, + candidates: Iterable[int], + pos: Any, + max_dist: float, + *, + point_factory: Any, +) -> int | None: + nearest = None + nearest_dist = max_dist + seen: set[int] = set() + for bond_id in candidates: + if bond_id in seen: + continue + seen.add(bond_id) + bond = model.bond_for_id(bond_id) + if bond is None: + continue + a = model.atom_for_id(bond.a) + b = model.atom_for_id(bond.b) + if a is None or b is None: + continue + dist = distance_point_to_segment( + pos, point_factory(a.x, a.y), point_factory(b.x, b.y) + ) + if dist <= nearest_dist: + nearest = bond_id + nearest_dist = dist + return nearest diff --git a/app/chemvas/shell/icon_design.py b/app/chemvas/shell/icon_design.py index 04971017..25fb0bf2 100644 --- a/app/chemvas/shell/icon_design.py +++ b/app/chemvas/shell/icon_design.py @@ -2,14 +2,54 @@ from typing import TYPE_CHECKING -from PyQt6.QtCore import QByteArray, QRectF -from PyQt6.QtSvg import QSvgRenderer - from chemvas.shell.palette import PALETTE if TYPE_CHECKING: from PyQt6.QtGui import QPainter +DESIGN_ICON_NAMES: dict[str, str] = { + "icon_select": "move", + "icon_bond": "bond", + "icon_bond_bold": "bond_bold", + "icon_mark": "atom_orbit", + "icon_mark_plus": "plus", + "icon_mark_minus": "minus", + "icon_mark_circled_plus": "circled_plus", + "icon_mark_circled_minus": "circled_minus", + "icon_mark_radical": "radical", + "icon_text": "atom", + "icon_note": "note", + "icon_text_bold": "text_bold", + "icon_text_italic": "text_italic", + "icon_text_superscript": "text_superscript", + "icon_text_subscript": "text_subscript", + "icon_text_size_increase": "text_size_increase", + "icon_text_size_decrease": "text_size_decrease", + "icon_align_left": "align_left", + "icon_align_center": "align_center", + "icon_align_right": "align_right", + "icon_ring": "benzene", + "icon_ring_fill": "ring_fill", + "icon_eraser": "eraser", + "icon_bond_double": "bond_double", + "icon_bond_triple": "bond_triple", + "icon_bond_wedge": "wedge", + "icon_bond_hash": "hash", + "icon_bond_dotted": "bond_dotted", + "icon_arrow_width": "arrow_width", + "icon_arrow_head_scale": "arrow_head_scale", + "icon_flip_h": "flip_h", + "icon_flip_v": "flip_v", + "icon_rotate": "rotate", + "icon_arrow": "arrow", + "icon_ts_bracket": "bracket", + "icon_orbital": "orbital", + "icon_shape": "shape", + "icon_line": "line", + "icon_color": "color", + "icon_perspective": "perspective", +} + _ICON_COLOR = PALETTE["icon"] # One drawing language for every glyph: a 24-unit box, a 1.8-unit round-capped @@ -205,7 +245,7 @@ } -def _svg_document(name: str, color: str) -> str: +def design_icon_svg(name: str, color: str = "currentColor") -> str: fill = "currentColor" if name == "wedge" else "none" width = "1.6" if name == "hash" else "1.8" return ( @@ -223,8 +263,11 @@ def has_design_icon(name: str) -> bool: def draw_design_icon( painter: QPainter, name: str, *, color: str | None = None, size: float = 30.0 ) -> None: + from PyQt6.QtCore import QByteArray, QRectF + from PyQt6.QtSvg import QSvgRenderer + renderer = QSvgRenderer( - QByteArray(_svg_document(name, color or _ICON_COLOR).encode("utf-8")) + QByteArray(design_icon_svg(name, color or _ICON_COLOR).encode("utf-8")) ) # Render the 24-unit viewBox into the central 90% of the target size: the # glyphs already keep their own margin inside the box, so a 5% pad is @@ -233,4 +276,9 @@ def draw_design_icon( renderer.render(painter, QRectF(pad, pad, size * 0.9, size * 0.9)) -__all__ = ["draw_design_icon", "has_design_icon"] +__all__ = [ + "DESIGN_ICON_NAMES", + "design_icon_svg", + "draw_design_icon", + "has_design_icon", +] diff --git a/app/chemvas/shell/icon_factory.py b/app/chemvas/shell/icon_factory.py index 5b4a0848..889d9414 100644 --- a/app/chemvas/shell/icon_factory.py +++ b/app/chemvas/shell/icon_factory.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from chemvas.shell.icon_design import ( + DESIGN_ICON_NAMES, draw_design_icon, has_design_icon, ) @@ -49,64 +50,64 @@ def _design_icon(self, name: str, fallback: str) -> QIcon: return self.make_design_icon(name if has_design_icon(name) else fallback) def icon_select(self) -> QIcon: - return self.make_design_icon("move") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_select"]) def icon_bond(self) -> QIcon: - return self.make_design_icon("bond") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_bond"]) def icon_bond_bold(self) -> QIcon: - return self.make_design_icon("bond_bold") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_bond_bold"]) def icon_mark(self) -> QIcon: - return self.make_design_icon("atom_orbit") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_mark"]) def icon_mark_plus(self) -> QIcon: - return self.make_design_icon("plus") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_mark_plus"]) def icon_mark_minus(self) -> QIcon: - return self.make_design_icon("minus") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_mark_minus"]) def icon_mark_circled_plus(self) -> QIcon: - return self.make_design_icon("circled_plus") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_mark_circled_plus"]) def icon_mark_circled_minus(self) -> QIcon: - return self.make_design_icon("circled_minus") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_mark_circled_minus"]) def icon_mark_radical(self) -> QIcon: - return self.make_design_icon("radical") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_mark_radical"]) def icon_text(self) -> QIcon: - return self.make_design_icon("atom") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_text"]) def icon_note(self) -> QIcon: - return self.make_design_icon("note") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_note"]) def icon_text_bold(self) -> QIcon: - return self.make_design_icon("text_bold") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_text_bold"]) def icon_text_italic(self) -> QIcon: - return self.make_design_icon("text_italic") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_text_italic"]) def icon_text_superscript(self) -> QIcon: - return self.make_design_icon("text_superscript") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_text_superscript"]) def icon_text_subscript(self) -> QIcon: - return self.make_design_icon("text_subscript") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_text_subscript"]) def icon_text_size_increase(self) -> QIcon: - return self.make_design_icon("text_size_increase") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_text_size_increase"]) def icon_text_size_decrease(self) -> QIcon: - return self.make_design_icon("text_size_decrease") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_text_size_decrease"]) def icon_align_left(self) -> QIcon: - return self.make_design_icon("align_left") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_align_left"]) def icon_align_center(self) -> QIcon: - return self.make_design_icon("align_center") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_align_center"]) def icon_align_right(self) -> QIcon: - return self.make_design_icon("align_right") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_align_right"]) def icon_align_objects(self, mode: str) -> QIcon: return self._design_icon(f"align_objects_{mode}", "align_objects_left") @@ -115,28 +116,28 @@ def icon_distribute(self, axis: str) -> QIcon: return self._design_icon(f"distribute_{axis}", "distribute_horizontal") def icon_ring(self) -> QIcon: - return self.make_design_icon("benzene") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_ring"]) def icon_ring_fill(self) -> QIcon: - return self.make_design_icon("ring_fill") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_ring_fill"]) def icon_eraser(self) -> QIcon: - return self.make_design_icon("eraser") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_eraser"]) def icon_bond_double(self) -> QIcon: - return self.make_design_icon("bond_double") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_bond_double"]) def icon_bond_triple(self) -> QIcon: - return self.make_design_icon("bond_triple") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_bond_triple"]) def icon_bond_wedge(self) -> QIcon: - return self.make_design_icon("wedge") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_bond_wedge"]) def icon_bond_hash(self) -> QIcon: - return self.make_design_icon("hash") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_bond_hash"]) def icon_bond_dotted(self) -> QIcon: - return self.make_design_icon("bond_dotted") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_bond_dotted"]) def icon_arrow_preview(self, kind: str) -> QIcon: return self._design_icon(f"arrow_{kind}", "arrow_reaction") @@ -147,10 +148,10 @@ def icon_arrow_preset(self, label: str) -> QIcon: ) def icon_arrow_width(self) -> QIcon: - return self.make_design_icon("arrow_width") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_arrow_width"]) def icon_arrow_head_scale(self) -> QIcon: - return self.make_design_icon("arrow_head_scale") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_arrow_head_scale"]) def icon_orbital_preview(self, kind: str) -> QIcon: return self._design_icon(f"orbital_{kind}", "orbital_s") @@ -166,28 +167,28 @@ def icon_template_preview(self, label: str) -> QIcon: ) def icon_flip_h(self) -> QIcon: - return self.make_design_icon("flip_h") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_flip_h"]) def icon_flip_v(self) -> QIcon: - return self.make_design_icon("flip_v") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_flip_v"]) def icon_rotate(self) -> QIcon: - return self.make_design_icon("rotate") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_rotate"]) def icon_arrow(self) -> QIcon: - return self.make_design_icon("arrow") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_arrow"]) def icon_ts_bracket(self) -> QIcon: - return self.make_design_icon("bracket") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_ts_bracket"]) def icon_bracket_preview(self, kind: str) -> QIcon: return self._design_icon(f"bracket_{kind}", "bracket_square_pair") def icon_orbital(self) -> QIcon: - return self.make_design_icon("orbital") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_orbital"]) def icon_shape(self) -> QIcon: - return self.make_design_icon("shape") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_shape"]) def icon_shape_kind(self, kind: str) -> QIcon: return self._design_icon(f"shape_{kind}", "shape_circle") @@ -196,16 +197,16 @@ def icon_shape_stroke(self, style: str) -> QIcon: return self._design_icon(f"stroke_{style}", "stroke_solid") def icon_line(self) -> QIcon: - return self.make_design_icon("line") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_line"]) def icon_line_kind(self, kind: str) -> QIcon: return self._design_icon("line_plain" if kind == "line" else kind, "line_plain") def icon_color(self) -> QIcon: - return self.make_design_icon("color") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_color"]) def icon_perspective(self) -> QIcon: - return self.make_design_icon("perspective") + return self.make_design_icon(DESIGN_ICON_NAMES["icon_perspective"]) __all__ = ["MainWindowIconFactory"] diff --git a/app/chemvas/ui/annotations/arrows.py b/app/chemvas/ui/annotations/arrows.py index 51920806..b208bd1c 100644 --- a/app/chemvas/ui/annotations/arrows.py +++ b/app/chemvas/ui/annotations/arrows.py @@ -16,7 +16,12 @@ arrow_from_state, ) from chemvas.features.annotations import arrow_label_html, arrow_label_normal -from chemvas.features.rendering import arc_midpoint, arc_points, wavy_line_points +from chemvas.features.rendering import ( + arc_midpoint, + arc_points, + arrow_head_polylines, + wavy_line_points, +) from chemvas.ui.canvas.graphics_items import ( ArrowLabelItem, ArrowPathItem, @@ -427,37 +432,20 @@ def add_arrow_head( half: bool = False, mirrored: bool = False, ) -> None: - angle = math.atan2(end.y() - start.y(), end.x() - start.x()) - head_len = ( - self.context.renderer.style.bond_length_px * self.settings.arrow_head_scale + polylines = arrow_head_polylines( + (start.x(), start.y()), + (end.x(), end.y()), + head_len=self.context.renderer.style.bond_length_px + * self.settings.arrow_head_scale, + line_width=self.settings.arrow_line_width, + double=double, + half=half, + mirrored=mirrored, ) - head_angle = math.radians(-25 if mirrored else 25) - offsets = [0.0] - if double: - offset_mag = max(1.4, self.settings.arrow_line_width * 1.2) - offsets = [-offset_mag, offset_mag] - for offset in offsets: - dx = math.cos(angle + math.pi / 2) * offset - dy = math.sin(angle + math.pi / 2) * offset - tip = QPointF(end.x() + dx, end.y() + dy) if double else end - right = QPointF( - tip.x() - head_len * math.cos(angle + head_angle), - tip.y() - head_len * math.sin(angle + head_angle), - ) - # A half head keeps the barb on the side the line was offset toward, - # so an equilibrium pair carries both barbs on the outside and reads - # as the conventional harpoon arrow rather than two full heads. - if half: - path.moveTo(right) - path.lineTo(tip) - continue - left = QPointF( - tip.x() - head_len * math.cos(angle - head_angle), - tip.y() - head_len * math.sin(angle - head_angle), - ) - path.moveTo(left) - path.lineTo(tip) - path.lineTo(right) + for points in polylines: + path.moveTo(QPointF(*points[0])) + for point in points[1:]: + path.lineTo(QPointF(*point)) def arrow_pen(self, dotted: bool = False): pen = self.context.renderer.bond_pen() diff --git a/app/chemvas/ui/canvas/canvas_hit_testing_service.py b/app/chemvas/ui/canvas/canvas_hit_testing_service.py index ed12893d..f412e3e4 100644 --- a/app/chemvas/ui/canvas/canvas_hit_testing_service.py +++ b/app/chemvas/ui/canvas/canvas_hit_testing_service.py @@ -8,6 +8,11 @@ from PyQt6.QtWidgets import QGraphicsPathItem from chemvas.domain.document import VALID_ARROW_KINDS +from chemvas.features.selection import ( + distance_point_to_segment, + nearest_atom_id, + nearest_bond_id, +) from chemvas.ui.canvas.graphics_items import AtomDotItem from chemvas.ui.canvas.pick_radius_access import atom_pick_radius_for from chemvas.ui.canvas.spatial_index_state import ( @@ -269,30 +274,17 @@ def find_atom_near(self, x: float, y: float, max_dist: float) -> int | None: return None cell_radius = math.ceil(max_dist / cell_size) ix, iy = self.cell_coords(x, y, cell_size) - nearest_id = None - nearest_dist_sq = max_dist * max_dist - for cx in range(ix - cell_radius, ix + cell_radius + 1): - for cy in range(iy - cell_radius, iy + cell_radius + 1): - for ( - atom_id - ) in self.canvas.runtime_state.spatial_index_state.atom_grid.get( - (cx, cy), () - ): - atom = self.canvas.model.atom_for_id(atom_id) - if atom is None: - continue - dx = atom.x - x - dy = atom.y - y - dist_sq = dx * dx + dy * dy - # Lowest atom id breaks exact-distance ties so the pick - # does not depend on set iteration order. - if dist_sq < nearest_dist_sq or ( - dist_sq == nearest_dist_sq - and (nearest_id is None or atom_id < nearest_id) - ): - nearest_id = atom_id - nearest_dist_sq = dist_sq - return nearest_id + candidates = ( + atom_id + for cx in range(ix - cell_radius, ix + cell_radius + 1) + for cy in range(iy - cell_radius, iy + cell_radius + 1) + for atom_id in self.canvas.runtime_state.spatial_index_state.atom_grid.get( + (cx, cy), () + ) + ) + return nearest_atom_id( + self.canvas.model.atoms, candidates, x=x, y=y, max_dist=max_dist + ) def find_bond_near(self, pos: QPointF, max_dist: float) -> int | None: if not self.canvas.model.bonds: @@ -303,49 +295,19 @@ def find_bond_near(self, pos: QPointF, max_dist: float) -> int | None: return None cell_radius = math.ceil(max_dist / cell_size) ix, iy = self.cell_coords(pos.x(), pos.y(), cell_size) - nearest = None - nearest_dist = max_dist - seen: set[int] = set() - for cx in range(ix - cell_radius, ix + cell_radius + 1): - for cy in range(iy - cell_radius, iy + cell_radius + 1): - for ( - bond_id - ) in self.canvas.runtime_state.spatial_index_state.bond_grid.get( - (cx, cy), () - ): - if bond_id in seen: - continue - seen.add(bond_id) - bond = self.canvas.model.bond_for_id(bond_id) - if bond is None: - continue - a = self.canvas.model.atom_for_id(bond.a) - b = self.canvas.model.atom_for_id(bond.b) - if a is None or b is None: - continue - dist = self.distance_point_to_segment( - pos, - QPointF(a.x, a.y), - QPointF(b.x, b.y), - ) - if dist <= nearest_dist: - nearest = bond_id - nearest_dist = dist - return nearest + candidates = ( + bond_id + for cx in range(ix - cell_radius, ix + cell_radius + 1) + for cy in range(iy - cell_radius, iy + cell_radius + 1) + for bond_id in self.canvas.runtime_state.spatial_index_state.bond_grid.get( + (cx, cy), () + ) + ) + return nearest_bond_id( + self.canvas.model, candidates, pos, max_dist, point_factory=QPointF + ) - @staticmethod - def distance_point_to_segment(p: QPointF, a: QPointF, b: QPointF) -> float: - abx = b.x() - a.x() - aby = b.y() - a.y() - apx = p.x() - a.x() - apy = p.y() - a.y() - ab_len_sq = abx * abx + aby * aby - if ab_len_sq == 0: - return math.hypot(apx, apy) - t = max(0.0, min(1.0, (apx * abx + apy * aby) / ab_len_sq)) - cx = a.x() + abx * t - cy = a.y() + aby * t - return math.hypot(p.x() - cx, p.y() - cy) + distance_point_to_segment = staticmethod(distance_point_to_segment) def nearest_atom_hit(self, pos: QPointF) -> tuple[int, float] | None: atom_id = self.find_atom_near( diff --git a/app/chemvas/ui/canvas/canvas_move_controller.py b/app/chemvas/ui/canvas/canvas_move_controller.py index 146c494c..8f7050b6 100644 --- a/app/chemvas/ui/canvas/canvas_move_controller.py +++ b/app/chemvas/ui/canvas/canvas_move_controller.py @@ -3,17 +3,8 @@ from dataclasses import replace from typing import TYPE_CHECKING, Any -from PyQt6.QtCore import QPointF - from chemvas.domain.document import VALID_ARROW_KINDS from chemvas.features.selection import translate_projected_point_3d -from chemvas.ui.annotations.records import ( - moved_ts_bracket, - require_shape_record_for, - require_ts_bracket_record_for, - set_shape_record_for, - set_ts_bracket_record_for, -) from chemvas.ui.canvas.canvas_mark_registry import mark_registry_for from chemvas.ui.canvas.canvas_ring_fill_scene_service import rebuild_ring_fill_polygons from chemvas.ui.molecule.atom_coords_access import set_atom_coords_3d_for_id @@ -34,8 +25,13 @@ class CanvasMoveController: def __init__( - self, canvas: CanvasView, *, hit_testing_service: CanvasHitTestingService + self, + canvas: CanvasView, + *, + hit_testing_service: CanvasHitTestingService, + ring_polygon_rebuilder=rebuild_ring_fill_polygons, ) -> None: + self.ring_polygon_rebuilder = ring_polygon_rebuilder self.canvas = canvas self.marks = mark_registry_for(canvas) self.hit_testing_service = hit_testing_service @@ -90,6 +86,11 @@ def move_item( data["dy"] = center.y() - atom.y item.setData(1, data) elif kind == "shape": + from chemvas.ui.annotations.records import ( + require_shape_record_for, + set_shape_record_for, + ) + # Move a shape by rebuilding its path in scene coordinates and keeping # item.pos() at the origin. Using moveBy here would leave a non-zero # pos that a later resize (which rebuilds the path in scene space) @@ -107,6 +108,12 @@ def move_item( ), ) elif kind == "ts_bracket": + from chemvas.ui.annotations.records import ( + moved_ts_bracket, + require_ts_bracket_record_for, + set_ts_bracket_record_for, + ) + # Like a shape: the path is rebuilt in scene coordinates and # item.pos() stays at the origin. set_ts_bracket_record_for( @@ -247,7 +254,7 @@ def move_rings_for_atoms( ) -> None: # A ring fill is a polygon over its atoms, so moving them refits it # rather than translating it; the deltas are the caller's, not ours. - rebuild_ring_fill_polygons( + self.ring_polygon_rebuilder( self.canvas.model, atom_ids, self.canvas.runtime_state.ring_items() @@ -321,6 +328,8 @@ def set_atom_positions( if dot is not None: dot.setPos(x, y) for mark in list(self.marks.get_for_atom(atom_id) or ()): + from PyQt6.QtCore import QPointF + data = mark.data(1) or {} dx, dy = data.get("dx"), data.get("dy") center = ( diff --git a/app/chemvas/ui/canvas/canvas_ring_fill_scene_service.py b/app/chemvas/ui/canvas/canvas_ring_fill_scene_service.py index 67158c3b..61371adb 100644 --- a/app/chemvas/ui/canvas/canvas_ring_fill_scene_service.py +++ b/app/chemvas/ui/canvas/canvas_ring_fill_scene_service.py @@ -2,15 +2,11 @@ from typing import TYPE_CHECKING, Any -from PyQt6.QtCore import QPointF -from PyQt6.QtGui import QPolygonF - -from chemvas.ui.annotations.materialize import create_ring_item_from_state -from chemvas.ui.scene.scene_selectability import make_item_selectable - if TYPE_CHECKING: from collections.abc import Iterable + from PyQt6.QtCore import QPointF + from chemvas.domain.document import MoleculeModel from chemvas.ui.scene.scene_render_context import SceneRenderContext @@ -19,6 +15,9 @@ def rebuild_ring_fill_polygons( model: MoleculeModel, atom_ids: set[int], ring_items: Iterable[Any], + *, + point_factory=None, + polygon_factory=None, ) -> None: """Re-fit every ring polygon that touches ``atom_ids`` to its atoms. @@ -31,6 +30,14 @@ def rebuild_ring_fill_polygons( points is not a shape. """ + if point_factory is None: + from PyQt6.QtCore import QPointF + + point_factory = QPointF + if polygon_factory is None: + from PyQt6.QtGui import QPolygonF + + polygon_factory = QPolygonF for ring_item in ring_items: ring_atom_ids = ring_item.data(2) if not isinstance(ring_atom_ids, list): @@ -42,9 +49,9 @@ def rebuild_ring_fill_polygons( atom = model.atom_for_id(atom_id) if atom is None: continue - points.append(QPointF(atom.x, atom.y)) + points.append(point_factory(atom.x, atom.y)) if len(points) >= 3: - ring_item.setPolygon(QPolygonF(points)) + ring_item.setPolygon(polygon_factory(points)) class CanvasRingFillSceneService: @@ -69,6 +76,9 @@ def update_ring_fills_for_atoms( ) def create_ring_fill_item(self, points: list[QPointF], atom_ids: list[int]): + from chemvas.ui.annotations.materialize import create_ring_item_from_state + from chemvas.ui.scene.scene_selectability import make_item_selectable + ring_item = create_ring_item_from_state( { "points": [(point.x(), point.y()) for point in points], diff --git a/app/chemvas/ui/insert/ring_occupancy.py b/app/chemvas/ui/insert/ring_occupancy.py index 2e2a1aba..ed2b26f6 100644 --- a/app/chemvas/ui/insert/ring_occupancy.py +++ b/app/chemvas/ui/insert/ring_occupancy.py @@ -2,13 +2,12 @@ from typing import TYPE_CHECKING, Any, cast -from PyQt6.QtCore import QPointF, Qt - from chemvas.features.graph import find_rings if TYPE_CHECKING: from collections.abc import Mapping, Sequence + from PyQt6.QtCore import QPointF from PyQt6.QtWidgets import QGraphicsPolygonItem from chemvas.domain.document import Atom, Bond @@ -80,6 +79,8 @@ def point_inside_any_ring( *, ring_items: Sequence[QGraphicsPolygonItem], ) -> bool: + from PyQt6.QtCore import Qt + for ring_item in ring_items: try: polygon = ring_item.polygon() diff --git a/app/chemvas/ui/molecule/atom_coords_access.py b/app/chemvas/ui/molecule/atom_coords_access.py index f8217375..5dd12624 100644 --- a/app/chemvas/ui/molecule/atom_coords_access.py +++ b/app/chemvas/ui/molecule/atom_coords_access.py @@ -4,9 +4,6 @@ from dataclasses import dataclass, field from typing import Any -from chemvas.ui.molecule.bond_graphics_access import project_point_3d_for -from chemvas.ui.scene.scene_geometry import current_atom_coords_in_scene - AtomCoords3D = tuple[float, float, float] @@ -41,6 +38,8 @@ def stored_atom_coords_3d_matches_projection_for( atom = canvas.model.atom_for_id(atom_id) if atom is None: return False + from chemvas.ui.molecule.bond_graphics_access import project_point_3d_for + proj_x, proj_y = project_point_3d_for(canvas, coords) tolerance = max(1.0, canvas.renderer.style.bond_length_px * 0.15) return math.hypot(proj_x - atom.x, proj_y - atom.y) <= tolerance @@ -49,6 +48,8 @@ def stored_atom_coords_3d_matches_projection_for( def current_atom_coords_3d_for( canvas, atom_id: int ) -> tuple[float, float, float] | None: + from chemvas.ui.scene.scene_geometry import current_atom_coords_in_scene + rotation = canvas.runtime_state.rotation_state return current_atom_coords_in_scene( atom_id, diff --git a/app/chemvas/ui/molecule/atom_label_merge_service.py b/app/chemvas/ui/molecule/atom_label_merge_service.py index 7742c53d..43a52309 100644 --- a/app/chemvas/ui/molecule/atom_label_merge_service.py +++ b/app/chemvas/ui/molecule/atom_label_merge_service.py @@ -3,24 +3,20 @@ from typing import TYPE_CHECKING, cast from chemvas.features.rendering import STANDARD_BOND_STYLES -from chemvas.ui.annotations.state import atom_state_dict_for, bond_state_dict from chemvas.ui.canvas.canvas_atom_graphics_state import ( pop_atom_dot_for, pop_atom_item_for, ) from chemvas.ui.canvas.canvas_bond_graphics_state import pop_bond_items_for from chemvas.ui.molecule.atom_coords_access import pop_atom_coords_3d_for -from chemvas.ui.scene.scene_item_access import ( - remove_item_from_canvas_scene, - remove_items_from_canvas_scene, -) if TYPE_CHECKING: from chemvas.domain.document import Bond class AtomLabelMergeService: - def __init__(self, canvas, *, graph_service) -> None: + def __init__(self, canvas, *, graph_service, capture_history: bool = True) -> None: + self.capture_history = capture_history self.canvas = canvas self.graph_service = graph_service @@ -31,10 +27,15 @@ def merge_overlapping_atoms(self, atom_id: int) -> tuple[list[int], dict]: merge_ids = self._overlapping_atom_ids(atom_id) if not merge_ids: return [], {} - merge_info = { - "atom_states": { + atom_states = {} + if self.capture_history: + from chemvas.ui.annotations.state import atom_state_dict_for + + atom_states = { mid: atom_state_dict_for(self.canvas, mid) for mid in merge_ids - }, + } + merge_info = { + "atom_states": atom_states, "bond_before_states": {}, "deleted_bond_ids": [], } @@ -46,7 +47,8 @@ def merge_overlapping_atoms(self, atom_id: int) -> tuple[list[int], dict]: } if atom_coords_3d: merge_info["atom_coords_3d"] = atom_coords_3d - self._capture_bond_states_touching_merged_atoms(merge_ids, merge_info) + if self.capture_history: + self._capture_bond_states_touching_merged_atoms(merge_ids, merge_info) self._remove_merged_atom_items(merge_ids) self._retarget_bonds(merge_ids, atom_id) self._delete_self_loop_bonds(merge_info) @@ -76,6 +78,8 @@ def _overlapping_atom_ids(self, atom_id: int) -> list[int]: def _capture_bond_states_touching_merged_atoms( self, merge_ids: list[int], merge_info: dict ) -> None: + from chemvas.ui.annotations.state import bond_state_dict + for bond_id, bond in enumerate(self.canvas.model.bonds): if bond is None: continue @@ -86,9 +90,17 @@ def _remove_merged_atom_items(self, merge_ids: list[int]) -> None: for other_id in merge_ids: label = pop_atom_item_for(self.canvas, other_id) if label is not None: + from chemvas.ui.scene.scene_item_access import ( + remove_item_from_canvas_scene, + ) + remove_item_from_canvas_scene(self.canvas, label) dot = pop_atom_dot_for(self.canvas, other_id) if dot is not None: + from chemvas.ui.scene.scene_item_access import ( + remove_item_from_canvas_scene, + ) + remove_item_from_canvas_scene(self.canvas, dot) def _retarget_bonds(self, merge_ids: list[int], atom_id: int) -> None: @@ -129,12 +141,19 @@ def _delete_duplicate_bonds(self, merge_info: dict) -> None: def _delete_bond(self, bond_id: int, merge_info: dict) -> None: bond = cast("Bond", self.canvas.model.bond_for_id(bond_id)) - if bond_id not in merge_info["bond_before_states"]: + if self.capture_history and bond_id not in merge_info["bond_before_states"]: + from chemvas.ui.annotations.state import bond_state_dict + merge_info["bond_before_states"][bond_id] = bond_state_dict(bond) - remove_items_from_canvas_scene( - self.canvas, - self.canvas.runtime_state.bond_graphics_state.bond_items.get(bond_id, []), + items = self.canvas.runtime_state.bond_graphics_state.bond_items.get( + bond_id, [] ) + if items: + from chemvas.ui.scene.scene_item_access import ( + remove_items_from_canvas_scene, + ) + + remove_items_from_canvas_scene(self.canvas, items) pop_bond_items_for(self.canvas, bond_id) self.canvas.model.clear_bond(bond_id) merge_info["deleted_bond_ids"].append(bond_id) diff --git a/app/chemvas/ui/molecule/atom_label_renderer.py b/app/chemvas/ui/molecule/atom_label_renderer.py index 3f896d02..d6b718fe 100644 --- a/app/chemvas/ui/molecule/atom_label_renderer.py +++ b/app/chemvas/ui/molecule/atom_label_renderer.py @@ -14,6 +14,7 @@ reversed_display_text, split_hydride_label, ) +from chemvas.features.graph import connected_atom_unit_vectors from chemvas.ui.canvas.graphics_items import AtomDotItem, AtomLabelItem from chemvas.ui.canvas.pick_radius_access import atom_pick_radius from chemvas.ui.scene.scene_graphics_operations import detach_graphics_item @@ -22,7 +23,6 @@ if TYPE_CHECKING: from collections.abc import Iterable - from chemvas.domain.document import MoleculeModel from chemvas.ui.scene.scene_render_context import SceneRenderContext @@ -40,26 +40,6 @@ def uses_compact_label_hit_shape(text: str) -> bool: return False -def connected_atom_unit_vectors( - model: MoleculeModel, atom_id: int -) -> list[tuple[float, float]]: - atom = model.atoms.get(atom_id) - if atom is None: - return [] - vectors = [] - for bond in model.bonds: - if bond is None or (bond.a != atom_id and bond.b != atom_id): - continue - other = model.atoms.get(bond.b if bond.a == atom_id else bond.a) - if other is None: - continue - dx, dy = other.x - atom.x, other.y - atom.y - length = math.hypot(dx, dy) - if length > 1e-9: - vectors.append((dx / length, dy / length)) - return vectors - - def _open_direction(vectors: list[tuple[float, float]]) -> tuple[float, float]: """Direction toward the open side of an atom, for hydride label placement. diff --git a/app/chemvas/ui/molecule/atom_label_service.py b/app/chemvas/ui/molecule/atom_label_service.py index f5f95877..a5a58648 100644 --- a/app/chemvas/ui/molecule/atom_label_service.py +++ b/app/chemvas/ui/molecule/atom_label_service.py @@ -1,14 +1,10 @@ from __future__ import annotations +from functools import cached_property from typing import TYPE_CHECKING -from PyQt6.QtWidgets import QInputDialog - from chemvas.core.history import history_transaction_scope -from chemvas.ui.molecule.atom_label_history_recorder import AtomLabelHistoryRecorder from chemvas.ui.molecule.atom_label_merge_service import AtomLabelMergeService -from chemvas.ui.scene.scene_group_operations import group_connection_allowed_for -from chemvas.ui.transactions.document import document_transaction if TYPE_CHECKING: from collections.abc import Callable, Iterable @@ -25,6 +21,8 @@ def __init__( graph_service, history_service=None, hover_refresh: Callable[[], None] | None = None, + merge_service=None, + connection_allowed=None, ) -> None: self.canvas = canvas self.history = history_service @@ -32,15 +30,27 @@ def __init__( self.graph_service = graph_service self._hover_refresh = hover_refresh or (lambda: None) self.drawing = canvas.render_context.atom_labels - self._history_recorder = AtomLabelHistoryRecorder( - canvas, - history_service=history_service, + self.merge_service = merge_service or AtomLabelMergeService( + canvas, graph_service=graph_service ) - self.merge_service = AtomLabelMergeService( - canvas, - graph_service=graph_service, + if connection_allowed is None: + from chemvas.ui.scene.scene_group_operations import ( + group_connection_allowed_for, + ) + + def connection_allowed(ids): + return group_connection_allowed_for(canvas, ids) + + self.connection_allowed = connection_allowed + + @cached_property + def _history_recorder(self): + from chemvas.ui.molecule.atom_label_history_recorder import ( + AtomLabelHistoryRecorder, ) + return AtomLabelHistoryRecorder(self.canvas, history_service=self.history) + def atom_item_for_id(self, atom_id: int): return self.drawing.atom_item_for_id(atom_id) @@ -136,6 +146,8 @@ def add_or_update_atom_label( literal_label=literal_label, ) return + from chemvas.ui.transactions.document import document_transaction + with ( document_transaction(self.canvas, history_service=self.history), history_transaction_scope(self.history.operations), @@ -149,17 +161,24 @@ def add_or_update_atom_label( literal_label=literal_label, ) - def add_labelled_atom(self, text: str, x: float, y: float) -> int: - """Add an atom drawn with ``text`` as one recorded document edit.""" + def add_labelled_atom( + self, text: str, x: float, y: float, *, record: bool = True + ) -> int: + """Add a labelled atom; an unrecorded caller owns the candidate transaction.""" + if not record: + atom_id = self.canvas.services.canvas_atom_mutation_service.add_atom( + text, x, y + ) + self.add_or_update_atom_label(atom_id, text, record=False, show_carbon=True) + return atom_id + from chemvas.ui.transactions.document import document_transaction + with ( document_transaction(self.canvas, history_service=self.history), history_transaction_scope(self.history.operations), ): before_next_atom_id = int(self.canvas.model.next_atom_id) - atom_id = self.canvas.services.canvas_atom_mutation_service.add_atom( - text, x, y - ) - self.add_or_update_atom_label(atom_id, text, record=False, show_carbon=True) + atom_id = self.add_labelled_atom(text, x, y, record=False) self._history_recorder.record_added_atom( atom_id, before_next_atom_id=before_next_atom_id ) @@ -180,9 +199,7 @@ def _apply_atom_label( atom = self.canvas.model.atoms[atom_id] if allow_merge and text and (text.upper() != "C" or show_carbon): merge_ids = self.merge_service._overlapping_atom_ids(atom_id) - if merge_ids and not group_connection_allowed_for( - self.canvas, {atom_id, *merge_ids} - ): + if merge_ids and not self.connection_allowed({atom_id, *merge_ids}): return before_element = atom.element before_explicit_label = atom.explicit_label @@ -259,6 +276,8 @@ def _apply_atom_label( ) def prompt_atom_label(self, atom_id: int) -> None: + from PyQt6.QtWidgets import QInputDialog + atom = self.canvas.model.atom_for_id(atom_id) if atom is None: return diff --git a/app/chemvas/ui/molecule/bond_graphics_draw_service.py b/app/chemvas/ui/molecule/bond_graphics_draw_service.py index a0f0dfb9..5375f159 100644 --- a/app/chemvas/ui/molecule/bond_graphics_draw_service.py +++ b/app/chemvas/ui/molecule/bond_graphics_draw_service.py @@ -3,9 +3,6 @@ import math from typing import TYPE_CHECKING -from PyQt6.QtCore import QPointF -from PyQt6.QtGui import QPolygonF - from chemvas.features.rendering import ( BOLD_BOND_STYLES, bold_double_strip_geometry, @@ -21,11 +18,30 @@ if TYPE_CHECKING: + from PyQt6.QtCore import QPointF + from chemvas.ui.scene.scene_render_context import SceneRenderContext class BondGraphicsDrawService: - def __init__(self, context: SceneRenderContext, *, renderer) -> None: + def __init__( + self, + context: SceneRenderContext, + *, + renderer, + point_factory=None, + polygon_factory=None, + ) -> None: + if point_factory is None: + from PyQt6.QtCore import QPointF + + point_factory = QPointF + if polygon_factory is None: + from PyQt6.QtGui import QPolygonF + + polygon_factory = QPolygonF + self.point_factory = point_factory + self.polygon_factory = polygon_factory self.context = context self.renderer = renderer @@ -63,7 +79,7 @@ def bold_strip_polygon( # edge (a true mitre) so the run reads as one continuous outline. if a_id is None and b_id is None: corners = strip_corners(x1, y1, x2, y2, nx, ny, base_width, bold_width) - return QPolygonF([QPointF(x, y) for x, y in corners]) + return self.polygon_factory([self.point_factory(x, y) for x, y in corners]) outer_off = -base_width / 2.0 inner_off = bold_width - base_width / 2.0 dx = x2 - x1 @@ -82,7 +98,7 @@ def bold_strip_polygon( inner_start = self._miter_corner( x1, y1, nx, ny, inner_off, dx, dy, a_id, start_nb, bold_width ) - return QPolygonF([outer_start, outer_end, inner_end, inner_start]) + return self.polygon_factory([outer_start, outer_end, inner_end, inner_start]) def _bold_strip_normal(self, bond, a, b) -> tuple[float, float]: if is_bold_double_bond_style(bond.style, bond.order): @@ -159,7 +175,7 @@ def _bold_neighbor(self, vertex_id, other_id, away_dx, away_dy): def _miter_corner( self, vx, vy, nx, ny, off, dx, dy, vertex_id, neighbor, bold_width ) -> QPointF: - base = QPointF(vx + nx * off, vy + ny * off) + base = self.point_factory(vx + nx * off, vy + ny * off) if neighbor is None or vertex_id is None: return base nb_a = self.context.model.atoms.get(neighbor.a) @@ -185,7 +201,7 @@ def _miter_corner( return base if math.hypot(point[0] - vx, point[1] - vy) > _MITER_LIMIT * bold_width: return base - return QPointF(point[0], point[1]) + return self.point_factory(point[0], point[1]) def draw_parallel_bonds( self, diff --git a/app/chemvas/ui/molecule/bond_line_geometry_service.py b/app/chemvas/ui/molecule/bond_line_geometry_service.py index a320fcfd..4524387f 100644 --- a/app/chemvas/ui/molecule/bond_line_geometry_service.py +++ b/app/chemvas/ui/molecule/bond_line_geometry_service.py @@ -3,8 +3,6 @@ import math from typing import TYPE_CHECKING -from PyQt6.QtCore import QPointF - from chemvas.features.rendering import ( DOUBLE_STYLE_DEFAULT, DOUBLE_STYLE_OUTER, @@ -18,11 +16,14 @@ ) if TYPE_CHECKING: + from PyQt6.QtCore import QPointF + from chemvas.ui.scene.scene_render_context import SceneRenderContext class BondLineGeometryService: - def __init__(self, context: SceneRenderContext) -> None: + def __init__(self, context: SceneRenderContext, *, point_factory=None) -> None: + self._point_factory = point_factory self.context = context self.graph = context.state.graph_state @@ -159,7 +160,12 @@ def _double_neighbor_target( points.append((other.x, other.y)) if not points: return None - return QPointF( + point_factory = self._point_factory + if point_factory is None: + from PyQt6.QtCore import QPointF + + point_factory = QPointF + return point_factory( sum(point[0] for point in points) / len(points), sum(point[1] for point in points) / len(points), ) diff --git a/app/chemvas/ui/molecule/structure_benzene_build_service.py b/app/chemvas/ui/molecule/structure_benzene_build_service.py index 7a2a3a31..d11549da 100644 --- a/app/chemvas/ui/molecule/structure_benzene_build_service.py +++ b/app/chemvas/ui/molecule/structure_benzene_build_service.py @@ -3,8 +3,6 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from PyQt6.QtCore import QPointF - from chemvas.ui.insert.ring_occupancy import point_inside_any_ring from chemvas.ui.molecule.structure_benzene_logic import plan_benzene_ring_points from chemvas.ui.molecule.structure_geometry_logic import ( @@ -15,6 +13,8 @@ if TYPE_CHECKING: from collections.abc import Callable + from PyQt6.QtCore import QPointF + from chemvas.ui.molecule.structure_build_committer import StructureBuildCommitter @@ -26,9 +26,22 @@ class BenzenePlacement: class StructureBenzeneBuildService: - def __init__(self, canvas, committer: StructureBuildCommitter) -> None: + def __init__( + self, + canvas, + committer: StructureBuildCommitter, + *, + point_factory=None, + center_inside_existing_ring=None, + ) -> None: self.canvas = canvas self.committer = committer + if point_factory is None: + from PyQt6.QtCore import QPointF + + point_factory = QPointF + self.point_factory = point_factory + self.center_inside_existing_ring = center_inside_existing_ring def benzene_ring_points( self, @@ -50,17 +63,21 @@ def benzene_ring_points( bonds=self.canvas.model.bonds, atoms=self.canvas.model.atoms, bond_length=self.canvas.renderer.style.bond_length_px, - center_inside_existing_ring=lambda: point_inside_any_ring( - center, ring_items=self.canvas.runtime_state.ring_items() + center_inside_existing_ring=lambda: ( + self.center_inside_existing_ring(center) + if self.center_inside_existing_ring is not None + else point_inside_any_ring( + center, ring_items=self.canvas.runtime_state.ring_items() + ) ), regular_ring_points_for_bond=( lambda ring_size, bond_id, point: regular_ring_points_for_bond( - ring_size, bond_id, QPointF(point[0], point[1]) + ring_size, bond_id, self.point_factory(point[0], point[1]) ) ), regular_ring_points_for_atom=regular_ring_points_for_atom, compute_free_points=compute_free_benzene_ring_points, - make_point=QPointF, + make_point=self.point_factory, ) def add_benzene_ring( diff --git a/app/chemvas/ui/molecule/structure_bond_build_service.py b/app/chemvas/ui/molecule/structure_bond_build_service.py index 0aeb12ca..1a884161 100644 --- a/app/chemvas/ui/molecule/structure_bond_build_service.py +++ b/app/chemvas/ui/molecule/structure_bond_build_service.py @@ -11,9 +11,7 @@ DOUBLE_STYLE_OUTER, style_for_existing_bond_overlay, ) -from chemvas.ui.annotations.state import bond_state_dict from chemvas.ui.canvas.canvas_window_access import notify_error_for -from chemvas.ui.scene.scene_group_operations import group_connection_allowed_for if TYPE_CHECKING: from PyQt6.QtCore import QPointF @@ -30,12 +28,14 @@ def __init__( hit_testing_service, move_controller, graph_service, + connection_allowed=None, ) -> None: self.canvas = canvas self.committer = committer self.hit_testing_service = hit_testing_service self.move_controller = move_controller self.graph_service = graph_service + self.connection_allowed = connection_allowed def add_bond_between_points( self, @@ -43,7 +43,10 @@ def add_bond_between_points( end: QPointF, style: str, order: int, + *, + record: bool = True, ) -> tuple[int, int] | None: + """Build through the same rules; an unrecorded caller owns its transaction.""" snap_tol = self.canvas.renderer.style.bond_length_px * 0.1 if ( start == end @@ -83,13 +86,18 @@ def add_bond_between_points( ) return None anchors = {atom_id for atom_id in (start_id, end_id) if atom_id is not None} - if ( - existing_bond_id is None - and anchors - and not group_connection_allowed_for(self.canvas, anchors) - ): - return None - snapshot = self.committer.begin_recorded_change() + if existing_bond_id is None and anchors: + if self.connection_allowed is None: + from chemvas.ui.scene.scene_group_operations import ( + group_connection_allowed_for, + ) + + allowed = group_connection_allowed_for(self.canvas, anchors) + else: + allowed = self.connection_allowed(anchors) + if not allowed: + return None + snapshot = self.committer.begin_recorded_change() if record else None try: if start_id is None: start_id = self.committer.add_atom("C", start.x(), start.y()) @@ -104,24 +112,37 @@ def add_bond_between_points( order, start_id, end_id, + record=record, ) - if result is None: - self.committer.abort_recorded_change(snapshot) - else: - self.committer.release_recorded_change(snapshot) + if snapshot is not None: + if result is None: + self.committer.abort_recorded_change(snapshot) + else: + self.committer.release_recorded_change(snapshot) return result return self._add_new_bond(snapshot, start_id, end_id, style, order) except Exception as error: - self.committer.abort_recorded_change(snapshot, original_error=error) + if snapshot is not None: + self.committer.abort_recorded_change(snapshot, original_error=error) raise def _update_existing_bond( - self, bond_id: int, style: str, order: int, start_id: int, end_id: int + self, + bond_id: int, + style: str, + order: int, + start_id: int, + end_id: int, + *, + record: bool = True, ) -> tuple[int, int] | None: bond = self.canvas.model.bond_for_id(bond_id) if bond is None: return None - before_state = bond_state_dict(bond) + if record: + from chemvas.ui.annotations.state import bond_state_dict + + before_state = bond_state_dict(bond) next_style, next_order = style_for_existing_bond_overlay( bond.style, bond.order, @@ -133,12 +154,13 @@ def _update_existing_bond( self.move_controller.redraw_bond(bond_id) self.move_controller.redraw_connected_bonds(bond.a, skip_bond_id=bond_id) self.move_controller.redraw_connected_bonds(bond.b, skip_bond_id=bond_id) - after_state = bond_state_dict(bond) - self.canvas.services.canvas_history_recording_service.record_bond_update( - bond_id, - before_state, - after_state, - ) + if record: + after_state = bond_state_dict(bond) + self.canvas.services.canvas_history_recording_service.record_bond_update( + bond_id, + before_state, + after_state, + ) return start_id, end_id def _add_new_bond( @@ -152,13 +174,15 @@ def _add_new_bond( bond_id = self.committer.add_bond(start_id, end_id, order) bond = self.canvas.model.bond_for_id(bond_id) if bond is None: - self.committer.abort_recorded_change(snapshot) + if snapshot is not None: + self.committer.abort_recorded_change(snapshot) return None bond.style = style self.committer.add_bond_graphics(bond_id) self.move_controller.redraw_connected_bonds(start_id, skip_bond_id=bond_id) self.move_controller.redraw_connected_bonds(end_id, skip_bond_id=bond_id) - self.committer.record_additions(snapshot) + if snapshot is not None: + self.committer.record_additions(snapshot) return start_id, end_id diff --git a/app/chemvas/ui/molecule/structure_build_committer.py b/app/chemvas/ui/molecule/structure_build_committer.py index 4831131b..ce725d0d 100644 --- a/app/chemvas/ui/molecule/structure_build_committer.py +++ b/app/chemvas/ui/molecule/structure_build_committer.py @@ -5,19 +5,13 @@ from chemvas.features.graph import first_matching_bond_id from chemvas.ui.canvas.canvas_scene_items_state import SCENE_ITEM_COLLECTION_ATTRS -from chemvas.ui.canvas.molecule_scene_renderer import ( - prepare_molecule_for_scene, - render_molecule, -) -from chemvas.ui.molecule.atom_label_access import add_or_update_atom_label -from chemvas.ui.molecule.structure_insert_access import record_insert_additions_for from chemvas.ui.molecule.structure_mutation_access import add_bond_for -from chemvas.ui.transactions.document import DocumentSavepoint if TYPE_CHECKING: from PyQt6.QtCore import QPointF from chemvas.ui.canvas.canvas_view import CanvasView + from chemvas.ui.transactions.document import DocumentSavepoint @dataclass(slots=True, kw_only=True) @@ -33,6 +27,8 @@ def __init__(self, canvas: CanvasView) -> None: self.canvas = canvas def begin_recorded_change(self) -> StructureBuildHistorySnapshot: + from chemvas.ui.transactions.document import DocumentSavepoint + return StructureBuildHistorySnapshot( before_next_atom_id=int(self.canvas.model.next_atom_id), before_bond_count=len(self.canvas.model.bonds), @@ -48,6 +44,10 @@ def record_additions( *, added_scene_items: list | None = None, ) -> None: + from chemvas.ui.molecule.structure_insert_access import ( + record_insert_additions_for, + ) + kwargs: dict[str, Any] = { "before_next_atom_id": snapshot.before_next_atom_id, "before_bond_count": snapshot.before_bond_count, @@ -164,6 +164,8 @@ def add_atom_label( ) def label_non_carbon_atoms(self, atom_ids: list[int], elements: list[str]) -> None: + from chemvas.ui.molecule.atom_label_access import add_or_update_atom_label + for atom_id, element in zip(atom_ids, elements, strict=False): if element != "C": atom = self.canvas.model.atom_for_id(atom_id) @@ -329,6 +331,11 @@ def _projected_ring_double_bond_count( return double_count def render_model(self) -> None: + from chemvas.ui.canvas.molecule_scene_renderer import ( + prepare_molecule_for_scene, + render_molecule, + ) + context = self.canvas.render_context prepare_molecule_for_scene(context.model) render_molecule(context) diff --git a/app/chemvas/ui/molecule/structure_geometry_access.py b/app/chemvas/ui/molecule/structure_geometry_access.py index 7858af3a..57448c95 100644 --- a/app/chemvas/ui/molecule/structure_geometry_access.py +++ b/app/chemvas/ui/molecule/structure_geometry_access.py @@ -1,14 +1,13 @@ from __future__ import annotations import math +from typing import TYPE_CHECKING -from PyQt6.QtCore import QPointF - +from chemvas.features.graph import connected_atom_unit_vectors from chemvas.ui.insert.ring_occupancy import ( graph_ring_polygons_for_bond, ring_polygon_points_for_bond, ) -from chemvas.ui.molecule.atom_label_renderer import connected_atom_unit_vectors from chemvas.ui.molecule.structure_geometry_logic import ( compute_regular_ring_points_for_atom, compute_regular_ring_points_for_bond, @@ -24,9 +23,18 @@ ring_points, ) +if TYPE_CHECKING: + from PyQt6.QtCore import QPointF + + +def qpoints_from_pairs( + points: list[tuple[float, float]], *, point_factory=None +) -> list[QPointF]: + if point_factory is None: + from PyQt6.QtCore import QPointF -def qpoints_from_pairs(points: list[tuple[float, float]]) -> list[QPointF]: - return [QPointF(x, y) for x, y in points] + point_factory = QPointF + return [point_factory(x, y) for x, y in points] def point_pairs(points: list[QPointF]) -> list[tuple[float, float]]: @@ -41,14 +49,18 @@ def point_pair(point: QPointF | None) -> tuple[float, float] | None: def template_geometry_result( result: tuple[list[tuple[float, float]], list[tuple[int, float, float]]] | None, + *, + point_factory=None, ) -> tuple[list[QPointF], list[tuple[int, float, float]]] | None: if result is None: return None points, merge = result - return qpoints_from_pairs(points), merge + return qpoints_from_pairs(points, point_factory=point_factory), merge def atom_point_for(canvas, atom_id: int) -> QPointF: + from PyQt6.QtCore import QPointF + atom = canvas.model.atoms[atom_id] return QPointF(atom.x, atom.y) @@ -70,8 +82,13 @@ def default_bond_angle_for_vectors(vectors: list[tuple[float, float]]) -> float: def default_bond_endpoint_for( - canvas, start: QPointF, start_atom_id: int | None + canvas, start: QPointF, start_atom_id: int | None, *, point_factory=None ) -> QPointF: + if point_factory is None: + from PyQt6.QtCore import QPointF + + point_factory = QPointF + angle = 0.0 if start_atom_id is not None: angle = default_bond_angle_for_vectors( @@ -79,7 +96,7 @@ def default_bond_endpoint_for( ) rad = math.radians(angle) bond_len = canvas.renderer.style.bond_length_px - return QPointF( + return point_factory( start.x() + math.cos(rad) * bond_len, start.y() + math.sin(rad) * bond_len ) @@ -87,6 +104,8 @@ def default_bond_endpoint_for( def sprout_bond_endpoint_for( canvas, atom_id: int, *, cyclic: bool = False ) -> QPointF | None: + from PyQt6.QtCore import QPointF + atom = canvas.model.atom_for_id(atom_id) default_endpoint = None if atom is not None and not cyclic: @@ -159,6 +178,8 @@ def regular_ring_points_for_atom_for( canvas, n: int, atom_id: int, + *, + point_factory=None, ) -> tuple[list[QPointF], list[tuple[int, float, float]]] | None: result = compute_regular_ring_points_for_atom( n, @@ -167,7 +188,7 @@ def regular_ring_points_for_atom_for( bonds=canvas.model.bonds, bond_length=canvas.renderer.style.bond_length_px, ) - return template_geometry_result(result) + return template_geometry_result(result, point_factory=point_factory) def _compute_bond_template_geometry_for( @@ -177,6 +198,7 @@ def _compute_bond_template_geometry_for( bond_id: int, *, center_hint: QPointF | None = None, + point_factory=None, ) -> tuple[list[QPointF], list[tuple[int, float, float]]] | None: atoms = canvas.model.atoms bonds = canvas.model.bonds @@ -210,7 +232,7 @@ def _compute_bond_template_geometry_for( for polygon in occupied ): return None - return template_geometry_result(result) + return template_geometry_result(result, point_factory=point_factory) def regular_ring_points_for_bond_for( @@ -218,6 +240,8 @@ def regular_ring_points_for_bond_for( n: int, bond_id: int, center_hint: QPointF | None = None, + *, + point_factory=None, ) -> tuple[list[QPointF], list[tuple[int, float, float]]] | None: return _compute_bond_template_geometry_for( canvas, @@ -225,6 +249,7 @@ def regular_ring_points_for_bond_for( n, bond_id, center_hint=center_hint, + point_factory=point_factory, ) diff --git a/app/chemvas/ui/molecule/structure_growth_geometry.py b/app/chemvas/ui/molecule/structure_growth_geometry.py index d821d776..5804c932 100644 --- a/app/chemvas/ui/molecule/structure_growth_geometry.py +++ b/app/chemvas/ui/molecule/structure_growth_geometry.py @@ -4,11 +4,11 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from PyQt6.QtCore import QPointF - if TYPE_CHECKING: from collections.abc import Mapping, Sequence + from PyQt6.QtCore import QPointF + from chemvas.domain.document import Atom, Bond @@ -23,6 +23,8 @@ class BondPlacementContext: def fused_benzene_centers( center: QPointF, step: float, count: int, mode: str = "linear" ) -> list[QPointF]: + from PyQt6.QtCore import QPointF + if count == 2: return [ QPointF(center.x() - step / 2.0, center.y()), @@ -68,6 +70,8 @@ def resolve_bond_placement_context( bonds: Sequence[Bond | None], atoms: Mapping[int, Atom], ) -> BondPlacementContext | None: + from PyQt6.QtCore import QPointF + if not (0 <= bond_id < len(bonds)): return None bond = bonds[bond_id] @@ -86,6 +90,8 @@ def resolve_bond_placement_context( def mirrored_local_points(points: Sequence[QPointF], mirrored: bool) -> list[QPointF]: + from PyQt6.QtCore import QPointF + if not mirrored: return [QPointF(point) for point in points] return [QPointF(point.x(), -point.y()) for point in points] diff --git a/app/chemvas/ui/molecule/template_geometry.py b/app/chemvas/ui/molecule/template_geometry.py index b07ab91b..98075577 100644 --- a/app/chemvas/ui/molecule/template_geometry.py +++ b/app/chemvas/ui/molecule/template_geometry.py @@ -329,7 +329,7 @@ def bond_side_is_occupied( break # An occupancy polygon need not share this bond; retain point containment # for those polygons and for degenerate outlines without a winding. - return _polygon_contains_point(center, polygon) + return polygon_contains_point(center, polygon) def _distance(a: Point2D, b: Point2D) -> float: @@ -347,7 +347,7 @@ def _points_center(points: Sequence[Point2D]) -> Point2D: ) -def _polygon_contains_point(point: Point2D, polygon: Sequence[Point2D]) -> bool: +def polygon_contains_point(point: Point2D, polygon: Sequence[Point2D]) -> bool: if len(polygon) < 3: return False x, y = point @@ -369,6 +369,7 @@ def _polygon_contains_point(point: Point2D, polygon: Sequence[Point2D]) -> bool: "cyclohexane_chair_flipped_points", "cyclohexane_chair_points", "place_template_on_bond", + "polygon_contains_point", "regular_ring_points_for_atom", "regular_ring_points_for_bond", "regular_ring_radius", diff --git a/app/chemvas/ui/scene/scene_delete_plan.py b/app/chemvas/ui/scene/scene_delete_plan.py index 7ad9e972..d59b4057 100644 --- a/app/chemvas/ui/scene/scene_delete_plan.py +++ b/app/chemvas/ui/scene/scene_delete_plan.py @@ -1,23 +1,21 @@ -"""Delete-selection classification and planning over live Qt scene items. - -Formerly ``scene_delete_logic``. The module consumes ``QGraphicsItem`` -instances directly (isinstance checks, ``data()`` lookups), so the Qt-free -``*_logic`` role contract never applied; the name now says what it is. -""" +"""Shared deletion planning; Qt item classification stays at its input boundary.""" from __future__ import annotations from dataclasses import dataclass, field from typing import TYPE_CHECKING -from PyQt6.QtWidgets import QGraphicsItem, QGraphicsPolygonItem, QGraphicsTextItem - -from chemvas.domain.document import bond_endpoint_ids, orphaned_atom_ids -from chemvas.ui.annotations.state import ARROW_KINDS +from chemvas.domain.document import ( + VALID_ARROW_KINDS, + bond_endpoint_ids, + orphaned_atom_ids, +) if TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence + from PyQt6.QtWidgets import QGraphicsItem, QGraphicsPolygonItem, QGraphicsTextItem + from chemvas.domain.document import Bond @@ -44,6 +42,8 @@ class DeleteSelectionPlan: def classify_delete_selection(items: Sequence[QGraphicsItem]) -> DeleteSelectionBuckets: + from PyQt6.QtWidgets import QGraphicsPolygonItem, QGraphicsTextItem + buckets = DeleteSelectionBuckets() for item in items: kind = item.data(0) @@ -63,7 +63,7 @@ def classify_delete_selection(items: Sequence[QGraphicsItem]) -> DeleteSelection buckets.note_items.append(item) elif kind == "mark": buckets.mark_items.append(item) - elif kind in ARROW_KINDS: + elif kind in VALID_ARROW_KINDS: buckets.arrow_items.append(item) elif kind == "ts_bracket": buckets.ts_bracket_items.append(item) diff --git a/app/chemvas/ui/scene/scene_geometry.py b/app/chemvas/ui/scene/scene_geometry.py index b1af7609..ca91d98b 100644 --- a/app/chemvas/ui/scene/scene_geometry.py +++ b/app/chemvas/ui/scene/scene_geometry.py @@ -15,8 +15,8 @@ ) from PyQt6.QtWidgets import QGraphicsTextItem -from chemvas.domain.document import Bond -from chemvas.features.graph import find_rings +from chemvas.features.graph import build_ring_edge_index +from chemvas.features.rendering import line_normal from chemvas.features.selection import project_point_3d, translate_projected_point_3d from chemvas.ui.canvas.canvas_geometry_logic import ( line_rect_clip_t as line_rect_clip_t_helper, @@ -283,21 +283,7 @@ def bond_offset_unit_3d(self, a_id: int, b_id: int, target=None): nx, ny = -nx, -ny return nx, ny - @staticmethod - def line_normal(x1: float, y1: float, x2: float, y2: float, target=None): - dx, dy = x2 - x1, y2 - y1 - length = math.hypot(dx, dy) - if length < 1e-9: - return 0.0, 0.0 - nx, ny = -dy / length, dx / length - if ( - target is not None - and nx * (target.x() - (x1 + x2) * 0.5) - + ny * (target.y() - (y1 + y2) * 0.5) - < 0 - ): - return -nx, -ny - return nx, ny + line_normal = staticmethod(line_normal) def _clip_label_line(self, item, p1, p2, width, offsets): transform = item.sceneTransform() @@ -346,17 +332,7 @@ def _ring_atom_ids_for_bond(self, bond) -> list[int] | None: or self._ring_graph_neighbors is not graph.atom_neighbors or self._ring_graph_version != graph.graph_version ): - topology = { - (min(edge.a, edge.b), max(edge.a, edge.b)) - for edge in model.bonds - if edge is not None and edge.a in model.atoms and edge.b in model.atoms - } - rings = find_rings(Bond(a, b) for a, b in sorted(topology)) - self._rings_by_edge = {} - for ring in rings: - for index, a in enumerate(ring): - b = ring[(index + 1) % len(ring)] - self._rings_by_edge.setdefault((min(a, b), max(a, b)), ring) + self._rings_by_edge = build_ring_edge_index(model.atoms, model.bonds) self._ring_model = model self._ring_graph_neighbors = graph.atom_neighbors self._ring_graph_version = graph.graph_version diff --git a/app/chemvas/ui/tools/bond_tool.py b/app/chemvas/ui/tools/bond_tool.py index f19e3d5d..b390ab7d 100644 --- a/app/chemvas/ui/tools/bond_tool.py +++ b/app/chemvas/ui/tools/bond_tool.py @@ -2,13 +2,8 @@ from typing import override -from PyQt6.QtCore import QLineF, QPointF, Qt +from PyQt6.QtCore import QPointF, Qt -from chemvas.features.rendering import ( - BOLD_BOND_STYLES, - is_dotted_double_bond_style, - style_for_existing_bond_overlay, -) from chemvas.ui.canvas.canvas_window_access import notify_error_for from chemvas.ui.insert.preview_scene_renderer import clear_scene_items from chemvas.ui.molecule.bond_preview_access import ( @@ -20,6 +15,8 @@ from chemvas.ui.molecule.structure_mutation_access import add_bond_between_points_for from chemvas.ui.selection.selection_queries import scene_selected_items_for from chemvas.ui.tools.bond_tool_logic import ( + apply_active_bond_style, + is_short_bond_gesture, resolve_bond_endpoint_target, resolve_bond_press_target, resolve_bond_snap_target, @@ -96,58 +93,17 @@ def _set_preview_items(self, start: QPointF, end: QPointF) -> None: self._preview_signature = signature def _apply_active_style_to_bond(self, bond_id: int) -> bool: - bond = self.canvas.model.bond_for_id(bond_id) - if bond is None: - return False settings = self.canvas.runtime_state.tool_settings_state - active_bond_style = settings.active_bond_style - if bond.style == "double_either" and ( - active_bond_style in BOLD_BOND_STYLES or active_bond_style == "dotted" - ): - notify_error_for( - self.canvas, - "This appearance change would erase unknown double-bond stereo. " - "Choose Double (2) first to clear it explicitly.", - ) - return True - if active_bond_style in {"wedge", "hash"}: - self.context.apply_bond_style(bond_id, active_bond_style, 1) - return True - if active_bond_style in BOLD_BOND_STYLES: - next_style, next_order = style_for_existing_bond_overlay( - bond.style, bond.order, active_bond_style, settings.active_bond_order - ) - self.context.apply_bond_style(bond_id, next_style, next_order) - return True - if active_bond_style == "dotted": - next_style, next_order = style_for_existing_bond_overlay( - bond.style, - bond.order, - "dotted", - 1, - ) - if bond.order == 2 and not is_dotted_double_bond_style( - next_style, next_order - ): - notify_error_for( - self.canvas, - "Dotted overlay needs an inner or outer plain double bond. " - "Choose Double, then its position, and try Dotted again.", - ) - return True - self.context.apply_bond_style(bond_id, next_style, next_order) - return True - if active_bond_style in {"single", "double", "triple"}: - if (bond.style, bond.order) != ( - active_bond_style, - settings.active_bond_order, - ): - self.context.apply_bond_style( - bond_id, active_bond_style, settings.active_bond_order - ) - return True - self.context.cycle_bond_style(bond_id) - return True + return apply_active_bond_style( + self.canvas.model.bond_for_id(bond_id), + settings.active_bond_style, + settings.active_bond_order, + apply_style=lambda style, order: self.context.apply_bond_style( + bond_id, style, order + ), + cycle_style=lambda: self.context.cycle_bond_style(bond_id), + notify_error=lambda message: notify_error_for(self.canvas, message), + ) def _clear_existing_selection(self) -> None: if scene_selected_items_for(self.canvas): @@ -218,11 +174,13 @@ def on_mouse_release(self, event) -> bool: release_pos = self.context.scene_pos_from_event(event) end_pos = self._snap_to_atom(release_pos, ignore_start=True) end_pos = self._snap_endpoint(self._start_pos, end_pos) - if self._press_scene_pos is not None: - dist = QLineF(self._press_scene_pos, release_pos).length() - else: - dist = 0.0 - if dist < self.canvas.renderer.style.bond_length_px * 0.1: + if is_short_bond_gesture( + (self._press_scene_pos.x(), self._press_scene_pos.y()) + if self._press_scene_pos is not None + else None, + (release_pos.x(), release_pos.y()), + self.canvas.renderer.style.bond_length_px, + ): end_pos = default_bond_endpoint_for( self.canvas, self._start_pos, self._start_atom_id ) diff --git a/app/chemvas/ui/tools/bond_tool_logic.py b/app/chemvas/ui/tools/bond_tool_logic.py index a5670ba8..d0ac8988 100644 --- a/app/chemvas/ui/tools/bond_tool_logic.py +++ b/app/chemvas/ui/tools/bond_tool_logic.py @@ -4,8 +4,16 @@ from dataclasses import dataclass from typing import TYPE_CHECKING +from chemvas.features.rendering import ( + BOLD_BOND_STYLES, + is_dotted_double_bond_style, + style_for_existing_bond_overlay, +) + if TYPE_CHECKING: - from chemvas.domain.document import MoleculeModel + from collections.abc import Callable + + from chemvas.domain.document import Bond, MoleculeModel @dataclass(frozen=True) @@ -14,6 +22,19 @@ class BondSnapTarget: start_atom_id: int | None +def is_short_bond_gesture( + press: tuple[float, float] | None, + release: tuple[float, float], + bond_length: float, +) -> bool: + distance = ( + math.hypot(press[0] - release[0], press[1] - release[1]) + if press is not None + else 0.0 + ) + return distance < bond_length * 0.1 + + def resolve_bond_press_target( *, atom_id: int | None, @@ -105,8 +126,65 @@ def resolve_bond_endpoint_target( ) +def apply_active_bond_style( + bond: Bond | None, + active_bond_style: str, + active_bond_order: int, + *, + apply_style: Callable[[str, int], None], + cycle_style: Callable[[], None], + notify_error: Callable[[str], object], +) -> bool: + """Apply the existing bond-tool click policy through the presentation adapter.""" + if bond is None: + return False + if bond.style == "double_either" and ( + active_bond_style in BOLD_BOND_STYLES or active_bond_style == "dotted" + ): + notify_error( + "This appearance change would erase unknown double-bond stereo. " + "Choose Double (2) first to clear it explicitly.", + ) + return True + if active_bond_style in {"wedge", "hash"}: + apply_style(active_bond_style, 1) + return True + if active_bond_style in BOLD_BOND_STYLES: + next_style, next_order = style_for_existing_bond_overlay( + bond.style, bond.order, active_bond_style, active_bond_order + ) + apply_style(next_style, next_order) + return True + if active_bond_style == "dotted": + next_style, next_order = style_for_existing_bond_overlay( + bond.style, + bond.order, + "dotted", + 1, + ) + if bond.order == 2 and not is_dotted_double_bond_style(next_style, next_order): + notify_error( + "Dotted overlay needs an inner or outer plain double bond. " + "Choose Double, then its position, and try Dotted again.", + ) + return True + apply_style(next_style, next_order) + return True + if active_bond_style in {"single", "double", "triple"}: + if (bond.style, bond.order) != ( + active_bond_style, + active_bond_order, + ): + apply_style(active_bond_style, active_bond_order) + return True + cycle_style() + return True + + __all__ = [ "BondSnapTarget", + "apply_active_bond_style", + "is_short_bond_gesture", "resolve_bond_endpoint_target", "resolve_bond_press_target", "resolve_bond_snap_target", diff --git a/app/chemvas/ui/tools/text_tool.py b/app/chemvas/ui/tools/text_tool.py index bbe2c831..3de71b54 100644 --- a/app/chemvas/ui/tools/text_tool.py +++ b/app/chemvas/ui/tools/text_tool.py @@ -11,6 +11,7 @@ add_or_update_atom_label, ) from chemvas.ui.tools.text_tool_logic import ( + apply_text_input, normalize_text_symbol, plan_text_input, resolve_text_tool_target, @@ -74,25 +75,15 @@ def on_mouse_press(self, event) -> bool: if not ok: return True text = normalize_text_symbol(text) - if atom_id is not None and not text and existing_element.upper() != "C": - # An empty label would leave this atom drawn as a bare skeleton - # vertex while the model and every export still carry the - # element — the drawing and the chemistry would silently - # disagree. Refuse it like other unrepresentable input. Carbon - # matching is case-insensitive because the dot fallback that - # keeps a cleared carbon findable follows the same contract. - notify_error_for( - self.canvas, - "Cannot hide the symbol of a non-carbon atom. Enter C to " - "make it carbon, or delete the atom instead.", - ) - return True - if atom_id is None: - if text: - add_labelled_atom_for(self.canvas, cast("str", text), pos.x(), pos.y()) - return True - add_or_update_atom_label( - self.canvas, atom_id, cast("str", text), show_carbon=True + apply_text_input( + target, + cast("str", text), + existing_element, + add_atom=lambda text, x, y: add_labelled_atom_for(self.canvas, text, x, y), + update_label=lambda atom_id, text, **kwargs: add_or_update_atom_label( + self.canvas, atom_id, text, **kwargs + ), + notify_error=lambda message: notify_error_for(self.canvas, message), ) return True diff --git a/app/chemvas/ui/tools/text_tool_logic.py b/app/chemvas/ui/tools/text_tool_logic.py index 7a846271..1e50b5d7 100644 --- a/app/chemvas/ui/tools/text_tool_logic.py +++ b/app/chemvas/ui/tools/text_tool_logic.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from collections.abc import Mapping @@ -108,3 +108,26 @@ def _bond(bonds: list[Bond | None], bond_id: int | None) -> Bond | None: "plan_text_input", "resolve_text_tool_target", ] + + +def apply_text_input( + target: TextToolTarget, + text: str, + existing_element: str, + *, + add_atom: Any, + update_label: Any, + notify_error: Any, +) -> None: + """Apply the text tool's existing carbon and insertion rules through its ports.""" + if target.atom_id is not None and not text and existing_element.upper() != "C": + notify_error( + "Cannot hide the symbol of a non-carbon atom. Enter C to " + "make it carbon, or delete the atom instead." + ) + return + if target.atom_id is None: + if text: + add_atom(text, *target.pos) + return + update_label(target.atom_id, text, show_carbon=True) diff --git a/app/chemvas/ui/window/main_window_config.py b/app/chemvas/ui/window/main_window_config.py index 01bf8aad..6cfcbbd8 100644 --- a/app/chemvas/ui/window/main_window_config.py +++ b/app/chemvas/ui/window/main_window_config.py @@ -156,9 +156,44 @@ "Verdana", ) + +TOOL_HINTS: dict[str, str] = { + "select": "Select: double-click arrows/lines for labels", + "bond": "Bond: click-drag to draw", + "text": "Atom / Text: click to place label", + "mark": "Mark: click atom or label", + "benzene": "Ring: click to place template", + "arrow": "Arrow: drag to draw; double-click for labels", + "line": "Line: double-click for labels; Shift locks angle", + "note": "Text: click to add/edit; Esc to finish", + "ts_bracket": "Brackets: drag around selection", + "orbital": "Orbital: click to place", + "perspective": "Perspective: drag selection to rotate", + "color": "Color: choose a swatch", + "ring_fill": "Ring Fill: select a complete ring, then choose a fill color", +} + + +BOND_ORDER_SEGMENTS = [ + ("Single", "icon_bond", "Single bond (1)"), + ("Double", "icon_bond_double", "Double bond (2)"), + ("Triple", "icon_bond_triple", "Triple bond (3)"), +] + + +BOND_MODIFIERS = [ + ("Bold", "icon_bond_bold", "Bold bond (B)"), + ("Wedge", "icon_bond_wedge", "Wedge bond (W)"), + ("Hash", "icon_bond_hash", "Hash bond (Shift+H)"), + ("Dotted", "icon_bond_dotted", "Dotted bond"), +] + + __all__ = [ "ARROW_MENU_SPECS", "ARROW_PRESET_SPECS", + "BOND_MODIFIERS", + "BOND_ORDER_SEGMENTS", "BOND_TOOL_ACTION_SPECS", "BRACKET_MENU_SPECS", "COLOR_PALETTE_SPECS", @@ -170,4 +205,15 @@ "TOOLBAR_TOOL_ACTION_ORDER", "TOOLBAR_TOOL_GROUPS", "TOOL_ACTION_SPECS", + "TOOL_HINTS", ] + + +ATOM_INPUT_SPEC: dict[str, str | int] = { + "placeholder": "Atom", + "tooltip": "Atom Symbol", + "status": "Set the atom symbol used by atom and bond tools", + "min_width": 60, + "max_width": 240, + "max_length": 255, +} diff --git a/app/chemvas/ui/window/main_window_context_bar_page_factories.py b/app/chemvas/ui/window/main_window_context_bar_page_factories.py index 8654d3ae..ee0f7c12 100644 --- a/app/chemvas/ui/window/main_window_context_bar_page_factories.py +++ b/app/chemvas/ui/window/main_window_context_bar_page_factories.py @@ -22,6 +22,10 @@ MARK_TOOL_ACTION_SPECS, TEMPLATE_ENTRY_SPECS, ) +from chemvas.ui.window.main_window_config import BOND_MODIFIERS as _BOND_MODIFIERS +from chemvas.ui.window.main_window_config import ( + BOND_ORDER_SEGMENTS as _BOND_ORDER_SEGMENTS, +) from chemvas.ui.window.main_window_context_bar_widgets import ( BondLengthSpinBox, KindMenuButton, @@ -46,19 +50,6 @@ from chemvas.ui.window.main_window_like import MainWindowLike -_BOND_ORDER_SEGMENTS = [ - ("Single", "icon_bond", "Single bond (1)"), - ("Double", "icon_bond_double", "Double bond (2)"), - ("Triple", "icon_bond_triple", "Triple bond (3)"), -] - -_BOND_MODIFIERS = [ - ("Bold", "icon_bond_bold", "Bold bond (B)"), - ("Wedge", "icon_bond_wedge", "Wedge bond (W)"), - ("Hash", "icon_bond_hash", "Hash bond (Shift+H)"), - ("Dotted", "icon_bond_dotted", "Dotted bond"), -] - _LABEL_BY_STYLE = {value: label for label, value in BOND_STYLE_BY_LABEL.items()} diff --git a/app/chemvas/ui/window/main_window_context_bar_widgets.py b/app/chemvas/ui/window/main_window_context_bar_widgets.py index 679d9c29..efffc5c8 100644 --- a/app/chemvas/ui/window/main_window_context_bar_widgets.py +++ b/app/chemvas/ui/window/main_window_context_bar_widgets.py @@ -32,6 +32,7 @@ TOOLBAR_BUTTON_STYLE, ) from chemvas.shell.toolbar_buttons import CornerMenuButton +from chemvas.ui.window.main_window_config import ATOM_INPUT_SPEC class _StepArrowButton(QToolButton): @@ -319,15 +320,15 @@ def slider_dropdown_button(icon, tooltip: str, slider: QSlider) -> QToolButton: def atom_symbol_input(current_symbol: str, set_symbol) -> QLineEdit: input_box = QLineEdit() input_box.setObjectName("atomInput") - input_box.setPlaceholderText("Atom") - input_box.setMinimumWidth(60) - input_box.setMaximumWidth(240) + input_box.setPlaceholderText(str(ATOM_INPUT_SPEC["placeholder"])) + input_box.setMinimumWidth(int(ATOM_INPUT_SPEC["min_width"])) + input_box.setMaximumWidth(int(ATOM_INPUT_SPEC["max_width"])) input_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) input_box.setFixedHeight(CONTEXT_BAR_BUTTON_HEIGHT) - input_box.setMaxLength(255) + input_box.setMaxLength(int(ATOM_INPUT_SPEC["max_length"])) input_box.setText(current_symbol) - input_box.setToolTip("Atom Symbol") - input_box.setStatusTip("Set the atom symbol used by atom and bond tools") + input_box.setToolTip(str(ATOM_INPUT_SPEC["tooltip"])) + input_box.setStatusTip(str(ATOM_INPUT_SPEC["status"])) input_box.textChanged.connect(set_symbol) return input_box diff --git a/app/chemvas/ui/window/main_window_status_service.py b/app/chemvas/ui/window/main_window_status_service.py index 08c3069a..f5ae7706 100644 --- a/app/chemvas/ui/window/main_window_status_service.py +++ b/app/chemvas/ui/window/main_window_status_service.py @@ -22,6 +22,7 @@ selection_status_count_for, ) from chemvas.ui.session.session_recovery_service import recover_unsaved_work_for_window +from chemvas.ui.window.main_window_config import TOOL_HINTS from chemvas.ui.window.main_window_document_dialogs import prompt_zoom_percent from chemvas.ui.window.main_window_ports import ( active_canvas_name_for_window, @@ -106,23 +107,6 @@ def mouseDoubleClickEvent(self, event) -> None: self._on_double() -TOOL_HINTS: dict[str, str] = { - "select": "Select: double-click arrows/lines for labels", - "bond": "Bond: click-drag to draw", - "text": "Atom / Text: click to place label", - "mark": "Mark: click atom or label", - "benzene": "Ring: click to place template", - "arrow": "Arrow: drag to draw; double-click for labels", - "line": "Line: double-click for labels; Shift locks angle", - "note": "Text: click to add/edit; Esc to finish", - "ts_bracket": "Brackets: drag around selection", - "orbital": "Orbital: click to place", - "perspective": "Perspective: drag selection to rotate", - "color": "Color: choose a swatch", - "ring_fill": "Ring Fill: select a complete ring, then choose a fill color", -} - - # A paused Quit or autosave is painted first, so recovery guidance never hides it. _NOTICE_ORDER = ("quit", "autosave", "recovery") # QStatusBar places this much space between two visible items. diff --git a/app/chemvas/web/app.mjs b/app/chemvas/web/app.mjs new file mode 100644 index 00000000..16c4c545 --- /dev/null +++ b/app/chemvas/web/app.mjs @@ -0,0 +1,394 @@ +import {SessionClient} from './transport.mjs'; +import {sceneMarkup} from './scene.mjs'; + +const $ = id => document.getElementById(id); +const editor = new SessionClient(request => api('session', request)); +const canvas = $('canvas'); +const fragment = new URLSearchParams(location.hash.slice(1)); +const token = fragment.get('token') ?? sessionStorage.getItem('chemvas-browser-token') ?? ''; +if (fragment.has('token')) { + sessionStorage.setItem('chemvas-browser-token', token); + history.replaceState(null, '', location.pathname); +} +let tool = 'bond', selection = new Set(), gesture = null, preview = null, loading = false; +let view = {x: -25, y: -25, width: 645, height: 892}; +let ui = null, bondStyle = 'single'; +let previewInfo = null, previewSerial = 0, previewPending = false; +const supportedTools = new Set(['select', 'bond', 'benzene', 'delete', 'text']); + +async function api(path, body) { + const response = await fetch(`/api/${path}`, { + method: body === undefined ? 'GET' : 'POST', + headers: {'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json'}, + body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body), + }); + const value = await response.json(); + if (!response.ok) throw new Error(value.error ?? 'The request failed.'); + return value; +} + +function notice(text = '', error = false) { + $('notice').textContent = text; + $('notice').hidden = !text; + $('notice').classList.toggle('error', error); +} + +function render() { + const busy = editor.busy || loading; + document.querySelectorAll('[data-idle]').forEach(item => { item.disabled = busy; }); + document.querySelectorAll('[data-editable]').forEach(item => { item.disabled = busy || editor.readOnly; }); + document.querySelectorAll('[data-tool]').forEach(item => { + item.setAttribute('aria-pressed', String(item.dataset.tool === tool)); + item.disabled = !supportedTools.has(item.dataset.tool) || busy || (editor.readOnly && item.dataset.tool !== 'select'); + }); + $('undo').disabled = busy || !editor.canUndo; + $('redo').disabled = busy || !editor.canRedo; + $('delete').disabled = busy || editor.readOnly || !selection.size; + if (!editor.document) return; + const state = editor.document.state; + const name = editor.name.replace(/\.chemvas$/i, ''); + document.title = `${editor.dirty ? '• ' : ''}${name} — Chemvas`; + $('canvas-status').textContent = `Canvas: ${name}`; + $('selection').textContent = `Selection: ${selection.size}`; + $('tool-status').textContent = `Tool: ${ui?.groups.flat().find(item => item.key === tool)?.label ?? tool}`; + document.querySelectorAll('[data-context]').forEach(item => { item.hidden = item.dataset.context !== tool; }); + document.querySelectorAll('[data-bond]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.bond === bondStyle))); + $('bond-length').value = state.settings.bond_length_px; + $('drawing').innerHTML = sceneMarkup(previewInfo?.document ?? editor.document, {selection, preview: gesture?.kind === 'bond' && previewInfo ? null : preview, drawing: previewInfo?.drawing ?? editor.info.drawing}); + canvas.dataset.tool = tool; + canvas.setAttribute('viewBox', `${view.x} ${view.y} ${view.width} ${view.height}`); + for (const id of ['paper']) { + $(id).setAttribute('width', editor.info.sheet[0]); + $(id).setAttribute('height', editor.info.sheet[1]); + } + $('zoom-level').textContent = `${Math.round((canvas.getScreenCTM()?.a ?? 1) * 100)}%`; + $('status').textContent = busy ? 'Applying edit…' : editor.readOnly ? 'Read-only · incomplete preview' : (ui?.hints[tool] ?? 'Eraser: click to erase'); +} + +function fitPage() { + if (!editor.info) return; + const [width, height] = editor.info.sheet; + view = {x: -25, y: -25, width: width + 50, height: height + 50}; + render(); +} + +function zoom(factor) { + if (view.width * factor < 40 || view.width * factor > 10000) return; + view = {x: view.x + view.width * (1 - factor) / 2, y: view.y + view.height * (1 - factor) / 2, width: view.width * factor, height: view.height * factor}; + render(); +} + +function point(event) { + const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(canvas.getScreenCTM().inverse()); + return {x: p.x, y: p.y}; +} + +function cancelGesture() { + const pointer = gesture?.pointer; + gesture = preview = previewInfo = null; + previewSerial++; + if (pointer !== undefined && canvas.hasPointerCapture(pointer)) canvas.releasePointerCapture(pointer); + render(); +} + +async function edit(change) { + if (loading || editor.busy || editor.readOnly) return; + notice(); + const pending = editor.perform(change); + render(); + try { await pending; return true; } + catch (error) { notice(error.message, true); return false; } + finally { render(); } +} + +async function loadDocument(infoPromise, name) { + loading = true; + cancelGesture(); + render(); + try { + const info = await infoPromise; + await editor.load(info, name); + selection = new Set(); + tool = 'bond'; + notice(info.unsupported.length ? `Incomplete, read-only preview: ${info.unsupported.join(', ')}. These elements are not faithfully displayed. Save copy preserves their data; use the desktop app to edit or export this drawing.` : ''); + actualSize(); + } catch (error) { notice(error.message, true); } + finally { loading = false; render(); } +} + +function mayReplace() { + return !editor.busy && !loading && (!editor.dirty || confirm('Discard unsaved changes? Save a copy first if you want to keep them.')); +} + +function download(text, name, type) { + const url = URL.createObjectURL(new Blob([text], {type})); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = name; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 30000); +} + +async function atomInput(change) { + loading = true; + render(); + let text; + try { + const plan = await api('atom-input', {document: editor.document, edit: change, symbol: $('atom-symbol').value}); + text = plan.text; + if (plan.needs_prompt) { + const dialog = $('atom-dialog'); + $('atom-label').value = plan.initial; + dialog.returnValue = 'cancel'; + const closed = new Promise(resolve => dialog.addEventListener('close', resolve, {once: true})); + dialog.showModal(); + $('atom-label').focus(); + $('atom-label').select(); + await closed; + text = dialog.returnValue === 'ok' ? $('atom-label').value : null; + } + } catch (error) { notice(error.message, true); } + finally { loading = false; render(); } + if (text !== null && text !== undefined) await edit({...change, text}); +} + +function selectedItems(keys = selection) { + return [...keys].map(key => { + const [target, id] = key.split(':'); + return {target, id: Number(id)}; + }); +} + +async function deleteSelection() { + if (!selection.size) return; + cancelGesture(); + const items = selectedItems(); + const change = {kind: 'delete_selection', selection: items}; + if (await edit(change)) selection.clear(); + render(); +} + +function selectAll() { + if (!editor.document || editor.readOnly || editor.busy || loading) return; + cancelGesture(); + tool = 'select'; + const model = editor.document.state.model; + selection = new Set([ + ...Object.keys(model.atoms).map(id => `atom:${id}`), + ...model.bonds.flatMap((bond, id) => bond ? [`bond:${id}`] : []), + ]); + render(); +} + +function setTool(next) { if (supportedTools.has(next)) { cancelGesture(); tool = next; render(); } } + +canvas.addEventListener('pointerdown', event => { + if (!editor.document || editor.busy || loading || gesture || event.button > 1) return; + canvas.focus(); + const p = point(event), item = event.target.closest('[data-item]')?.dataset.item ?? null; + const [kind, rawId] = item?.split(':') ?? []; + const id = Number(rawId); + if (event.button === 1 || tool === 'pan' || event.altKey) { + event.preventDefault(); + gesture = {kind: 'pan', start: p, view: {...view}, pointer: event.pointerId}; + } else if (tool === 'select') { + if (event.shiftKey && item) { + if (selection.has(item)) selection.delete(item); else selection.add(item); + } else { + if (!item) selection.clear(); + else { + if (!selection.has(item)) selection = new Set([item]); + if (!editor.readOnly) gesture = {kind: 'move', start: p, selection: selectedItems(), pointer: event.pointerId}; + } + } + } else if (!editor.readOnly) { + if (tool === 'delete') { selection = new Set(item ? [item] : []); void deleteSelection(); } + else if (tool === 'text') void atomInput({kind: 'atom', x: p.x, y: p.y, atom_id: kind === 'atom' ? id : null, bond_id: kind === 'bond' ? id : null}); + else if (tool === 'benzene') void edit({kind: 'ring', x: p.x, y: p.y, atom_id: kind === 'atom' ? id : null, bond_id: kind === 'bond' ? id : null}); + else if (tool === 'bond' && kind === 'bond') { + void edit({kind: 'bond_style', id, style: bondStyle}); + } else { + gesture = {kind: tool, start: p, pointer: event.pointerId}; + } + } + if (gesture) canvas.setPointerCapture(event.pointerId); + render(); +}); + +canvas.addEventListener('pointermove', event => { + if (!editor.document) return; + const p = point(event); + + if (!gesture) return; + if (gesture.kind === 'pan') { + view.x += gesture.start.x - p.x; + view.y += gesture.start.y - p.y; + } else if (gesture.kind === 'move') { + preview = {kind: 'move', end: p}; + previewSerial++; + void refreshGesturePreview(); + } else if (gesture.kind === 'bond') { + preview = {kind: 'line', start: gesture.start, end: p}; + previewSerial++; + void refreshGesturePreview(); + } + render(); +}); + +canvas.addEventListener('pointerup', event => { + if (!gesture) return; + const completed = gesture, p = point(event); + cancelGesture(); + if (completed.kind === 'bond') { + void edit(bondRequest(completed, p)); + } else if (completed.kind === 'move' && (p.x !== completed.start.x || p.y !== completed.start.y)) { + void edit(moveRequest(completed, p)); + } +}); +canvas.addEventListener('pointercancel', cancelGesture); +canvas.addEventListener('lostpointercapture', () => { if (gesture) cancelGesture(); }); +canvas.addEventListener('wheel', event => { event.preventDefault(); if (!gesture) zoom(event.deltaY > 0 ? 1.1 : 1 / 1.1); }, {passive: false}); +window.addEventListener('blur', cancelGesture); + + +$('new').onclick = () => { if (mayReplace()) void loadDocument(api('new'), 'Canvas 1.chemvas'); }; +$('open').onclick = () => { if (mayReplace()) $('file').click(); }; +$('file').onchange = async () => { + const file = $('file').files[0]; + $('file').value = ''; + if (!file) return; + if (file.size > 2 * 1024 * 1024) { notice('The browser adapter opens files up to 2 MiB.', true); return; } + await loadDocument(file.text().then(text => api('open', text)), file.name); +}; +$('save').onclick = () => { + if (!editor.document) return; + download(JSON.stringify(editor.document, null, 2) + '\n', editor.name.replace(/\.chemvas$/i, '') + '-web-copy.chemvas', 'application/json'); + notice('Save copy requested. Check your downloads before closing; the original file has not changed.'); +}; +for (const action of ['undo', 'redo']) $(action).onclick = async () => { + cancelGesture(); const pending = editor[action](); render(); + try { await pending; selection = new Set(); } catch (error) { notice(error.message, true); } finally { render(); } +}; +$('delete').onclick = deleteSelection; +$('select-all').onclick = selectAll; +$('zoom-in').onclick = () => zoom(.8); +$('zoom-out').onclick = () => zoom(1.25); +$('fit').onclick = fitPage; +$('fit-menu').onclick = fitPage; +$('actual-size').onclick = $('zoom-level').onclick = actualSize; +$('zoom-in-menu').onclick = () => zoom(.8); +$('zoom-out-menu').onclick = () => zoom(1.25); +$('save-as').onclick = () => $('save').click(); +$('bond-length').onchange = () => void edit({kind: 'bond_length', value: Number($('bond-length').value)}); +$('atom-label-cancel').onclick = () => $('atom-dialog').close('cancel'); +$('help').onclick = () => $('help-dialog').showModal(); +$('close-help').onclick = () => $('help-dialog').close(); +window.addEventListener('beforeunload', event => { if (editor.dirty || editor.busy) { event.preventDefault(); event.returnValue = ''; } }); +document.addEventListener('keydown', event => { + if (event.isComposing || event.target.matches('input, textarea, select') || document.querySelector('dialog[open]')) return; + if (event.key === 'Escape') { cancelGesture(); return; } + if (editor.busy || loading) return; + const key = event.key.toLowerCase(), command = event.ctrlKey || event.metaKey; + if (command && key === 'a') { event.preventDefault(); selectAll(); } + else if (command && key === 'n') { event.preventDefault(); $('new').click(); } + else if (['F5', 'F6', 'F7', 'F8'].includes(event.key)) { event.preventDefault(); $({'F5': 'actual-size', 'F6': 'fit', 'F7': 'zoom-in', 'F8': 'zoom-out'}[event.key]).click(); } + else if (command && key === 'z') { event.preventDefault(); $(event.shiftKey ? 'redo' : 'undo').click(); } + else if (command && key === 's') { event.preventDefault(); $('save').click(); } + else if (command && key === 'o') { event.preventDefault(); $('open').click(); } + else if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); if (!editor.readOnly) void deleteSelection(); } + else if (!command) { + const next = {' ': 'select', x: 'bond', a: 'text', j: 'benzene', e: 'arrow', t: 'note'}[key]; + if (next && !event.shiftKey && !event.altKey && (!editor.readOnly || next === 'select')) { event.preventDefault(); setTool(next); } + if (tool === 'bond' && !editor.readOnly) { + const style = {'1': 'single', '2': 'double', '3': 'triple', b: 'bold_in', w: 'wedge', ...(event.shiftKey ? {h: 'hash'} : {})}[key]; + if (style) { bondStyle = style; render(); } + } + } +}); +new ResizeObserver(() => render()).observe(canvas); +try { ui = await api('ui'); buildControls(); await loadDocument(api('new'), 'Canvas 1.chemvas'); } catch (error) { notice(error.message, true); } + + +function actualSize() { + if (!editor.info) return; + const [width, height] = editor.info.sheet; + view = {x: (width - canvas.clientWidth) / 2, y: (height - canvas.clientHeight) / 2, width: canvas.clientWidth, height: canvas.clientHeight}; + render(); +} + +function buildControls() { + const atomInput = $('atom-symbol'); + atomInput.value = ui.atom_input.value; + atomInput.placeholder = ui.atom_input.placeholder; + atomInput.title = ui.atom_input.tooltip; + atomInput.maxLength = $('atom-label').maxLength = ui.atom_input.max_length; + atomInput.style.minWidth = `${ui.atom_input.min_width}px`; + atomInput.style.maxWidth = `${ui.atom_input.max_width}px`; + // Existing desktop declarations drive order, naming and artwork. This is a DOM adapter. + function button(spec) { + const element = document.createElement('button'); + element.title = spec.tip ?? spec.label; + element.setAttribute('aria-label', spec.label); + element.innerHTML = spec.icon; + return element; + } + for (const entries of ui.groups) { + const group = document.createElement('div'); + group.className = 'tool-group'; + for (const spec of entries) { + const element = button(spec); + element.dataset.tool = spec.key; + element.onclick = () => setTool(spec.key); + group.append(element); + } + $('tools').append(group); + } + const spacer = document.createElement('span'); spacer.className = 'spacer'; $('tools').append(spacer); + for (const spec of ui.panels) { const element = button(spec); element.disabled = true; $('tools').append(element); } + for (const entries of ui.bond_groups) { + const group = document.createElement('div'); group.className = 'segments'; + for (const spec of entries) { + const element = button(spec); + element.dataset.bond = spec.key; + if (spec.key === 'dotted') element.disabled = true; + else { element.dataset.editable = ''; element.onclick = () => { bondStyle = spec.key; render(); }; } + group.append(element); + } + $('bond-options').append(group); + } + const ring = button(ui.groups.flat().find(item => item.key === 'benzene')); + ring.setAttribute('aria-pressed', 'true'); + $('ring-options').append(ring); + document.querySelectorAll('.menus details').forEach(menu => { + menu.addEventListener('toggle', () => { if (menu.open) document.querySelectorAll('.menus details').forEach(other => { if (other !== menu) other.open = false; }); }); + menu.querySelectorAll('button').forEach(item => item.addEventListener('click', () => { menu.open = false; })); + }); + document.addEventListener('pointerdown', event => { if (!event.target.closest('.menus')) document.querySelectorAll('.menus details').forEach(menu => { menu.open = false; }); }); +} + +window.addEventListener('pagehide', () => { if (editor.info?.session) fetch('/api/session', {method: 'POST', headers: {'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json'}, body: JSON.stringify({session: editor.info.session, action: 'close'}), keepalive: true}).catch(() => {}); }); + + +function bondRequest(active, end) { + return {kind: 'bond', start: [active.start.x, active.start.y], end: [end.x, end.y], style: bondStyle}; +} + +async function refreshGesturePreview() { + if (previewPending || !['bond', 'move'].includes(gesture?.kind) || !preview) return; + const serial = previewSerial, projected = preview; + const change = gesture.kind === 'move' ? moveRequest(gesture, projected.end) : bondRequest(gesture, projected.end); + previewPending = true; + try { + const info = await api('preview', {document: editor.document, edit: change}); + if (serial === previewSerial && gesture) { previewInfo = info; render(); } + } catch { /* A release reports errors through the committed edit path. */ } + finally { + previewPending = false; + if (serial !== previewSerial && gesture) void refreshGesturePreview(); + } +} + + +function moveRequest(active, end) { + return {kind: 'move', selection: active.selection, dx: end.x - active.start.x, dy: end.y - active.start.y}; +} diff --git a/app/chemvas/web/index.html b/app/chemvas/web/index.html new file mode 100644 index 00000000..d3481a1a --- /dev/null +++ b/app/chemvas/web/index.html @@ -0,0 +1,52 @@ + + + + + + Canvas 1 — Chemvas + + + + + + + +
+
BOND
+ + + +
+ +
+ + + + +
+
Connecting…Tool: BondCanvas: Canvas 1Selection: 0Zoom
+ +

Atom Label

+

About Chemvas

Chemvas browser adapter in development. Tool order, icons, palette, button dimensions and tooltips come directly from the desktop application.

Connected: bond drawing, benzene insertion, bond styles, atom labels, multiple selection, dragging, deletion, undo/redo and .chemvas open/save. Controls awaiting migration are disabled. Rendering and interactions are not yet fully equivalent to Qt.

Save downloads a copy; it does not overwrite the opened file. There is no autosave or recovery yet. Verify downloads before closing. Other document elements are preserved in read-only files, but may not appear correctly.

Space: Select · X: Bond · A: Atom · J: Ring. Shift-click adds/removes selections; ⌘/Ctrl+A selects all. Drag a selection to move it. Middle-button drag pans. F5–F8 control zoom.

+ + diff --git a/app/chemvas/web/scene.mjs b/app/chemvas/web/scene.mjs new file mode 100644 index 00000000..e45455bb --- /dev/null +++ b/app/chemvas/web/scene.mjs @@ -0,0 +1,60 @@ +// Display primitives supplied by the existing Chemvas rendering services. +function escapeText(value) { + return String(value).replace(/[&<>"']/g, char => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char])); +} + +const number = value => Number(value).toFixed(4); +const line = (x1, y1, x2, y2, extra = '') => ``; + +export function sceneMarkup(document, {selection = new Set(), preview = null, drawing} = {}) { + const state = document.state; + const atoms = {...state.model.atoms}; + const parts = []; + for (const ring of state.ring_fills ?? []) { + if (!ring.color || !ring.alpha) continue; + parts.push(``); + } + state.model.bonds.forEach((bond, index) => { + if (!bond) return; + const a = atoms[bond.a], b = atoms[bond.b]; + const key = `bond:${index}`; + parts.push(``); + parts.push(`Bond ${bond.a}–${bond.b}, ${escapeText(bond.style)}`); + if (selection.has(key)) parts.push(line(a.x, a.y, b.x, b.y, 'stroke="#0d9488" stroke-width="7" opacity="0.2"')); + // No bond/ring algorithm lives here: the desktop planner supplied these primitives. + for (const primitive of drawing.bonds[index] ?? []) { + if (primitive.line) parts.push(line(...primitive.line)); + else if (primitive.polygon) parts.push(``); + } + parts.push(line(a.x, a.y, b.x, b.y, 'stroke="transparent" stroke-width="8" pointer-events="stroke"')); + parts.push(''); + }); + for (const [id, atom] of Object.entries(atoms)) { + const key = `atom:${id}`, x = number(atom.x), y = number(atom.y); + parts.push(``); + parts.push(`Atom ${id}: ${escapeText(atom.element)}`); + if (selection.has(key)) parts.push(``); + if (Object.hasOwn(drawing.atom_labels, id)) parts.push(`${escapeText(drawing.atom_labels[id])}`); + parts.push(``); + parts.push(''); + } + state.arrows.forEach((arrow, index) => { + const [x1, y1] = arrow.start, [x2, y2] = arrow.end; + const color = escapeText(arrow.color ?? '#000000'); + parts.push(``); + if (selection.has(`arrow:${index}`)) parts.push(line(x1, y1, x2, y2, 'stroke="#0d9488" stroke-width="7" opacity="0.2"')); + for (const points of drawing.arrows[index]) parts.push(``); + parts.push(line(x1, y1, x2, y2, 'stroke="transparent" stroke-width="8" pointer-events="stroke"')); + parts.push(''); + for (const [side, text] of Object.entries(arrow.labels ?? {})) { + parts.push(`${escapeText(text)}`); + } + }); + state.notes.forEach((note, index) => { + parts.push(``); + String(note.text).split('\n').forEach((text, i) => parts.push(`${escapeText(text)}`)); + parts.push(''); + }); + if (preview?.kind === 'line') parts.push(line(preview.start.x, preview.start.y, preview.end.x, preview.end.y, 'stroke="#0d9488" stroke-width="1.5" stroke-dasharray="3 2" pointer-events="none"')); + return parts.join(''); +} diff --git a/app/chemvas/web/style.css b/app/chemvas/web/style.css new file mode 100644 index 00000000..c471c2bf --- /dev/null +++ b/app/chemvas/web/style.css @@ -0,0 +1,59 @@ +/* Values are served from the existing Qt palette and toolbar_styles module. */ +:root { color-scheme: light; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 13px; color: var(--text); background: var(--surface-app); } +* { box-sizing: border-box; } +body { margin: 0; height: 100dvh; display: flex; flex-direction: column; overflow: hidden; } +button, input, select { font: inherit; color: inherit; } +button { border: 1px solid transparent; background: transparent; border-radius: 6px; padding: 0 9px; height: 26px; cursor: default; white-space: nowrap; } +button:hover:not(:disabled), summary:hover { background: var(--hover); } +button:active:not(:disabled) { background: var(--pressed); } +button:focus-visible, summary:focus-visible, input:focus-visible, select:focus-visible:focus-visible { outline: 1px solid var(--accent); } +button:disabled { color: var(--text-faint); } +button:disabled svg { opacity: .4; } +button[aria-pressed="true"] { background: var(--checked-bg); border-color: var(--checked-border); color: var(--checked-text); } +input, select { border: 1px solid var(--border-strong); border-radius: 5px; padding: 3px 6px; background: var(--surface-input); height: 26px; } +input { width: 90px; } +input:disabled { color: var(--text-faint); } +label { display: flex; align-items: center; gap: 6px; } +[hidden] { display: none !important; } +.spacer { flex: 1; } +.menus { display: flex; align-items: center; height: 25px; flex: none; background: var(--surface-bar); border-bottom: 1px solid var(--border); z-index: 5; } +summary { list-style: none; padding: 4px 10px; cursor: default; } +summary::-webkit-details-marker { display: none; } +.menu { position: absolute; min-width: 230px; background: var(--surface-pop); border: 1px solid var(--border); border-radius: 6px; padding: 5px; box-shadow: 0 5px 16px #0002; max-height: calc(100dvh - 40px); overflow: auto; } +.menu button { display: flex; width: 100%; text-align: left; align-items: center; justify-content: space-between; gap: 22px; } +kbd { font: inherit; font-size: 11px; color: var(--text-muted); } +hr { border: 0; border-top: 1px solid var(--border); margin: 5px; } +.tools { height: var(--toolbar-thickness); flex: none; display: flex; align-items: center; padding: 0 6px; gap: 12px; background: var(--surface-bar); border-bottom: 1px solid var(--border); overflow-x: auto; } +.tool-group { display: flex; gap: 4px; flex: none; position: relative; } +.tool-group + .tool-group::before { content: ""; position: absolute; left: -7px; top: 7px; width: 1px; height: 18px; background: var(--border); } +.tools button { width: var(--toolbar-button-size); height: var(--toolbar-button-size); padding: 0; display: grid; place-items: center; flex: none; } +button svg { width: var(--toolbar-icon-size); height: var(--toolbar-icon-size); padding: 1px; } +.options { display: flex; align-items: center; min-height: var(--toolbar-thickness); flex: none; padding: 0 8px; gap: 12px; background: var(--surface-context); border-bottom: 1px solid var(--border); overflow-x: auto; } +.context { display: flex; align-items: center; gap: 12px; flex: none; height: var(--context-bar-content-height); } +.context .segments { display: flex; align-items: center; border: 1px solid var(--border-strong); background: var(--surface-input); border-radius: 6px; overflow: hidden; } +.segments button { border-radius: 0; height: var(--context-bar-button-height); width: 28px; padding: 0; display: grid; place-items: center; } +.segments button + button { border-left-color: var(--border); } +.segments button svg { width: var(--context-bar-icon-size); height: var(--context-bar-icon-size); } +.caption { font-size: 10px; font-weight: 600; color: var(--text-muted); letter-spacing: .5px; } +.length { color: var(--text-muted); gap: 2px; } +.length input { width: 65px; } +.smiles { font-size: 11px; color: var(--text-muted); flex: none; } +.smiles input { width: 150px; } +button.primary { background: var(--accent); color: var(--accent-contrast); } +button.primary:disabled { opacity: .45; } +#notice { padding: 7px 10px; background: #fff6df; color: #735011; font-size: 12px; line-height: 1.5; flex: none; } +#notice.error { background: var(--danger-bg); color: var(--danger-text); } +.workspace { flex: 1; min-height: 0; min-width: 0; overflow: hidden; background: #e7e7e6; } +#canvas { width: 100%; height: 100%; display: block; touch-action: none; user-select: none; outline: none; } +#canvas[data-tool="bond"], #canvas[data-tool="benzene"] { cursor: crosshair; } +#paper { filter: drop-shadow(0 1px 2px #0002); } +footer { height: 28px; flex: none; display: flex; align-items: center; padding: 0 6px; gap: 8px; border-top: 1px solid var(--border); background: var(--surface-bar); font-size: 11px; color: var(--text-muted); white-space: nowrap; } +#status { overflow: hidden; text-overflow: ellipsis; } +.zoom { display: flex; align-items: center; border: 1px solid var(--border-strong); border-radius: 6px; height: 24px; background: var(--surface-input); } +.zoom button { height: 22px; font-size: 11px; padding: 0 7px; } +#zoom-level { min-width: 44px; } +dialog { width: min(500px, 90vw); padding: 24px; border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 12px 60px #0002; color: inherit; } +dialog::backdrop { background: #0003; } +dialog h2 { margin: 0 0 16px; font-size: 18px; } +dialog p { line-height: 1.6; color: var(--text-muted); } +@media (max-width: 720px) { #canvas-status, #tool-status { display: none; } } diff --git a/app/chemvas/web/transport.mjs b/app/chemvas/web/transport.mjs new file mode 100644 index 00000000..ac9feadb --- /dev/null +++ b/app/chemvas/web/transport.mjs @@ -0,0 +1,35 @@ +// Browser transport only. Chemvas's Python CanvasHistoryService owns undo/redo. +export class SessionClient { + #info = null; + #busy = false; + #send; + name = 'Canvas 1.chemvas'; + + constructor(send) { this.#send = send; } + get info() { return this.#info; } + get document() { return this.info?.document ?? null; } + get busy() { return this.#busy; } + get readOnly() { return Boolean(this.info?.unsupported.length); } + get canUndo() { return !this.#busy && Boolean(this.info?.can_undo); } + get canRedo() { return !this.#busy && Boolean(this.info?.can_redo); } + get dirty() { return Boolean(this.info?.dirty); } + + async #dispatch(action, extra = {}) { + if (this.#busy) throw new Error('Wait for the current edit to finish.'); + this.#busy = true; + try { + const result = await this.#send({session: this.info?.session, revision: this.info?.revision ?? 0, action, ...extra}); + this.#info = result; + } finally { this.#busy = false; } + } + async load(info, name = 'Canvas 1.chemvas') { + await this.#dispatch('load', {document: info.document}); + this.name = name; + } + async perform(edit) { + if (this.readOnly) throw new Error('This drawing is read-only in the browser adapter.'); + await this.#dispatch('edit', {edit}); + } + async undo() { if (this.canUndo) await this.#dispatch('undo'); } + async redo() { if (this.canRedo) await this.#dispatch('redo'); } +} diff --git a/docs/ARCHITECTURE.ko.md b/docs/ARCHITECTURE.ko.md index ec249345..015c7ec0 100644 --- a/docs/ARCHITECTURE.ko.md +++ b/docs/ARCHITECTURE.ko.md @@ -1,5 +1,12 @@ # 아키텍처 +브라우저 어댑터는 [사용 안내](WEB_ADAPTER.ko.md)와 +[ADR 0029](adr/0029-browser-adapter.md)를 따른다. Python 세션은 +기존 문서·기능 API와 CanvasHistoryService를 재사용한다. 브라우저는 승인된 +상태를 표시하고 UI 정의·그림은 기존 소유자에서 읽는다. SVG 표시와 이벤트 연결은 +각각 응집된 연결 코드로 유지한다. +아래 Qt 구조는 기존 기본 데스크톱 편집기에 해당한다. + [English](ARCHITECTURE.md) ## 패키지별 책임 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2faf8694..2d935263 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,5 +1,11 @@ # Architecture +The browser presentation adapter is described in [WEB_ADAPTER.md](WEB_ADAPTER.md) +and [ADR 0029](adr/0029-browser-adapter.md). Its Python session +reuses existing document and feature APIs and CanvasHistoryService. The browser +mirrors accepted state; toolbar definitions and artwork come from the existing +desktop owners. SVG materialization and event wiring are cohesive adapters. The Qt architecture below remains the default desktop editor. + [한국어](ARCHITECTURE.ko.md) ## Package Responsibilities diff --git a/docs/WEB_ADAPTER.ko.md b/docs/WEB_ADAPTER.ko.md new file mode 100644 index 00000000..74cdbf07 --- /dev/null +++ b/docs/WEB_ADAPTER.ko.md @@ -0,0 +1,95 @@ +# 브라우저 어댑터 + +[English](WEB_ADAPTER.md) + +기존 Chemvas 편집기의 화면·입력을 브라우저 어댑터로 옮기는 작업이다. +Qt 앱을 함께 유지하며, 전체 사용 흐름과 화면의 동등성을 목표로 개발 중이다. + +## 실행 + +```bash +python -m pip install -e . +chemvas --ui web +chemvas --ui qt +``` + +`make check` 이후 macOS/Linux는 `.venv/bin/chemvas`, Windows는 +`.venv/Scripts/chemvas.exe`를 사용한다. Python 3.12+가 필요하다. +`chemvas --ui web --no-browser`는 실행 URL을 출력한다. 터미널을 유지하고 +Ctrl+C로 서버를 종료한다. URL의 세션 인증 정보는 로컬에서만 사용한다. +프런트엔드 빌드·CDN·Node 런타임은 필요하지 않다. 웹 경로는 Qt를 import하지 +않으며, 통합 패키지는 데스크톱 앱을 위해 Qt를 계속 설치한다. + +## 연결된 기존 구현 + +- 도구 순서·이름·툴팁·결합 옵션·상태 안내는 기존 `main_window_config`에서 읽는다. + SVG 아이콘, 색상, 버튼 크기와 ACS1996 그리기 기본값도 양쪽이 공유한다. +- 결합 생성은 `StructureBondBuildService`와 `StructureBuildCommitter`가 실행한다. + 가까운 원자 선택, 끝점 스냅, 클릭 시 기본 방향, 결합 스타일 클릭 정책도 Qt와 + 같은 함수를 쓴다. 기존 원자와 결합을 재사용하는 판단은 원래 생성기가 담당한다. +- 벤젠 삽입은 `StructureBenzeneBuildService`와 같은 커미터를 호출한다. + 원자·결합에 붙이기, 융합, 원자 합치기, 결합 차수와 고리 기록을 그대로 사용한다. + 삭제는 항목 수에 관계없이 같은 선택 삭제 요청으로 기존 삭제 계획을 실행한다. +- 굵은 결합(Bold)은 기존 `BondGraphicsDrawService`의 다각형을 표시한다. + 이중·삼중 결합의 차수, 고리 방향과 인접 굵은 결합 접합 계산을 그대로 쓴다. +- SVG 결합 선은 기존 `BondGeometryPlanService`, `BondLineGeometryService`, + `BondRingDoubleGeometryService`의 결과를 표시한다. 고리 중심 선택도 공유한다. +- 원자 도구는 기존 `AtomLabelService`와 `AtomLabelMergeService`를 호출한다. + 탄소 표시, 겹친 원자와 중복 결합의 병합, 비탄소 기호 숨김 거부 규칙을 공유한다. + 입력창 속성과 클릭 대상·입력 계획도 Qt 소유자에서 가져온다. +- 선택 항목 이동은 기존 `CanvasMoveController`를 호출한다. 결합 끝점은 기존 + 선택 확장 함수로 중복 없이 계산하고, 고리 채움은 기존 polygon 갱신기로 맞춘다. + 드래그 미리보기와 확정 편집은 같은 서버 경로를 쓴다. +- Undo/Redo는 기존 `CanvasHistoryService`와 `CanvasHistoryState`가 담당한다. + 서버가 기존 연산으로 임시 후보를 만들고 검증·기록한 뒤 승인된 상태를 전달한다. + JavaScript는 승인된 상태를 표시한다. + +A로 원자 도구를 선택한다. 상단에 기호를 입력한 뒤 클릭하면 해당 기호를 적용하고, +입력창이 비어 있으면 기존 원자 기호를 담은 대화상자를 연다. 취소는 문서를 바꾸지 않는다. +X로 결합을 그리고 J로 벤젠을 놓거나 기존 원자·결합에 붙인다. +결합 도구에서 B를 누르면 Bold를 선택한다. +Space로 선택하고 드래그해 이동한다. Shift+클릭으로 선택을 추가·해제하고, +⌘/Ctrl+A로 전체 구조를 선택한다. Delete는 선택 전체를 한 명령으로 삭제하며 +지우개는 클릭한 항목을 삭제한다. Escape·도구 전환·포커스 이탈은 미리보기를 취소한다. 결합을 클릭하면 선택한 스타일을 +적용하고, 결합 도구의 짧은 클릭은 기존 기본 방향으로 결합을 만든다. +제스처 하나를 Undo 한 번으로 취소한다. 가운데 버튼 또는 Alt+드래그로 이동하고 +F5–F8로 확대를 조절한다. + +File → Open으로 `.chemvas`를 열고 Save로 사본을 다운로드한다. +버전 7–9의 문서 버전과 데이터를 유지하며 Qt에서도 사본을 열 수 있다. +다운로드 시작만으로 디스크 저장 완료를 확인할 수 없어 변경 표시와 종료 경고는 +유지한다. 브라우저 자동 저장·복구는 아직 연결하지 않았다. + +## 남은 연결 작업 + +텍스트·화살표 편집, 사각 영역 선택과 선택 외곽선·핸들, 패널, SMILES, +화학 클립보드와 출판 출력은 기존 기능의 연결이 남아 있다. +원래 도구·메뉴 자리는 유지하고 미연결 동작은 비활성으로 표시한다. +이 기능을 대신하는 별도 간이 편집기와 SVG 내보내기는 두지 않는다. + +텍스트·화살표·이미지·그룹·추가 주석·미지원 스타일이 있는 문서는 +불완전한 읽기 전용 미리보기로 연다. 저장 사본에는 원본 데이터를 유지하고 +편집 요청은 거부한다. 글꼴 측정, 라벨 자르기, 결합 접합, 서식 텍스트와 +메뉴·파일 대화상자·클립보드는 브라우저·플랫폼별 연결이 필요하다. +전체 편집 기능은 Qt에서 제공한다. + +서버는 loopback에만 바인딩하고 Host·Origin·실행 인증 정보를 검사한다. +정해진 정적 파일만 제공하며 메모리 세션은 최대 16개다. +요청 2 MiB, 원자 2,000개·결합 3,000개, 기존 히스토리 100개 명령 제한을 쓴다. +전체 프로세스 메모리 제한은 아니다. 새로 고치거나 닫으면 미저장 작업을 잃을 수 있다. + +## 유지보수와 검증 + +`bootstrap/web_adapter.py`는 HTTP·세션·기존 서비스 연결을 담당한다. +브라우저 모듈은 통신·SVG 표시·DOM 이벤트를 맡고, 편집 알고리즘은 기존 소유자에 둔다. +Qt에 묶인 경계는 점 생성 함수를 받거나 외부 트랜잭션의 후보 편집을 허용하되, +기존 Qt 기본 동작은 유지한다. 별도 편집 엔진, 도구별 전달 파일, +복사한 롤백 블록을 추가하지 않는다. [ADR 0029](adr/0029-browser-adapter.md)를 따른다. + +`make check`는 Qt 없는 import, 실제 HTTP, 제거한 시험판 동작 거부, +편집 실패 시 상태 보존, Undo/Redo, 문서 보존과 JavaScript를 검사한다. +테스트에는 Node.js 20+가 필요하다. 실제 Qt와 웹에 같은 결합 생성·클릭 방향· +벤젠 붙이기·융합·삭제·결합 스타일·원자 표기·겹친 원자 병합·원자/결합 이동을 실행해 결과를 비교한다. +두 가지 결합 길이와 고리 기록 유무별 선 좌표, 웹 → Qt 저장 → 웹 편집·Undo → +Qt 재열기도 검사한다. 제스처와 배치는 실제 브라우저로 확인한다. +전체 화면·복구·출력의 동등성은 아직 검증되지 않았으며 Qt 종료는 별도 인수 검증 뒤 결정한다. diff --git a/docs/WEB_ADAPTER.md b/docs/WEB_ADAPTER.md new file mode 100644 index 00000000..a13318ff --- /dev/null +++ b/docs/WEB_ADAPTER.md @@ -0,0 +1,98 @@ +# Browser adapter + +[한국어](WEB_ADAPTER.ko.md) + +Chemvas is migrating its existing editor to a browser presentation adapter while +keeping the Qt application. The browser adapter is in development; full workflow +and visual parity remain the acceptance target. + +## Run + +```bash +python -m pip install -e . +chemvas --ui web +chemvas --ui qt +``` + +After `make check`, use `.venv/bin/chemvas` on macOS/Linux or +`.venv/Scripts/chemvas.exe` on Windows. Python 3.12+ is required. +`chemvas --ui web --no-browser` prints the local launch URL. Keep the terminal +open; Ctrl+C stops the server. The URL contains a session credential and stays +local. No frontend build, CDN or Node runtime is needed. The browser path imports +no Qt; the combined package still installs Qt for the desktop application. + +## Connected implementations + +- Toolbar order, names, tooltips, bond options and status hints come from the + existing `main_window_config`. Both adapters use `shell.icon_design` artwork, + the existing palette, dimensions and ACS1996 rendering metrics. +- Bond creation executes `StructureBondBuildService` and `StructureBuildCommitter`. + Atom picking, endpoint snapping, click direction and bond-style click policy + use the same functions as Qt. Existing atoms and bonds are reused by the builder. +- Benzene insertion executes `StructureBenzeneBuildService` and the same committer. + Attachment, fusion, atom merging, bond orders and ring records keep their existing + owners. Single and multiple deletion use one request to the existing deletion planner. +- Bold polygons come from `BondGraphicsDrawService`, preserving bond order, ring + orientation and adjacent bold-bond mitres. Only point/polygon construction changes. +- SVG bond primitives come from `BondGeometryPlanService`, + `BondLineGeometryService` and `BondRingDoubleGeometryService`, including the + existing ring-edge selection policy. +- `CanvasHistoryService` and `CanvasHistoryState` own Undo/Redo. The server builds + a private candidate using the original operations, validates it, records one + command, then publishes it. JavaScript mirrors accepted state. + +Draw bonds with X, place or attach benzene with J, select with Space and delete +with Delete or the eraser. Click a bond to apply its selected style. A short bond +click uses the original default direction. B selects Bold while using the Bond tool. Undo/Redo treats each gesture as one +edit. Middle-button or Alt-drag pans; F5–F8 controls zoom. + +Space selects the selection tool. Drag selected atoms/bonds to move them, +Shift-click to add or remove selections, and use Command/Ctrl+A to select the +whole structure. Delete removes the selection in one command. Escape, tool +changes and focus loss cancel the preview. + +A selects the Atom tool. Enter a symbol in the context bar and click to apply it; +an empty context field opens the existing-symbol prompt. Cancel leaves the document unchanged. + +File → Open reads `.chemvas`; Save downloads a copy that Qt can open. Document +versions 7–9 retain their version and data. Download initiation cannot prove a +completed disk save, so the dirty marker and close warning remain visible. +There is no autosave or recovery in the browser yet. + +## Connections still in progress + +Text and arrow editing, marquee selection, selection outlines and handles, +panels, SMILES, chemistry clipboard and publication export await their existing +workflow adapters. Their original toolbar/menu positions remain visible with +unconnected actions disabled. The browser has no separate simplified editors +or SVG export command for these actions. + +Text, arrows, images, groups, extra annotation types and unsupported styles open +as incomplete read-only previews. Their original data remains in downloaded +copies; editing is rejected. Font measurement, label clipping, bond junctions, +rich text, menus, file dialogs and clipboard behavior still require browser and +platform work. Qt remains the complete editor. + +The server binds to loopback, verifies Host/Origin and the launch credential, and +serves an explicit asset allowlist. It stores up to 16 in-memory sessions, accepts +requests up to 2 MiB and documents up to 2,000 atoms/3,000 bonds, and uses the +existing 100-command history limit. These are per-request/session limits, not an +overall process memory cap. Refreshing or closing can discard unsaved work. + +## Maintenance and verification + +`bootstrap/web_adapter.py` owns HTTP/session and existing-service connections. +The browser modules contain transport, SVG materialization and DOM/event wiring. +Editing algorithms remain in their existing owners. A Qt-specific boundary may +accept a point factory or an unrecorded candidate caller; it must preserve the +native default behavior. Do not add a second edit engine, per-tool forwarding +files or copied rollback blocks. See [ADR 0029](adr/0029-browser-adapter.md). + +`make check` covers Qt-free imports, actual HTTP requests, rejected retired actions, +failed-edit atomicity, Undo/Redo, document preservation and JavaScript checks. +Tests require Node.js 20+. Differential tests compare actual Qt and browser +bond creation, click direction, benzene attachment/fusion, deletion and bond +styles. They also compare ring line coordinates at two bond lengths with and +without ring records, and exercise web → Qt save → web edit/Undo → Qt reopening. +Real browser checks cover gestures and layout. These checks do not establish +complete UI, recovery or output parity; Qt retirement needs separate acceptance. diff --git a/docs/adr/0029-browser-adapter.md b/docs/adr/0029-browser-adapter.md new file mode 100644 index 00000000..5df0ae78 --- /dev/null +++ b/docs/adr/0029-browser-adapter.md @@ -0,0 +1,70 @@ +# ADR 0029: Browser presentation adapter over existing editing owners + +- Status: Accepted +- Date: 2026-09-28 +- Extends: [ADR 0005](0005-responsibility-based-editor-boundaries.md) + +## Problem + +The browser must preserve Chemvas editing rules while the Qt application remains +available. Independent browser commands, label/movement policies, annotation +editors and an SVG download path would introduce a second set of behaviors. +ADR 0005 requires one owner for each mutation rule and rejects obligatory +access/port/service layers. + +## Decision + +Select presentation through `chemvas --ui qt` or `chemvas --ui web`; Qt is the +default. Use browser-native modules and SVG without a frontend build framework. +Reuse the existing toolbar declarations, artwork, palette and dimensions. +Keep HTTP/session, DOM wiring and SVG materialization cohesive; do not split +by tool or impose file-length targets. + +`BrowserStructureAdapter` connects the existing `StructureBondBuildService`, +`StructureBenzeneBuildService` and `StructureBuildCommitter` to document records. +Atom picking, endpoint geometry, bond-click policy and deletion planning use +shared existing owners. Existing model serialization preserves document data. +Rendering services supply SVG bond primitives, including ring topology order. +Qt imports stay at native materialization or transaction boundaries. Injected +point factories and unrecorded build calls preserve native defaults. + +The Python session owns a committed document. An edit runs against a private +candidate, validates the whole result, records it through the existing +`CanvasHistoryService`, then publishes it. JavaScript has no history stack. +Gesture previews are disposable candidates using the same edit path. Revision +checks reject stale writes; session access is serialized. The adapter adds no +copied try/restore sequence. + +Unconnected tools stay at their original UI positions and remain disabled. +There is no separate graph-patch editing endpoint, plain-note dialog, simplified +arrow editor or browser SVG export workflow. Text, arrows and other unsupported +content remain in read-only documents, including saved copies, until their +existing workflows are connected. + +Open uploads a selected file; Save downloads a copy. There is no filesystem write +API or persistent document store. The server binds to loopback, verifies its own +Host/Origin and fresh launch credential, and serves a fixed asset allowlist. +Qt remains supported until full workflow, recovery, document and output parity +is demonstrated and a separate retirement decision is made. + +## Verification and limits + +`tests/test_web_adapter.py` checks Qt-free imports, actual HTTP authorization, +malformed input, document versions 7–9, failed candidate publication, history and +rejection of retired demo actions. It invokes `tests/web_adapter.test.mjs` for +transport failure, concurrency, text escaping and non-mutating gesture display. + +Differential tests compare actual Qt and browser bonds, benzene attachment/fusion, +deletion, styles and line coordinates, plus document reopening across adapters. +Native tests retain recorded-build rollback and ring-cache behavior. Real browser +checks cover gestures and layout; these are not complete visual-parity evidence. + +Atom labels and merging reuse `AtomLabelService` and `AtomLabelMergeService`; +selected-atom/bond dragging uses `CanvasMoveController`. Shift-click and Select All +provide multiple selection. Bold polygons reuse `BondGraphicsDrawService`. +Annotations, marquee selection, selection handles, recovery and publication export +remain migration work. Font measurement, label clipping, +bond junctions and platform input also remain incomplete. Requests are limited +to 2 MiB and documents to 2,000 atoms/3,000 bonds, with at most 16 memory sessions +and the existing history limit. These bounds do not cap total process memory. +The existing Qt document and scientific contracts remain in effect. diff --git a/pyproject.toml b/pyproject.toml index 4e2e49a1..2d4b2e8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ include = ["chemvas*"] namespaces = false [tool.setuptools.package-data] -chemvas = ["assets/icon/*.svg", "assets/icon/*.png"] +chemvas = ["assets/icon/*.svg", "assets/icon/*.png", "web/*.html", "web/*.css", "web/*.mjs"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/test_atom_label_service.py b/tests/test_atom_label_service.py index cc195dd9..678606fd 100644 --- a/tests/test_atom_label_service.py +++ b/tests/test_atom_label_service.py @@ -241,7 +241,7 @@ def test_prompt_atom_label_applies_dialog_result(self) -> None: service.add_or_update_atom_label = Mock() with patch( - "chemvas.ui.molecule.atom_label_service.QInputDialog.getText", + "PyQt6.QtWidgets.QInputDialog.getText", side_effect=[ (" N ", True), (" ", True), diff --git a/tests/test_ring_label_clearance.py b/tests/test_ring_label_clearance.py index f410eda3..41a92be4 100644 --- a/tests/test_ring_label_clearance.py +++ b/tests/test_ring_label_clearance.py @@ -147,7 +147,7 @@ def test_exterior_substituents_do_not_pull_ring_double_outward(canvas, reverse): def test_ring_queries_scan_topology_once_per_graph_revision(canvas, monkeypatch): from unittest.mock import Mock - from chemvas.ui.scene import scene_geometry + from chemvas.features import graph ids, edges = _ring(canvas) context = canvas.render_context @@ -163,8 +163,8 @@ def __iter__(self): bonds = CountedBonds(canvas.model.bonds) canvas.model.bonds = bonds context.state.graph_state.bump_version() - finder = Mock(wraps=scene_geometry.find_rings) - monkeypatch.setattr(scene_geometry, "find_rings", finder) + finder = Mock(wraps=graph.find_rings) + monkeypatch.setattr(graph, "find_rings", finder) for _ in range(4): for edge in edges: assert geometry.ring_center_for_bond(bonds[edge]) is not None diff --git a/tests/test_verify_dist.py b/tests/test_verify_dist.py index b3e143e8..1edb1aa0 100644 --- a/tests/test_verify_dist.py +++ b/tests/test_verify_dist.py @@ -22,6 +22,10 @@ def current_package_files(): if path.is_file() and ( path.suffix == ".py" + or ( + path.parent == package / "web" + and path.suffix in {".html", ".css", ".mjs"} + ) or ( path.parent == package / "assets" / "icon" and path.suffix in {".svg", ".png"} diff --git a/tests/test_web_adapter.py b/tests/test_web_adapter.py new file mode 100644 index 00000000..01f4dab5 --- /dev/null +++ b/tests/test_web_adapter.py @@ -0,0 +1,1039 @@ +from __future__ import annotations + +import http.client +import json +import shutil +import subprocess +import sys +import threading +from copy import deepcopy +from pathlib import Path + +import pytest + +from chemvas.bootstrap.web_adapter import ( + MAX_REQUEST_BYTES, + BrowserServer, + BrowserSession, + atom_input_plan, + document_info, + edit_document, + new_document, + ui_css, + ui_spec, +) +from chemvas.domain.document import build_document_payload, extract_document_state + +ROOT = Path(__file__).resolve().parents[1] + + +def draw_bond(payload, start=(30, 40), end=(50, 40), style="single"): + return edit_document( + { + "document": payload, + "edit": { + "kind": "bond", + "start": list(start), + "end": list(end), + "style": style, + }, + } + ) + + +def test_adapter_imports_without_qt_or_site_packages(): + result = subprocess.run( + [ + sys.executable, + "-I", + "-S", + "-B", + "-c", + ( + "import sys; sys.path.insert(0, sys.argv[1]); " + "from chemvas.bootstrap.web_adapter import new_document, document_info, ui_spec, BrowserSession, edit_document; " + "assert not document_info(new_document())['unsupported']; ui_spec(); BrowserSession(); edit_document({'document': new_document(), 'edit': {'kind': 'bond', 'start': [100, 100], 'end': [100, 100], 'style': 'single'}}); " + "ring = edit_document({'document': new_document(), 'edit': {'kind': 'ring', 'x': 100, 'y': 100}}); " + "edit_document({'document': ring['document'], 'edit': {'kind': 'delete_selection', 'selection': [{'target': 'bond', 'id': 0}]}}); " + "edit_document({'document': ring['document'], 'edit': {'kind': 'atom', 'x': 100, 'y': 100, 'text': 'N'}}); " + "atoms = ring['document']['state']['model']['atoms']; atoms[77] = dict(atoms[0]); ring['document']['state']['model']['next_atom_id'] = 78; " + "merged = edit_document({'document': ring['document'], 'edit': {'kind': 'atom', 'atom_id': 0, 'x': atoms[0]['x'], 'y': atoms[0]['y'], 'text': 'O'}}); assert 77 not in merged['document']['state']['model']['atoms']; " + "edit_document({'document': merged['document'], 'edit': {'kind': 'move', 'selection': [{'target': 'bond', 'id': 0}], 'dx': 5, 'dy': 10}}); " + "edit_document({'document': merged['document'], 'edit': {'kind': 'bond_style', 'id': 0, 'style': 'bold_in'}}); " + "assert not any(n.split('.')[0] in {'PyQt6', 'PIL', 'rdkit'} for n in sys.modules)" + ), + str(ROOT / "app"), + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + +def test_atomic_shared_edit_and_rejection_leave_input_untouched(): + source = new_document() + before = deepcopy(source) + changed = draw_bond(source) + assert source == before + assert len(changed["document"]["state"]["model"]["atoms"]) == 2 + with pytest.raises(ValueError): + draw_bond(source, end=(float("nan"), 40)) + assert source == before + # A rejected transaction consumed neither an ID nor a history state. + assert draw_bond(source) == changed + + +@pytest.mark.parametrize("version", [7, 8, 9]) +def test_supported_document_versions_roundtrip_without_changing_source(version): + payload = build_document_payload(new_document()["state"], version) + encoded = json.dumps(payload) + info = document_info(json.loads(encoded)) + assert info["document"] == json.loads(encoded) + assert info["document"]["version"] == version + updated = draw_bond(info["document"])["document"] + assert updated["version"] == version + extract_document_state(updated) + assert json.dumps(payload) == encoded + + +def test_unsupported_content_is_preserved_and_server_rejects_edits(): + payload = new_document() + payload["state"]["notes"] = [ + {"text": "Important", "html": "

Important

", "x": 10, "y": 20} + ] + original = deepcopy(payload) + info = document_info(payload) + assert "text annotations" in info["unsupported"] + assert info["document"] == original + with pytest.raises(ValueError, match="read-only"): + draw_bond(payload) + assert payload == original + + +@pytest.mark.parametrize( + "edit", + [ + {"kind": "graph", "operations": []}, + {"kind": "note", "text": "Text", "x": 10, "y": 20}, + {"kind": "arrow", "start": [20, 30], "end": [80, 30]}, + {"kind": "delete", "target": "note", "id": 0}, + {"kind": "delete", "target": "atom", "id": 0}, + ], +) +def test_removed_demo_actions_cannot_publish_or_consume_history(edit): + session = BrowserSession() + before = deepcopy(session.info) + with pytest.raises(ValueError): + session.dispatch({"revision": 0, "action": "edit", "edit": edit}) + assert session.info == before + assert session.revision == 0 + assert not session.history.can_undo() + + +def test_delete_atom_removes_its_bonds_without_reusing_ids(): + payload = draw_bond(new_document())["document"] + result = edit_document( + { + "document": payload, + "edit": { + "kind": "delete_selection", + "selection": [{"target": "atom", "id": 0}], + }, + } + ) + model = result["document"]["state"]["model"] + assert len(model["atoms"]) == 0 + assert model["bonds"] == [] + assert model["next_atom_id"] == 2 + extract_document_state(result["document"]) + + +@pytest.fixture +def server(): + with BrowserServer() as running: + thread = threading.Thread(target=running.serve_forever, daemon=True) + thread.start() + try: + yield running + finally: + running.shutdown() + thread.join(timeout=5) + + +def request(server, path, *, method="GET", body=None, authorized=True, headers=None): + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=5) + values = {"Content-Type": "application/json"} + if authorized: + values["Authorization"] = "Bearer " + server.token + values.update(headers or {}) + try: + connection.request(method, path, body=body, headers=values) + response = connection.getresponse() + return response.status, response.read(), dict(response.getheaders()) + finally: + connection.close() + + +def test_http_auth_host_origin_and_static_allowlist(server): + assert request(server, "/api/new", authorized=False)[0] == 401 + assert ( + request(server, "/api/new", headers={"Origin": "https://example.org"})[0] == 403 + ) + assert request(server, "/api/new", headers={"Host": "example.org"})[0] == 403 + assert request(server, "/../pyproject.toml")[0] == 404 + status, body, headers = request(server, "/") + assert status == 200 and b"Chemical drawing canvas" in body + assert "frame-ancestors 'none'" in headers["Content-Security-Policy"] + assert "Access-Control-Allow-Origin" not in headers + for path in ("/app.mjs", "/transport.mjs", "/scene.mjs", "/style.css"): + assert request(server, path)[0] == 200 + + +def test_http_open_edit_rejects_malformed_and_oversized_documents(server): + status, body, _ = request(server, "/api/new") + assert status == 200 + extract_document_state(json.loads(body)["document"]) + status, body, _ = request( + server, + "/api/session", + method="POST", + body=json.dumps( + { + "revision": 0, + "action": "edit", + "edit": {"kind": "ring", "x": 100, "y": 100}, + } + ), + ) + assert status == 200 + assert len(json.loads(body)["document"]["state"]["model"]["atoms"]) == 6 + assert ( + request( + server, "/api/open", method="POST", body='{"version": 7, "version": 9}' + )[0] + == 400 + ) + assert request(server, "/api/open", method="POST", body='{"x": NaN}')[0] == 400 + assert ( + request( + server, + "/api/open", + method="POST", + body="{}", + headers={"Content-Length": str(MAX_REQUEST_BYTES + 1)}, + )[0] + == 413 + ) + assert ( + request( + server, + "/api/open", + method="POST", + body="{}", + headers={"Content-Type": "text/plain"}, + )[0] + == 415 + ) + assert request(server, "/api/edit", method="POST", body="{}")[0] == 404 + # Serving another request proves a failed edit did not poison a session. + assert request(server, "/api/new")[0] == 200 + + +def test_browser_state_and_svg_contracts(): + node = shutil.which("node") + assert node is not None, ( + "The browser adapter checks require Node.js 20+ (runtime has no Node dependency)." + ) + for source in (ROOT / "app/chemvas/web").glob("*.mjs"): + result = subprocess.run( + [node, "--check", str(source)], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr + result = subprocess.run( + [node, "--test", str(ROOT / "tests/web_adapter.test.mjs")], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_existing_desktop_history_owns_browser_undo_and_rejected_edits(): + from chemvas.ui.canvas.canvas_history_service import CanvasHistoryService + from chemvas.ui.molecule.structure_geometry_logic import ( + compute_free_benzene_ring_points, + ) + + session = BrowserSession() + assert isinstance(session.history, CanvasHistoryService) + before = deepcopy(session.info) + ring = session.dispatch( + {"revision": 0, "action": "edit", "edit": {"kind": "ring", "x": 100, "y": 100}} + ) + points = compute_free_benzene_ring_points((100, 100), bond_length=20) + atoms = list(ring["document"]["state"]["model"]["atoms"].values()) + assert [(atom["x"], atom["y"]) for atom in atoms] == points + restored = session.dispatch({"revision": 1, "action": "undo"}) + assert restored["document"] == before["document"] + assert restored["can_redo"] + with pytest.raises(ValueError): + session.dispatch( + { + "revision": 2, + "action": "edit", + "edit": { + "kind": "delete_selection", + "selection": [{"target": "atom", "id": 99}], + }, + } + ) + assert session.revision == 2 + assert session.state.redo_stack + redo = session.dispatch({"revision": 2, "action": "redo"}) + assert redo["document"] == ring["document"] + with pytest.raises(ValueError, match="stale"): + session.dispatch({"revision": 2, "action": "undo"}) + assert session.info["document"] == ring["document"] + + +def test_browser_ui_uses_the_desktop_definitions(): + from chemvas.shell.icon_design import design_icon_svg + from chemvas.shell.palette import PALETTE + from chemvas.ui.window.main_window_config import TOOLBAR_TOOL_ACTION_ORDER + + spec = ui_spec() + assert [ + item["key"] for group in spec["groups"] for item in group + ] == TOOLBAR_TOOL_ACTION_ORDER + assert spec["groups"][1][0]["icon"] == design_icon_svg("bond") + assert spec["groups"][1][0]["tip"] == "Bond (Shortcut: X)" + assert PALETTE["accent"] in ui_css() + assert "--toolbar-thickness: 38px" in ui_css() + + +@pytest.mark.parametrize("length", [20, 40]) +@pytest.mark.parametrize("fused", [False, True]) +@pytest.mark.parametrize("records", [False, True]) +def test_benzene_visible_lines_match_the_actual_qt_scene( + desktop_canvas, length, fused, records +): + from PyQt6.QtWidgets import QGraphicsLineItem + + payload = new_document() + payload["state"]["settings"]["bond_length_px"] = length + browser = edit_document( + {"document": payload, "edit": {"kind": "ring", "x": 100, "y": 100}} + ) + if fused: + browser = edit_document( + { + "document": browser["document"], + "edit": {"kind": "ring", "x": 100, "y": 100, "bond_id": 0}, + } + ) + if not records: + browser["document"]["state"]["ring_fills"] = [] + browser = document_info(browser["document"]) + canvas = desktop_canvas + canvas.services.canvas_document_session_service.apply_state( + extract_document_state(browser["document"]) + ) + count = 0 + for bond_id, items in canvas.runtime_state.bond_graphics_state.bond_items.items(): + actual = [item.line() for item in items if isinstance(item, QGraphicsLineItem)] + expected = browser["drawing"]["bonds"][str(bond_id)] + assert len(actual) == len(expected) + for line, primitive in zip(actual, expected, strict=True): + assert primitive["line"] == pytest.approx( + (line.x1(), line.y1(), line.x2(), line.y2()), abs=1e-9 + ) + count += 1 + assert count == (16 if fused else 9) + + +def test_main_entry_selects_browser_before_qt(monkeypatch): + from chemvas.bootstrap import application, web_adapter + + seen = [] + monkeypatch.setattr(sys, "argv", ["chemvas", "--ui", "web", "--no-browser"]) + monkeypatch.setattr(web_adapter, "main", lambda argv: seen.append(argv)) + application.main() + assert seen == [["--no-browser"]] + + +def test_bond_input_uses_existing_endpoint_policy(): + payload = new_document() + edit = { + "kind": "bond", + "start": [100, 100], + "end": [153, 107], + "style": "single", + } + info = edit_document({"document": payload, "edit": edit}) + atoms = list(info["document"]["state"]["model"]["atoms"].values()) + assert [(atom["x"], atom["y"]) for atom in atoms] == [(100, 100), (120, 100)] + assert info["drawing"]["bonds"]["0"][0]["line"] == pytest.approx( + (100, 100, 120, 100) + ) + + +@pytest.fixture +def desktop_canvas(qt_application): + from tests.canvas_factory import build_canvas_view + + canvas = build_canvas_view() + yield canvas + canvas.services.canvas_scene_reset_service.clear_scene() + canvas.close() + + +@pytest.mark.parametrize( + "attachment", ["free", "atom", "single", "double", "triple", "occupied", "interior"] +) +def test_browser_benzene_build_matches_existing_desktop_workflow( + desktop_canvas, attachment +): + from PyQt6.QtCore import QPointF + + canvas = desktop_canvas + builder = canvas.services.structure_build_service + session = canvas.services.canvas_document_session_service + center = QPointF(100, 100) + atom_id = bond_id = None + if attachment != "free": + builder.add_benzene_ring(center) + if attachment == "atom": + atom_id = 0 + elif attachment != "occupied": + bond_id = 0 + if attachment in {"single", "double", "triple"}: + bond = canvas.model.bonds[0] + bond.order = {"single": 1, "double": 2, "triple": 3}[attachment] + bond.style = attachment + if attachment == "interior": + builder.add_benzene_ring(center, attach_bond_id=0) + before = session.snapshot_state() + payload = build_document_payload(before, 9) + browser = edit_document( + { + "document": payload, + "edit": { + "kind": "ring", + "x": center.x(), + "y": center.y(), + "atom_id": atom_id, + "bond_id": bond_id, + }, + } + ) + assert not browser["unsupported"] + builder.add_benzene_ring(center, attach_atom_id=atom_id, attach_bond_id=bond_id) + actual = session.snapshot_state() + expected = extract_document_state(browser["document"]) + assert expected["model"] == actual["model"] + assert expected["ring_fills"] == actual["ring_fills"] + if attachment in {"triple", "occupied", "interior"}: + assert expected["model"] == before["model"] + + +@pytest.mark.parametrize("target", ["atom", "bond"]) +@pytest.mark.parametrize("shape", ["chain", "ring"]) +def test_browser_deletion_matches_desktop_document(desktop_canvas, target, shape): + from PyQt6.QtCore import QPointF + + canvas = desktop_canvas + builder = canvas.services.structure_build_service + if shape == "ring": + builder.add_benzene_ring(QPointF(100, 100)) + else: + builder.add_bond_between_points(QPointF(20, 20), QPointF(40, 20), "single", 1) + builder.add_bond_between_points(QPointF(40, 20), QPointF(60, 20), "single", 1) + snapshot = canvas.services.canvas_document_session_service.snapshot_state + before = snapshot() + browser = edit_document( + { + "document": build_document_payload(before, 9), + "edit": { + "kind": "delete_selection", + "selection": [{"target": target, "id": 0}], + }, + } + ) + getattr(canvas.services.scene_delete_controller, "delete_" + target)(0) + actual = snapshot() + expected = extract_document_state(browser["document"]) + assert expected["model"] == actual["model"] + assert expected["ring_fills"] == actual["ring_fills"] + + +def test_failed_shared_ring_build_keeps_document_history_and_identifiers(monkeypatch): + from chemvas.ui.molecule.structure_build_committer import StructureBuildCommitter + + session = BrowserSession() + before = deepcopy(session.info) + original = StructureBuildCommitter.add_bond + + def fail_after_first_bond(self, *args, **kwargs): + if len(self.canvas.model.bonds): + raise ValueError("injected construction failure") + return original(self, *args, **kwargs) + + monkeypatch.setattr(StructureBuildCommitter, "add_bond", fail_after_first_bond) + with pytest.raises(ValueError, match="injected"): + session.dispatch( + { + "revision": 0, + "action": "edit", + "edit": {"kind": "ring", "x": 100, "y": 100}, + } + ) + assert session.info == before + assert session.revision == 0 + assert not session.history.can_undo() + monkeypatch.setattr(StructureBuildCommitter, "add_bond", original) + result = session.dispatch( + {"revision": 0, "action": "edit", "edit": {"kind": "ring", "x": 100, "y": 100}} + ) + assert result["document"]["state"]["model"]["next_atom_id"] == 6 + assert len(result["document"]["state"]["ring_fills"]) == 1 + + +@pytest.mark.parametrize( + "style", ["single", "double", "triple", "wedge", "hash", "double_center", "bold_in"] +) +def test_bond_click_uses_the_same_policy_as_qt(desktop_canvas, style): + from PyQt6.QtCore import QPointF + + from chemvas.ui.window.main_window_toolbar_logic import bond_style_from_label + + canvas = desktop_canvas + canvas.services.structure_build_service.add_bond_between_points( + QPointF(20, 20), + QPointF(40, 20), + "double", + 2, + ) + snapshot = canvas.services.canvas_document_session_service.snapshot_state + source = build_document_payload(snapshot(), 9) + browser = edit_document( + {"document": source, "edit": {"kind": "bond_style", "id": 0, "style": style}} + ) + settings = canvas.runtime_state.tool_settings_state + settings.active_bond_style = style + settings.active_bond_order = bond_style_from_label(style.capitalize())[1] + assert canvas.services.tool_controller.tools["bond"]._apply_active_style_to_bond(0) + assert json.loads( + json.dumps(extract_document_state(browser["document"])["model"]) + ) == json.loads(json.dumps(snapshot()["model"])) + + +def test_ring_document_continues_web_qt_web_with_shared_records( + desktop_canvas, tmp_path +): + from PyQt6.QtCore import QPointF + + session = BrowserSession() + web = session.dispatch( + {"revision": 0, "action": "edit", "edit": {"kind": "ring", "x": 100, "y": 100}} + ) + canvas = desktop_canvas + desktop = canvas.services.canvas_document_session_service + desktop.apply_state(extract_document_state(web["document"])) + assert ( + desktop.snapshot_state()["model"] + == extract_document_state(web["document"])["model"] + ) + canvas.services.structure_build_service.add_benzene_ring( + QPointF(100, 100), attach_bond_id=0 + ) + saved_path = tmp_path / "adapter-roundtrip.chemvas" + desktop.save_to_file(str(saved_path)) + reopened = session.dispatch( + { + "revision": 1, + "action": "load", + "document": json.loads(saved_path.read_text()), + } + ) + assert len(reopened["document"]["state"]["model"]["atoms"]) == 10 + assert len(reopened["document"]["state"]["ring_fills"]) == 2 + assert not reopened["unsupported"] + removed = session.dispatch( + { + "revision": 2, + "action": "edit", + "edit": { + "kind": "delete_selection", + "selection": [{"target": "bond", "id": 0}], + }, + } + ) + assert removed["document"]["state"]["ring_fills"] == [] + restored = session.dispatch({"revision": 3, "action": "undo"}) + assert restored["document"] == reopened["document"] + desktop.apply_state(extract_document_state(restored["document"])) + assert ( + json.loads(json.dumps(desktop.snapshot_state()["model"])) + == extract_document_state(reopened["document"])["model"] + ) + + +@pytest.mark.parametrize( + "style", ["single", "double", "triple", "wedge", "hash", "bold_in"] +) +@pytest.mark.parametrize("placement", ["free", "attached", "existing"]) +def test_bond_construction_uses_the_existing_qt_builder( + desktop_canvas, style, placement +): + from PyQt6.QtCore import QPointF + + from chemvas.ui.window.main_window_toolbar_logic import bond_style_from_label + + builder = desktop_canvas.services.structure_build_service + snapshot = desktop_canvas.services.canvas_document_session_service.snapshot_state + if placement != "free": + builder.add_bond_between_points(QPointF(30, 40), QPointF(50, 40), "single", 1) + start = (50, 40) if placement == "attached" else (30, 40) + end = (70, 40) if placement == "attached" else (50, 40) + source = build_document_payload(snapshot(), 9) + browser = draw_bond(source, start=start, end=end, style=style) + builder.add_bond_between_points( + QPointF(*start), + QPointF(*end), + style, + bond_style_from_label(style.capitalize())[1], + ) + assert extract_document_state(browser["document"])["model"] == snapshot()["model"] + assert len(browser["document"]["state"]["model"]["atoms"]) == ( + 3 if placement == "attached" else 2 + ) + + +def test_bond_click_reuses_existing_default_direction(desktop_canvas): + from PyQt6.QtCore import QPointF + + builder = desktop_canvas.services.structure_build_service + builder.add_bond_between_points(QPointF(30, 40), QPointF(50, 40), "single", 1) + snapshot = desktop_canvas.services.canvas_document_session_service.snapshot_state + source = build_document_payload(snapshot(), 9) + browser = draw_bond(source, start=(50, 40), end=(50, 40)) + endpoint = builder.default_bond_endpoint(QPointF(50, 40), 1) + builder.add_bond_between_points(QPointF(50, 40), endpoint, "single", 1) + assert extract_document_state(browser["document"])["model"] == snapshot()["model"] + + +def test_failed_bond_build_keeps_candidate_private_and_redo_available(monkeypatch): + from chemvas.ui.molecule.structure_build_committer import StructureBuildCommitter + + session = BrowserSession() + session.dispatch( + {"revision": 0, "action": "edit", "edit": {"kind": "ring", "x": 100, "y": 100}} + ) + session.dispatch({"revision": 1, "action": "undo"}) + before = deepcopy(session.info) + + def fail_after_atoms(self, *args, **kwargs): + assert len(self.canvas.model.atoms) == 2 + raise ValueError("injected bond failure") + + monkeypatch.setattr(StructureBuildCommitter, "add_bond", fail_after_atoms) + with pytest.raises(ValueError, match="injected"): + session.dispatch( + { + "revision": 2, + "action": "edit", + "edit": { + "kind": "bond", + "start": [30, 40], + "end": [50, 40], + "style": "single", + }, + } + ) + assert session.info == before + assert session.revision == 2 + assert session.history.can_redo() + assert ( + session.dispatch({"revision": 2, "action": "redo"})["document"]["state"][ + "model" + ]["next_atom_id"] + == 6 + ) + + +@pytest.mark.parametrize("text", ["N", "O", " C ", "c", "NH2", "Ph", ""]) +@pytest.mark.parametrize("existing", [False, True]) +def test_atom_input_matches_native_service(desktop_canvas, text, existing): + from chemvas.ui.tools.text_tool_logic import ( + apply_text_input, + resolve_text_tool_target, + ) + + canvas = desktop_canvas + document = canvas.services.canvas_document_session_service + labels = canvas.services.atom_label_service + atom_id = None + if existing: + atom_id = labels.add_labelled_atom("C", 100, 100) + before = build_document_payload(document.snapshot_state(), 9) + edit = {"kind": "atom", "x": 100, "y": 100, "text": text, "atom_id": atom_id} + result = edit_document({"document": before, "edit": edit}) + target = resolve_text_tool_target( + canvas.model, pos=(100, 100), item_atom_id=atom_id + ) + apply_text_input( + target, + text.strip(), + "C" if existing else "", + add_atom=labels.add_labelled_atom, + update_label=labels.add_or_update_atom_label, + notify_error=lambda message: pytest.fail(message), + ) + assert result["document"]["state"]["model"] == document.snapshot_state()["model"] + if text.strip().upper() == "C": + assert result["drawing"]["atom_labels"][str(atom_id or 0)] == text.strip() + if existing and not text: + assert result["drawing"]["atom_labels"] == {} + + +def test_atom_overlap_merge_matches_native_and_restores_history(desktop_canvas): + canvas = desktop_canvas + mutation = canvas.services.canvas_atom_mutation_service + a = mutation.add_atom("C", 100, 100) + b = mutation.add_atom("C", 100.3, 100) + c = mutation.add_atom("C", 120, 100) + canvas.services.canvas_bond_mutation_service.add_bond(a, c, 1) + bid = canvas.services.canvas_bond_mutation_service.add_bond(b, c, 2) + canvas.model.bonds[bid].style = "double" + document = canvas.services.canvas_document_session_service + before = build_document_payload(document.snapshot_state(), 9) + session = BrowserSession() + session.dispatch({"revision": 0, "action": "load", "document": before}) + changed = session.dispatch( + { + "revision": 1, + "action": "edit", + "edit": {"kind": "atom", "x": 100, "y": 100, "atom_id": a, "text": "O"}, + } + ) + canvas.services.atom_label_service.add_or_update_atom_label( + a, "O", show_carbon=True + ) + assert changed["document"]["state"]["model"] == document.snapshot_state()["model"] + assert len(changed["document"]["state"]["model"]["atoms"]) == 2 + restored = session.dispatch({"revision": 2, "action": "undo"}) + assert restored["document"] == before + assert ( + session.dispatch({"revision": 3, "action": "redo"})["document"] + == changed["document"] + ) + + +def test_atom_input_plan_uses_native_target_and_prompt_policy(): + payload = draw_bond(new_document(), start=(100, 100), end=(120, 100))["document"] + request = {"document": payload, "edit": {"x": 119, "y": 102}, "symbol": ""} + assert atom_input_plan(request) == { + "text": None, + "needs_prompt": True, + "initial": "C", + } + request["symbol"] = " O " + assert atom_input_plan(request)["text"] == "O" + changed = edit_document( + {"document": payload, "edit": {"kind": "atom", "x": 119, "y": 102, "text": "O"}} + ) + assert changed["document"]["state"]["model"]["atoms"][1]["element"] == "O" + assert len(changed["document"]["state"]["model"]["atoms"]) == 2 + + +def test_failed_atom_edit_keeps_candidate_history_and_redo(monkeypatch): + from chemvas.ui.molecule.atom_label_merge_service import AtomLabelMergeService + + session = BrowserSession() + created = session.dispatch( + { + "revision": 0, + "action": "edit", + "edit": {"kind": "atom", "x": 100, "y": 100, "text": "N"}, + } + ) + before = session.dispatch({"revision": 1, "action": "read"}) + with pytest.raises(ValueError, match="Cannot hide"): + session.dispatch( + { + "revision": 1, + "action": "edit", + "edit": {"kind": "atom", "x": 100, "y": 100, "text": ""}, + } + ) + assert session.dispatch({"revision": 1, "action": "read"}) == before + session.dispatch({"revision": 1, "action": "undo"}) + before = session.dispatch({"revision": 2, "action": "read"}) + + def fail(self, atom_id): + raise ValueError("merge failed after atom creation") + + monkeypatch.setattr(AtomLabelMergeService, "merge_overlapping_atoms", fail) + with pytest.raises(ValueError, match="merge failed"): + session.dispatch( + { + "revision": 2, + "action": "edit", + "edit": {"kind": "atom", "x": 100, "y": 100, "text": "O"}, + } + ) + assert session.dispatch({"revision": 2, "action": "read"}) == before + assert ( + session.dispatch({"revision": 2, "action": "redo"})["document"] + == created["document"] + ) + + +def test_atom_control_uses_native_default_and_properties(): + from chemvas.ui.canvas.canvas_tool_settings_state import CanvasToolSettingsState + from chemvas.ui.window.main_window_config import ATOM_INPUT_SPEC + + assert ui_spec()["atom_input"] == { + **ATOM_INPUT_SPEC, + "value": CanvasToolSettingsState().atom_symbol, + } + + +@pytest.mark.parametrize( + "items", + [ + [{"target": "atom", "id": 0}], + [{"target": "bond", "id": 0}], + [ + {"target": "atom", "id": 0}, + {"target": "bond", "id": 0}, + {"target": "bond", "id": 1}, + ], + [{"target": "atom", "id": i} for i in range(6)], + ], +) +@pytest.mark.parametrize("delta", [(25.5, -10.25), (-14, 37), (0, 0)]) +def test_browser_movement_matches_native_controller_and_ring_polygon( + desktop_canvas, items, delta +): + from PyQt6.QtCore import QPointF + + from chemvas.features.selection import selected_atom_ids_with_bond_endpoints + + canvas = desktop_canvas + canvas.services.structure_build_service.add_benzene_ring(QPointF(100, 100)) + documents = canvas.services.canvas_document_session_service + before = build_document_payload(documents.snapshot_state(), 9) + moved = edit_document( + { + "document": before, + "edit": { + "kind": "move", + "selection": items, + "dx": delta[0], + "dy": delta[1], + }, + } + ) + atoms = selected_atom_ids_with_bond_endpoints( + [item["id"] for item in items if item["target"] == "atom"], + [item["id"] for item in items if item["target"] == "bond"], + bonds=canvas.model.bonds, + ) + canvas.services.move_controller.move_atoms(atoms, *delta) + native = documents.snapshot_state() + assert moved["document"]["state"]["model"] == native["model"] + assert moved["document"]["state"]["ring_fills"] == native["ring_fills"] + for atom_id, atom in moved["document"]["state"]["model"]["atoms"].items(): + source = before["state"]["model"]["atoms"][atom_id] + assert atom["x"] == source["x"] + (delta[0] if atom_id in atoms else 0) + assert atom["y"] == source["y"] + (delta[1] if atom_id in atoms else 0) + if delta == (0, 0): + assert moved["document"] == before + + +def test_move_failure_and_preview_leave_session_and_redo_unchanged(monkeypatch): + from chemvas.ui.canvas.canvas_move_controller import CanvasMoveController + + session = BrowserSession() + first = session.dispatch( + {"revision": 0, "action": "edit", "edit": {"kind": "ring", "x": 100, "y": 100}} + ) + change = { + "kind": "move", + "selection": [{"target": "bond", "id": 0}], + "dx": 10, + "dy": 20, + } + preview = edit_document({"document": first["document"], "edit": change}) + assert session.dispatch({"revision": 1, "action": "read"}) == first + moved = session.dispatch({"revision": 1, "action": "edit", "edit": change}) + assert moved["document"] == preview["document"] + assert len(session.state.history) == 2 + undone = session.dispatch({"revision": 2, "action": "undo"}) + assert undone["document"] == first["document"] + before = session.dispatch({"revision": 3, "action": "read"}) + original = CanvasMoveController.move_atom + + def fail(self, atom_id, dx, dy): + original(self, atom_id, dx, dy) + raise ValueError("redraw failed after movement") + + monkeypatch.setattr(CanvasMoveController, "move_atom", fail) + with pytest.raises(ValueError, match="after movement"): + session.dispatch({"revision": 3, "action": "edit", "edit": change}) + assert session.dispatch({"revision": 3, "action": "read"}) == before + assert ( + session.dispatch({"revision": 3, "action": "redo"})["document"] + == moved["document"] + ) + + +def test_zero_move_does_not_consume_redo_or_history(): + session = BrowserSession() + session.dispatch( + {"revision": 0, "action": "edit", "edit": {"kind": "ring", "x": 100, "y": 100}} + ) + change = { + "kind": "move", + "selection": [{"target": "bond", "id": 0}], + "dx": 10, + "dy": 20, + } + session.dispatch({"revision": 1, "action": "edit", "edit": change}) + before = session.dispatch({"revision": 2, "action": "undo"}) + after = session.dispatch( + {"revision": 3, "action": "edit", "edit": {**change, "dx": 0, "dy": 0}} + ) + assert after["document"] == before["document"] + assert after["can_redo"] + assert len(session.state.history) == 1 + + +@pytest.mark.parametrize( + "selection", + [ + [{"target": "atom", "id": True}], + [{"target": "bond", "id": -1}], + [{"target": "atom", "id": 999}], + [{"target": "note", "id": 0}], + None, + ], +) +def test_invalid_move_selection_is_rejected(selection): + source = draw_bond(new_document())["document"] + with pytest.raises(ValueError): + edit_document( + { + "document": source, + "edit": {"kind": "move", "selection": selection, "dx": 1, "dy": 2}, + } + ) + + +def test_multiple_delete_is_one_shared_plan_and_undo(): + session = BrowserSession() + ring = session.dispatch( + {"revision": 0, "action": "edit", "edit": {"kind": "ring", "x": 100, "y": 100}} + ) + changed = session.dispatch( + { + "revision": 1, + "action": "edit", + "edit": { + "kind": "delete_selection", + "selection": [{"target": "atom", "id": i} for i in range(6)], + }, + } + ) + assert changed["document"]["state"]["model"]["atoms"] == {} + assert changed["document"]["state"]["ring_fills"] == [] + assert len(session.state.history) == 2 + assert ( + session.dispatch({"revision": 2, "action": "undo"})["document"] + == ring["document"] + ) + + +@pytest.mark.parametrize("dx", [float("nan"), float("inf"), float("-inf")]) +def test_non_finite_move_is_rejected_without_changing_source(dx): + source = draw_bond(new_document())["document"] + before = deepcopy(source) + with pytest.raises(ValueError, match="finite"): + edit_document( + { + "document": source, + "edit": { + "kind": "move", + "selection": [{"target": "atom", "id": 0}], + "dx": dx, + "dy": 1, + }, + } + ) + assert source == before + + +@pytest.mark.parametrize("style", ["bold_in", "bold_center", "bold_out"]) +@pytest.mark.parametrize("order", [1, 2, 3]) +@pytest.mark.parametrize("ring", [False, True]) +def test_bold_geometry_matches_native_items(desktop_canvas, style, order, ring): + from PyQt6.QtWidgets import QGraphicsLineItem, QGraphicsPolygonItem + + if ring: + payload = edit_document( + {"document": new_document(), "edit": {"kind": "ring", "x": 100, "y": 100}} + )["document"] + else: + payload = draw_bond(new_document())["document"] + payload = draw_bond(payload, start=(50, 40), end=(60, 57.32))["document"] + for bond in payload["state"]["model"]["bonds"]: + bond["style"], bond["order"] = style, order + browser = document_info(payload) + assert not browser["unsupported"] + desktop_canvas.services.canvas_document_session_service.apply_state( + extract_document_state(payload) + ) + for ( + bond_id, + items, + ) in desktop_canvas.runtime_state.bond_graphics_state.bond_items.items(): + primitives = browser["drawing"]["bonds"][str(bond_id)] + painted = [ + item + for item in items + if isinstance(item, (QGraphicsLineItem, QGraphicsPolygonItem)) + ] + assert len(painted) == len(primitives) + for item, primitive in zip(painted, primitives, strict=True): + if isinstance(item, QGraphicsLineItem): + line = item.line() + assert primitive["line"] == pytest.approx( + (line.x1(), line.y1(), line.x2(), line.y2()), abs=1e-9 + ) + else: + actual = [(p.x(), p.y()) for p in item.polygon()] + assert len(actual) == len(primitive["polygon"]) + for expected, point in zip(primitive["polygon"], actual, strict=True): + assert expected == pytest.approx(point, abs=1e-9) + + +def test_bold_overlay_preserves_double_order_and_undo(): + session = BrowserSession() + source = draw_bond(new_document(), style="double")["document"] + session.dispatch({"revision": 0, "action": "load", "document": source}) + bold = session.dispatch( + { + "revision": 1, + "action": "edit", + "edit": {"kind": "bond_style", "id": 0, "style": "bold_in"}, + } + ) + bond = bold["document"]["state"]["model"]["bonds"][0] + assert (bond["style"], bond["order"]) == ("bold_in", 2) + assert session.dispatch({"revision": 2, "action": "undo"})["document"] == source + assert ( + session.dispatch({"revision": 3, "action": "redo"})["document"] + == bold["document"] + ) diff --git a/tests/web_adapter.test.mjs b/tests/web_adapter.test.mjs new file mode 100644 index 00000000..8632b420 --- /dev/null +++ b/tests/web_adapter.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {SessionClient} from '../app/chemvas/web/transport.mjs'; +import {sceneMarkup} from '../app/chemvas/web/scene.mjs'; + +function info(count = 0) { + const atoms = Object.fromEntries(Array.from({length: count}, (_, id) => [id, {element: id ? 'O' : 'C', x: 30 + 20 * id, y: 40, explicit_label: false, color: '#000000'}])); + return {drawing: {atom_labels:Object.fromEntries(Object.entries(atoms).filter(([, a]) => a.element !== "C").map(([id, a]) => [id, a.element])), bonds:{}, line_width:1.5, font_size:12}, unsupported: [], sheet: [595, 842], document: {type: 'chemvas', version: 9, state: {model: {atoms, bonds: [], next_atom_id: count}, arrows: [], notes: [], settings: {bond_length_px: 20, text_font_family: 'Arial', text_font_size: 12, text_color: '#222222'}}}}; +} + +test('transport sends revisions and only mirrors accepted server state', async () => { + const calls = []; + let response = {...info(), session: 'test', revision: 1, dirty: false, can_undo: false, can_redo: true}; + const editor = new SessionClient(async request => { calls.push(request); if (request.action === 'edit') throw new Error('Rejected'); return response; }); + await editor.load(info()); + const before = editor.document; + await assert.rejects(editor.perform({kind: 'ring'}), /Rejected/); + assert.equal(editor.document, before); + assert.equal(editor.canRedo, true); + assert.equal(editor.busy, false); + assert.equal(calls[1].revision, 1); + assert.equal(calls[1].session, 'test'); + response = {...info(6), session: 'test', revision: 2, can_undo: true, can_redo: false, dirty: true}; + await editor.redo(); + assert.equal(Object.keys(editor.document.state.model.atoms).length, 6); + assert.equal(calls[2].action, 'redo'); +}); + +test('pending request excludes concurrent replacement and edits', async () => { + let resolve; + const editor = new SessionClient(() => new Promise(done => { resolve = done; })); + const pending = editor.load(info()); + assert.equal(editor.busy, true); + await assert.rejects(editor.load(info(8)), /Wait/); + await assert.rejects(editor.perform({}), /Wait/); + resolve(info(1)); + await pending; + assert.equal(editor.busy, false); +}); + +test('unsupported content cannot be edited', async () => { + const source = info(1); source.unsupported = ['images']; + const editor = new SessionClient(async () => source); + await editor.load(source); + await assert.rejects(editor.perform({}), /read-only/); +}); + +test('SVG display escapes untrusted labels', () => { + const source = info(1); + source.drawing.atom_labels[0] = ''; + const svg = sceneMarkup(source.document, {drawing: source.drawing}); + assert.ok(svg.includes('<script>')); + assert.ok(!svg.includes(''; + source.drawing.atom_layouts[0] = [{text: '', size: 12, x: 30, y: 40}]; const svg = sceneMarkup(source.document, {drawing: source.drawing}); assert.ok(svg.includes('<script>')); assert.ok(!svg.includes('