Source code for vibesketch.document.generics

"""Generic structures in the drawing as document entries: R-group cores, their
definitions and bracketed groups (#90).

They are derived from the drawing like species, and written as CXSMILES
without coordinates: R-group atoms carry their labels (``$_R1$``), a
definition's attachment points ``_AP1``, and brackets their S-groups
(``Sg:n:...``). The drawing stays the source of truth; nothing here is
edited by clients (maintainer decisions, 2026-10-24).
"""

from __future__ import annotations

from typing import Any

from vibesketch.document.ket import KetFragment, KetScene
from vibesketch.document.model import Document, Generic, RGroup

__all__ = ["cxsmiles", "derive_generics", "generics_report"]


[docs] def cxsmiles(mol: Any) -> str: """``mol`` as canonical CXSMILES without coordinates, its R-group atoms as ``$_R1$``.""" from rdkit import Chem copy = Chem.Mol(mol) for atom in copy.GetAtoms(): if atom.GetAtomicNum() == 0 and atom.HasProp("dummyLabel"): label = atom.GetProp("dummyLabel") if label.startswith("R"): atom.SetProp("atomLabel", f"_{label}") atom.ClearProp("dummyLabel") fields = Chem.CXSmilesFields.CX_ALL_BUT_COORDS ^ Chem.CXSmilesFields.CX_ATOM_PROPS text = str(Chem.MolToCXSmiles(copy, Chem.SmilesWriteParams(), fields)) # RDKit CXSMILES omits MUL groups. Retain their count and canonical atom # membership in an ordinary CX atom property, so different drawings do # not collapse to one generic entry merely because their parent is equal. multiples = [g for g in Chem.GetMolSubstanceGroups(copy) if g.GetProp("TYPE") == "MUL"] if multiples: import json Chem.MolToSmiles(copy) order = json.loads(copy.GetProp("_smilesAtomOutputOrder")) positions = {atom: index for index, atom in enumerate(order)} markers = sorted( f"{g.GetProp('LABEL')}:" + ",".join(str(positions[a]) for a in sorted(g.GetAtoms(), key=lambda a: positions[a])) for g in multiples ) for atom in copy.GetAtoms(): for name in atom.GetPropNames(): if name != "atomLabel": atom.ClearProp(name) copy.GetAtomWithIdx(order[0]).SetProp("vibesketchMUL", ";".join(markers)) text = str( Chem.MolToCXSmiles( copy, Chem.SmilesWriteParams(), fields | Chem.CXSmilesFields.CX_ATOM_PROPS ) ) return text
[docs] def generics_report(document: Document) -> dict[str, list[dict[str, Any]]]: """The document's generic structures and R-group definitions, as JSON data.""" return { "generics": [g.model_dump(mode="json", exclude_none=True) for g in document.generics], "rgroups": [r.model_dump(mode="json", exclude_none=True) for r in document.rgroups], }
def _has_r_atom(mol: Any) -> bool: """Whether ``mol`` has an R-group atom, numbered or not (an unnumbered one is R).""" return any( atom.GetAtomicNum() == 0 and atom.HasProp("dummyLabel") and str(atom.GetProp("dummyLabel")).startswith("R") for atom in mol.GetAtoms() ) def _numbers(mol: Any) -> list[int]: """The R-group numbers a core's labelled atoms use, R1 as 1, in order.""" found = set() for atom in mol.GetAtoms(): label = atom.GetProp("dummyLabel") if atom.HasProp("dummyLabel") else "" if atom.GetAtomicNum() == 0 and label[1:].isdigit(): found.add(int(label[1:])) return sorted(found) def _definition(fragment: KetFragment, node: dict[str, Any]) -> RGroup: from rdkit import Chem logic = node.get("rlogic") or {} substituents = sorted( cxsmiles(piece) for piece in Chem.GetMolFrags(fragment.figure, asMols=True) ) occurrence = str(logic.get("range") or "").strip() return RGroup( number=int(logic["number"]), substituents=substituents, occurrence=occurrence or None, rest_h=logic.get("resth") is True, if_then=int(logic["ifthen"]) if logic.get("ifthen") else None, )
[docs] def derive_generics( document: Document, scene: KetScene, labels: dict[int, str], taken: set[str], warnings: list[str], ) -> tuple[list[Generic], list[RGroup]]: """The drawing's R-group cores, bracketed groups and R-group definitions. Only what figures can draw becomes an entry; a structure the drawing cannot express stays a note, as before. A generic structure keeps its id and compound number while its CXSMILES is the same. ``taken`` holds the identifiers already used, and gains the new ones. """ from vibesketch.document.derive import _next_id previous: dict[tuple[str, str], Generic] = {(g.kind, g.cxsmiles): g for g in document.generics} generics: dict[tuple[str, str], Generic] = {} rgroups: dict[int, RGroup] = {} for position, fragment in enumerate(scene.fragments): if fragment.mol is not None or fragment.figure is None: continue node = document.canvas.data.get(fragment.node) if isinstance(node, dict) and node.get("type") == "rgroup": definition = _definition(fragment, node) if definition.number in rgroups: warnings.append(f"R{definition.number} is defined twice; the first is kept") else: rgroups[definition.number] = definition continue numbers = _numbers(fragment.figure) kind = "rgroup-core" if numbers or _has_r_atom(fragment.figure) else "bracketed" text = cxsmiles(fragment.figure) if (kind, text) in generics: continue old = previous.get((kind, text)) data: dict[str, Any] = { "id": old.id if old is not None else _next_id("g", taken), "kind": kind, "cxsmiles": text, "rgroups": numbers, } label = labels.get(position) or (old.label if old is not None else None) if label is not None: data["label"] = label generics[(kind, text)] = Generic.model_validate(data) return list(generics.values()), [rgroups[n] for n in sorted(rgroups)]