"""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"
@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()
}