Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/agents/scene_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ Parses a CIF and returns a scene dict consumable by
Per-species counts come from MolCrysKit's
`StoichiometryAnalyzer.get_simplest_unit()` (unit-cell species counts
divided by their GCD), not MatterVis-local hard-coded heuristics.
Extended ionic and inorganic solids do not keep that molecular unwrap:
displayed formula-unit centres are folded into the home cell so a cell
overlay does not show a one-sided exterior halo.
- `unit_cell` — every atom of the conventional cell, with PBC bond
imaging. When MolCrysKit canonical bond records are available, every
manifested boundary-fragment image receives the corresponding canonical
Expand All @@ -94,6 +97,13 @@ Parses a CIF and returns a scene dict consumable by
triggers an image, the complete molecule is translated. Image shifts are
unioned per member; face signals from different members are never combined
into an unsupported diagonal image.
Extended ionic and inorganic solids use a different unit-cell contract.
Each crystallographic site is wrapped into the home cell and, when
boundary replicas are on, copied to the symmetric face, edge, and corner
images at 0 and 1. Molecular formula-unit unwrap and spanning-bond
context images (the rocksalt shell at fractional `-0.5`) are not drawn.
With boundary replicas off, the same lattices stay on the wrapped sites
and do not grow a bonded-image shell outside the cell.
Library callers that need a strict publication cell can pass
`include_boundary_replicas=False` to `build_scene_from_atoms` or
`build_bundle_scene`. Atom centres are wrapped into `[0, 1)` and no adjacent
Expand All @@ -105,6 +115,14 @@ Parses a CIF and returns a scene dict consumable by
`include_boundary_replicas=False` and
`include_cross_boundary_bond_endpoints=False`. It is display-only. Source
fractional coordinates on the loaded bundle stay unchanged.
Binary alkali and alkaline-earth halide contacts stay in `scene["bonds"]`.
An explicit ball-and-stick style draws the complete first-neighbor
network on those wrapped sites. A neighbor that has no sphere inside
the closed cell is a stick ending at that image coordinate; those ends
occur on every symmetry-equivalent face. Ball and space-filling
renderers do not stroke the contacts. When `--style` is omitted, an
ionic or inorganic lattice uses `ball` and `unit_cell`, the same
default as the viewer.
- `asymmetric_unit` — only the asymmetric unit is drawn.
- `cluster` — **free molecular cluster mode**. Every parsed atom is
drawn unchanged; no formula-unit selection or periodic image
Expand Down
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ substitutes one frame backend for another.
| Flag | Default | Description |
|------|---------|-------------|
| --view MODE | auto | Periodic input uses unit_cell; nonperiodic or synthetic-cell input uses cluster |
| --style STYLE | ball_stick | ball_stick, ball, space_filling, stick, ortep, or wireframe |
| --style STYLE | auto | Omitted: `ball_stick` for molecules and covalent networks; `ball` plus the unit cell for ionic and inorganic lattices. Explicit `ball_stick` on an ionic solid draws the first-neighbor coordination network. |
| --shading MODE | smooth | smooth or flat mesh shading |
| --backend BACKEND | cpu | cpu (3D), matplotlib (projected 2D), plotly, or explicit native GPU PNG; never selected by fallback |
| --orthogonal | yes | Orthographic projection |
Expand Down
5 changes: 1 addition & 4 deletions mat_viewer/app/backend_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from ..cache_keys import figure_state_cache_key
from ..config import current_config
from ..render.radius import INORGANIC_DEFAULT_BOND_RADIUS
from ..structure.bonds import inorganic_solid_profile
from ..topology.analysis import _MAX_REGISTERED_CN
from .camera_helpers import *
Expand Down Expand Up @@ -33,10 +34,6 @@
# comfortably covers typical tab-switch + display-mode + slider
# patterns without unbounded memory growth.
FIGURE_CACHE_MAX = 16
# Ionic and extended inorganic lattices are read more clearly with a thin
# coordination stroke. Keep the value in state construction so callers can
# still override it through a preset or an explicit state patch.
INORGANIC_DEFAULT_BOND_RADIUS = 0.12


class _CoreBackendMixin:
Expand Down
4 changes: 4 additions & 0 deletions mat_viewer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,10 @@ def _agent_render_main(args: argparse.Namespace) -> None:
)

try:
if args.style is None:
from .render.cli_controls import resolve_omitted_render_style

args.style = resolve_omitted_render_style(args)
fast_result = render_batch_if_selected(
args,
install_command=check_payload["requirements"]["install"],
Expand Down
4 changes: 2 additions & 2 deletions mat_viewer/render/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ def _build_render_parser(
p.add_argument(
"--style",
choices=_STYLES,
default="ball_stick",
help="Atom/bond rendering style (default: ball_stick).",
default=None,
help="Omitted: ball_stick, or ball for ionic and inorganic lattices.",
)
p.add_argument(
"--shading",
Expand Down
34 changes: 34 additions & 0 deletions mat_viewer/render/cli_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,40 @@ def add_bond_policy_arguments(parser: argparse.ArgumentParser) -> None:
)


def resolve_omitted_render_style(args: argparse.Namespace) -> str:
"""Pick the viewer-aligned style when ``--style`` was omitted.

Ionic and extended inorganic lattices default to space-filling balls.
Molecules and covalent networks stay on ball-and-stick. An explicit
``--style`` never reaches this function.
"""
import sys
from contextlib import redirect_stdout

from ..agent import load_structure
from ..structure.bonds import prefers_lattice_ball_default

context = (
redirect_stdout(sys.stderr)
if getattr(args, "json_output", False)
else redirect_stdout(sys.stdout)
)
with context:
probe = load_structure(
args.input,
input_format=args.input_format,
type_map=args.type_map,
frame=args.frame if args.frame is not None else 0,
bond_scale=args.bond_scale,
bond_thresholds=bond_thresholds_from_args(args),
)
bundle = probe.frames[0].bundle
scene = getattr(bundle, "scene", {}) or {}
if prefers_lattice_ball_default(scene, getattr(bundle, "raw_atoms", None)):
return "ball"
return "ball_stick"


def bond_thresholds_from_args(args: argparse.Namespace) -> dict[tuple[str, str], float]:
from ..structure.bonds import parse_bond_threshold_spec

Expand Down
87 changes: 80 additions & 7 deletions mat_viewer/render/display_modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,63 @@
import numpy as np
from molcrys_kit.utils.geometry import frac_to_cart

# Snap values that ``np.mod`` leaves on the 1-boundary back onto 0 so a
# crystallographic site is not stored twice, once at each end of the axis.
_WRAP_ONE_ATOL = 1e-9


def _wrap_fractional(frac: Any) -> np.ndarray | None:
"""Fold one fractional coordinate into the home cell, with 1 identified as 0."""
arr = np.asarray(frac, dtype=float)
if arr.shape != (3,) or not np.all(np.isfinite(arr)):
return None
wrapped = np.mod(arr, 1.0)
near_zero = np.isclose(wrapped, 0.0, rtol=0.0, atol=_WRAP_ONE_ATOL)
near_one = np.isclose(wrapped, 1.0, rtol=0.0, atol=_WRAP_ONE_ATOL)
if np.any(near_zero) or np.any(near_one):
wrapped = np.array(wrapped, dtype=float, copy=True)
wrapped[near_zero | near_one] = 0.0
return wrapped


def _independent_wrapped_sites(
atoms: list[dict[str, Any]],
M: Any,
) -> list[dict[str, Any]]:
"""Return one wrapped crystallographic site per input atom.

Ionic and inorganic lattices are not finite molecules. Keeping the
MolCrysKit molecule index would union every member's face shift and
translate the whole framework, and a non-zero periodic rank would
suppress those face images in favour of a one-sided coordination
shell. Each site is therefore replicated on its own.
"""
M_arr = np.asarray(M, dtype=float)
out: list[dict[str, Any]] = []
for fallback_index, atom in enumerate(atoms):
frac = _wrap_fractional(atom.get("frac"))
if frac is None:
continue
copied = dict(atom)
copied["frac"] = frac
copied["cart"] = frac_to_cart(frac, M_arr)
copied["_wrapped_frac"] = np.array(frac, dtype=float, copy=True)
copied["_image_shift"] = (0, 0, 0)
copied.setdefault("_source_index", fallback_index)
for key in (
"_source_molecule_index",
"_periodic_component_rank",
"_unwrapped",
"_cell_spanning_component",
"_is_boundary_replica",
"_is_fragment_boundary_replica",
"_is_framework_context_replica",
"_is_bonded_image_replica",
):
copied.pop(key, None)
out.append(copied)
return out


def _asymmetric_unit_atoms(atoms: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Deduplicate atoms by label + element + disorder tags."""
Expand Down Expand Up @@ -42,11 +99,9 @@ def _wrapped_unit_cell_atoms(
out: list[dict[str, Any]] = []
for source_index, atom in enumerate(atoms):
source_frac = atom.get("_wrapped_frac", atom.get("frac"))
frac = np.asarray(source_frac, dtype=float)
if frac.shape != (3,) or not np.all(np.isfinite(frac)):
wrapped = _wrap_fractional(source_frac)
if wrapped is None:
continue
wrapped = np.mod(frac, 1.0)
wrapped[np.isclose(wrapped, 1.0, rtol=0.0, atol=1e-9)] = 0.0
copied = dict(atom)
copied["frac"] = wrapped
copied["cart"] = frac_to_cart(wrapped, M_arr)
Expand All @@ -69,6 +124,7 @@ def selected_atoms_for_mode(
formula_unit_atoms: list[dict[str, Any]] | None = None,
unwrapped_atoms: list[dict[str, Any]] | None = None,
include_boundary_replicas: bool = True,
lattice_site_display: bool = False,
) -> list[dict[str, Any]]:
"""Return the drawable atom list for the given display mode.

Expand All @@ -78,11 +134,24 @@ def selected_atoms_for_mode(
- ``"unit_cell"`` — conventional cell with boundary replicas
- ``"asymmetric_unit"`` — only the asymmetric unit
- ``"cluster"`` — every parsed atom, no PBC processing

``lattice_site_display`` is set for extended ionic and inorganic
solids. Those views use wrapped crystallographic sites. Unit-cell
replicas are the symmetric 0/1 face, edge, and corner images of each
site. Formula-unit centres are folded into the home cell instead of
keeping a molecular unwrap that sits outside the box.
"""
from .boundary_replicas import expand_boundary_replicas

continuous_atoms = unwrapped_atoms if unwrapped_atoms else atoms
if display_mode == "unit_cell":
if lattice_site_display:
sites = _independent_wrapped_sites(atoms, M)
if not include_boundary_replicas:
for site in sites:
site["_strict_unit_cell"] = True
return sites
return expand_boundary_replicas(sites, M)
if not include_boundary_replicas:
return _wrapped_unit_cell_atoms(atoms, M)
base = [dict(atom) for atom in continuous_atoms]
Expand All @@ -93,6 +162,10 @@ def selected_atoms_for_mode(
return [dict(atom) for atom in atoms]
# formula_unit: defer to MolCrysKit
if formula_unit_atoms is not None:
return [dict(atom) for atom in formula_unit_atoms]
from mat_viewer.structure import molcrys_bridge
return molcrys_bridge.select_formula_unit(atoms, M)
chosen = [dict(atom) for atom in formula_unit_atoms]
else:
from mat_viewer.structure import molcrys_bridge
chosen = molcrys_bridge.select_formula_unit(atoms, M)
if lattice_site_display:
return _independent_wrapped_sites(chosen, M)
return chosen
18 changes: 14 additions & 4 deletions mat_viewer/render/planning.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
from .mesh_overlays import polyhedron_primitives as _polyhedron_primitives
from .property_planning import prepare_render_property, property_color_for_atom, reserve_property_colorbar, resolve_render_property_context
from ..style.disorder import bond_effective_opacity
from .radius import atom_visual_radius, clamp_displacement_radius, hydrogen_radius_floor
from .radius import atom_visual_radius, clamp_displacement_radius, coordination_stick_radius, hydrogen_radius_floor


def prepare_render(
Expand Down Expand Up @@ -126,6 +126,12 @@ def prepare_render(
else np.asarray([1.0, 1.0, 0.8])
)

lattice_coordination = bool(scene.get("is_ionic_solid"))
stick_radius = coordination_stick_radius(
render_spec.bond_radius,
representation=default_representation,
ionic_solid=lattice_coordination,
)
for index, atom in enumerate(atoms):
element = (
str(_value(atom, "symbol", "elem", "element", default="C"))
Expand Down Expand Up @@ -251,8 +257,9 @@ def prepare_render(
atom,
representation="ball_stick",
atom_scale=render_spec.atom_scale,
bond_radius=render_spec.bond_radius,
bond_radius=stick_radius,
min_atom_radius=render_spec.min_atom_radius,
lattice_coordination=lattice_coordination,
)
)
displacement = np.eye(3) * (placeholder_radius / 1.54) ** 2
Expand Down Expand Up @@ -297,8 +304,9 @@ def prepare_render(
atom,
representation=representation,
atom_scale=render_spec.atom_scale,
bond_radius=render_spec.bond_radius,
bond_radius=stick_radius,
min_atom_radius=render_spec.min_atom_radius,
lattice_coordination=lattice_coordination,
)
)
primitives.append(
Expand Down Expand Up @@ -448,7 +456,7 @@ def prepare_render(
atom_i=atoms[first_index],
atom_j=atoms[second_index],
)
radius = render_spec.bond_radius * float(
radius = stick_radius * float(
_value(bond, "_render_radius_scale", default=1.0)
)
override_color = _value(bond, "_render_color", default=None)
Expand Down Expand Up @@ -1272,6 +1280,7 @@ def _atom_radius(
atom_scale: float = 1.0,
bond_radius: float | None = None,
min_atom_radius: float | None = None,
lattice_coordination: bool = False,
) -> float:
value = float(_value(atom, "atom_radius", "radius", default=0.5))
rendered = atom_visual_radius(
Expand All @@ -1281,6 +1290,7 @@ def _atom_radius(
atom_scale=atom_scale,
bond_radius=bond_radius,
min_atom_radius=min_atom_radius,
lattice_coordination=lattice_coordination,
)
if representation in {"space_filling", "ball"}:
return max(rendered, 0.2)
Expand Down
Loading
Loading