Skip to content

Commit 139e4f7

Browse files
authored
Fix cell edge clipping by hidden atoms (closes #41)
Cell edge lines were clipped at atom spheres even when those atoms were not drawn. Atoms hidden by PolyhedronSpec.hide_centre, AtomStyle.visible=False, hide_vertices, or slab clipping no longer produce spurious gaps in cell outline edges. The fix filters coords and radii to only visible atoms before passing them to _collect_cell_edges(). Also renames the local hidden_atoms variable in _draw_scene to all_hidden_atoms to avoid shadowing precomputed.hidden_atoms. Bumps version to 0.14.1.
1 parent 85df279 commit 139e4f7

File tree

4 files changed

+158
-9
lines changed

4 files changed

+158
-9
lines changed

docs/changelog.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
Changelog
22
=========
33

4+
0.14.1
5+
------
6+
7+
- Fixed cell edge lines being clipped at atoms that are not drawn.
8+
Atoms hidden by :attr:`~hofmann.PolyhedronSpec.hide_centre`,
9+
:attr:`~hofmann.AtomStyle.visible`, or slab clipping no longer
10+
produce spurious gaps in cell outline edges.
11+
(`#41 <https://github.com/bjmorgan/hofmann/issues/41>`_).
12+
413
0.14.0
514
------
615

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.0"
7+
version = "0.14.1"
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/rendering/painter.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -538,11 +538,11 @@ def _draw_scene(
538538
# AtomStyle.visible=False hiding is always applied. Polyhedra-
539539
# driven hiding (hide_centre, hide_bonds, hide_vertices) is only
540540
# applied when polyhedra are actually being drawn.
541-
hidden_atoms = set(precomputed.style_hidden_atoms)
542-
hidden_bond_ids = set(precomputed.style_hidden_bond_ids)
541+
all_hidden_atoms = set(precomputed.style_hidden_atoms)
542+
all_hidden_bond_ids = set(precomputed.style_hidden_bond_ids)
543543
if show_polyhedra:
544-
hidden_atoms |= precomputed.hidden_atoms
545-
hidden_bond_ids |= precomputed.hidden_bond_ids
544+
all_hidden_atoms |= precomputed.hidden_atoms
545+
all_hidden_bond_ids |= precomputed.hidden_bond_ids
546546

547547
face_by_depth_slot, vertex_max_face_slot = _collect_polyhedra_faces(
548548
precomputed, polyhedra_list, poly_skip, slab_visible,
@@ -557,7 +557,7 @@ def _draw_scene(
557557
if show_polyhedra and vertex_max_face_slot:
558558
atom_depths_sorted = depth[order]
559559
for vi, max_slot in vertex_max_face_slot.items():
560-
if vi in hidden_atoms:
560+
if vi in all_hidden_atoms:
561561
continue
562562
vi_slot = int(np.searchsorted(atom_depths_sorted, depth[vi]))
563563
if vi_slot <= max_slot:
@@ -592,9 +592,13 @@ def _draw_scene(
592592
(xy[:, 0].max() - xy[:, 0].min()) / 2,
593593
(xy[:, 1].max() - xy[:, 1].min()) / 2,
594594
) + cell_margin
595+
# Only clip cell edges at atoms that are actually drawn (#41).
596+
clip_visible = slab_visible.copy()
597+
if all_hidden_atoms:
598+
clip_visible[list(all_hidden_atoms)] = False
595599
cell_edge_by_depth_slot = _collect_cell_edges(
596600
lattice, view, style.cell_style, depth, order, cell_pad,
597-
coords, radii_3d * atom_scale,
601+
coords[clip_visible], (radii_3d * atom_scale)[clip_visible],
598602
)
599603

600604
# Collect raw vertex arrays in painter's order, then batch-add
@@ -627,7 +631,7 @@ def _draw_scene(
627631
bond_id = id(bond)
628632
if bond_id in drawn_bonds:
629633
continue
630-
if bond_id in hidden_bond_ids:
634+
if bond_id in all_hidden_bond_ids:
631635
drawn_bonds.add(bond_id)
632636
continue
633637
if bond_id in poly_clip_hidden_bonds:
@@ -735,7 +739,7 @@ def _draw_scene(
735739

736740
# Draw atom circle (unless hidden by a polyhedron spec or
737741
# deferred for polyhedron vertex ordering).
738-
if (k not in hidden_atoms
742+
if (k not in all_hidden_atoms
739743
and k not in deferred_vertex_atoms):
740744
fc_atom = (*atom_colours[k], 1.0)
741745
all_verts.append(unit_circle * atom_screen_radii[k] + xy[k])

tests/test_rendering/test_cell_edges.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
BondSpec,
1111
CellEdgeStyle,
1212
Frame,
13+
PolyhedronSpec,
1314
RenderStyle,
1415
StructureScene,
1516
ViewState,
@@ -244,3 +245,138 @@ def test_cell_edges_clipped_at_corner_atoms(self):
244245
# edge polygons (clipping shortens edges but doesn't remove them).
245246
assert n_corner >= 1
246247
assert n_mid >= 1
248+
249+
def test_hidden_polyhedra_centre_does_not_clip_cell_edge(self):
250+
"""A polyhedron centre with hide_centre=True should not clip cell edges.
251+
252+
Reproduces https://github.com/bjmorgan/hofmann/issues/41.
253+
"""
254+
a = 10.0
255+
d = 1.8
256+
ti = np.array([a / 2, 0.0, 0.0])
257+
offsets = d / np.sqrt(3) * np.array([
258+
[1, 1, 1], [1, -1, -1], [-1, 1, -1], [-1, -1, 1],
259+
])
260+
coords = np.vstack([ti[None, :], ti + offsets])
261+
262+
def _make_scene(hide_centre):
263+
return StructureScene(
264+
species=["Ti", "O", "O", "O", "O"],
265+
frames=[Frame(coords=coords, lattice=np.eye(3) * a)],
266+
atom_styles={
267+
"Ti": AtomStyle(1.2, (0.2, 0.4, 0.9)),
268+
"O": AtomStyle(0.5, (0.9, 0.1, 0.1)),
269+
},
270+
bond_specs=[
271+
BondSpec(species=("Ti", "O"), min_length=0.5,
272+
max_length=3.5, radius=0.1,
273+
colour=(0.4, 0.4, 0.4)),
274+
],
275+
polyhedra=[
276+
PolyhedronSpec(centre="Ti", hide_centre=hide_centre,
277+
hide_bonds=True),
278+
],
279+
)
280+
281+
render_kwargs = dict(show=False, show_polyhedra=True,
282+
show_cell=True)
283+
284+
# Centre hidden — should not clip cell edges.
285+
fig_hidden = render_mpl(_make_scene(hide_centre=True),
286+
**render_kwargs)
287+
n_hidden = len(fig_hidden.axes[0].collections[0].get_paths())
288+
plt.close(fig_hidden)
289+
290+
# Centre visible — should clip cell edges.
291+
fig_visible = render_mpl(_make_scene(hide_centre=False),
292+
**render_kwargs)
293+
n_visible = len(fig_visible.axes[0].collections[0].get_paths())
294+
plt.close(fig_visible)
295+
296+
# The only difference is Ti visibility. When hidden it should
297+
# not split cell edges, producing fewer polygon paths.
298+
assert n_hidden < n_visible
299+
300+
def test_invisible_atom_does_not_clip_cell_edge(self):
301+
"""An atom with AtomStyle.visible=False should not clip cell edges."""
302+
a = 10.0
303+
coords = np.array([[a / 2, 0.0, 0.0]])
304+
# Scene with invisible atom on the cell edge.
305+
scene_invisible = StructureScene(
306+
species=["A"],
307+
frames=[Frame(coords=coords, lattice=np.eye(3) * a)],
308+
atom_styles={"A": AtomStyle(1.0, (0.5, 0.5, 0.5),
309+
visible=False)},
310+
)
311+
# Baseline: atom far from any cell edge but at the same depth
312+
# (z=0) so depth-splitting of cell edges is identical.
313+
scene_no_clip = StructureScene(
314+
species=["A"],
315+
frames=[Frame(
316+
coords=np.array([[a / 2, a / 2, 0.0]]),
317+
lattice=np.eye(3) * a,
318+
)],
319+
atom_styles={"A": AtomStyle(1.0, (0.5, 0.5, 0.5),
320+
visible=False)},
321+
)
322+
fig_inv = render_mpl(scene_invisible, show=False, show_cell=True)
323+
n_inv = len(fig_inv.axes[0].collections[0].get_paths())
324+
plt.close(fig_inv)
325+
326+
fig_no_clip = render_mpl(scene_no_clip, show=False, show_cell=True)
327+
n_no_clip = len(fig_no_clip.axes[0].collections[0].get_paths())
328+
plt.close(fig_no_clip)
329+
330+
# An invisible atom on a cell edge should produce the same
331+
# number of paths as one that doesn't intersect any edge.
332+
assert n_inv == n_no_clip
333+
334+
def test_hidden_polyhedra_vertices_do_not_clip_cell_edge(self):
335+
"""Vertex atoms hidden by hide_vertices=True should not clip cell edges."""
336+
a = 10.0
337+
d = 1.8
338+
ti = np.array([a / 2, a / 2, a / 2])
339+
# One O vertex sits on the cell edge at (a/2, 0, 0).
340+
coords = np.array([
341+
ti,
342+
np.array([a / 2, 0.0, 0.0]),
343+
ti + d * np.array([1, -1, -1]) / np.sqrt(3),
344+
ti + d * np.array([-1, 1, -1]) / np.sqrt(3),
345+
ti + d * np.array([-1, -1, 1]) / np.sqrt(3),
346+
])
347+
348+
def _make_scene(hide_vertices):
349+
return StructureScene(
350+
species=["Ti", "O", "O", "O", "O"],
351+
frames=[Frame(coords=coords, lattice=np.eye(3) * a)],
352+
atom_styles={
353+
"Ti": AtomStyle(0.5, (0.2, 0.4, 0.9)),
354+
"O": AtomStyle(1.0, (0.9, 0.1, 0.1)),
355+
},
356+
bond_specs=[
357+
BondSpec(species=("Ti", "O"), min_length=0.5,
358+
max_length=6.0, radius=0.1,
359+
colour=(0.4, 0.4, 0.4)),
360+
],
361+
polyhedra=[
362+
PolyhedronSpec(centre="Ti",
363+
hide_vertices=hide_vertices,
364+
hide_bonds=True),
365+
],
366+
)
367+
368+
render_kwargs = dict(show=False, show_polyhedra=True,
369+
show_cell=True)
370+
371+
fig_hidden = render_mpl(_make_scene(hide_vertices=True),
372+
**render_kwargs)
373+
n_hidden = len(fig_hidden.axes[0].collections[0].get_paths())
374+
plt.close(fig_hidden)
375+
376+
fig_visible = render_mpl(_make_scene(hide_vertices=False),
377+
**render_kwargs)
378+
n_visible = len(fig_visible.axes[0].collections[0].get_paths())
379+
plt.close(fig_visible)
380+
381+
# When vertex is hidden, it should not clip the cell edge.
382+
assert n_hidden < n_visible

0 commit comments

Comments
 (0)