Skip to content

Commit a29aa71

Browse files
authored
Refactor bond pipeline: padding before completion (fixes #38)
Reorder the bond/atom materialisation pipeline so geometric padding runs before completion, fixing incomplete coordination shells on padding image atoms near cell boundaries. - Treat padding atoms as base atoms alongside physical atoms - Extend completion to materialise missing partners for padding atoms - Move bond creation into _complete_polyhedra_vertices - Add scenario-driven pipeline stage tests - Document pipeline stages and bond ownership in build_rendering_set
1 parent 139e4f7 commit a29aa71

5 files changed

Lines changed: 383 additions & 83 deletions

File tree

docs/changelog.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
Changelog
22
=========
33

4+
0.14.2
5+
------
6+
7+
- Fixed bond completion missing padding image atoms near cell
8+
boundaries. Padding atoms now get their full coordination
9+
shell, matching physical atoms.
10+
411
0.14.1
512
------
613

714
- Fixed cell edge lines being clipped at atoms that are not drawn.
815
Atoms hidden by :attr:`~hofmann.PolyhedronSpec.hide_centre`,
916
:attr:`~hofmann.AtomStyle.visible`, or slab clipping no longer
1017
produce spurious gaps in cell outline edges.
11-
(`#41 <https://github.com/bjmorgan/hofmann/issues/41>`_).
1218

1319
0.14.0
1420
------

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "hofmann"
7-
version = "0.14.1"
7+
version = "0.14.2"
88
description = "A modern Python reimagining of the XBS ball-and-stick crystal structure viewer"
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/hofmann/construction/rendering_set.py

Lines changed: 164 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -139,16 +139,21 @@ def _discover_bonds_for_new_atoms(
139139
image_registry: dict[tuple[int, ImageVector], int],
140140
rendering_bonds: list[Bond],
141141
) -> None:
142-
"""Find bonds between newly created image atoms and existing atoms.
142+
"""Connect padding atoms to existing base atoms and to each other.
143143
144144
For each new atom ``(phys_idx, shift)``, scans periodic bonds
145145
involving ``phys_idx`` and adds rendering bonds to any target that
146146
already exists in the expanded set. Does NOT materialise new
147147
atoms — only connects to existing ones.
148148
149+
Used exclusively for padding bond discovery (pipeline step 4).
150+
149151
Args:
150152
new_atoms: List of ``(phys_idx, shift, expanded_idx)``.
151-
periodic_bonds: Original periodic bonds.
153+
periodic_bonds: All bonds (direct and periodic) from
154+
:func:`compute_bonds`. Direct bonds are included so
155+
that padding images can connect to other padding images
156+
of directly bonded atoms.
152157
coords: Physical atom coordinates.
153158
lattice: Lattice matrix.
154159
image_registry: Maps ``(phys_idx, image)`` to expanded index.
@@ -199,13 +204,15 @@ def _complete_polyhedra_vertices(
199204
polyhedra_specs: list,
200205
image_registry: dict[tuple[int, ImageVector], int],
201206
materialise: Callable[[int, ImageVector], int],
202-
) -> list[tuple[int, ImageVector, int]]:
207+
rendering_bonds: list[Bond],
208+
) -> None:
203209
"""Ensure polyhedron centres have complete coordination shells.
204210
205211
For each atom (physical or image) matching a
206212
:class:`PolyhedronSpec` centre pattern, materialises any missing
207-
bonded neighbours. Single-pass: newly created vertex atoms are
208-
not themselves checked as potential centres.
213+
bonded neighbours and creates the corresponding centre-vertex bonds.
214+
Single-pass: newly created vertex atoms are not themselves checked
215+
as potential centres.
209216
210217
Args:
211218
species: Species labels for physical atoms.
@@ -214,20 +221,17 @@ def _complete_polyhedra_vertices(
214221
n_physical: Number of physical atoms.
215222
periodic_bonds: Original periodic bonds.
216223
polyhedra_specs: Polyhedron specification rules.
217-
image_registry: Current ``(phys_idx, image)`` expanded index map.
224+
image_registry: Current ``(phys_idx, image)`` -> expanded index map.
218225
materialise: Callback ``(phys_idx, image_tuple) -> exp_idx``.
219-
220-
Returns:
221-
List of ``(phys_idx, shift, expanded_idx)`` for newly created
222-
vertex atoms.
226+
rendering_bonds: Mutable list to append new centre-vertex bonds to.
223227
"""
224228
from hofmann.model import PolyhedronSpec as _PSpec
225229

226230
# Collect all centre patterns.
227231
centre_patterns = [s.centre for s in polyhedra_specs
228232
if isinstance(s, _PSpec)]
229233
if not centre_patterns:
230-
return []
234+
return
231235

232236
def _is_centre(sp: str) -> bool:
233237
return any(fnmatch(sp, pat) for pat in centre_patterns)
@@ -241,8 +245,6 @@ def _is_centre(sp: str) -> bool:
241245
neg_img = _neg_image(img)
242246
atom_bonds.setdefault(b, []).append((a, neg_img, bond.spec))
243247

244-
new_atoms: list[tuple[int, ImageVector, int]] = []
245-
246248
# Snapshot of all atoms to check (physical + current images).
247249
# We freeze this before adding vertices so the step is single-pass.
248250
atoms_to_check: list[tuple[int, ImageVector]] = [
@@ -257,15 +259,36 @@ def _is_centre(sp: str) -> bool:
257259

258260
for other_phys, bond_img, spec in atom_bonds[phys_idx]:
259261
target_shift = _add_images(shift, bond_img)
262+
# Both endpoints physical — bond already in direct_bonds.
263+
if shift == (0, 0, 0) and target_shift == (0, 0, 0):
264+
continue
260265
if target_shift == (0, 0, 0):
261-
continue # Physical atom, already exists.
262-
target_key = (other_phys, target_shift)
263-
if target_key in image_registry:
264-
continue # Already materialised.
265-
exp_idx = materialise(other_phys, target_shift)
266-
new_atoms.append((other_phys, target_shift, exp_idx))
266+
target_idx = other_phys # Physical atom, always exists.
267+
else:
268+
target_key = (other_phys, target_shift)
269+
if target_key in image_registry:
270+
target_idx = image_registry[target_key]
271+
else:
272+
target_idx = materialise(other_phys, target_shift)
273+
274+
# Create centre-vertex bond.
275+
# Safe: atoms_to_check is a frozen snapshot taken before
276+
# this loop, so all image centres are already registered.
277+
if shift == (0, 0, 0):
278+
centre_idx = phys_idx
279+
else:
280+
centre_idx = image_registry[(phys_idx, shift)]
267281

268-
return new_atoms
282+
if shift == (0, 0, 0):
283+
centre_coord = coords[phys_idx]
284+
else:
285+
centre_coord = coords[phys_idx] + np.array(shift, dtype=float) @ lattice
286+
if target_shift == (0, 0, 0):
287+
target_coord = coords[other_phys]
288+
else:
289+
target_coord = coords[other_phys] + np.array(target_shift, dtype=float) @ lattice
290+
length = float(np.linalg.norm(target_coord - centre_coord))
291+
rendering_bonds.append(Bond(centre_idx, target_idx, length, spec))
269292

270293

271294
def build_rendering_set(
@@ -282,10 +305,48 @@ def build_rendering_set(
282305
283306
Takes physical atoms and their periodic bonds (including cross-
284307
boundary bonds with non-zero ``image`` fields) and produces an
285-
expanded set of atoms and bonds suitable for rendering. Image
286-
atoms are materialised according to the ``complete`` and
287-
``recursive`` settings on each bond spec, geometric cell-face
288-
padding, and polyhedra vertex completion.
308+
expanded set of atoms and bonds suitable for rendering.
309+
310+
The atom categories are:
311+
- **Base**: physical atoms (in the unit cell) plus padding images
312+
(near cell faces). These exist before completion runs and are
313+
treated uniformly.
314+
- **C** (completion): image atoms materialised to satisfy a
315+
``complete`` filter.
316+
- **R** (recursive): image atoms materialised by recursive bond
317+
following.
318+
- **V** (vertex): image atoms materialised by polyhedra vertex
319+
completion.
320+
321+
The rendering pipeline proceeds in the following stages:
322+
323+
1. **Compute all bonds**: Input includes periodic bonds from
324+
:func:`compute_bonds`.
325+
2. **Add direct bonds**: Base <-> Base within cell (bonds with
326+
``image == (0, 0, 0)``).
327+
3. **Geometric padding**: Atoms near cell faces are duplicated on
328+
opposite sides; padding atoms join the base set.
329+
4. **Padding bond discovery**: Rendering bonds are created between
330+
padding atoms and existing base atoms. No new atoms are
331+
materialised.
332+
5. **Completion**: Bonds matching ``complete`` filters are added;
333+
C atoms are materialised (Base <-> C).
334+
6. **Recursive expansion**: Bonds matching ``recursive`` filters
335+
are traversed to materialise R atoms (Base <-> R and R <-> R).
336+
7. **Polyhedra vertex completion**: Coordination shells are
337+
completed; V atoms are materialised (Base <-> V and C <-> V).
338+
8. **Bond deduplication**: Duplicate bonds are removed.
339+
340+
Bond ownership (each bond is owned by exactly one pipeline step):
341+
- Base <-> Base (direct, no image): Step 2
342+
- Base <-> Base (periodic): Step 4
343+
- Base <-> C: Step 5
344+
- Base <-> R: Step 6
345+
- R <-> R: Step 6
346+
- Base <-> V: Step 7
347+
- C <-> V: Step 7
348+
- R <-> V: not produced (recursive expansion and polyhedra
349+
vertex completion are separate use cases)
289350
290351
Args:
291352
species: Species labels for the physical atoms.
@@ -340,9 +401,24 @@ def _materialise(phys_idx: int, image: ImageVector) -> int:
340401
image_source.append(phys_idx)
341402
return idx
342403

343-
# --- Single-pass completion (complete set, recursive=False) ---
344404
rendering_bonds: list[Bond] = list(direct_bonds)
345405

406+
# --- Geometric padding (pbc_padding) ---
407+
# Materialise image atoms for physical atoms near cell faces.
408+
padding_new_atoms: list[tuple[int, ImageVector, int]] = []
409+
if pbc_padding is not None and pbc_padding > 0:
410+
padding_new_atoms = _expand_padding(
411+
coords, lattice, n_physical, pbc_padding, _materialise,
412+
)
413+
414+
# --- Bond discovery for padding atoms ---
415+
if padding_new_atoms:
416+
_discover_bonds_for_new_atoms(
417+
padding_new_atoms, periodic_bonds, coords, lattice,
418+
image_registry, rendering_bonds,
419+
)
420+
421+
# --- Single-pass completion (complete set, recursive=False) ---
346422
for bond in periodic:
347423
spec = bond.spec
348424
if spec.recursive:
@@ -368,6 +444,50 @@ def _materialise(phys_idx: int, image: ImageVector) -> int:
368444
Bond(b, a_img_idx, bond.length, spec)
369445
)
370446

447+
# --- Completion for padding atoms ---
448+
# Mirrors the physical-atom completion loop, checking both
449+
# bond directions for each padding atom.
450+
#
451+
# Unlike the physical-atom loop (which iterates ``periodic``
452+
# only), this iterates ALL bonds including direct ones. A
453+
# padding atom at shift (-1,0,0) needs its direct-bond
454+
# neighbours materialised at the same shift — the physical-
455+
# atom loop skips direct bonds because those neighbours already
456+
# exist in the cell.
457+
if padding_new_atoms:
458+
padding_by_phys: dict[int, list[tuple[ImageVector, int]]] = {}
459+
for phys_idx, shift, exp_idx in padding_new_atoms:
460+
padding_by_phys.setdefault(phys_idx, []).append((shift, exp_idx))
461+
462+
for bond in periodic_bonds:
463+
spec = bond.spec
464+
if spec.recursive:
465+
continue
466+
if spec.complete is False:
467+
continue
468+
469+
a, b = bond.index_a, bond.index_b
470+
img = bond.image
471+
472+
# Check both directions: (a -> b via img) and (b -> a via -img).
473+
for src, tgt, bond_img in [(a, b, img),
474+
(b, a, _neg_image(img))]:
475+
if not _complete_matches(spec.complete, species[src]):
476+
continue
477+
for shift, exp_idx in padding_by_phys.get(src, []):
478+
target_shift = _add_images(shift, bond_img)
479+
if target_shift == (0, 0, 0):
480+
target_idx = tgt
481+
else:
482+
target_idx = _materialise(tgt, target_shift)
483+
src_coord = coords[src] + np.array(shift, dtype=float) @ lattice
484+
if target_shift == (0, 0, 0):
485+
tgt_coord = coords[tgt]
486+
else:
487+
tgt_coord = coords[tgt] + np.array(target_shift, dtype=float) @ lattice
488+
length = float(np.linalg.norm(tgt_coord - src_coord))
489+
rendering_bonds.append(Bond(exp_idx, target_idx, length, spec))
490+
371491
# --- Recursive expansion (recursive=True specs only) ---
372492
recursive_specs = [s for s in bond_specs if s.recursive]
373493
if recursive_specs:
@@ -435,18 +555,16 @@ def _materialise(phys_idx: int, image: ImageVector) -> int:
435555
b_idx = max(exp_idx, target_idx)
436556
if a_idx != b_idx:
437557
# Compute length from expanded coordinates.
438-
a_coord = (
439-
coords[phys_idx]
440-
+ np.array(shift, dtype=float) @ lattice
441-
if shift != (0, 0, 0)
442-
else coords[phys_idx]
443-
)
444-
b_coord = (
445-
coords[other_phys]
446-
+ np.array(target_shift, dtype=float) @ lattice
447-
if target_shift != (0, 0, 0)
448-
else coords[other_phys]
449-
)
558+
if shift != (0, 0, 0):
559+
a_coord = (coords[phys_idx]
560+
+ np.array(shift, dtype=float) @ lattice)
561+
else:
562+
a_coord = coords[phys_idx]
563+
if target_shift != (0, 0, 0):
564+
b_coord = (coords[other_phys]
565+
+ np.array(target_shift, dtype=float) @ lattice)
566+
else:
567+
b_coord = coords[other_phys]
450568
length = float(np.linalg.norm(b_coord - a_coord))
451569
rendering_bonds.append(
452570
Bond(a_idx, b_idx, length, spec)
@@ -456,34 +574,14 @@ def _materialise(phys_idx: int, image: ImageVector) -> int:
456574
break
457575
queue = next_queue
458576

459-
# --- Geometric padding (pbc_padding) ---
460-
# Materialise image atoms for physical atoms near cell faces.
461-
padding_new_atoms: list[tuple[int, ImageVector, int]] = []
462-
if pbc_padding is not None and pbc_padding > 0:
463-
padding_new_atoms = _expand_padding(
464-
coords, lattice, n_physical, pbc_padding, _materialise,
465-
)
466-
467-
# --- Bond discovery for padding atoms ---
468-
# Scan periodic bonds for connections to newly created padding atoms.
469-
if padding_new_atoms:
470-
_discover_bonds_for_new_atoms(
471-
padding_new_atoms, periodic_bonds, coords, lattice,
472-
image_registry, rendering_bonds,
473-
)
474-
475577
# --- Polyhedra vertex completion ---
476578
# Ensure every polyhedron centre has its full coordination shell.
477579
if polyhedra_specs:
478-
vertex_new = _complete_polyhedra_vertices(
580+
_complete_polyhedra_vertices(
479581
species, coords, lattice, n_physical, periodic_bonds,
480582
polyhedra_specs, image_registry, _materialise,
583+
rendering_bonds,
481584
)
482-
if vertex_new:
483-
_discover_bonds_for_new_atoms(
484-
vertex_new, periodic_bonds, coords, lattice,
485-
image_registry, rendering_bonds,
486-
)
487585

488586
# --- Build output ---
489587
if image_species:
@@ -500,7 +598,13 @@ def _materialise(phys_idx: int, image: ImageVector) -> int:
500598
expanded_coords = coords.copy()
501599
source_indices = np.arange(n_physical)
502600

503-
# Deduplicate rendering bonds.
601+
# Deduplicate rendering bonds by atom-pair key (spec-agnostic).
602+
# Multiple pipeline stages may produce a bond between the same
603+
# atom pair (e.g. padding bond discovery and completion); the
604+
# first-appended bond is kept. If two different BondSpecs both
605+
# match the same atom pair, only one bond is rendered — this is
606+
# inherent to the atom-pair dedup and matches the visual intent
607+
# (one cylinder per pair).
504608
seen: set[tuple[int, int]] = set()
505609
unique_bonds: list[Bond] = []
506610
for bond in rendering_bonds:

0 commit comments

Comments
 (0)