Source code for vibesketch.document.scheme

"""Tidy scheme: a whole reaction drawing laid out the way chemists draw schemes (#20).

Tidy scheme is an explicit action, never automatic, and keeps what the user
decided: the order of the steps, which molecule is a reactant, a reagent or
a product, and every annotation. It first cleans each molecule
(:mod:`vibesketch.document.clean`), then places them, reading the drawing
exactly as :mod:`vibesketch.document.derive` does:

* The steps of a linear sequence, where one step's products are the next
  step's reactants, go in one row. Molecules are centred on the arrows'
  line. A row longer than ``width`` continues below: the arrow turns down
  and the next row runs back the other way, as mechanisms are often drawn.
  Each other sequence starts a row of its own, in step order.
* A divergent scheme, where a molecule feeds two or three steps (#81),
  keeps its first step in the row. The second leaves by a diagonal arrow
  down to a row below, the third up to a row above, and each branch runs
  on to the right in its row.
* A convergent scheme, where two sequences meet in one step (#81), is laid
  out as a Y: the two molecules that react sit side by side, one sequence
  arriving from above and the other from below, and the joint step runs on
  to the right.
* Each arrow is long enough for what it carries: the reagent molecules drawn
  above it and its condition texts below it, in their order. Beside a
  vertical or diagonal arrow they sit on its outer side, reagents first.
* A side whose molecules plus signs join is laid out in a row with fresh
  plus signs. A side the user arranged otherwise, such as a base drawn by
  the hydrogen it takes, keeps that arrangement and its plus signs.
* Compound numbers are centred under their structures.
* Curved (electron-pushing) arrows, crossed-out arrows and free text are not
  laid out. Each end of a curved arrow moves with the molecule it is drawn
  at; free text moves with the nearest molecule.
* Molecules in no step go in a last row.

The result is checked: :func:`vibesketch.document.derive.derive` must read
the same reactions back, with the same conditions and compound numbers. If
it would not, a layout without row breaks is tried. A branched scheme's
steps may come back in another order, since derive orders them by where
they are drawn. If that fails too, or the scheme branches in a way the
layout cannot express (a molecule feeding four steps, a branch that
branches again, a join fed by a branch, or two branches that would run
into each other), the drawing is left as it was and the report says why.
All distances are in drawing bond lengths.
"""

from __future__ import annotations

import copy
import math
from dataclasses import dataclass, field
from typing import Any

from vibesketch.document.derive import (
    Derivation,
    _condition_texts,
    _label_texts,
    _labels,
    _plus_groups,
    _roles,
    derive,
    is_reaction_arrow,
)
from vibesketch.document.ket import KetScene, _text_of, read_scene
from vibesketch.document.model import Canvas, Document

__all__ = ["WIDTH", "SchemeReport", "scheme_metrics", "tidy_ket", "tidy_scheme"]

#: The default longest row, in bond lengths: about a two-column journal page.
WIDTH = 24.0

_Box = tuple[float, float, float, float]
_Point = tuple[float, float]

#: Room around atom centres for their labels.
_MARGIN = 0.3
#: The shortest reaction arrow.
_ARROW_MIN = 3.0
#: Between a side and the end of an arrow.
_ARROW_GAP = 1.0
#: Beyond the widest text or reagent row, at each end of an arrow.
_PAD = 0.5
#: Between an arrow and what is drawn above, below or beside it.
_CLEAR = 0.3
#: Between stacked texts, and between reagents side by side.
_STACK_GAP = 0.15
_REAGENT_GAP = 0.8
#: Between the molecules of a side, a plus sign in the middle.
_PLUS_SPACE = 1.6
#: Between a structure and its compound number.
_LABEL_GAP = 0.15
#: Between the two molecules where two sequences join: near enough to be read as one side
#: (derive groups molecules within 1.5 of each other, measured between their atoms).
_JOIN_GAP = 0.8
#: Between a joining arrow and its molecule: short, so the other molecule stays out of its reach.
_JOIN_ARROW_GAP = 0.5
#: Between molecules in a row of molecules that take part in no step.
_LOOSE_GAP = 2.0
#: Between rows.
_ROW_GAP = 1.5
#: The renderer's line height, in font sizes (:mod:`vibesketch.render.scene`).
_LINE_HEIGHT = 1.3
#: Half the size of a plus sign, and of an arrow's line, for overlap checks.
_PLUS_HALF, _ARROW_HALF = 0.2, 0.15


[docs] @dataclass class SchemeReport: """What tidying a scheme did.""" #: Whether the drawing was laid out; ``False`` when it was left as drawn. tidied: bool = False #: Molecules that got standard coordinates first. cleaned: int = 0 #: Rows in the laid-out scheme. rows: int = 0 #: Molecules clean-up left as drawn, one line each. left: list[str] = field(default_factory=list) #: Why the scheme was left as drawn, when it was. reason: str | None = None
class _Refused(Exception): """The drawing cannot be tidied; the message says why.""" # ── Reading the canvas ────────────────────────────────────────────────── @dataclass class _Canvas: """A KET document with its scene and the root node behind each scene item.""" ket: dict[str, Any] scene: KetScene arrows: list[int] pluses: list[int] texts: list[int] #: The publication font size, in bond lengths. em: float @classmethod def read(cls, ket: dict[str, Any], preset: str = "acs-1996") -> _Canvas: scene = read_scene(ket) arrows: list[int] = [] pluses: list[int] = [] texts: list[int] = [] for position, node in enumerate(ket.get("root", {}).get("nodes", [])): if "$ref" in node: continue kind = node.get("type") if kind == "arrow": arrows.append(position) elif kind == "plus": pluses.append(position) elif kind == "text" and _text_of(node): texts.append(position) if (len(arrows), len(pluses), len(texts)) != ( len(scene.arrows), len(scene.pluses), len(scene.texts), ): raise _Refused("the canvas could not be read consistently") from vibesketch.render import RenderRefused from vibesketch.render.styles import resolve try: style = resolve(preset) except RenderRefused as error: raise ValueError(str(error)) from None return cls(ket, scene, arrows, pluses, texts, style.size / style.bond) def node(self, position: int) -> dict[str, Any]: node: dict[str, Any] = self.ket["root"]["nodes"][position] return node def box(self, fragment: int) -> _Box: xmin, ymin, xmax, ymax = self.scene.fragments[fragment].bounds return (xmin - _MARGIN, ymin - _MARGIN, xmax + _MARGIN, ymax + _MARGIN) def text_size(self, text: int) -> tuple[float, float]: """The text's size as published (the document's style), or on the canvas if larger.""" from vibesketch.render.fonts import advance node = self.node(self.texts[text]) lines = _text_of(node).splitlines() or [""] width = max(advance(line) for line in lines) * self.em height = len(lines) * _LINE_HEIGHT * self.em box = node.get("boundingBox") if isinstance(box, dict): width = max(width, float(box.get("width", 0))) height = max(height, float(box.get("height", 0))) return width, height def text_box(self, text: int) -> _Box: x, y = self.scene.texts[text].centre width, height = self.text_size(text) return (x - width / 2, y - height / 2, x + width / 2, y + height / 2) def _centre(box: _Box) -> _Point: return ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2) def _size(box: _Box) -> tuple[float, float]: return (box[2] - box[0], box[3] - box[1]) def _union(boxes: list[_Box]) -> _Box: return ( min(b[0] for b in boxes), min(b[1] for b in boxes), max(b[2] for b in boxes), max(b[3] for b in boxes), ) def _distance(box: _Box, point: _Point) -> float: dx = max(box[0] - point[0], 0.0, point[0] - box[2]) dy = max(box[1] - point[1], 0.0, point[1] - box[3]) return math.hypot(dx, dy) # ── The plan: what goes where, before any coordinates ─────────────────── @dataclass(eq=False) class _Block: """A reactant or product side, or a lone molecule: placed as one.""" fragments: list[int] #: Laid out in a row with fresh plus signs, rather than kept as drawn. in_row: bool #: The structures' box as drawn, and the extra depth their labels need below. box: _Box = (0.0, 0.0, 0.0, 0.0) below: float = 0.0 #: Where the block's box centre goes. target: _Point = (0.0, 0.0) #: For a block kept as drawn: how far each molecule is eased off its neighbours. offsets: dict[int, _Point] = field(default_factory=dict) @property def width(self) -> float: return _size(self.box)[0] @property def height(self) -> float: return _size(self.box)[1] @dataclass(eq=False) class _Step: arrow: int #: The reactant side; two blocks where two sequences join. tails: list[_Block] head: _Block reagents: list[int] texts: list[int] #: Placed by the layout: the arrow's ends. ends: tuple[_Point, _Point] = ((0.0, 0.0), (0.0, 0.0)) @property def tail(self) -> _Block: return self.tails[-1] @dataclass class _Chain: """A linear sequence: each step's products are the next step's reactants.""" steps: list[_Step] @dataclass class _Tree: """A sequence with branches: a molecule that feeds several steps. ``main`` follows the first step leaving each molecule; each branch is the other step leaving a molecule on ``main``, and the sequence after it. """ main: list[_Step] branches: list[list[_Step]] @dataclass class _Join: """Two sequences whose products react together, and the sequence after.""" upper: list[_Step] lower: list[_Step] join: _Step after: list[_Step] _Component = _Chain | _Tree | _Join @dataclass class _Plan: canvas: _Canvas #: Every step, in derive's order. steps: list[_Step] components: list[_Component] loose: list[_Block] labels: dict[int, int] # fragment -> text #: Fragment target centres for molecules placed alone (reagents, plus rows). placed: dict[int, _Point] = field(default_factory=dict) #: Condition texts' target centres. texts: dict[int, _Point] = field(default_factory=dict) #: Fresh plus signs. pluses: list[_Point] = field(default_factory=list) @property def branched(self) -> bool: return any(not isinstance(c, _Chain) for c in self.components) def _attachments(canvas: _Canvas) -> list[list[int]]: """For each curved or crossed-out arrow, the fragment nearest each of its ends.""" scene = canvas.scene found: list[list[int]] = [] for arrow in scene.arrows: if is_reaction_arrow(arrow.mode) or not scene.fragments: found.append([]) continue found.append( [ min(range(len(scene.fragments)), key=lambda f: _distance(canvas.box(f), end)) for end in (arrow.tail, arrow.head) ] ) return found _UNSUPPORTED = ( "the scheme branches or joins in a way tidying cannot lay out yet: it handles linear " "sequences, a molecule feeding up to three steps, and two sequences joining once" ) def _plan(canvas: _Canvas) -> _Plan: scene = canvas.scene labels = _label_texts(scene) used = set(labels.values()) groups = _plus_groups(scene) attached = _attachments(canvas) def block(fragments: list[int]) -> _Block: joined = len({groups[f] for f in fragments}) == 1 # A curved arrow between two of the side's molecules pins their arrangement. pinned = any(len(set(ends)) == 2 and set(ends) <= set(fragments) for ends in attached) # In the order derive reads the side, so a row reads back the same. return _Block(list(fragments), in_row=joined and not pinned) # Products first, so a step may come before the step that makes its reactants. roles = list(_roles(scene)) owner: dict[int, _Block] = {} heads_of: list[_Block] = [] for _, tails, _, heads, _ in roles: if not tails or not heads: raise _Refused("an arrow without molecules on both sides is not a reaction yet") if any(f in owner for f in heads): raise _Refused(_UNSUPPORTED) head = block(heads) owner.update(dict.fromkeys(heads, head)) heads_of.append(head) reagent_of = {f for _, _, reagents, _, _ in roles for f in reagents} if reagent_of & set(owner): raise _Refused(_UNSUPPORTED) starts: dict[frozenset[int], _Block] = {} steps: list[_Step] = [] for (arrow, tails, reagents, _, _), head in zip(roles, heads_of, strict=True): # noqa: B007 index = next(i for i, a in enumerate(scene.arrows) if a is arrow) texts = _condition_texts(scene, arrow, used) if set(tails) & reagent_of: raise _Refused(_UNSUPPORTED) made = [f for f in tails if f in owner] if not made: # Reactants no step makes: the start of a sequence, perhaps feeding several steps. key = frozenset(tails) if key not in starts and any(key & other for other in starts): raise _Refused(_UNSUPPORTED) tail_blocks = [starts.setdefault(key, block(tails))] else: existing: list[_Block] = [] for fragment in tails: found = owner.get(fragment) if found is not None and all(found is not e for e in existing): existing.append(found) covered = {f for b in existing for f in b.fragments} if len(made) != len(tails) or covered != set(tails) or len(existing) > 2: raise _Refused(_UNSUPPORTED) tail_blocks = existing steps.append(_Step(index, tail_blocks, head, list(reagents), texts)) taken = set(owner) | reagent_of | {f for b in starts.values() for f in b.fragments} components = _components(steps) loose = [ _Block([f], in_row=True) for f in sorted( (f for f in range(len(scene.fragments)) if f not in taken), key=lambda f: ( -round(scene.fragments[f].centre[1] / 1.5), scene.fragments[f].centre[0], ), ) ] plan = _Plan(canvas, steps, components, loose, labels) for step in steps: for side in (*step.tails, step.head): _measure(plan, side) for side in loose: _measure(plan, side) return plan def _components(steps: list[_Step]) -> list[_Component]: """The steps grouped into chains, trees and joins, each in derive's order.""" producer = {id(s.head): s for s in steps} consumers: dict[int, list[_Step]] = {} for step in steps: for tail in step.tails: consumers.setdefault(id(tail), []).append(step) def follow(start: _Block) -> list[_Step]: """From ``start``, the first step leaving each molecule, to the end.""" found: list[_Step] = [] block = start while consumers.get(id(block)): step = consumers[id(block)][0] found.append(step) block = step.head return found def back(block: _Block) -> list[_Step]: """The sequence that produced ``block``, from its start.""" found: list[_Step] = [] while id(block) in producer: step = producer[id(block)] if len(step.tails) != 1 or len(consumers.get(id(step.tail), [])) != 1: raise _Refused(_UNSUPPORTED) found.insert(0, step) block = step.tail return found components: list[_Component] = [] seen: set[int] = set() for step in steps: if id(step) in seen: continue joins = [s for s in steps if len(s.tails) > 1] root = step while id(root.tail) in producer and len(root.tails) == 1: root = producer[id(root.tail)] join = next((s for s in joins if id(s) not in seen and _reaches(root, s, consumers)), None) if join is not None: upper, lower = (back(tail) for tail in join.tails) if not upper or not lower: raise _Refused(_UNSUPPORTED) after = follow(join.head) members = [*upper, *lower, join, *after] if any(len(consumers.get(id(s.head), [])) > 1 for s in members): raise _Refused(_UNSUPPORTED) components.append(_Join(upper, lower, join, after)) else: main = follow(root.tail) branches: list[list[_Step]] = [] for member in [root.tail, *(s.head for s in main)]: for extra in consumers.get(id(member), [])[1:]: branch = [extra, *follow(extra.head)] if any(len(consumers.get(id(s.head), [])) > 1 for s in branch): raise _Refused(_UNSUPPORTED) # a branch of a branch branches.append(branch) if len(consumers.get(id(root.tail), [])) > 3 or any( len(consumers.get(id(s.head), [])) > 3 for s in main ): raise _Refused(_UNSUPPORTED) members = [*main, *(s for b in branches for s in b)] components.append(_Tree(main, branches) if branches else _Chain(main)) seen.update(id(s) for s in members) if len(seen) != len(steps): raise _Refused(_UNSUPPORTED) return components def _reaches(root: _Step, target: _Step, consumers: dict[int, list[_Step]]) -> bool: """Whether ``target`` lies downstream of ``root``.""" stack, visited = [root], set() while stack: step = stack.pop() if step is target: return True if id(step) in visited: continue visited.add(id(step)) stack.extend(consumers.get(id(step.head), [])) return False def _measure(plan: _Plan, block: _Block) -> None: """The block's size: laid out in a row, or as drawn; and its labels' depth.""" canvas = plan.canvas boxes = [canvas.box(f) for f in block.fragments] if block.in_row: width = sum(_size(b)[0] for b in boxes) + _PLUS_SPACE * (len(boxes) - 1) height = max(_size(b)[1] for b in boxes) block.box = (0.0, -height / 2, width, height / 2) else: block.offsets = _eased(boxes, block.fragments) block.box = _union( [ (b[0] + dx, b[1] + dy, b[2] + dx, b[3] + dy) for b, (dx, dy) in zip(boxes, block.offsets.values(), strict=True) ] ) depth = 0.0 for fragment in block.fragments: if fragment in plan.labels: _, height = canvas.text_size(plan.labels[fragment]) # Measured from the block's bottom, generously for an uneven side. depth = max(depth, _LABEL_GAP + height) block.below = depth def _eased(boxes: list[_Box], fragments: list[int]) -> dict[int, _Point]: """Small moves that part molecules a side keeps as drawn, where their boxes overlap. Each overlapping pair is parted along the axis it overlaps less on, the later molecule moving, so the arrangement the user chose stays. """ offsets = [(0.0, 0.0)] * len(boxes) def moved(index: int) -> _Box: b, (dx, dy) = boxes[index], offsets[index] return (b[0] + dx, b[1] + dy, b[2] + dx, b[3] + dy) for _ in range(4 * len(boxes)): changed = False for i in range(len(boxes)): for j in range(i + 1, len(boxes)): a, b = moved(i), moved(j) ox = min(a[2], b[2]) - max(a[0], b[0]) oy = min(a[3], b[3]) - max(a[1], b[1]) if ox <= 1e-6 or oy <= 1e-6: continue (ax, ay), (bx, by) = _centre(a), _centre(b) dx, dy = offsets[j] if ox < oy: dx += math.copysign(ox + 0.05, bx - ax or 1.0) else: dy += math.copysign(oy + 0.05, by - ay or 1.0) offsets[j] = (dx, dy) changed = True if not changed: break return dict(zip(fragments, offsets, strict=True)) # ── Layout ────────────────────────────────────────────────────────────── @dataclass(eq=False) class _Link: """A step drawn as a vertical arrow between a row and the next one below. ``upper`` is in the upper row and ``lower`` in the lower; the arrow points down from ``upper`` (``down``) or up from ``lower``. Beside it go the step's reagents and texts, on the ``outward`` side (+1 right, -1 left); a ``clear`` link keeps them in the gap between the two rows' contents. """ step: _Step upper: _Block lower: _Block down: bool outward: float clear: bool #: How close the arrow comes to its molecules. gap: float = _ARROW_GAP #: A branch: the arrow runs diagonally to the branch's first molecule, and the #: branch's row ``segment`` is moved sideways to where the arrow lands. diagonal: bool = False segment: _Row | None = None @dataclass(eq=False) class _Row: """Items placed in a row's own frame: baseline at y = 0.""" blocks: list[_Block] = field(default_factory=list) steps: list[_Step] = field(default_factory=list) #: The vertical arrows from this row to the next. links: list[_Link] = field(default_factory=list) top: float = 0.0 bottom: float = 0.0 #: In a band of branches: each branch's own part of the row. segments: list[_Row] = field(default_factory=list) def _arrow_length(plan: _Plan, step: _Step) -> float: canvas = plan.canvas reagents = sum(_size(canvas.box(f))[0] for f in step.reagents) + _REAGENT_GAP * max( len(step.reagents) - 1, 0 ) texts = max((canvas.text_size(t)[0] for t in step.texts), default=0.0) return max(_ARROW_MIN, reagents + 2 * _PAD, texts + 2 * _PAD) def _beside_height(plan: _Plan, step: _Step) -> float: canvas = plan.canvas heights = [_size(canvas.box(f))[1] for f in step.reagents] heights += [canvas.text_size(t)[1] for t in step.texts] return sum(heights) + _STACK_GAP * max(len(heights) - 1, 0) def _place_block(row: _Row, block: _Block, centre_x: float) -> None: block.target = (centre_x, 0.0) row.blocks.append(block) row.top = max(row.top, block.height / 2) row.bottom = min(row.bottom, -block.height / 2 - block.below) def _place_step(plan: _Plan, row: _Row, step: _Step, tail_x: float, head_x: float) -> None: """A horizontal arrow from ``tail_x`` to ``head_x``, its reagents above, texts below.""" canvas = plan.canvas step.ends = ((tail_x, 0.0), (head_x, 0.0)) middle = (tail_x + head_x) / 2 widths = [_size(canvas.box(f))[0] for f in step.reagents] x = middle - (sum(widths) + _REAGENT_GAP * max(len(widths) - 1, 0)) / 2 for fragment, width in zip(step.reagents, widths, strict=True): height = _size(canvas.box(fragment))[1] plan.placed[fragment] = (x + width / 2, _CLEAR + height / 2) row.top = max(row.top, _CLEAR + height) x += width + _REAGENT_GAP y = -_CLEAR for text in step.texts: _, height = canvas.text_size(text) plan.texts[text] = (middle, y - height / 2) y -= height + _STACK_GAP row.bottom = min(row.bottom, y + _STACK_GAP) row.steps.append(step) def _line(plan: _Plan, row: _Row, steps: list[_Step], cursor: float) -> float: """Each step and its head to the right of ``cursor``; returns the new cursor.""" for step in steps: tail_x = cursor + _ARROW_GAP head_x = tail_x + _arrow_length(plan, step) _place_step(plan, row, step, tail_x, head_x) cursor = head_x + _ARROW_GAP _place_block(row, step.head, cursor + step.head.width / 2) cursor += step.head.width return cursor def _shift_x(plan: _Plan, row: _Row, dx: float) -> None: """Move a row sideways, everything placed in it included.""" for block in row.blocks: block.target = (block.target[0] + dx, block.target[1]) for step in row.steps: (tx, ty), (hx, hy) = step.ends step.ends = ((tx + dx, ty), (hx + dx, hy)) for fragment in step.reagents: x, y = plan.placed[fragment] plan.placed[fragment] = (x + dx, y) for text in step.texts: x, y = plan.texts[text] plan.texts[text] = (x + dx, y) def _chain_rows(plan: _Plan, chain: _Chain, width: float) -> list[_Row]: """A sequence in a row; past ``width`` the arrow turns down and the next row runs back.""" rows: list[_Row] = [] row = _Row() rows.append(row) direction = 1.0 first = chain.steps[0].tail start = 0.0 _place_block(row, first, start + first.width / 2) cursor = start + first.width for step in chain.steps: length = _arrow_length(plan, step) reach = abs(cursor + direction * (2 * _ARROW_GAP + length + step.head.width) - start) if reach > width and row.steps: # Turn down: the head goes below the tail, the next row runs back. turn_x = step.tail.target[0] outward = direction row.links.append(_Link(step, step.tail, step.head, True, outward, clear=False)) row = _Row() rows.append(row) direction = -direction _place_block(row, step.head, turn_x) start = turn_x - direction * step.head.width / 2 cursor = turn_x + direction * step.head.width / 2 continue tail_x = cursor + direction * _ARROW_GAP head_x = tail_x + direction * length _place_step(plan, row, step, tail_x, head_x) cursor = head_x + direction * _ARROW_GAP _place_block(row, step.head, cursor + direction * step.head.width / 2) cursor += direction * step.head.width return rows def _tree_rows(plan: _Plan, tree: _Tree) -> list[_Row]: """The main sequence in a row; each branch leaves its molecule down (or up) to a row of its own. A molecule's first extra step leaves it downwards, its second upwards. Branches share a band below the main row and one above it, side by side; the tree is refused when two in one band would run into each other. """ main = _Row() first = tree.main[0].tail _place_block(main, first, first.width / 2) _line(plan, main, tree.main, first.width) bands = {True: _Row(), False: _Row()} # below, above used_at: dict[int, int] = {} for branch in tree.branches: hub, head = branch[0].tail, branch[0].head down = used_at.get(id(hub), 0) == 0 used_at[id(hub)] = used_at.get(id(hub), 0) + 1 segment = _Row() _place_block(segment, head, 0.0) _line(plan, segment, branch[1:], head.width / 2) band = bands[down] band.blocks.extend(segment.blocks) band.steps.extend(segment.steps) band.top, band.bottom = max(band.top, segment.top), min(band.bottom, segment.bottom) band.segments.append(segment) upper, lower = (hub, head) if down else (head, hub) link = _Link(branch[0], upper, lower, down, 1.0, clear=True, diagonal=True) link.segment = segment (main if down else band).links.append(link) rows = [bands[False]] if bands[False].blocks else [] rows.append(main) if bands[True].blocks: rows.append(bands[True]) return rows def _join_rows(plan: _Plan, join: _Join) -> list[_Row]: """Two sequences meeting in one step, laid out as a Y. The two molecules that react sit side by side, near enough that derive reads them as one side, with no plus sign, which would join them to the steps that make them as well. The first sequence ends above the left molecule and arrives by a downward arrow, the second ends below the right one and arrives by an upward arrow, and the joint step and what follows run on to the right. """ left, right = join.join.tails middle = _Row() _place_block(middle, left, left.width / 2) x = left.width + _JOIN_GAP _place_block(middle, right, x + right.width / 2) _line(plan, middle, [join.join, *join.after], x + right.width) def ending_at(steps: list[_Step], anchor: _Block) -> _Row: """The sequence up to the last step's reactants, those centred above or below ``anchor``.""" row = _Row() first = steps[0].tail _place_block(row, first, first.width / 2) _line(plan, row, steps[:-1], first.width) last = steps[-1].tail _shift_x(plan, row, anchor.target[0] - last.target[0]) return row upper = ending_at(join.upper, left) lower = ending_at(join.lower, right) # Short joining arrows keep the other molecule out of each one's reach. upper.links.append( _Link( join.upper[-1], join.upper[-1].tail, left, True, -1.0, clear=True, gap=_JOIN_ARROW_GAP, ) ) # fmt: skip middle.links.append( _Link( join.lower[-1], right, join.lower[-1].tail, False, 1.0, clear=True, gap=_JOIN_ARROW_GAP, ) ) # fmt: skip return [upper, middle, lower] def _rows(plan: _Plan, width: float) -> list[_Row]: """Every row in its own frame; links join a row to the next.""" rows: list[_Row] = [] for component in plan.components: if isinstance(component, _Chain): rows.extend(_chain_rows(plan, component, width)) elif isinstance(component, _Tree): rows.extend(_tree_rows(plan, component)) else: rows.extend(_join_rows(plan, component)) row = _Row() cursor = 0.0 for block in plan.loose: if row.blocks and cursor + _LOOSE_GAP + block.width > width: rows.append(row) row, cursor = _Row(), 0.0 if row.blocks: cursor += _LOOSE_GAP _place_block(row, block, cursor + block.width / 2) cursor += block.width if row.blocks: rows.append(row) return rows #: A branch arrow's slope: 1 down (or up) for every 1.5 across. _BRANCH_RISE = math.atan2(1.0, 1.5) def _beside_items(plan: _Plan, step: _Step) -> list[tuple[int, bool, float, float]]: """The step's reagents, then its texts, as (index, is text, width, height).""" canvas = plan.canvas items = [(f, False, *_size(canvas.box(f))) for f in step.reagents] items += [(t, True, *canvas.text_size(t)) for t in step.texts] return items def _along(items: list[tuple[int, bool, float, float]], ux: float, uy: float) -> float: """How much of an arrow pointing (ux, uy) the items take, side by side along it.""" spans = [width * abs(ux) + height * abs(uy) for _, _, width, height in items] return sum(spans) + _STACK_GAP * max(len(spans) - 1, 0) def _link_need(plan: _Plan, link: _Link, above: _Row, below: _Row) -> float: """How far apart the two rows' baselines must be for this vertical arrow.""" if link.diagonal: sine, cosine = math.sin(_BRANCH_RISE), math.cos(_BRANCH_RISE) room = _along(_beside_items(plan, link.step), cosine, sine) + 2 * _PAD hub, head = (link.upper, link.lower) if link.down else (link.lower, link.upper) span = _leaving(hub, link.down) + _leaving(head, not link.down) return (span + 2 * link.gap + max(_ARROW_MIN, room)) * sine arrow = ( link.upper.height / 2 + link.upper.below + 2 * link.gap + _ARROW_MIN + link.lower.height / 2 ) if not link.clear: beside = max(_ARROW_MIN, _beside_height(plan, link.step) + 2 * _PAD) return max(arrow, arrow - _ARROW_MIN + beside) # Beside content goes between the lowest of the upper row and the highest of the lower. room = -above.bottom + below.top + _beside_height(plan, link.step) + 2 * _PAD return max(arrow, room) def _stack(plan: _Plan, rows: list[_Row]) -> None: """Rows one below the other; a vertical arrow sets the gap it needs.""" # Baselines from the rows' own frames first, then every row moved to its own. baselines = [0.0] for above, row in zip(rows, rows[1:], strict=False): gap = above.bottom - _ROW_GAP - row.top for link in above.links: gap = min(gap, -_link_need(plan, link, above, row)) baselines.append(baselines[-1] + gap) for row, baseline in zip(rows, baselines, strict=True): _shift_row(plan, row, baseline) for index, row in enumerate(rows[:-1]): for link in row.links: _place_link(plan, link, row, rows[index + 1]) for row in rows: _check_segments(plan, row) def _extent(plan: _Plan, row: _Row) -> tuple[float, float]: """The left and right ends of what a row holds.""" xs = [x for b in row.blocks for x in (b.target[0] - b.width / 2, b.target[0] + b.width / 2)] xs += [x for s in row.steps for x in (s.ends[0][0], s.ends[1][0])] return min(xs), max(xs) def _check_segments(plan: _Plan, row: _Row) -> None: """Branches that share a band must not run into each other.""" spans = sorted(_extent(plan, segment) for segment in row.segments) for (_, right), (left, _) in zip(spans, spans[1:], strict=False): if right + _LOOSE_GAP > left: raise _Refused( "two branches of the scheme would run into each other; tidying lays out " "branches side by side below and above the main sequence" ) def _shift_row(plan: _Plan, row: _Row, dy: float) -> None: for block in row.blocks: block.target = (block.target[0], block.target[1] + dy) for step in row.steps: (tx, ty), (hx, hy) = step.ends step.ends = ((tx, ty + dy), (hx, hy + dy)) for fragment in step.reagents: x, y = plan.placed[fragment] plan.placed[fragment] = (x, y + dy) for text in step.texts: x, y = plan.texts[text] plan.texts[text] = (x, y + dy) row.top += dy row.bottom += dy def _leaving(block: _Block, downwards: bool) -> float: """How far from its centre a line at the branch slope leaves a block's box. ``downwards`` lines leave through the bottom, past the compound number, or the right side; the others through the top or the right side. """ sine, cosine = math.sin(_BRANCH_RISE), math.cos(_BRANCH_RISE) vertical = block.height / 2 + (block.below if downwards else 0.0) return min(block.width / 2 / cosine, vertical / sine) def _place_branch(plan: _Plan, link: _Link) -> None: """A branch's diagonal arrow, its row moved to where the arrow lands, items beside it. The arrow leaves the right half of the molecule it branches from and lands on the left half of the branch's first molecule. The reagents, then the texts, sit on the side away from the branch's row, nearest the arrow's end. """ sine, cosine = math.sin(_BRANCH_RISE), math.cos(_BRANCH_RISE) hub, head = (link.upper, link.lower) if link.down else (link.lower, link.upper) if link.down: ux, uy, nx, ny = cosine, -sine, -sine, -cosine # down the slope; beside: lower left else: ux, uy, nx, ny = cosine, sine, -sine, cosine # up the slope; beside: upper left # The line through both centres: the head's centre goes where the slope reaches its row. (cx, cy) = hub.target distance = abs(head.target[1] - cy) / sine assert link.segment is not None _shift_x(plan, link.segment, cx + distance * cosine - head.target[0]) start = _leaving(hub, link.down) + link.gap end = distance - _leaving(head, not link.down) - link.gap x0, y0 = cx + ux * start, cy + uy * start x1, y1 = cx + ux * end, cy + uy * end link.step.ends = ((x0, y0), (x1, y1)) items = _beside_items(plan, link.step) if not link.down: items.reverse() # the first item highest, as derive reads them length = math.hypot(x1 - x0, y1 - y0) s = max(0.05 * length, 0.95 * length - _along(items, ux, uy)) for index, is_text, width, height in items: half = (width * abs(ux) + height * abs(uy)) / 2 reach = (width * abs(nx) + height * abs(ny)) / 2 + _CLEAR s += half point = (x0 + ux * s + nx * reach, y0 + uy * s + ny * reach) (plan.texts if is_text else plan.placed)[index] = point s += half + _STACK_GAP def _place_link(plan: _Plan, link: _Link, above: _Row, below: _Row) -> None: """The vertical arrow of a link, its reagents and texts beside it.""" if link.diagonal: _place_branch(plan, link) return canvas = plan.canvas upper, lower = link.upper, link.lower x = (upper if link.down else lower).target[0] # at the step's reactants top = upper.target[1] - upper.height / 2 - upper.below - link.gap bottom = lower.target[1] + lower.height / 2 + link.gap link.step.ends = ((x, top), (x, bottom)) if link.down else ((x, bottom), (x, top)) centre = (above.bottom + below.top) / 2 if link.clear else (top + bottom) / 2 y = centre + _beside_height(plan, link.step) / 2 for fragment in link.step.reagents: width, height = _size(canvas.box(fragment)) plan.placed[fragment] = (x + link.outward * (_CLEAR + width / 2), y - height / 2) y -= height + _STACK_GAP for text in link.step.texts: width, height = canvas.text_size(text) plan.texts[text] = (x + link.outward * (_CLEAR + width / 2), y - height / 2) y -= height + _STACK_GAP def _targets(plan: _Plan, rows: list[_Row]) -> dict[int, _Point]: """Each fragment's move, from where its box is to where it goes.""" canvas = plan.canvas moves: dict[int, _Point] = {} for row in rows: for block in row.blocks: cx, cy = block.target if block.in_row: x = cx - block.width / 2 for position, fragment in enumerate(block.fragments): box = canvas.box(fragment) width = _size(box)[0] if position: plan.pluses.append((x + _PLUS_SPACE / 2, cy)) x += _PLUS_SPACE old = _centre(box) moves[fragment] = (x + width / 2 - old[0], cy - old[1]) x += width else: old = _centre(block.box) for fragment in block.fragments: ex, ey = block.offsets.get(fragment, (0.0, 0.0)) moves[fragment] = (cx - old[0] + ex, cy - old[1] + ey) for fragment, (x, y) in plan.placed.items(): old = _centre(canvas.box(fragment)) moves[fragment] = (x - old[0], y - old[1]) return moves # ── Writing the result ────────────────────────────────────────────────── def _move_text(node: dict[str, Any], dx: float, dy: float) -> None: box = node.get("boundingBox") if isinstance(box, dict): box["x"] = round(float(box["x"]) + dx, 6) box["y"] = round(float(box["y"]) + dy, 6) data = node.get("data") if isinstance(data, dict): position = data.get("position") if isinstance(position, dict): position["x"] = round(float(position.get("x", 0)) + dx, 6) position["y"] = round(float(position.get("y", 0)) + dy, 6) for point in data.get("pos") or []: if isinstance(point, dict): point["x"] = round(float(point.get("x", 0)) + dx, 6) point["y"] = round(float(point.get("y", 0)) + dy, 6) def _write(plan: _Plan, rows: list[_Row]) -> dict[str, Any]: canvas = plan.canvas scene = canvas.scene moves = _targets(plan, rows) # Keep the scheme where the drawing was: its top left corner stays put. before = _content_box(canvas, {}) after = _content_box(canvas, moves, plan) shift = (before[0] - after[0], before[3] - after[3]) moves = {f: (dx + shift[0], dy + shift[1]) for f, (dx, dy) in moves.items()} def moved(point: _Point, fragment: int) -> _Point: dx, dy = moves.get(fragment, (0.0, 0.0)) return (point[0] + dx, point[1] + dy) ket = copy.deepcopy(canvas.ket) for index, piece in enumerate(scene.fragments): dx, dy = moves.get(index, (0.0, 0.0)) atoms = ket[piece.node]["atoms"] for atom in piece.atoms: location = atoms[atom]["location"] location[0] = round(float(location[0]) + dx, 6) location[1] = round(float(location[1]) + dy, 6) nodes = ket["root"]["nodes"] for step in plan.steps: (tx, ty), (hx, hy) = step.ends node = nodes[canvas.arrows[step.arrow]] z = [float(p.get("z", 0.0)) for p in node["data"]["pos"]] node["data"]["pos"] = [ {"x": round(tx + shift[0], 6), "y": round(ty + shift[1], 6), "z": z[0]}, {"x": round(hx + shift[0], 6), "y": round(hy + shift[1], 6), "z": z[-1]}, ] attached = _attachments(canvas) for index, ends in enumerate(attached): if not ends: continue node = nodes[canvas.arrows[index]] arrow = scene.arrows[index] for point, end, fragment in zip( node["data"]["pos"][:2], (arrow.tail, arrow.head), ends, strict=True ): x, y = moved(end, fragment) point["x"], point["y"] = round(x, 6), round(y, 6) for text, (x, y) in plan.texts.items(): cx, cy = scene.texts[text].centre _move_text(nodes[canvas.texts[text]], x + shift[0] - cx, y + shift[1] - cy) labelled = {text: fragment for fragment, text in plan.labels.items()} for text, fragment in labelled.items(): xmin, ymin, xmax, _ = canvas.box(fragment) dx, dy = moves.get(fragment, (0.0, 0.0)) _, height = canvas.text_size(text) cx, cy = scene.texts[text].centre target = ((xmin + xmax) / 2 + dx, ymin + dy - _LABEL_GAP - height / 2) _move_text(nodes[canvas.texts[text]], target[0] - cx, target[1] - cy) for text, centre in enumerate(scene.texts): if text in plan.texts or text in labelled or not scene.fragments: continue nearest = min( range(len(scene.fragments)), key=lambda f: _distance(canvas.box(f), centre.centre) ) dx, dy = moves.get(nearest, (0.0, 0.0)) _move_text(nodes[canvas.texts[text]], dx, dy) # Plus signs: a side kept as drawn keeps its own; the rest are drawn afresh. kept: list[int] = [] rigid = {f for row in rows for block in row.blocks if not block.in_row for f in block.fragments} for index, plus in enumerate(scene.pluses): if not scene.fragments: continue nearest = min(range(len(scene.fragments)), key=lambda f: _distance(canvas.box(f), plus)) if nearest in rigid: node = nodes[canvas.pluses[index]] x, y = moved(plus, nearest) node["location"] = [round(x, 6), round(y, 6), 0.0] kept.append(canvas.pluses[index]) dropped = set(canvas.pluses) - set(kept) ket["root"]["nodes"] = [n for i, n in enumerate(nodes) if i not in dropped] for x, y in plan.pluses: ket["root"]["nodes"].append( {"type": "plus", "location": [round(x + shift[0], 6), round(y + shift[1], 6), 0.0]} ) return ket def _content_box(canvas: _Canvas, moves: dict[int, _Point], plan: _Plan | None = None) -> _Box: """The box of the molecules, moved by ``moves``; with ``plan``, of its arrows too.""" boxes: list[_Box] = [] for index in range(len(canvas.scene.fragments)): xmin, ymin, xmax, ymax = canvas.box(index) dx, dy = moves.get(index, (0.0, 0.0)) boxes.append((xmin + dx, ymin + dy, xmax + dx, ymax + dy)) if plan is not None: for step in plan.steps: (tx, ty), (hx, hy) = step.ends boxes.append((min(tx, hx), min(ty, hy), max(tx, hx), max(ty, hy))) else: for arrow in canvas.scene.arrows: if is_reaction_arrow(arrow.mode): (tx, ty), (hx, hy) = arrow.tail, arrow.head boxes.append((min(tx, hx), min(ty, hy), max(tx, hx), max(ty, hy))) return _union(boxes) if boxes else (0.0, 0.0, 0.0, 0.0) # ── Checks ────────────────────────────────────────────────────────────── def _reading(derivation: Derivation, ordered: bool = True) -> tuple[Any, ...]: """What the drawing says: the reactions (in order, if ``ordered``) and the compound numbers. derive orders a branched scheme's steps by where they are drawn, so for one the order may follow the new layout; the reactions themselves, their ids, conditions and numbers may not change. """ document = derivation.document reactions = [] for reaction in document.reactions: data = reaction.model_dump(mode="json") # Within a side, molecules are listed in reading order, which a new # arrangement may change; which molecules they are may not. for side in ("reactants", "reagents", "products"): data[side] = sorted(data[side]) reactions.append(data) labels = tuple(sorted((s.id, s.label or "") for s in document.species)) found = tuple(str(r) for r in reactions) return (found if ordered else tuple(sorted(found))), labels def _point_line_distance(p: _Point, a: _Point, b: _Point) -> float: """From ``p`` to the segment from ``a`` to ``b``.""" dx, dy = b[0] - a[0], b[1] - a[1] length = dx * dx + dy * dy t = ( 0.0 if length == 0 else max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length)) ) return math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy) def _lines_cross(a: _Point, b: _Point, c: _Point, d: _Point) -> bool: def side(p: _Point, q: _Point, r: _Point) -> float: return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]) return side(a, b, c) * side(a, b, d) < 0 and side(c, d, a) * side(c, d, b) < 0 def _lines_distance(a: _Point, b: _Point, c: _Point, d: _Point) -> float: """Between two segments.""" if _lines_cross(a, b, c, d): return 0.0 return min( _point_line_distance(a, c, d), _point_line_distance(b, c, d), _point_line_distance(c, a, b), _point_line_distance(d, a, b), ) def _line_box_distance(a: _Point, b: _Point, box: _Box) -> float: """Between a segment and a box: zero when the segment touches or crosses it.""" xmin, ymin, xmax, ymax = box if any(xmin <= p[0] <= xmax and ymin <= p[1] <= ymax for p in (a, b)): return 0.0 corners = [(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)] edges = list(zip(corners, corners[1:] + corners[:1], strict=True)) return min(_lines_distance(a, b, c, d) for c, d in edges)
[docs] def scheme_metrics(ket: dict[str, Any], *, preset: str = "acs-1996") -> dict[str, float]: """How tidy a drawing's scheme is, its texts measured as ``preset`` publishes them. ``overlaps`` counts the pairs of molecules, texts and plus signs whose boxes intersect (a molecule's box reaches 0.3 bond lengths beyond its atoms), and reaction arrows, as lines 0.3 wide, that touch a box or each other; curved arrows are not counted. ``baseline_offset`` is the largest distance of a reactant or product side's centre from its arrow's line, along the line's normal. ``arrow_room`` is the smallest margin of a reaction arrow's length over its widest condition text, measured along the arrow. """ canvas = _Canvas.read(ket, preset) scene = canvas.scene boxes: list[_Box] = [canvas.box(f) for f in range(len(scene.fragments))] boxes += [canvas.text_box(t) for t in range(len(scene.texts))] boxes += [ (x - _PLUS_HALF, y - _PLUS_HALF, x + _PLUS_HALF, y + _PLUS_HALF) for x, y in scene.pluses ] overlaps = sum( 1 for i, a in enumerate(boxes) for b in boxes[i + 1 :] if min(a[2], b[2]) - max(a[0], b[0]) > 1e-6 and min(a[3], b[3]) - max(a[1], b[1]) > 1e-6 ) # Arrows as lines, so a diagonal one is not taken for its bounding box. lines = [(a.tail, a.head) for a in scene.arrows if is_reaction_arrow(a.mode)] overlaps += sum( 1 for line in lines for box in boxes if _line_box_distance(*line, box) < _ARROW_HALF ) overlaps += sum( 1 for i, a in enumerate(lines) for b in lines[i + 1 :] if _lines_distance(*a, *b) < 2 * _ARROW_HALF ) offset = 0.0 room = math.inf _, used = _labels(scene) for arrow, tails, _, heads, _ in _roles(scene): (tx, ty), (hx, hy) = arrow.tail, arrow.head length = math.hypot(hx - tx, hy - ty) or 1.0 nx, ny = -(hy - ty) / length, (hx - tx) / length for side in (tails, heads): if side: cx, cy = _centre(_union([canvas.box(f) for f in side])) offset = max(offset, abs((cx - tx) * nx + (cy - ty) * ny)) # A text's extent along the arrow: its width beside a horizontal one. ux, uy = abs(hx - tx) / length, abs(hy - ty) / length extents = [ ux * width + uy * height for width, height in (canvas.text_size(t) for t in _condition_texts(scene, arrow, used)) ] room = min(room, length - max(extents, default=0.0)) return { "overlaps": float(overlaps), "baseline_offset": offset, "arrow_room": room if math.isfinite(room) else 0.0, }
# ── Entry points ────────────────────────────────────────────────────────
[docs] def tidy_ket( ket: dict[str, Any], *, width: float = WIDTH, document: Document | None = None ) -> tuple[dict[str, Any], SchemeReport]: """A copy of ``ket`` with its scheme laid out, and what was done. ``document`` is the document the drawing belongs to: the check that the reactions read back unchanged keeps its species and reaction ids, and texts are measured in its publication style. """ from vibesketch.document import new_document from vibesketch.document.clean import clean_ket if math.isnan(width) or width <= 0: raise ValueError("the row width must be positive") base = document if document is not None else new_document() before = derive(base.model_copy(update={"canvas": Canvas(format="ket", data=ket)})) cleaned, cleaning = clean_ket(ket) report = SchemeReport(cleaned=cleaning.cleaned, left=list(cleaning.left)) reasons: list[str] = [] for limit in (width, math.inf): try: canvas = _Canvas.read(cleaned, base.style.preset) plan = _plan(canvas) rows = _rows(plan, limit) _stack(plan, rows) result = _write(plan, rows) except _Refused as refusal: report.reason = str(refusal) return copy.deepcopy(ket), report # Read on top of the first reading, so a reaction keeps its id wherever it is drawn. tidied = derive( before.document.model_copy(update={"canvas": Canvas(format="ket", data=result)}) ) ordered = not plan.branched if _reading(tidied, ordered) == _reading(before, ordered): report.tidied, report.rows = True, len(rows) return result, report reasons.append("the laid-out scheme would read as different reactions") if limit == math.inf: break report.reason = reasons[-1] if reasons else "the scheme could not be laid out" return copy.deepcopy(ket), report
[docs] def tidy_scheme(document: Document, *, width: float = WIDTH) -> tuple[Derivation, SchemeReport]: """``document`` with its scheme laid out; the normalized layer is re-derived. The reactions and species do not change, so they keep their ids, names, conformers and jobs. A drawing that cannot be tidied is returned as it was, and the report says why. """ if not document.canvas.data: return Derivation(document), SchemeReport(reason="the drawing is empty") ket, report = tidy_ket(document.canvas.data, width=width, document=document) updated = document.model_copy(update={"canvas": Canvas(format="ket", data=ket)}) return derive(updated), report