@@ -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
271294def 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