"""Laboratory apparatus pictograms drawn from ordinary shapes and text (#91).
Each pictogram is vibe-sketch's own line art, made of the canvas engine's
rectangles, ellipses and lines only, like the TLC plate. So the canvas shows
it, edits move or change its parts, and every figure prints it: SVG, PNG,
PDF and the chemfig export's TikZ picture. Nothing special is stored, and a
pictogram is a schematic, not a drawing to scale. The canvas has no arcs, so
a round-bottom flask is a circle with its neck drawn on top.
The geometry is in bond lengths, from the pictogram's bottom centre with y
pointing up, as KET's coordinates do.
"""
from __future__ import annotations
import copy
import math
from typing import Any
from vibesketch.document.model import Canvas, Document
from vibesketch.document.tlc import _bounds, _shape
__all__ = ["APPARATUS", "ApparatusRefused", "with_apparatus"]
Point = tuple[float, float]
Part = tuple[str, Point, Point]
#: Where the round-bottom flask's neck meets its bulb, a circle of radius 1 about (0, 1).
_NECK = 0.25
_SHOULDER = 1 + math.sqrt(1 - _NECK**2)
def _mirrored(*lines: tuple[Point, Point]) -> list[Part]:
"""Each line and its mirror image in the vertical axis."""
parts: list[Part] = []
for (x1, y1), (x2, y2) in lines:
parts.append(("line", (x1, y1), (x2, y2)))
parts.append(("line", (-x1, y1), (-x2, y2)))
return parts
#: The pictograms, as shapes with their two points each.
APPARATUS: dict[str, list[Part]] = {
"flask": [
("ellipse", (-1.0, 0.0), (1.0, 2.0)),
*_mirrored(((-_NECK, _SHOULDER), (-_NECK, 3.0))),
],
"conical-flask": [
*_mirrored(((-0.25, 2.2), (-0.25, 3.0)), ((-0.25, 2.2), (-1.0, 0.0))),
("line", (-1.0, 0.0), (1.0, 0.0)),
],
"beaker": [
*_mirrored(((-0.8, 0.0), (-0.8, 1.8))),
("line", (-0.8, 0.0), (0.8, 0.0)),
("line", (-0.8, 1.8), (-0.95, 1.95)),
],
"condenser": [
*_mirrored(((-0.12, 0.0), (-0.12, 4.0))),
("rectangle", (-0.35, 0.6), (0.35, 3.4)),
("line", (0.35, 0.9), (0.75, 0.9)),
("line", (-0.35, 3.1), (-0.75, 3.1)),
],
"separatory-funnel": [
("rectangle", (-0.15, 3.4), (0.15, 3.7)),
*_mirrored(
((-0.15, 3.0), (-0.15, 3.4)),
((-0.15, 3.0), (-0.8, 2.5)),
((-0.8, 2.5), (-0.06, 1.0)),
((-0.06, 1.0), (-0.06, 0.0)),
),
("rectangle", (-0.25, 0.45), (0.25, 0.6)),
],
"column": [
*_mirrored(
((-0.3, 0.8), (-0.3, 4.5)),
((-0.3, 0.8), (-0.06, 0.5)),
((-0.06, 0.5), (-0.06, 0.0)),
),
("line", (-0.3, 3.2), (0.3, 3.2)),
("rectangle", (-0.2, 0.25), (0.2, 0.38)),
],
"vial": [
("rectangle", (-0.4, 0.0), (0.4, 1.6)),
("rectangle", (-0.45, 1.6), (0.45, 2.0)),
],
"hotplate": [
("rectangle", (-1.2, 0.0), (1.2, 0.5)),
("ellipse", (0.7, 0.12), (0.95, 0.38)),
],
}
#: Space between the drawing and a pictogram placed beside it, in bond lengths.
_GAP = 2.0
#: How far below the pictogram its label sits, in bond lengths.
_LABEL_DROP = 0.45
_SCALES = (0.25, 4.0)
_MAX_LABEL = 40
[docs]
class ApparatusRefused(ValueError):
"""A pictogram that cannot be drawn as asked."""
[docs]
def with_apparatus(
document: Document,
kind: str,
at: Point | None = None,
*,
scale: float = 1.0,
label: str | None = None,
) -> Document:
"""``document`` with the ``kind`` pictogram added.
``at`` is the pictogram's bottom centre in KET coordinates; without it the
pictogram stands to the right of everything drawn, centred on the
drawing's height. ``scale`` multiplies its size; ``label`` is written
under it.
"""
from vibesketch.document.ket import text_node
parts = APPARATUS.get(kind)
if parts is None:
raise ApparatusRefused(f"no pictogram {kind!r}; pictograms: {', '.join(APPARATUS)}")
if not (math.isfinite(scale) and _SCALES[0] <= scale <= _SCALES[1]):
raise ApparatusRefused(f"the scale is from {_SCALES[0]} to {_SCALES[1]}")
if label is not None and (
not label.strip() or len(label) > _MAX_LABEL or "\n" in label or "\r" in label
):
raise ApparatusRefused(f"a label is one line of 1 to {_MAX_LABEL} characters")
data: dict[str, Any] = copy.deepcopy(document.canvas.data) or {"root": {"nodes": []}}
xs = [x for _, a, b in parts for x in (a[0], b[0])]
height = max(y for _, a, b in parts for y in (a[1], b[1])) * scale
if at is None:
found = _bounds(data)
left = found[0] + _GAP if found else 0.0
x = left - min(xs) * scale
y = (found[1] + found[2]) / 2 - height / 2 if found else -height / 2
else:
x, y = float(at[0]), float(at[1])
if not (math.isfinite(x) and math.isfinite(y)):
raise ApparatusRefused("the position must be finite")
def placed(point: Point) -> Point:
return (round(x + point[0] * scale, 6), round(y + point[1] * scale, 6))
nodes = [_shape(mode, placed(a), placed(b)) for mode, a, b in parts]
if label is not None:
nodes.append(text_node(label.strip(), (x, y - _LABEL_DROP)))
data.setdefault("root", {"nodes": []}).setdefault("nodes", []).extend(nodes)
return document.model_copy(update={"canvas": Canvas(format="ket", data=data)})