Changelog

0.20.0

  • Sites can now be partially occupied or shared between multiple species. Pass a Composition in species to declare a mixed site; the renderer draws it as a pie of wedges, one per constituent species, with vacancy fractions filled opaquely with the canvas background colour. from_pymatgen() propagates partial occupancies from the source structure.

  • New render style fields wedge_start_angle, show_wedge_edges, and vacancy_colour control the appearance of mixed-site wedges.

  • visible now applies only to pure-string site rows. Constituents of a Composition are always rendered, regardless of their visible flag, and the legend follows suit. This keeps wedge rendering consistent with bond detection and rule lookups (which already operated on every constituent of a mixed site).

0.19.0

  • StructureScene.species is now stored as a tuple. The sequence is fixed at construction.

  • New select_by_species() method filters a full-length per-atom array to keep only selected species, filling the rest with the appropriate missing sentinel (NaN for numeric, None for categorical).

  • set_atom_data() gains by_species and by_index keyword arguments for sparse per-atom metadata assignment. by_species maps species labels to values; by_index maps atom indices. Both can be combined in one call, with by_index taking precedence at overlapping atoms.

  • set_atom_data() no longer accepts a dict as its positional values argument. Use by_index= instead.

  • Missing entries in sparse categorical atom data are now filled with None (object-dtype) instead of empty strings. Both are treated as missing by the rendering pipeline; None cannot collide with a real label.

  • The AtomData container is no longer re-exported from hofmann or hofmann.model. The only supported way to obtain an instance is to read the atom_data property of a scene; direct construction is considered an internal implementation detail. Users who imported AtomData from hofmann should migrate to the StructureScene write methods: set_atom_data() for assignment, del_atom_data() for targeted removal, and clear_2d_atom_data() for dropping all per-frame entries at once. The class itself is documented in the API reference for inspection of instances obtained via scene.atom_data.

  • The per-atom metadata container no longer takes a frames argument at construction and no longer exposes an n_frames property. Each write declares its expected frame count at the call site; the container no longer stores any reference to the scene’s trajectory list.

  • StructureScene gains del_atom_data() for targeted removal of a single entry and clear_2d_atom_data() for dropping every per-frame (2-D) entry in one go while preserving static per-atom (1-D) entries.

  • set_atom_data() now validates the prospective post-write state of the container in a single walk. A 2-D input’s shape[0] must equal len(scene.frames), and any already-stored 2-D entry not being overridden by this write must agree with the same frame count. Mismatches raise ValueError naming the offending key and, for the stale-stored case, pointing at clear_2d_atom_data() for recovery.

  • A single 2-D per-atom metadata entry can now be reassigned in place at a new shape after extending the trajectory:

    scene.frames.append(new_frame)
    scene.set_atom_data("energy", new_energy_at_new_shape)
    

    The stored version of the key is treated as overridden by the pending write and replaced atomically. For scenes holding two or more 2-D entries, clear_2d_atom_data() is still required before the first reassignment because the other 2-D entries are now stale. The resulting error names the stale key so the user knows what to drop.

  • Appending to scene.frames after assigning 2-D per-atom metadata used to silently leave the container holding a stale array. Rendering now raises a clear error at the start of every render_* call, identifying the mismatch rather than failing later with a confusing numpy IndexError.

  • The atom_data property now returns a read-only mapping view. The underlying container inherits from collections.abc.Mapping, so every mutation entry point raises at the Python protocol level: scene.atom_data[key] = arr and del scene.atom_data[key] raise TypeError, while scene.atom_data.pop(...), .popitem(), .setdefault(), .update(...), and .clear() raise AttributeError (none of those methods exist on a Mapping). Use the scene’s write methods instead.

  • The atom_data setter has been removed. scene.atom_data = ... raises AttributeError.

  • The repr of scene.atom_data now includes array shape information:

    >>> scene.atom_data
    AtomData({'charge': (3,), 'energy': (5, 3)})
    

    Previously only the keys were shown.

0.18.0

  • The AtomData container now exposes derived per-key metadata via read-only mapping attributes ranges and labels, replacing the previous global_range() and global_labels() methods. Callers migrate with a direct substitution: ad.global_range(key) becomes ad.ranges[key], and ad.global_labels(key) becomes ad.labels[key]. The results are computed eagerly on assignment, so every access is a simple dictionary lookup.

  • The AtomData container rejects unsupported dtypes at assignment time with a clear error message. Supported dtypes are bool, integer, float, string, and object; complex, datetime, bytes, and other dtypes now raise ValueError at assignment rather than failing later in the rendering pipeline.

  • resolve_atom_colours is no longer part of the public API. Colour resolution goes through the StructureScene rendering methods.

0.17.0

  • StructureScene atom_data arrays are now stored read-only. In-place mutation of a returned array raises ValueError: assignment destination is read-only instead of silently bypassing shape validation and cache invalidation. Update values by building a new array and passing it through set_atom_data().

0.16.0

  • New light_direction parameter on RenderStyle controls the direction of the virtual light source for polyhedra face shading, specified in screen space (x = right, y = up, z = towards viewer). The default (0, 0, 1) preserves the existing behaviour; set an off-axis direction such as (-0.3, 0.5, 1.0) for visible face shading from top-down viewing angles.

0.15.1

  • Rendering now warns when a species has no AtomStyle. The warning lists all affected species.

0.15.0

  • Interactive viewer: frame indicator (f), go-to-frame (g), set-step (s), and step-aware frame navigation.

  • New render_animation() method for exporting trajectories as GIF or MP4 animations.

  • atom_data is now a validated AtomData container that checks array shapes on assignment. It also accepts 2-D arrays of shape (n_frames, n_atoms) so that colourmap-based colouring can vary per frame in animations and the interactive viewer.

0.14.2

  • Fixed bond completion missing padding image atoms near cell boundaries. Padding atoms now get their full coordination shell, matching physical atoms.

0.14.1

  • Fixed cell edge lines being clipped at atoms that are not drawn. Atoms hidden by hide_centre, visible, or slab clipping no longer produce spurious gaps in cell outline edges.

0.14.0

  • Breaking: Frame now carries a lattice field (shape (3, 3) or None). The lattice field on StructureScene has been replaced by a read-only property that delegates to frames[0].lattice. Code that constructed a StructureScene with lattice=... should move the lattice onto each Frame instead.

    This correctly supports NPT (variable-cell) trajectories where the unit cell changes between frames. Rendering functions now resolve the lattice from the current frame, so cell edges, periodic bonds, and axes widgets update per frame.

  • New from_ase() constructor and from_ase() classmethod for building scenes directly from ASE Atoms objects, without requiring pymatgen. Supports both periodic and non-periodic systems, single structures and trajectories (list[Atoms] or ase.io.Trajectory), and the same style, bond, polyhedra, and view options as from_pymatgen(). ASE is available as an optional dependency: pip install "hofmann[ase]". from_ase() stores the lattice per frame, correctly supporting NPT trajectories where the cell changes between frames.

0.13.1

  • Added widget positioning documentation covering the corner, margin, and custom coordinate parameters shared by AxesStyle and LegendStyle.

  • Documentation figures are now generated at Sphinx build time via a builder-inited hook, rather than being pre-generated and committed to the repository. This ensures figures always reflect the current rendering code. pymatgen is now included in the docs optional extra. Set SKIP_IMAGE_GEN=1 to skip figure generation during rapid local iteration.

0.13.0

  • Breaking: LegendItem is now an abstract base class. Use the concrete subclasses AtomLegendItem (circle markers), PolygonLegendItem (regular-polygon markers with sides and rotation), or PolyhedronLegendItem (miniature 3D icons with shape and optional rotation).

    Migration:

    • LegendItem(key=..., colour=...) becomes AtomLegendItem(key=..., colour=...).

    • LegendItem(key=..., colour=..., sides=6) becomes PolygonLegendItem(key=..., colour=..., sides=6).

    • LegendItem(key=..., colour=..., polyhedron="octahedron") becomes PolyhedronLegendItem(key=..., colour=..., shape="octahedron").

    • LegendItem.from_polyhedron_spec(...) becomes PolyhedronLegendItem.from_polyhedron_spec(...).

    Serialisation: to_dict() now includes a "type" discriminator; LegendItem.from_dict() dispatches to the correct subclass. Saved style files from 0.12.x that contain polygon or polyhedron legend items must be re-saved; only plain atom-style dicts (no sides/polyhedron fields) are handled without a "type" key.

  • PolyhedronLegendItem gains a rotation parameter accepting a (3, 3) rotation matrix or an (Rx, Ry) tuple of angles in degrees. When None (the default), the standard oblique legend viewing angle is used.

  • Fix legend edge width scaling: legend marker outlines were incorrectly multiplied by the widget display-space scaling factor, making them appear thicker than the corresponding edges in the scene.

0.12.0

  • LegendItem gains a polyhedron field. Set it to "octahedron", "tetrahedron", or "cuboctahedron" to render a miniature 3D-shaded polyhedron icon in the legend instead of a flat circle or polygon marker. Polyhedron icons default to twice the flat-marker radius so that the 3D shading is legible at typical figure sizes.

  • LegendItem gains per-item edge_colour and edge_width fields. When set, these override the scene-level outline settings; when unset, items fall back to the scene’s outline_colour and outline_width. Setting show_outlines=False disables edges only for items that do not define their own edge styling.

  • New from_polyhedron_spec() classmethod creates a legend item from a PolyhedronSpec, inheriting colour, alpha, and edge settings without duplication.

  • render_legend() gains a polyhedra_shading parameter controlling the shading strength of 3D polyhedron icons (0 = flat, 1 = full).

  • Fix bounding box computation in render_legend() to account for edge linewidth, preventing clipped outlines on tightly-cropped legend images.

0.11.1

  • Internal: extract legend rendering from painter.py into a dedicated legend.py module, and move the shared widget scaling constant into _widget_scale.py.

0.11.0

  • Internal: legend drawing now runs through LegendItem objects. A new _build_legend_items helper assembles items from the scene’s species and atom styles, and _draw_legend_widget consumes the resulting list.

  • New LegendItem class bundles per-entry legend data (key, colour, optional label, optional radius) with validated property setters following the BondSpec pattern.

  • LegendStyle gains an items parameter. Pass a tuple of LegendItem instances to display a fully custom legend (e.g. for colour_by data) instead of the default species-based entries.

  • LegendItem supports regular-polygon markers via sides and rotation fields. Set sides (>= 3) to draw a polygon instead of a circle, and rotation to rotate it in degrees. Useful for indicating polyhedra types in the legend.

  • LegendItem gains a gap_after field for non-uniform vertical spacing. Each item can override the style-level spacing for the gap below it; None falls back to LegendStyle.spacing.

  • LegendItem gains an alpha field (0.0–1.0, default 1.0) for semi-transparent marker faces. Marker outlines remain fully opaque, matching the visual style of polyhedra.

0.10.2

  • Internal: replaced four parallel per-polyhedron lists in _PrecomputedScene (poly_base_colours, poly_alphas, poly_edge_colours, poly_edge_widths) with a single list of frozen _PolyhedronRenderData dataclass instances, making the coupling between colour, alpha, and edge style explicit.

0.10.1

  • BondSpec validation is now extracted into per-field private methods, removing duplication between __init__ and property setters. The colour setter now validates its input via normalise_colour(), closing a gap where invalid colours were silently accepted post-construction.

0.10.0

  • New show_legend option on RenderStyle draws a vertical column of coloured circles with species labels. Customise placement, font size, circle sizing, spacing, and label gap via LegendStyle.

  • LegendStyle.circle_radius accepts three forms: a uniform float, a (min, max) tuple for proportional sizing based on AtomStyle.radius, or a per-species dict for explicit control.

  • render_legend() produces a tightly-cropped legend image without any structure, useful for composing figures in external tools. Supports figsize for fixed output dimensions and transparent backgrounds.

  • LegendStyle.label_gap controls the horizontal gap between legend circles and species labels (default 5.0 points).

  • LegendStyle.labels accepts a dict mapping species names to display strings. Common chemical notation is auto-formatted: trailing charges become superscripts with tight kerning ("Sr2+" renders as Sr^2+), embedded digits become subscripts ("TiO6" renders as TiO_6), and strings containing $ are passed through as explicit matplotlib mathtext.

  • Legend entry spacing is automatically widened when labels contain super/subscripts, unless the user has explicitly set a custom spacing value.

0.9.0

  • Periodic boundary handling has moved from scene construction to render time. from_pymatgen() no longer expands image atoms at construction; instead, Bond carries an image field recording which lattice translation the bond crosses, and a new RenderingSet pipeline materialises image atoms on demand during rendering. This means the same scene can be rendered with different PBC settings without reconstruction.

    The pipeline applies five stages in order:

    1. Single-pass completion (complete on BondSpec)

    2. Recursive expansion (recursive on BondSpec)

    3. Geometric padding (pbc_padding on RenderStyle)

    4. Polyhedra vertex completion

    5. Molecule deduplication (deduplicate_molecules on RenderStyle)

    See Scenes and structures for details.

  • pbc, pbc_padding, max_recursive_depth, and deduplicate_molecules are now fields on RenderStyle rather than StructureScene, so rendering style is fully separated from structure data.

  • Bond detection for periodic structures dispatches based on the inscribed sphere radius of the unit cell. When all bond lengths are shorter than the inscribed sphere radius (the common case), the minimum image convention (MIC) gives an efficient single-pass computation. When bond lengths are comparable to cell dimensions, all 27 image offsets are checked iteratively. Both paths use O(n^2) peak memory.

  • Recursive expansion can produce duplicate molecular fragments. The deduplication stage detects extended structures (slabs, frameworks) that wrap the unit cell and protects them from removal, removes non-wrapped components whose source atoms are a subset of a wrapped component, and selects a canonical copy among remaining duplicates using an unwrapped fractional centre-of-mass tie-breaker.

  • StructureScene now validates that view is a ViewState on assignment, with a helpful hint when a tuple from render_mpl_interactive() is accidentally assigned without unpacking.

  • Passing None for a nullable style keyword argument (e.g. pbc_padding=None) in render_mpl() now correctly passes through as an explicit override rather than being silently dropped.

0.8.0

  • The monolithic model.py (1888 lines) and render_mpl.py (2517 lines) have been split into three sub-packages organised by architectural layer:

    • hofmann.model – data types (colour utilities, atom/bond/polyhedron specs, rendering styles, view state, and the scene container).

    • hofmann.construction – scene building (file parsing, bond and polyhedra computation, element defaults, style I/O, and pymatgen/XBS scene constructors).

    • hofmann.rendering – display (projection, bond geometry, cell edge rendering, the painter’s algorithm, static output, and the interactive viewer).

    All public import paths are preserved: from hofmann import BondSpec and from hofmann.model import BondSpec continue to work.

0.7.1

  • Avoid quadratic array growth in _merge_expansions during periodic boundary expansion. Accepted image coordinates are now collected in a list with O(1) hash-based deduplication and concatenated once at the end, matching the approach already used by _expand_neighbour_shells.

  • StructureScene now validates that every frame has the same number of atoms as the species list at construction time, raising ValueError immediately instead of failing with a confusing error during rendering.

  • look_along() now returns self, enabling one-liner construction such as ViewState(centre=centroid).look_along([1, 1, 1]).

  • Passing None for a style keyword argument in render_mpl() now resets that field to the RenderStyle class default instead of being silently ignored.

  • BondSpec, AtomStyle, PolyhedronSpec, and ViewState now validate their numeric fields at construction time, raising ValueError for out-of-range values (e.g. negative radii, min_length > max_length, alpha outside [0, 1], non-positive zoom or view_distance).

  • render_mpl(), render_mpl_interactive(), centre_on(), and from_pymatgen() now raise descriptive ValueError messages for out-of-range index arguments (frame_index, atom_index, centre_atom) instead of leaking bare IndexError exceptions.

0.7.0

  • from_pymatgen() (and the from_pymatgen() classmethod) now accept atom_styles, title, view, and atom_data keyword arguments, allowing styles to be configured at construction time rather than requiring post-hoc mutation.

  • New JSON-based style persistence. All style classes gain to_dict() / from_dict() methods for serialisation, and module-level save_styles() / load_styles() functions write and read style files containing any combination of atom_styles, bond_specs, polyhedra, and render_style sections. save_styles() and load_styles() provide convenience methods on the scene itself. See Styles and presets for details.

  • New StyleSet dataclass returned by load_styles().

0.6.0

  • BondSpec now only requires species and max_length. min_length defaults to 0.0; radius and colour default to class-level values (BondSpec.default_radius = 0.1, BondSpec.default_colour = 0.5) which can be changed to set project-wide defaults. The repr() shows <default ...> for values that have not been explicitly set.

0.5.0

  • render_mpl() now accepts an ax parameter to render into an existing matplotlib axes, enabling multi-panel figures and composition with other plots.

0.4.0

  • Default bond detection now uses VESTA bond length cutoffs (bundled as JSON, sourced from pymatgen) instead of the covalent-radii-sum heuristic. Self-bonds (e.g. C-C) are included automatically when present in the VESTA data. The tolerance and self_bonds parameters on default_bond_specs() have been removed.

0.3.0

  • Bond completion across periodic boundaries. BondSpec gains a complete flag for single-pass completion of bonds at cell boundaries, and a recursive flag for iterative search that follows chains of bonds across periodic images. See Scenes and structures for details.

  • AtomStyle gains a visible flag (default True). Setting it to False hides atoms of that species and suppresses their bonds without removing them from the scene.

  • BondSpec.complete now validates the species name against the bond spec’s species pair, catching typos that previously resulted in a silent no-op.

  • Removed PolyhedraVertexMode enum and the polyhedra_vertex_mode field on RenderStyle. Vertex atoms are now always drawn in front of their connected polyhedral faces (the previous default behaviour).

0.2.2

  • Add dodecahedron project logo to README and repository.

0.2.1

  • Static renderer now uses per-axis viewport extents, producing tightly cropped output for non-square scenes.

0.2.0

  • Per-atom metadata colouring via colourmaps. Use set_atom_data() and the colour_by parameter on render_mpl() to map numerical or categorical data to atom colours.

  • Multiple colour_by layers with priority merging. Pass a list of keys to apply different colouring rules to different atom subsets; the first non-missing value wins for each atom.

  • Polyhedra without an explicit colour now inherit the resolved colour_by colour of their centre atom.

  • New public API: resolve_atom_colours for programmatic colour resolution, and CmapSpec type alias for colourmap specifications.

0.1.0

Initial release.

  • Ball-and-stick rendering via matplotlib with depth-sorted painter’s algorithm.

  • Publication-quality vector output (SVG, PDF).

  • XBS .bs and .mv file format support.

  • pymatgen Structure interoperability (optional dependency).

  • Periodic boundary condition expansion with bond-aware and polyhedra-vertex-aware image generation.

  • Coordination polyhedra with configurable slab clipping modes.

  • Interactive matplotlib viewer with mouse rotation and zoom.