Source code for vibesketch.render.nanostructures

"""Nanostructure depictions for figures, chemfig and the canvas (#138).

A generated nanostructure (#137) has thousands of atoms, which a figure does
not draw one by one with labels. It is drawn the way papers draw it, from its
atoms and parameters:

* ``lattice``: the side projection of the lattice, the front wall solid and
  the rear wall lighter or left out (``rear``); a sheet or ribbon seen from
  above, a fullerene from the front;
* ``schematic``: a tube's outline with the front wall's hexagons as a light
  texture, for schemes; the ends of inner walls show at the left end;
* ``end-on``: a tube's cross-section, one ring per wall;
* ``unrolled``: the graphene sheet a wall is rolled from, with the chiral
  vector Ch and the translation vector T (Saito, Dresselhaus and
  Dresselhaus 1998).

A long tube is drawn with its middle cut out and a break mark (``brk``); the
optional label gives (n,m), the diameter and the number of walls. Lines are
the style preset's width (thinner for the lattice); grey is used for the rear
wall and texture only, never colour (IUPAC 2008, GR-0.5).

On the canvas a depiction is one KET image (Ketcher 3.18), whose SVG carries a
``metadata`` element naming the species, its key, the style and the anchors:
points where a molecule attaches, such as a tube's ends. Ketcher moves and
resizes the image and keeps its data, so figures find the species again and
draw it afresh, in the figure's preset, in the image's box. The SVG in the
canvas is never itself embedded in a figure: a KET file may come from anyone.
"""

from __future__ import annotations

import base64
import html
import json
import math
import re
from dataclasses import dataclass, field
from typing import Any, Literal

import numpy as np

from vibesketch.document.model import Fullerene, Nanotube, Species

__all__ = [
    "ANCHOR_REACH",
    "STYLES",
    "Depiction",
    "DepictionRefused",
    "Picture",
    "canvas_image",
    "depiction_of",
    "picture",
    "caption_room",
    "picture_svg",
    "picture_tikz",
]

Style = Literal["lattice", "schematic", "end-on", "unrolled"]
STYLES: tuple[Style, ...] = ("lattice", "schematic", "end-on", "unrolled")
#: The metadata element's id and the schema of its JSON.
_METADATA = "vibesketch-nanostructure"
_SCHEMA = "vibe-sketch-nanostructure/1"
#: The largest image data read back from a canvas, base64 characters.
_MAX_DATA = 8 * 2**20
#: How close, in bond lengths, an attachment atom must be to an anchor to join it.
ANCHOR_REACH = 0.6
#: Grey of the rear wall and of the schematic texture, 0 black to 1 white.
_REAR, _TEXTURE = 0.7, 0.6


[docs] class DepictionRefused(ValueError): """A depiction that cannot be drawn as asked; the message says why."""
[docs] @dataclass(frozen=True) class Depiction: """How one nanostructure species is drawn.""" species: str key: str style: Style = "schematic" #: Print (n,m), the diameter and the walls under it. label: bool = False #: The rear wall in the lattice style: lighter, or left out. rear: Literal["light", "omit"] = "light" #: Cut out the middle of a long tube: always, never, or when it is over six diameters. brk: Literal["auto", "on", "off"] = "auto"
[docs] def metadata(self, anchors: dict[str, list[float]]) -> dict[str, Any]: return { "schema": _SCHEMA, "species": self.species, "key": self.key, "style": self.style, "label": self.label, "rear": self.rear, "break": self.brk, "anchors": anchors, }
@dataclass class _Line: points: list[tuple[float, float]] weight: float = 1.0 grey: float = 0.0 closed: bool = False filled: bool = False @dataclass class _Ellipse: centre: tuple[float, float] rx: float ry: float weight: float = 1.0 grey: float = 0.0 @dataclass class _Text: at: tuple[float, float] text: str #: Set one line below ``at``, outside the picture's box (a caption), not at it. below: bool = False
[docs] @dataclass class Picture: """A depiction in its own coordinates (angstrom, y up), with named anchors. An anchor is a point and the direction a bond leaves it, ``(x, y, dx, dy)``. """ items: list[_Line | _Ellipse | _Text] = field(default_factory=list) anchors: dict[str, tuple[float, float, float, float]] = field(default_factory=dict)
[docs] def bounds(self) -> tuple[float, float, float, float]: xs: list[float] = [] ys: list[float] = [] for item in self.items: if isinstance(item, _Line): xs.extend(p[0] for p in item.points) ys.extend(p[1] for p in item.points) elif isinstance(item, _Ellipse): xs.extend((item.centre[0] - item.rx, item.centre[0] + item.rx)) ys.extend((item.centre[1] - item.ry, item.centre[1] + item.ry)) elif not item.below: xs.append(item.at[0]) ys.append(item.at[1]) if not xs: raise DepictionRefused("the depiction has nothing to draw") return min(xs), min(ys), max(xs), max(ys)
# --- building the picture --- def _components(count: int, bonds: list[tuple[int, int, float]]) -> list[int]: """Each atom's connected component, by union-find.""" parent = list(range(count)) def root(i: int) -> int: while parent[i] != i: parent[i] = parent[parent[i]] i = parent[i] return i for i, j, _ in bonds: parent[root(i)] = root(j) return [root(i) for i in range(count)]
[docs] def picture(species: Species, depiction: Depiction) -> Picture: """The depiction of a nanostructure species; raises :class:`DepictionRefused`.""" made = species.nanostructure if made is None or not species.conformers: raise DepictionRefused(f"species {species.id} is not a generated nanostructure") if made.key != depiction.key: raise DepictionRefused( f"the picture was made for {depiction.key}, but species {species.id} is {made.key}" ) parameters = made.parameters tube = isinstance(parameters, Nanotube) if depiction.style in ("end-on", "unrolled", "schematic") and not tube: raise DepictionRefused(f"the {depiction.style} style draws nanotubes; use lattice") if depiction.style == "unrolled": assert isinstance(parameters, Nanotube) result = _unrolled(parameters) else: result = _projected(species, depiction) if depiction.label: _add_label(result, species) return result
def _projected(species: Species, depiction: Depiction) -> Picture: made = species.nanostructure assert made is not None parameters = made.parameters positions = np.array(species.conformers[0].xyz_angstrom, dtype=float) symbols = species.conformers[0].symbols bonds = made.bonds result = Picture() if isinstance(parameters, Nanotube): radius = np.hypot(positions[:, 0], positions[:, 1]) components = _components(len(symbols), bonds) outer = components[int(np.argmax(np.where(np.array(symbols) == "C", radius, -1)))] keep = [c == outer for c in components] z = positions[:, 2] low, high = float(z.min()), float(z.max()) outer_radius = float(radius[[i for i, k in enumerate(keep) if k]].max()) # Side view: the axis runs right, y stays up, +x comes out of the page. view = np.column_stack([z - (low + high) / 2, positions[:, 1], positions[:, 0]]) cut = _cut(depiction, high - low, 2 * outer_radius) if depiction.style == "end-on": return _end_on(parameters, positions, components, symbols) else: keep = [True] * len(symbols) view = positions.copy() cut = None outer_radius = 0.0 if not isinstance(parameters, Fullerene): view[:, 2] = 1.0 # a flat sheet is all front view = view - np.array([*view[:, :2].mean(axis=0), 0.0]) * [1, 1, 0] shift = np.zeros(len(view)) if cut is not None: start, end, gap = cut hidden = (view[:, 0] > start) & (view[:, 0] < end) shift = np.where(view[:, 0] >= end, -(end - start) + gap, 0.0) keep = [k and not h for k, h in zip(keep, hidden, strict=True)] xy = view[:, :2].copy() xy[:, 0] += shift lattice = depiction.style == "lattice" weight = 0.6 if lattice else 0.5 for i, j, _ in bonds: if not (keep[i] and keep[j]) or "H" in (symbols[i], symbols[j]): continue if abs(shift[i] - shift[j]) > 1e-9: continue # a bond across the cut front = view[i, 2] + view[j, 2] >= 0 if lattice: if not front and depiction.rear == "omit": continue grey = 0.0 if front else _REAR else: if not front: continue grey = _TEXTURE result.items.append( _Line( [(float(xy[i, 0]), float(xy[i, 1])), (float(xy[j, 0]), float(xy[j, 1]))], weight, grey, ) # fmt: skip ) if isinstance(parameters, Nanotube): _tube_outline(result, parameters, depiction, xy, keep, outer_radius, cut, shift) return result def _cut(depiction: Depiction, length: float, diameter: float) -> tuple[float, float, float] | None: """Where a long tube's middle is cut out: from, to, and the gap left, in its view.""" if depiction.brk == "off" or depiction.style == "end-on": return None if depiction.brk == "auto" and length <= 6 * diameter: return None keep = min(2 * diameter, length / 3) return -length / 2 + keep, length / 2 - keep, 0.8 * diameter def _tube_outline( result: Picture, tube: Nanotube, depiction: Depiction, xy: Any, keep: list[bool], radius: float, cut: tuple[float, float, float] | None, shift: Any, ) -> None: """The schematic's outline, the break mark, and the anchors at the ends.""" shown = xy[np.array(keep)] left, right = float(shown[:, 0].min()), float(shown[:, 0].max()) if depiction.style == "schematic": end = radius * 0.35 sides: list[tuple[float, float]] = [] if cut is None: sides = [(left, right)] else: start = float(cut[0]) sides = [(left, start), (start + cut[2], right)] for a, b in sides: result.items.append(_Line([(a, radius), (b, radius)])) result.items.append(_Line([(a, -radius), (b, -radius)])) result.items.append(_Ellipse((left, 0.0), end, radius)) result.items.append(_Ellipse((right, 0.0), end, radius)) # The inner walls' ends, seen at the left end. for wall in tube.walls[:-1]: from vibesketch.chem.nanostructures import diameter_nm inner = diameter_nm(wall) * 5 result.items.append(_Ellipse((left, 0.0), end * inner / radius, inner, 0.6)) if cut is not None: start, _, gap = cut reach = radius * 1.25 for offset in (0.3 * gap, 0.7 * gap): x = start + offset result.items.append(_Line([(x - 0.25 * gap, -reach), (x + 0.25 * gap, reach)])) result.anchors["lower-end"] = (left, 0.0, -1.0, 0.0) result.anchors["upper-end"] = (right, 0.0, 1.0, 0.0) result.anchors["sidewall"] = ((left + right) / 2, radius, 0.0, 1.0) def _end_on(tube: Nanotube, positions: Any, components: list[int], symbols: list[str]) -> Picture: from vibesketch.chem.nanostructures import diameter_nm result = Picture() for wall in tube.walls: radius = diameter_nm(wall) * 5 result.items.append(_Ellipse((0.0, 0.0), radius, radius)) outer = diameter_nm(tube.walls[-1]) * 5 result.anchors["sidewall"] = (0.0, outer, 0.0, 1.0) return result def _unrolled(tube: Nanotube) -> Picture: """The sheet of the innermost wall, with Ch along x and T along y.""" from vibesketch.chem.nanostructures import A_LATTICE, translation_angstrom, unrolled wall = tube.walls[0] ch_length = A_LATTICE * math.sqrt(wall.n**2 + wall.n * wall.m + wall.m**2) # Enough periods around the Ch x T cell that a flat cell still reads as a sheet. periods = max(1, min(8, round(ch_length / (3 * translation_angstrom(wall))))) points, pairs = unrolled(wall, periods, margin=0.1) result = Picture() for i, j in pairs: result.items.append( _Line( [ (float(points[i, 0]), float(points[i, 1])), (float(points[j, 0]), float(points[j, 1])), ], 0.5, _TEXTURE, ) # fmt: skip ) ch = ch_length t = translation_angstrom(wall) result.items.append(_Line([(0, 0), (ch, 0), (ch, t), (0, t)], 0.6, 0.0, closed=True)) for end, name in (((ch, 0.0), "Ch"), ((0.0, t), "T")): _arrow(result, (0.0, 0.0), end) result.items.append(_Text((end[0] / 2, -1.6) if name == "Ch" else (-1.6, end[1] / 2), name)) return result def _arrow(result: Picture, start: tuple[float, float], end: tuple[float, float]) -> None: dx, dy = end[0] - start[0], end[1] - start[1] length = math.hypot(dx, dy) ux, uy = dx / length, dy / length head = min(1.2, length / 4) base = (end[0] - ux * head, end[1] - uy * head) result.items.append(_Line([start, base], 1.2)) wing = head * 0.4 result.items.append( _Line( [ end, (base[0] - uy * wing, base[1] + ux * wing), (base[0] + uy * wing, base[1] - ux * wing), ], 1.0, closed=True, filled=True, ) # fmt: skip ) def _add_label(result: Picture, species: Species) -> None: from vibesketch.chem.nanostructures import diameter_nm made = species.nanostructure assert made is not None parameters = made.parameters if isinstance(parameters, Nanotube): walls = "@".join(f"({w.n},{w.m})" for w in parameters.walls) count = len(parameters.walls) text = f"{walls}, d = {diameter_nm(parameters.walls[-1]):.2f} nm" if count > 1: text += f", {count} walls" else: text = made.formula left, bottom, right, _ = result.bounds() result.items.append(_Text(((left + right) / 2, bottom), text, below=True)) # --- output --- def _grey(value: float) -> str: level = round(255 * value) return f"#{level:02x}{level:02x}{level:02x}" def _fit( pic: Picture, box: tuple[float, float, float, float] ) -> tuple[float, float, float, float, float]: """Scale and offsets that fit ``pic`` (y up) into ``box`` (left, top, width, height, y down).""" left, bottom, right, top = pic.bounds() width, height = max(right - left, 1e-6), max(top - bottom, 1e-6) scale = min(box[2] / width, box[3] / height) x0 = box[0] + (box[2] - width * scale) / 2 - left * scale y0 = box[1] + (box[3] - height * scale) / 2 + top * scale return scale, x0, y0, width * scale, height * scale
[docs] def picture_svg( pic: Picture, box: tuple[float, float, float, float], line: float, font: str, size: float, ) -> str: """SVG elements of ``pic`` fitted into ``box`` (SVG units, y down).""" scale, x0, y0, _, _ = _fit(pic, box) def at(x: float, y: float) -> str: return f"{x0 + x * scale:.3f},{y0 - y * scale:.3f}" parts: list[str] = [] for item in pic.items: if isinstance(item, _Line): colour = _grey(item.grey) path = " L ".join(at(*p) for p in item.points) fill = colour if item.filled else "none" close = " Z" if item.closed else "" parts.append( f"<path d='M {path}{close}' fill='{fill}' stroke='{colour}' " f"stroke-width='{line * item.weight:.3f}' stroke-linecap='round' " "stroke-linejoin='round'/>" ) elif isinstance(item, _Ellipse): cx, cy = at(*item.centre).split(",") parts.append( f"<ellipse cx='{cx}' cy='{cy}' rx='{item.rx * scale:.3f}' " f"ry='{item.ry * scale:.3f}' fill='none' stroke='{_grey(item.grey)}' " f"stroke-width='{line * item.weight:.3f}'/>" ) else: x, y = at(*item.at).split(",") baseline = float(y) + (1.3 * size if item.below else 0.35 * size) parts.append( f"<text x='{x}' y='{baseline:.3f}' text-anchor='middle' " f"font-family='{html.escape(font)}' font-size='{size}'>" f"{html.escape(item.text)}</text>" ) return "".join(parts)
[docs] def caption_room(pic: Picture, size: float) -> float: """How far a picture's caption reaches below its box, in the units of ``size``.""" return 1.7 * size if any(isinstance(i, _Text) and i.below for i in pic.items) else 0.0
[docs] def picture_tikz(pic: Picture, box: tuple[float, float, float, float]) -> list[str]: """TikZ commands of ``pic`` fitted into ``box`` (KET units, y up: left, top, width, height).""" scale, x0, y0, _, _ = _fit(pic, (box[0], -box[1], box[2], box[3])) def at(x: float, y: float) -> str: return f"({x0 + x * scale:.3f},{-(y0 - y * scale):.3f})" lines: list[str] = [] for item in pic.items: if isinstance(item, _Line): colour = f"black!{round(100 * (1 - item.grey))}" options = [f"draw={colour}", f"line width={0.5 * item.weight:.2f}pt", "line cap=round"] if item.filled: options.append(f"fill={colour}") path = " -- ".join(at(*p) for p in item.points) + (" -- cycle" if item.closed else "") lines.append(f"\\path[{','.join(options)}] {path};") elif isinstance(item, _Ellipse): colour = f"black!{round(100 * (1 - item.grey))}" lines.append( f"\\draw[{colour},line width={0.5 * item.weight:.2f}pt] {at(*item.centre)} " f"ellipse[x radius={item.rx * scale:.3f},y radius={item.ry * scale:.3f}];" ) else: text = item.text.replace("\\", "").replace("{", "").replace("}", "") where = "below,inner sep=2pt" if item.below else "inner sep=1pt" lines.append(f"\\node[{where}] at {at(*item.at)} {{{text}}};") return lines
# --- the canvas image ---
[docs] def canvas_image( species: Species, depiction: Depiction, at: tuple[float, float], width: float = 8.0, ) -> dict[str, Any]: """A KET image node of the depiction, its top-left corner at ``at``, ``width`` bonds wide. The image's SVG carries the depiction and its anchors in a ``metadata`` element; the anchors are given as fractions of the box, so they follow the image when it is moved or resized. """ pic = picture(species, depiction) left, bottom, right, top = pic.bounds() aspect = (top - bottom) / max(right - left, 1e-6) height = max(width * aspect, 0.5) room = caption_room(pic, 10) pixels = (width * 30.0, height * 30.0 + room) height += room / 30.0 anchors = { name: [round((x - left) / (right - left), 6), round((top - y) / (top - bottom), 6), dx, dy] for name, (x, y, dx, dy) in pic.anchors.items() } metadata = html.escape(json.dumps(depiction.metadata(anchors), sort_keys=True), quote=False) body = picture_svg(pic, (0.0, 0.0, pixels[0], pixels[1] - room), 1.0, "Arial, sans-serif", 10) svg = ( "<svg xmlns='http://www.w3.org/2000/svg' version='1.1' " f"width='{pixels[0]:.1f}' height='{pixels[1]:.1f}' " f"viewBox='0 0 {pixels[0]:.1f} {pixels[1]:.1f}'>" f"<metadata id='{_METADATA}'>{metadata}</metadata>{body}</svg>" ) return { "type": "image", "format": "image/svg+xml", "boundingBox": {"x": at[0], "y": at[1], "width": width, "height": height}, "data": base64.b64encode(svg.encode("utf-8")).decode("ascii"), }
[docs] def depiction_of(node: dict[str, Any]) -> tuple[Depiction, dict[str, list[float]]] | None: """The depiction a KET image node stands for and its anchors, or ``None`` for another image. Only the ``metadata`` element is read, as JSON within bounds; the rest of the SVG is ignored. """ from vibesketch.limits import parse_json if node.get("type") != "image" or node.get("format") != "image/svg+xml": return None data = node.get("data") if not isinstance(data, str) or len(data) > _MAX_DATA: return None try: text = base64.b64decode(data, validate=True).decode("utf-8") except (ValueError, UnicodeDecodeError): return None match = re.search(rf"<metadata id='{_METADATA}'>([^<]*)</metadata>", text) if match is None: return None try: found = parse_json(html.unescape(match.group(1)), "the nanostructure picture") except ValueError: return None if not isinstance(found, dict) or found.get("schema") != _SCHEMA: return None try: options: dict[str, Any] = { "style": found["style"] if found["style"] in STYLES else "schematic", "label": bool(found.get("label")), "rear": "omit" if found.get("rear") == "omit" else "light", "brk": found["break"] if found.get("break") in ("auto", "on", "off") else "auto", } depiction = Depiction(str(found["species"]), str(found["key"]), **options) except KeyError: return None anchors = { str(k): [float(v) for v in value][:4] for k, value in (found.get("anchors") or {}).items() if isinstance(value, list) and len(value) == 4 } return depiction, anchors
def image_box(node: dict[str, Any]) -> tuple[float, float, float, float]: """A KET image's box: left, top, width, height in KET units (y up); refused if not finite.""" box = node.get("boundingBox") or {} values = tuple(float(box.get(k, math.nan)) for k in ("x", "y", "width", "height")) if not all(math.isfinite(v) for v in values) or values[2] <= 0 or values[3] <= 0: raise DepictionRefused("a picture on the canvas has no usable box") return values # type: ignore[return-value] def anchor_points(node: dict[str, Any]) -> dict[str, tuple[float, float, float, float]]: """The anchors of a depiction image in KET coordinates, with their bond directions.""" found = depiction_of(node) if found is None: from vibesketch.render.surfaces import anchor_points as surface_anchors return surface_anchors(node) left, top, width, height = image_box(node) return { name: (left + u * width, top - v * height, dx, dy) for name, (u, v, dx, dy) in found[1].items() }