"""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