Source code for vibesketch.render.surfaces

"""Validated schematic surface images. Publication output never trusts canvas SVG (#140)."""

from __future__ import annotations

import base64
import html
import json
import math
import re
from dataclasses import replace
from typing import Any

from pydantic import ValidationError

from vibesketch.document.model import Surface
from vibesketch.render.nanostructures import (
    Depiction,
    DepictionRefused,
    Picture,
    _Ellipse,
    _Line,
    _Text,
    image_box,
    picture,
    picture_svg,
)

SCHEMA = "vibe-sketch-surface/1"
MARKER = "vibe-sketch-surface"


[docs] def surface_of(node: dict[str, Any]) -> Surface | None: """Read bounded metadata only; refuse corrupt recognised surface objects.""" 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) > 2_000_000: return None try: svg = base64.b64decode(data, validate=True).decode("utf-8") except (ValueError, UnicodeDecodeError): return None if MARKER not in svg: return None match = re.search(r"<metadata id=['\"]vibe-sketch-surface['\"]>([^<]*)</metadata>", svg) try: if match is None: raise ValueError("missing surface metadata") payload = parse_json(html.unescape(match[1]), "surface metadata") if not isinstance(payload, dict) or payload.pop("schema", None) != SCHEMA: raise ValueError("unsupported surface metadata schema") result = Surface.model_validate(payload) image_box(node) return result except (ValueError, ValidationError) as error: raise DepictionRefused(f"invalid surface metadata: {error}") from None
[docs] def anchor_points(node: dict[str, Any]) -> dict[str, tuple[float, float, float, float]]: surface = surface_of(node) if surface is None: return {} x, y, w, h = image_box(node) return {name: (x + a.u * w, y - a.v * h, a.dx, a.dy) for name, a in surface.anchors.items()}
[docs] def surface_picture(surface: Surface, species: dict[str, Any], aspect: float = 1) -> Picture: """Schematic geometry in a unit box; dimensions are labels, never physical coordinates.""" result = Picture() # Fixed bounds keep metadata's fractional anchors aligned with resized images. result.items.extend([_Line([(0, 0), (0, 0)], grey=1), _Line([(1, 1), (1, 1)], grey=1)]) if surface.kind == "nanoparticle": result.items.append(_Ellipse((0.5, 0.5), 0.5, 0.5)) if surface.ligand_shell: result.items.append(_Ellipse((0.5, 0.5), 0.43, 0.43, grey=0.5)) label = f"{surface.material}, {surface.diameter_nm:g} nm" result.items.append(_Text((0.5, 0.5), label)) if surface.ligand_shell: result.items.append(_Text((0.5, 0.32), surface.ligand_shell)) else: layers = surface.layers or [surface.material] slab_top = 0.25 if surface.kind == "nanotube-array" else 1.0 for i, layer in enumerate(layers): low, high = i * slab_top / len(layers), (i + 1) * slab_top / len(layers) result.items.append(_Line([(0, low), (1, low), (1, high), (0, high)], closed=True)) for j in range(10): x = j / 10 result.items.append(_Line([(x, low), (min(1, x + 0.06), high)], 0.35, 0.8)) text = layer + (f"({surface.facet})" if surface.facet and i == len(layers) - 1 else "") result.items.append(_Text((0.5, (low + high) / 2), text)) if surface.kind == "nanotube-array": tube = species.get(surface.nanotube or "") if ( tube is None or tube.nanostructure is None or tube.nanostructure.parameters.kind != "nanotube" ): raise DepictionRefused("a nanotube-array needs an existing nanotube species") glyph = picture(tube, Depiction(tube.id, tube.nanostructure.key, brk="off")) left, bottom, right, top = glyph.bounds() for index in range(surface.count): cx = (index + 0.5) / surface.count def turn(p: tuple[float, float], cx: float = cx) -> tuple[float, float]: return cx - p[1] * 0.8 / (surface.count * (top - bottom)), 0.25 + ( p[0] - left ) * 0.75 / (right - left) for item in glyph.items: if isinstance(item, _Line): result.items.append(replace(item, points=[turn(p) for p in item.points])) elif isinstance(item, _Ellipse): result.items.append( replace( item, centre=turn(item.centre), rx=item.ry * 0.8 / (surface.count * (top - bottom)), ry=item.rx * 0.75 / (right - left), ) ) for a in surface.anchors.values(): if a.repeat > 1: result.items.append(_Text((a.u, 1 - a.v - 0.22), f"x{a.repeat}")) for i, item in enumerate(result.items): if isinstance(item, _Line): result.items[i] = replace(item, points=[(x * aspect, y) for x, y in item.points]) elif isinstance(item, _Ellipse): result.items[i] = replace( item, centre=(item.centre[0] * aspect, item.centre[1]), rx=item.rx * aspect ) else: result.items[i] = replace(item, at=(item.at[0] * aspect, item.at[1])) return result
[docs] def canvas_image( surface: Surface, species: dict[str, Any], at: tuple[float, float], width: float, height: float ) -> dict[str, Any]: if not all(math.isfinite(v) for v in (*at, width, height)) or not ( 1 <= width <= 100 and 1 <= height <= 100 ): raise DepictionRefused( "surface dimensions must be 1 to 100 bond lengths and position finite" ) metadata = html.escape( json.dumps( {"schema": SCHEMA, **surface.model_dump(mode="json", exclude_none=True)}, sort_keys=True ), quote=False, ) body = picture_svg( surface_picture(surface, species, width / height), (0, 0, width * 30, height * 30), 1, "Arial", 10, ) svg = ( f"<svg xmlns='http://www.w3.org/2000/svg' width='{width * 30}' height='{height * 30}'>" f"<metadata id='{MARKER}'>{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()).decode(), }