diff --git a/.husky/pre-commit b/.husky/pre-commit index 9bbc52e..0312b76 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,4 @@ -npm test -npm run lint:fix +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx lint-staged \ No newline at end of file diff --git a/demo/dev_extended_graph.html b/demo/dev_extended_graph.html new file mode 100644 index 0000000..01f03b1 --- /dev/null +++ b/demo/dev_extended_graph.html @@ -0,0 +1,11 @@ + + + + + +

Test output

+
+ + + + \ No newline at end of file diff --git a/demo/public/cif/CaF2.cif b/demo/public/cif/CaF2.cif new file mode 100644 index 0000000..4030543 --- /dev/null +++ b/demo/public/cif/CaF2.cif @@ -0,0 +1,562 @@ + +data_CaF2 + +_audit_creation_method 'SHELXL-2019/3' +_shelx_SHELXL_version_number '2019/3' +_chemical_name_systematic ? +_chemical_name_common ? +_chemical_melting_point ? +_chemical_formula_moiety ? +_chemical_formula_sum + 'Ca F2' +_chemical_formula_weight 78.08 + +loop_ + _atom_type_symbol + _atom_type_description + _atom_type_scat_dispersion_real + _atom_type_scat_dispersion_imag + _atom_type_scat_source + 'Ca' 'Ca' 0.1378 0.1937 + 'International Tables Vol C Tables 4.2.6.8 and 6.1.1.4' + 'F' 'F' 0.0060 0.0061 + 'International Tables Vol C Tables 4.2.6.8 and 6.1.1.4' + +_space_group_crystal_system cubic +_space_group_IT_number 225 +_space_group_name_H-M_alt 'F m -3 m' +_space_group_name_Hall '-F 4 2 3' + +_shelx_space_group_comment +; +The symmetry employed for this shelxl refinement is uniquely defined +by the following loop, which should always be used as a source of +symmetry information in preference to the above space-group names. +They are only intended as comments. +; + +loop_ + _space_group_symop_operation_xyz + 'x, y, z' + '-x, -y, z' + '-x, y, -z' + 'x, -y, -z' + 'z, x, y' + 'z, -x, -y' + '-z, -x, y' + '-z, x, -y' + 'y, z, x' + '-y, z, -x' + 'y, -z, -x' + '-y, -z, x' + 'y, x, -z' + '-y, -x, -z' + 'y, -x, z' + '-y, x, z' + 'x, z, -y' + '-x, z, y' + '-x, -z, -y' + 'x, -z, y' + 'z, y, -x' + 'z, -y, x' + '-z, y, x' + '-z, -y, -x' + 'x, y+1/2, z+1/2' + '-x, -y+1/2, z+1/2' + '-x, y+1/2, -z+1/2' + 'x, -y+1/2, -z+1/2' + 'z, x+1/2, y+1/2' + 'z, -x+1/2, -y+1/2' + '-z, -x+1/2, y+1/2' + '-z, x+1/2, -y+1/2' + 'y, z+1/2, x+1/2' + '-y, z+1/2, -x+1/2' + 'y, -z+1/2, -x+1/2' + '-y, -z+1/2, x+1/2' + 'y, x+1/2, -z+1/2' + '-y, -x+1/2, -z+1/2' + 'y, -x+1/2, z+1/2' + '-y, x+1/2, z+1/2' + 'x, z+1/2, -y+1/2' + '-x, z+1/2, y+1/2' + '-x, -z+1/2, -y+1/2' + 'x, -z+1/2, y+1/2' + 'z, y+1/2, -x+1/2' + 'z, -y+1/2, x+1/2' + '-z, y+1/2, x+1/2' + '-z, -y+1/2, -x+1/2' + 'x+1/2, y, z+1/2' + '-x+1/2, -y, z+1/2' + '-x+1/2, y, -z+1/2' + 'x+1/2, -y, -z+1/2' + 'z+1/2, x, y+1/2' + 'z+1/2, -x, -y+1/2' + '-z+1/2, -x, y+1/2' + '-z+1/2, x, -y+1/2' + 'y+1/2, z, x+1/2' + '-y+1/2, z, -x+1/2' + 'y+1/2, -z, -x+1/2' + '-y+1/2, -z, x+1/2' + 'y+1/2, x, -z+1/2' + '-y+1/2, -x, -z+1/2' + 'y+1/2, -x, z+1/2' + '-y+1/2, x, z+1/2' + 'x+1/2, z, -y+1/2' + '-x+1/2, z, y+1/2' + '-x+1/2, -z, -y+1/2' + 'x+1/2, -z, y+1/2' + 'z+1/2, y, -x+1/2' + 'z+1/2, -y, x+1/2' + '-z+1/2, y, x+1/2' + '-z+1/2, -y, -x+1/2' + 'x+1/2, y+1/2, z' + '-x+1/2, -y+1/2, z' + '-x+1/2, y+1/2, -z' + 'x+1/2, -y+1/2, -z' + 'z+1/2, x+1/2, y' + 'z+1/2, -x+1/2, -y' + '-z+1/2, -x+1/2, y' + '-z+1/2, x+1/2, -y' + 'y+1/2, z+1/2, x' + '-y+1/2, z+1/2, -x' + 'y+1/2, -z+1/2, -x' + '-y+1/2, -z+1/2, x' + 'y+1/2, x+1/2, -z' + '-y+1/2, -x+1/2, -z' + 'y+1/2, -x+1/2, z' + '-y+1/2, x+1/2, z' + 'x+1/2, z+1/2, -y' + '-x+1/2, z+1/2, y' + '-x+1/2, -z+1/2, -y' + 'x+1/2, -z+1/2, y' + 'z+1/2, y+1/2, -x' + 'z+1/2, -y+1/2, x' + '-z+1/2, y+1/2, x' + '-z+1/2, -y+1/2, -x' + '-x, -y, -z' + 'x, y, -z' + 'x, -y, z' + '-x, y, z' + '-z, -x, -y' + '-z, x, y' + 'z, x, -y' + 'z, -x, y' + '-y, -z, -x' + 'y, -z, x' + '-y, z, x' + 'y, z, -x' + '-y, -x, z' + 'y, x, z' + '-y, x, -z' + 'y, -x, -z' + '-x, -z, y' + 'x, -z, -y' + 'x, z, y' + '-x, z, -y' + '-z, -y, x' + '-z, y, -x' + 'z, -y, -x' + 'z, y, x' + '-x, -y+1/2, -z+1/2' + 'x, y+1/2, -z+1/2' + 'x, -y+1/2, z+1/2' + '-x, y+1/2, z+1/2' + '-z, -x+1/2, -y+1/2' + '-z, x+1/2, y+1/2' + 'z, x+1/2, -y+1/2' + 'z, -x+1/2, y+1/2' + '-y, -z+1/2, -x+1/2' + 'y, -z+1/2, x+1/2' + '-y, z+1/2, x+1/2' + 'y, z+1/2, -x+1/2' + '-y, -x+1/2, z+1/2' + 'y, x+1/2, z+1/2' + '-y, x+1/2, -z+1/2' + 'y, -x+1/2, -z+1/2' + '-x, -z+1/2, y+1/2' + 'x, -z+1/2, -y+1/2' + 'x, z+1/2, y+1/2' + '-x, z+1/2, -y+1/2' + '-z, -y+1/2, x+1/2' + '-z, y+1/2, -x+1/2' + 'z, -y+1/2, -x+1/2' + 'z, y+1/2, x+1/2' + '-x+1/2, -y, -z+1/2' + 'x+1/2, y, -z+1/2' + 'x+1/2, -y, z+1/2' + '-x+1/2, y, z+1/2' + '-z+1/2, -x, -y+1/2' + '-z+1/2, x, y+1/2' + 'z+1/2, x, -y+1/2' + 'z+1/2, -x, y+1/2' + '-y+1/2, -z, -x+1/2' + 'y+1/2, -z, x+1/2' + '-y+1/2, z, x+1/2' + 'y+1/2, z, -x+1/2' + '-y+1/2, -x, z+1/2' + 'y+1/2, x, z+1/2' + '-y+1/2, x, -z+1/2' + 'y+1/2, -x, -z+1/2' + '-x+1/2, -z, y+1/2' + 'x+1/2, -z, -y+1/2' + 'x+1/2, z, y+1/2' + '-x+1/2, z, -y+1/2' + '-z+1/2, -y, x+1/2' + '-z+1/2, y, -x+1/2' + 'z+1/2, -y, -x+1/2' + 'z+1/2, y, x+1/2' + '-x+1/2, -y+1/2, -z' + 'x+1/2, y+1/2, -z' + 'x+1/2, -y+1/2, z' + '-x+1/2, y+1/2, z' + '-z+1/2, -x+1/2, -y' + '-z+1/2, x+1/2, y' + 'z+1/2, x+1/2, -y' + 'z+1/2, -x+1/2, y' + '-y+1/2, -z+1/2, -x' + 'y+1/2, -z+1/2, x' + '-y+1/2, z+1/2, x' + 'y+1/2, z+1/2, -x' + '-y+1/2, -x+1/2, z' + 'y+1/2, x+1/2, z' + '-y+1/2, x+1/2, -z' + 'y+1/2, -x+1/2, -z' + '-x+1/2, -z+1/2, y' + 'x+1/2, -z+1/2, -y' + 'x+1/2, z+1/2, y' + '-x+1/2, z+1/2, -y' + '-z+1/2, -y+1/2, x' + '-z+1/2, y+1/2, -x' + 'z+1/2, -y+1/2, -x' + 'z+1/2, y+1/2, x' + +_cell_length_a 5.45095(5) +_cell_length_b 5.45095(5) +_cell_length_c 5.45095(5) +_cell_angle_alpha 90 +_cell_angle_beta 90 +_cell_angle_gamma 90 +_cell_volume 161.963(5) +_cell_formula_units_Z 4 +_cell_measurement_temperature 293(2) +_cell_measurement_reflns_used ? +_cell_measurement_theta_min ? +_cell_measurement_theta_max ? + +_exptl_crystal_description ? +_exptl_crystal_colour ? +_exptl_crystal_density_meas ? +_exptl_crystal_density_method ? +_exptl_crystal_density_diffrn 3.202 +_exptl_crystal_F_000 152 +_exptl_transmission_factor_min ? +_exptl_transmission_factor_max ? +_exptl_crystal_size_max ? +_exptl_crystal_size_mid ? +_exptl_crystal_size_min ? +_exptl_absorpt_coefficient_mu 1.747 +_shelx_estimated_absorpt_T_min ? +_shelx_estimated_absorpt_T_max ? +_exptl_absorpt_correction_type ? +_exptl_absorpt_correction_T_min ? +_exptl_absorpt_correction_T_max ? +_exptl_absorpt_process_details ? +_exptl_absorpt_special_details ? +_diffrn_ambient_temperature 293(2) +_diffrn_radiation_wavelength 0.56087 +_diffrn_radiation_type AgK\a +_diffrn_source ? +_diffrn_measurement_device_type ? +_diffrn_measurement_method ? +_diffrn_detector_area_resol_mean ? +_diffrn_reflns_number 5299 +_diffrn_reflns_av_unetI/netI 0.0117 +_diffrn_reflns_av_R_equivalents 0.0677 +_diffrn_reflns_limit_h_min -13 +_diffrn_reflns_limit_h_max 13 +_diffrn_reflns_limit_k_min -13 +_diffrn_reflns_limit_k_max 13 +_diffrn_reflns_limit_l_min -13 +_diffrn_reflns_limit_l_max 13 +_diffrn_reflns_theta_min 5.112 +_diffrn_reflns_theta_max 44.256 +_diffrn_reflns_theta_full 19.665 +_diffrn_measured_fraction_theta_max 1.000 +_diffrn_measured_fraction_theta_full 1.000 +_diffrn_reflns_Laue_measured_fraction_max 1.000 +_diffrn_reflns_Laue_measured_fraction_full 1.000 +_diffrn_reflns_point_group_measured_fraction_max 1.000 +_diffrn_reflns_point_group_measured_fraction_full 1.000 +_reflns_number_total 96 +_reflns_number_gt 96 +_reflns_threshold_expression 'I > 2\s(I)' +_reflns_Friedel_coverage 0.000 +_reflns_Friedel_fraction_max . +_reflns_Friedel_fraction_full . + +_reflns_special_details +; + Reflections were merged by SHELXL according to the crystal + class for the calculation of statistics and refinement. + + _reflns_Friedel_fraction is defined as the number of unique + Friedel pairs measured divided by the number that would be + possible theoretically, ignoring centric projections and + systematic absences. +; + +_computing_data_collection ? +_computing_cell_refinement ? +_computing_data_reduction ? +_computing_structure_solution ? +_computing_structure_refinement 'SHELXL-2019/2 (Sheldrick, 2019)' +_computing_molecular_graphics ? +_computing_publication_material ? +_refine_special_details ? +_refine_ls_structure_factor_coef Fsqd +_refine_ls_matrix_type full +_refine_ls_weighting_scheme calc +_refine_ls_weighting_details +'w=1/[\s^2^(Fo^2^)] where P=(Fo^2^+2Fc^2^)/3' +_atom_sites_solution_primary ? +_atom_sites_solution_secondary ? +_atom_sites_solution_hydrogens . +_refine_ls_hydrogen_treatment undef +_refine_ls_extinction_method none +_refine_ls_extinction_coef . +_refine_ls_number_reflns 96 +_refine_ls_number_parameters 3 +_refine_ls_number_restraints 0 +_refine_ls_R_factor_all 0.0131 +_refine_ls_R_factor_gt 0.0131 +_refine_ls_wR_factor_ref 0.0311 +_refine_ls_wR_factor_gt 0.0311 +_refine_ls_goodness_of_fit_ref 3.746 +_refine_ls_restrained_S_all 3.746 +_refine_ls_shift/su_max 0.000 +_refine_ls_shift/su_mean 0.000 + +loop_ + _atom_site_label + _atom_site_type_symbol + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_U_iso_or_equiv + _atom_site_adp_type + _atom_site_occupancy + _atom_site_site_symmetry_order + _atom_site_calc_flag + _atom_site_refinement_flags_posn + _atom_site_refinement_flags_adp + _atom_site_refinement_flags_occupancy + _atom_site_disorder_assembly + _atom_site_disorder_group +Ca01 Ca 0.500000 0.500000 0.500000 0.00321(6) Uani 1 48 d S T P . . +F002 F 0.250000 0.750000 0.750000 0.00486(8) Uani 1 24 d S T P . . + +loop_ + _atom_site_aniso_label + _atom_site_aniso_U_11 + _atom_site_aniso_U_22 + _atom_site_aniso_U_33 + _atom_site_aniso_U_23 + _atom_site_aniso_U_13 + _atom_site_aniso_U_12 +Ca01 0.00321(6) 0.00321(6) 0.00321(6) 0.000 0.000 0.000 +F002 0.00486(8) 0.00486(8) 0.00486(8) 0.000 0.000 0.000 + +_geom_special_details +; + All esds (except the esd in the dihedral angle between two l.s. planes) + are estimated using the full covariance matrix. The cell esds are taken + into account individually in the estimation of esds in distances, angles + and torsion angles; correlations between esds in cell parameters are only + used when they are defined by crystal symmetry. An approximate (isotropic) + treatment of cell esds is used for estimating esds involving l.s. planes. +; + +loop_ + _geom_bond_atom_site_label_1 + _geom_bond_atom_site_label_2 + _geom_bond_distance + _geom_bond_site_symmetry_2 + _geom_bond_publ_flag +Ca01 F002 2.36033(3) 121_666 ? +Ca01 F002 2.36033(2) 25_544 ? +Ca01 F002 2.36033(3) 169_566 ? +Ca01 F002 2.36033(3) 145_566 ? +Ca01 F002 2.36033(3) . ? +Ca01 F002 2.36033(3) 97_666 ? +Ca01 F002 2.36033(3) 73_545 ? +Ca01 F002 2.36033(3) 49_554 ? + +loop_ + _geom_angle_atom_site_label_1 + _geom_angle_atom_site_label_2 + _geom_angle_atom_site_label_3 + _geom_angle + _geom_angle_site_symmetry_1 + _geom_angle_site_symmetry_3 + _geom_angle_publ_flag +F002 Ca01 F002 180.0 121_666 25_544 ? +F002 Ca01 F002 109.5 121_666 169_566 ? +F002 Ca01 F002 70.5 25_544 169_566 ? +F002 Ca01 F002 109.471(1) 121_666 145_566 ? +F002 Ca01 F002 70.529(1) 25_544 145_566 ? +F002 Ca01 F002 109.471(1) 169_566 145_566 ? +F002 Ca01 F002 70.5 121_666 . ? +F002 Ca01 F002 109.5 25_544 . ? +F002 Ca01 F002 70.5 169_566 . ? +F002 Ca01 F002 70.5 145_566 . ? +F002 Ca01 F002 109.5 121_666 97_666 ? +F002 Ca01 F002 70.529(1) 25_544 97_666 ? +F002 Ca01 F002 109.471(1) 169_566 97_666 ? +F002 Ca01 F002 109.5 145_566 97_666 ? +F002 Ca01 F002 180.0 . 97_666 ? +F002 Ca01 F002 70.529(1) 121_666 73_545 ? +F002 Ca01 F002 109.5 25_544 73_545 ? +F002 Ca01 F002 180.0 169_566 73_545 ? +F002 Ca01 F002 70.5 145_566 73_545 ? +F002 Ca01 F002 109.5 . 73_545 ? +F002 Ca01 F002 70.5 97_666 73_545 ? +F002 Ca01 F002 70.5 121_666 49_554 ? +F002 Ca01 F002 109.5 25_544 49_554 ? +F002 Ca01 F002 70.5 169_566 49_554 ? +F002 Ca01 F002 180.0 145_566 49_554 ? +F002 Ca01 F002 109.5 . 49_554 ? +F002 Ca01 F002 70.5 97_666 49_554 ? +F002 Ca01 F002 109.5 73_545 49_554 ? +F002 Ca01 Ca01 35.3 121_666 25 ? +F002 Ca01 Ca01 144.7 25_544 25 ? +F002 Ca01 Ca01 90.0 169_566 25 ? +F002 Ca01 Ca01 90.0 145_566 25 ? +F002 Ca01 Ca01 35.3 . 25 ? +F002 Ca01 Ca01 144.7 97_666 25 ? +F002 Ca01 Ca01 90.0 73_545 25 ? +F002 Ca01 Ca01 90.0 49_554 25 ? +F002 Ca01 Ca01 35.3 121_666 49 ? +F002 Ca01 Ca01 144.7 25_544 49 ? +F002 Ca01 Ca01 144.7 169_566 49 ? +F002 Ca01 Ca01 90.0 145_566 49 ? +F002 Ca01 Ca01 90.0 . 49 ? +F002 Ca01 Ca01 90.0 97_666 49 ? +F002 Ca01 Ca01 35.3 73_545 49 ? +F002 Ca01 Ca01 90.0 49_554 49 ? +Ca01 Ca01 Ca01 60.0 25 49 ? +F002 Ca01 Ca01 144.7 121_666 73_445 ? +F002 Ca01 Ca01 35.3 25_544 73_445 ? +F002 Ca01 Ca01 90.0 169_566 73_445 ? +F002 Ca01 Ca01 35.3 145_566 73_445 ? +F002 Ca01 Ca01 90.0 . 73_445 ? +F002 Ca01 Ca01 90.0 97_666 73_445 ? +F002 Ca01 Ca01 90.0 73_545 73_445 ? +F002 Ca01 Ca01 144.7 49_554 73_445 ? +Ca01 Ca01 Ca01 120.0 25 73_445 ? +Ca01 Ca01 Ca01 120.0 49 73_445 ? +F002 Ca01 Ca01 144.7 121_666 49_454 ? +F002 Ca01 Ca01 35.3 25_544 49_454 ? +F002 Ca01 Ca01 35.3 169_566 49_454 ? +F002 Ca01 Ca01 90.0 145_566 49_454 ? +F002 Ca01 Ca01 90.0 . 49_454 ? +F002 Ca01 Ca01 90.0 97_666 49_454 ? +F002 Ca01 Ca01 144.7 73_545 49_454 ? +F002 Ca01 Ca01 90.0 49_554 49_454 ? +Ca01 Ca01 Ca01 120.0 25 49_454 ? +Ca01 Ca01 Ca01 180.0 49 49_454 ? +Ca01 Ca01 Ca01 60.0 73_445 49_454 ? +Ca01 F002 Ca01 109.5 25 73_455 ? +Ca01 F002 Ca01 109.5 25 . ? +Ca01 F002 Ca01 109.5 73_455 . ? +Ca01 F002 Ca01 109.5 25 49_455 ? +Ca01 F002 Ca01 109.5 73_455 49_455 ? +Ca01 F002 Ca01 109.5 . 49_455 ? + + +_refine_diff_density_max 0.698 +_refine_diff_density_min -0.356 +_refine_diff_density_rms 0.116 + +_shelx_res_file +; +TITL caf_ag3_a.res in Fm-3m + CaF2.res + created by SHELXL-2019/3 at 14:19:48 on 31-Mar-2025 +REM Old TITL CaF_Ag3 in Fm-3m +REM SHELXT solution in Fm-3m +REM R1 0.012, Rweak 0.001, Alpha 0.002, Orientation as input +REM Formula found by SHELXT: F2 Ca +CELL 0.56087 5.450952 5.450952 5.450952 90 90 90 +ZERR 4 0.000052 0.000052 0.000052 0 0 0 +LATT 4 +SYMM -X,-Y,+Z +SYMM -X,+Y,-Z +SYMM +X,-Y,-Z +SYMM +Z,+X,+Y +SYMM +Z,-X,-Y +SYMM -Z,-X,+Y +SYMM -Z,+X,-Y +SYMM +Y,+Z,+X +SYMM -Y,+Z,-X +SYMM +Y,-Z,-X +SYMM -Y,-Z,+X +SYMM +Y,+X,-Z +SYMM -Y,-X,-Z +SYMM +Y,-X,+Z +SYMM -Y,+X,+Z +SYMM +X,+Z,-Y +SYMM -X,+Z,+Y +SYMM -X,-Z,-Y +SYMM +X,-Z,+Y +SYMM +Z,+Y,-X +SYMM +Z,-Y,+X +SYMM -Z,+Y,+X +SYMM -Z,-Y,-X +SFAC Ca F +DISP Ca 0.1378 0.1937 651.9347 +DISP F 0.006 0.0061 27.6988 +UNIT 4 8 + +L.S. 10 +PLAN 5 +CONF +MORE -1 +LIST -6 + +fmap 2 +acta +REM +REM +REM + +WGHT 0.000000 +FVAR 3.05797 +CA01 1 0.500000 0.500000 0.500000 10.02083 0.00321 0.00321 = + 0.00321 0.00000 0.00000 0.00000 +F002 2 0.250000 0.750000 0.750000 10.04167 0.00486 0.00486 = + 0.00486 0.00000 0.00000 0.00000 +REM +REM CaF2.hkl +REM +HKLF 4 + + + + +REM caf_ag3_a.res in Fm-3m +REM wR2 = 0.0311, GooF = S = 3.746, Restrained GooF = 3.746 for all data +REM R1 = 0.0131 for 96 Fo > 4sig(Fo) and 0.0131 for all 96 data +REM 3 parameters refined using 0 restraints + +END + +WGHT 0.0106 0.1059 + +REM Highest difference peak 0.698, deepest hole -0.356, 1-sigma level 0.116 +Q1 1 0.5000 0.5727 0.5000 10.12500 0.05 0.70 +Q2 1 0.1571 0.7500 0.7500 10.25000 0.05 0.30 +Q3 1 0.3803 0.6197 0.6197 10.16667 0.05 0.25 +Q4 1 0.2716 0.5000 0.5000 10.12500 0.05 0.22 +Q5 1 0.3349 0.8348 0.7288 10.50000 0.05 0.20 +; +_shelx_res_checksum 57963 diff --git a/demo/public/cif/ED_para_Ag_3.cif b/demo/public/cif/ED_para_Ag_3.cif new file mode 100644 index 0000000..acb885b --- /dev/null +++ b/demo/public/cif/ED_para_Ag_3.cif @@ -0,0 +1,333 @@ +data_ed_para_ag_3 +_audit_creation_date 2025-04-29 +_audit_creation_method +; +Olex2 1.5 +(compiled 2025.04.05 svn.rf049d14d for OlexSys, GUI svn.r7224) +; +_audit_contact_author_address ? +_audit_contact_author_email ? +_audit_contact_author_name '' +_audit_contact_author_phone ? +_publ_contact_author_id_orcid ? +_publ_section_references +; +Bourhis, L.J., Dolomanov, O.V., Gildea, R.J., Howard, J.A.K., Puschmann, H. + (2015). Acta Cryst. A71, 59-75. + +Dolomanov, O.V., Bourhis, L.J., Gildea, R.J, Howard, J.A.K. & Puschmann, H. + (2009), J. Appl. Cryst. 42, 339-341. +; +_chemical_formula_moiety 'C2 H2' +_chemical_formula_sum 'C2 H2' +_chemical_formula_weight 26.038 +loop_ + _atom_type_symbol + _atom_type_scat_dispersion_real + _atom_type_scat_dispersion_imag + _atom_type_scat_Cromer_Mann_a1 + _atom_type_scat_Cromer_Mann_a2 + _atom_type_scat_Cromer_Mann_a3 + _atom_type_scat_Cromer_Mann_a4 + _atom_type_scat_Cromer_Mann_b1 + _atom_type_scat_Cromer_Mann_b2 + _atom_type_scat_Cromer_Mann_b3 + _atom_type_scat_Cromer_Mann_b4 + _atom_type_scat_Cromer_Mann_c + _atom_type_scat_source + _atom_type_scat_dispersion_source + C 0.00130 0.00090 2.31000 1.02000 1.58860 0.86500 20.84390 10.20750 0.56870 + 51.65120 0.21560 'International Tables Volume C Table 6.1.1.4 (pp. 500-502)' + 'Brennan, Cowan, Rev. Sci. Instrum., 1992, 63, 850' + H 0.00000 0.00000 0.49300 0.32291 0.14019 0.04081 10.51090 26.12570 3.14236 + 57.79970 0.00304 'International Tables Volume C Table 6.1.1.4 (pp. 500-502)' + 'Brennan, Cowan, Rev. Sci. Instrum., 1992, 63, 850' + +_space_group_crystal_system 'tetragonal' +_space_group_IT_number 136 +_space_group_name_H-M_alt 'P 42/m n m' +_space_group_name_Hall '-P 4n 2n' +loop_ + _space_group_symop_id + _space_group_symop_operation_xyz + 1 x,y,z + 2 -y+1/2,x+1/2,z+1/2 + 3 y+1/2,-x+1/2,z+1/2 + 4 x+1/2,-y+1/2,-z+1/2 + 5 -x+1/2,y+1/2,-z+1/2 + 6 -x,-y,z + 7 y,x,-z + 8 -y,-x,-z + 9 -x,-y,-z + 10 y-1/2,-x-1/2,-z-1/2 + 11 -y-1/2,x-1/2,-z-1/2 + 12 -x-1/2,y-1/2,z-1/2 + 13 x-1/2,-y-1/2,z-1/2 + 14 x,y,-z + 15 -y,-x,z + 16 y,x,z + +_symmetry_Int_Tables_number 136 +_cell_length_a 7.70369(14) +_cell_length_b 7.70369(14) +_cell_length_c 9.2301(4) +_cell_angle_alpha 90 +_cell_angle_beta 90 +_cell_angle_gamma 90 +_cell_volume 547.78(3) +_cell_formula_units_Z 16 +_cell_measurement_temperature 293(2) +_exptl_absorpt_coefficient_mu 0.047 +_exptl_crystal_density_diffrn 1.263 +_exptl_crystal_F_000 224.042 +_diffrn_reflns_av_R_equivalents 0.0316 +_diffrn_reflns_av_unetI/netI 0.0204 +_diffrn_reflns_Laue_measured_fraction_full 1.0000 +_diffrn_reflns_Laue_measured_fraction_max 0.9295 +_diffrn_reflns_limit_h_max 14 +_diffrn_reflns_limit_h_min -9 +_diffrn_reflns_limit_k_max 16 +_diffrn_reflns_limit_k_min -15 +_diffrn_reflns_limit_l_max 18 +_diffrn_reflns_limit_l_min -17 +_diffrn_reflns_number 8909 +_diffrn_reflns_point_group_measured_fraction_full 1.0000 +_diffrn_reflns_point_group_measured_fraction_max 0.9295 +_diffrn_reflns_theta_full 19.6651 +_diffrn_reflns_theta_max 36.40 +_diffrn_reflns_theta_min 2.72 +_diffrn_measured_fraction_theta_full 1.0000 +_diffrn_measured_fraction_theta_max 0.9295 +_diffrn_radiation_type 'Ag K\a' +_diffrn_radiation_wavelength 0.56087 +_reflns_Friedel_coverage 0.0 +_reflns_limit_h_max 16 +_reflns_limit_h_min 0 +_reflns_limit_k_max 11 +_reflns_limit_k_min 0 +_reflns_limit_l_max 18 +_reflns_limit_l_min 0 +_reflns_number_gt 1225 +_reflns_number_total 1398 +_reflns_threshold_expression I>=2u(I) +_computing_molecular_graphics 'Olex2 1.5 (Dolomanov et al., 2009)' +_computing_publication_material 'Olex2 1.5 (Dolomanov et al., 2009)' +_computing_structure_refinement 'olex2.refine 1.5 (Bourhis et al., 2015)' +_computing_structure_solution 'SHELXT 2018/2 (Sheldrick, 2018)' +_refine_diff_density_max 0.6248 +_refine_diff_density_min -0.2893 +_refine_diff_density_rms 0.0926 +_refine_ls_d_res_high 0.4726 +_refine_ls_d_res_low 5.9144 +_refine_ls_goodness_of_fit_ref 1.2845 +_refine_ls_hydrogen_treatment constr +_refine_ls_matrix_type full +_refine_ls_number_constraints 5 +_refine_ls_number_parameters 22 +_refine_ls_number_reflns 1398 +_refine_ls_number_restraints 0 +_refine_ls_R_factor_all 0.0575 +_refine_ls_R_factor_gt 0.0523 +_refine_ls_restrained_S_all 1.2845 +_refine_ls_shift/su_max -0.0008 +_refine_ls_shift/su_mean 0.0001 +_refine_ls_structure_factor_coef Fsqd +_refine_ls_weighting_details + 'w=1/[\s^2^(Fo^2^)+(0.0986P)^2^+0.0164P] where P=(Fo^2^+2Fc^2^)/3' +_refine_ls_weighting_scheme calc +_refine_ls_wR_factor_gt 0.1661 +_refine_ls_wR_factor_ref 0.1718 +_olex2_refinement_description +; +1. Fixed Uiso + At 1.2 times of: + All C(H) groups, All C(H,H) groups, All C(C) groups +2. Others + Fixed Sof: H1a(0.5) H1b(0.5) +3.a Secondary CH2 refined with riding coordinates: + C1(H1a,H1b) +3.b Aromatic/amide H refined with riding coordinates: + C3(H3) +; +loop_ + _atom_site_label + _atom_site_type_symbol + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_U_iso_or_equiv + _atom_site_adp_type + _atom_site_occupancy + _atom_site_site_symmetry_order + _atom_site_refinement_flags_posn + _atom_site_refinement_flags_adp + _atom_site_refinement_flags_occupancy + C1 C 0.57243(6) 0.42757(6) 0.19722(7) 0.02740(16) Uani 1.000000 2 S T P + H1a H 0.52930(6) 0.32691(6) 0.14536(7) 0.03288(19) Uiso 0.500000 1 R . P + H1b H 0.67309(6) 0.47070(6) 0.14536(7) 0.03288(19) Uiso 0.500000 1 R . P + C2 C 0.62785(5) 0.37215(5) 0.34713(6) 0.01784(12) Uani 1.000000 2 S T P + C3 C 0.75189(5) 0.46730(5) 0.42462(5) 0.01922(12) Uani 1.000000 1 . . . + H3 H 0.83513(5) 0.53116(5) 0.37478(5) 0.02306(14) Uiso 1.000000 1 R . . + +loop_ + _atom_site_aniso_label + _atom_site_aniso_U_11 + _atom_site_aniso_U_22 + _atom_site_aniso_U_33 + _atom_site_aniso_U_12 + _atom_site_aniso_U_13 + _atom_site_aniso_U_23 + C1 0.0320(2) 0.0320(2) 0.0182(2) 0.0134(2) 0.00143(11) -0.00143(11) + C2 0.01723(14) 0.01723(14) 0.0191(2) 0.00413(12) 0.00109(9) -0.00109(9) + C3 0.01430(15) 0.01747(17) 0.0259(2) -0.00076(9) 0.00289(10) 0.00179(11) + +loop_ + _geom_bond_atom_site_label_1 + _geom_bond_atom_site_label_2 + _geom_bond_distance + _geom_bond_site_symmetry_2 + _geom_bond_publ_flag + C1 C1 1.5781(12) 6_665 ? + C1 H1a 0.9700 15_665 ? + C1 H1a 0.9700 . ? + C1 H1b 0.9700 . ? + C1 H1b 0.9700 15_665 ? + C1 C2 1.5097(8) . ? + C2 C3 1.4007(5) 15_665 ? + C2 C3 1.4007(5) . ? + C3 C3 1.3915(9) 14_556 ? + C3 H3 0.9300 . ? + +loop_ + _geom_angle_atom_site_label_1 + _geom_angle_atom_site_label_2 + _geom_angle_atom_site_label_3 + _geom_angle + _geom_angle_site_symmetry_1 + _geom_angle_site_symmetry_3 + _geom_angle_publ_flag + H1a C1 H1a 107.703(13) 15_665 . ? + H1b C1 H1a 107.7 15_665 15_665 ? + H1b C1 H1a 0.0 . 15_665 ? + H1b C1 H1a 0.0 15_665 . ? + H1b C1 H1a 107.7 . . ? + H1b C1 H1b 107.703(13) . 15_665 ? + C2 C1 H1a 108.850(17) . . ? + C2 C1 H1a 108.850(17) . 15_665 ? + C2 C1 H1b 108.850(17) . 15_665 ? + C2 C1 H1b 108.850(17) . . ? + C3 C2 C1 120.86(3) 15_665 . ? + C3 C2 C1 120.86(3) . . ? + C3 C2 C3 116.95(5) . 15_665 ? + C3 C3 C2 120.71(3) 14_556 15_665 ? + H3 C3 C2 119.65(3) . 15_665 ? + +loop_ + _geom_torsion_atom_site_label_1 + _geom_torsion_atom_site_label_2 + _geom_torsion_atom_site_label_3 + _geom_torsion_atom_site_label_4 + _geom_torsion + _geom_torsion_site_symmetry_1 + _geom_torsion_site_symmetry_2 + _geom_torsion_site_symmetry_3 + _geom_torsion_site_symmetry_4 + _geom_torsion_publ_flag + C1 C1 C2 C3 83.22(3) 6_665 . . 15_665 ? + C1 C1 C2 C3 -83.22(4) 6_665 . . . ? + C1 C2 C3 C2 0(120000000000000) . . . 15_665 ? + C1 C2 C3 C3 -152.49(4) . . 15_665 8_666 ? + C1 C2 C3 C3 152.49(4) . . . 14_556 ? + C2 C1 C1 C2 0.0 . . 6_665 6_665 ? + C2 C3 C2 C1 0(50000000000000) . . 15_665 15_665 ? + C2 C3 C2 C3 0(50000000000000) . . 15_665 15_665 ? + C2 C3 C3 C2 0.00(6) . . 14_556 8_666 ? + C2 C3 C3 C2 -0.00(6) . 15_665 8_666 14_556 ? + C3 C2 C3 C2 0(110000000000000) . 15_665 15_665 . ? + C3 C2 C3 C3 -14.47(6) 15_665 . . 14_556 ? + C3 C3 C2 C3 14.469(6) 8_666 15_665 . . ? + +_iucr_refine_instructions_details +; +TITL ed_para_ag_3_a.res in P4(2)/mnm +REM Ins file created by Rigaku Oxford Diffraction +REM Old TITL ED_para_Ag_3 in P4(2)/mnm +REM SHELXT solution in P4(2)/mnm: R1 0.192, Rweak 0.001, Alpha 0.017 +REM 0.855 for 48 systematic absences, Orientation as input +REM Formula found by SHELXT: H1b +REM PLAN 0 +CELL 0.56087 7.70369 7.70369 9.23008 90 90 90 +ZERR 16 0.00014 0.00014 0.00037 0 0 0 +LATT 1 +SYMM -X,-Y,+Z +SYMM 0.5-Y,0.5+X,0.5+Z +SYMM 0.5+Y,0.5-X,0.5+Z +SYMM 0.5-X,0.5+Y,0.5-Z +SYMM 0.5+X,0.5-Y,0.5-Z +SYMM +Y,+X,-Z +SYMM -Y,-X,-Z +SFAC C H +DISP C 0.0013 0.0009 7.4 +DISP H 0 0 0.7 +UNIT 32 32 + +L.S. 6 +PLAN 5 +CONF +LIST 6 +fmap 2 +MORE -1 +BOND $H +ACTA +WGHT 0.098616 0.016413 +FVAR 1.737231 +REM +REM +REM + +C1 1 0.572426 0.427574 0.197222 10.50000 0.03199 0.03199 = + 0.01821 -0.00143 0.00143 0.01336 +AFIX 23 +H1a 2 0.529299 0.326913 0.145357 10.50000 -1.20000 +H1b 2 0.673087 0.470701 0.145357 10.50000 -1.20000 +AFIX 0 +C2 1 0.627852 0.372148 0.347131 10.50000 0.01723 0.01723 = + 0.01905 -0.00109 0.00109 0.00413 +C3 1 0.751888 0.467302 0.424624 11.00000 0.01430 0.01747 = + 0.02589 0.00179 0.00289 -0.00076 +AFIX 43 +H3 2 0.835132 0.531162 0.374784 11.00000 -1.20000 +AFIX 0 +HKLF 4 + +END +Q2 1 0.7 0.42 0.375 11 0.05 0.47 +Q3 1 0.6 0.4 0.28125 10.5 0.05 0.44 +Q4 1 0.82 0.58 0.375 11 0.05 0.29 +Q5 1 0.6 0.4 0.15625 10.5 0.05 0.29 +Q1 1 0.76 0.48 0.5 10.5 0.05 0.62 + + REM The information below was added by Olex2. + REM + REM R1 = 0.0523 for 1225 Fo > 4sig(Fo) and 0.0575 for all 1398 data + REM 22 parameters refined using 0 restraints + REM Highest difference peak 0.6248, deepest hole -0.2893 + REM Mean Shift 0.0001, Max Shift -0.0008. + + REM +++ Tabular Listing of Refinement Information +++ + REM R1_all = 0.0575 + REM R1_gt = 0.0523 + REM wR_ref = 0.1718 + REM GOOF = 1.2845 + REM Shift_max = -0.0008 + REM Shift_mean = 0.0001 + REM Reflections_all = 1398 + REM Reflections_gt = 1225 + REM Parameters = 22 + REM Hole = -0.2893 + REM Peak = 0.6248 + REM Flack = n/a + + +; +_olex2_submission_special_instructions 'No special instructions were received' diff --git a/demo/src/dev.js b/demo/src/dev.js new file mode 100644 index 0000000..66c471f --- /dev/null +++ b/demo/src/dev.js @@ -0,0 +1,211 @@ +import { CrystalStructure, CIF } from '../../src'; +import { growFragment } from '../../src/lib/structure/structure-modifiers//growing/grow-fragment.js'; +import { growCell } from '../../src/lib/structure/structure-modifiers/growing/grow-cell.js'; + +/** + * Grows a fragment structure from CIF text and measures execution time + * @param {string} cifText - The CIF text content + * @returns {Promise<{output: object, executionTime: number}>} The grown structure and execution time + */ +async function growFragmentStructure(cifText) { + const cif = new CIF(cifText); + const structure = CrystalStructure.fromCIF(cif.getBlock(0)); + + const startTime = performance.now(); + const { grownStructure: output } = growFragment(structure); + const endTime = performance.now(); + + const executionTime = endTime - startTime; + //console.log(`Run ${runCount}: ${name} took ${executionTime.toFixed(2)} milliseconds`); + + return { output, executionTime }; +} + +/** + * Grows a cell structure from CIF text and measures execution time + * @param {string} cifText - The CIF text content + * @returns {Promise<{output: object, executionTime: number}>} The grown structure and execution time + */ +async function growCellStructure(cifText) { + const cif = new CIF(cifText); + const structure = CrystalStructure.fromCIF(cif.getBlock(0)); + + const startTime = performance.now(); + const output = growCell(structure); + const endTime = performance.now(); + + const executionTime = endTime - startTime; + //console.log(`Run ${runCount}: ${name} took ${executionTime.toFixed(2)} milliseconds`); + + return { output, executionTime }; +} + +/** + * Grows a cell fragment structure from CIF text and measures execution time + * @param {string} cifText - The CIF text content + * @returns {Promise<{output: object, executionTime: number}>} The grown structure and execution time + */ +async function growCellFragmentStructure(cifText) { + const cif = new CIF(cifText); + const structure = CrystalStructure.fromCIF(cif.getBlock(0)); + + const startTime = performance.now(); + const { grownStructure: fStructure, specialPositionAtoms: spAtoms } = growFragment(structure); + const output = growCell(fStructure, false, spAtoms); + const endTime = performance.now(); + + const executionTime = endTime - startTime; + //console.log(`Run ${runCount}: ${name} took ${executionTime.toFixed(2)} milliseconds`); + + return { output, executionTime }; +} + +/** + * Calculates statistical measures for timing data + * @param {number[]} timings - Array of execution times in milliseconds + * @returns {object} Statistics object containing mean, stdDev, min, max, median, and runs + */ +function calculateStatistics(timings) { + const sum = timings.reduce((acc, time) => acc + time, 0); + const mean = sum / timings.length; + + // Calculate standard deviation + const squaredDifferences = timings.map(time => Math.pow(time - mean, 2)); + const variance = squaredDifferences.reduce((acc, val) => acc + val, 0) / timings.length; + const stdDev = Math.sqrt(variance); + + // Calculate min, max, median + const sortedTimings = [...timings].sort((a, b) => a - b); + const min = sortedTimings[0]; + const max = sortedTimings[sortedTimings.length - 1]; + + let median; + if (sortedTimings.length % 2 === 0) { + median = (sortedTimings[sortedTimings.length/2 - 1] + sortedTimings[sortedTimings.length/2]) / 2; + } else { + median = sortedTimings[Math.floor(sortedTimings.length/2)]; + } + + return { + mean: mean.toFixed(2), + stdDev: stdDev.toFixed(2), + min: min.toFixed(2), + max: max.toFixed(2), + median: median.toFixed(2), + runs: timings.length, + }; +} + +/** + * Processes multiple crystal structures with statistical analysis for all growth methods + * @returns {Promise} Promise that resolves to results object containing stats for all structures and methods + */ +async function processStructuresWithStatistics() { + const baseUrl = import.meta.env.BASE_URL; + const structures = [ + 'ED_para_Ag_3.cif', + 'urea.cif', + 'CaF2.cif', + 'sucrose.cif', + ]; + + const numExecutions = 20; + const allResults = {}; + + // Process each structure + for (const structureName of structures) { + console.log(`\n=== Starting tests for ${structureName} ===`); + + // Fetch structure data once + const response = await fetch(`${baseUrl}cif/${structureName}`); + const cifText = await response.text(); + + try { + // Run multiple executions for growFragmentStructure + let fragmentStructure; + const fragmentTimings = []; + for (let i = 1; i <= numExecutions; i++) { + const { output, executionTime } = await growFragmentStructure(cifText); + fragmentStructure = output; + fragmentTimings.push(executionTime); + } + const fragmentStructureInfo = `N(Atoms): ${fragmentStructure.atoms.length}; ` + + `N(Bonds): ${fragmentStructure.bonds.length}; N(HBonds): ${fragmentStructure.hBonds.length}`; + + // Run multiple executions for growCellStructure + let cellStructure; + const cellTimings = []; + for (let i = 1; i <= numExecutions; i++) { + const { output, executionTime } = await growCellStructure(cifText); + cellStructure = output; + cellTimings.push(executionTime); + } + const cellStructureInfo = `N(Atoms): ${cellStructure.atoms.length}; ` + + `N(Bonds): ${cellStructure.bonds.length}; N(HBonds): ${cellStructure.hBonds.length}`; + + // Run multiple executions for growCellFragmentStructure + let cellFragmentStructure; + const cellFragmentTimings = []; + for (let i = 1; i <= numExecutions; i++) { + const { output, executionTime } = await growCellFragmentStructure(cifText); + cellFragmentStructure = output; + cellFragmentTimings.push(executionTime); + } + const cellFragmentStructureInfo = `N(Atoms): ${cellFragmentStructure.atoms.length}; ` + + `N(Bonds): ${cellFragmentStructure.bonds.length}; N(HBonds): ${cellFragmentStructure.hBonds.length}`; + + // Calculate statistics for all methods + const fragmentStats = calculateStatistics(fragmentTimings); + const cellStats = calculateStatistics(cellTimings); + const cellFragmentStats = calculateStatistics(cellFragmentTimings); + + allResults[structureName] = { + fragment: { timings: fragmentTimings, stats: fragmentStats, structureInfo: fragmentStructureInfo }, + cell: { timings: cellTimings, stats: cellStats, structureInfo: cellStructureInfo }, + cellFragment: { + timings: cellFragmentTimings, + stats: cellFragmentStats, + structureInfo: cellFragmentStructureInfo, + }, + }; + + } catch (error) { + console.error(`Error processing ${structureName}:`, error); + } + } + + // Print summary of all results + console.log('\n=== SUMMARY OF ALL RESULTS ==='); + for (const structureName in allResults) { + const { fragment, cell, cellFragment } = allResults[structureName]; + + console.log(`\n${structureName}:`); + console.log(' Fragment Growth:'); + console.log(` Mean=${fragment.stats.mean}ms, StdDev=${fragment.stats.stdDev}ms, ` + + `Min=${fragment.stats.min}ms, Max=${fragment.stats.max}ms, Median=${fragment.stats.median}ms`); + console.log(` ${fragment.structureInfo}`); + + console.log(' Cell Growth:'); + console.log(` Mean=${cell.stats.mean}ms, StdDev=${cell.stats.stdDev}ms, ` + + `Min=${cell.stats.min}ms, Max=${cell.stats.max}ms, Median=${cell.stats.median}ms`); + console.log(` ${cell.structureInfo}`); + + console.log(' Cell Fragment Growth:'); + console.log(` Mean=${cellFragment.stats.mean}ms, StdDev=${cellFragment.stats.stdDev}ms, ` + + `Min=${cellFragment.stats.min}ms, Max=${cellFragment.stats.max}ms, ` + + `Median=${cellFragment.stats.median}ms`); + console.log(` ${cellFragment.structureInfo}`); + } + + return allResults; +} + +// Start processing +processStructuresWithStatistics() + .then(_results => { + //console.log("All processing complete!"); + // You could do additional analysis on 'results' here if needed + }) + .catch(error => { + console.error('Error in main process:', error); + }); \ No newline at end of file diff --git a/demo/widget.html b/demo/widget.html index 8575190..3511c44 100644 --- a/demo/widget.html +++ b/demo/widget.html @@ -539,7 +539,7 @@

Growing symmetry

src="cif/urea.cif" caption="Structure B with symmetry expansion" hydrogen-mode="constant" - symmetry-mode="bonds-no-hbonds-no"> + symmetry-mode="none"> @@ -549,7 +549,7 @@

Growing symmetry

src="cif/urea.cif" caption="Structure B with symmetry expansion" hydrogen-mode="constant" - symmetry-mode="bonds-yes-hbonds-yes"> + symmetry-mode="fragment"> diff --git a/integration-tests/collect-problem-structures.sh b/integration-tests/collect-problem-structures.sh index dec20a1..1364dbc 100644 --- a/integration-tests/collect-problem-structures.sh +++ b/integration-tests/collect-problem-structures.sh @@ -34,6 +34,11 @@ while IFS= read -r line || [[ -n "$line" ]]; do filename=$(basename "$filepath") basename="${filename%.cif}" cp "$filepath" "$PROBLEM_DIR/structerr_$basename.cif" + elif [[ $line =~ "Modifier Error in "(/[^\"]+\.cif) ]]; then + filepath="${BASH_REMATCH[1]}" + filename=$(basename "$filepath") + basename="${filename%.cif}" + cp "$filepath" "$PROBLEM_DIR/moderr_$basename.cif" fi done < "integration-tests/logs/modifier-test-errors.log" diff --git a/package.json b/package.json index 596de7b..07f4fb1 100644 --- a/package.json +++ b/package.json @@ -59,15 +59,10 @@ "vite": "^6.0.11", "vitest": "^3.0.7" }, - "husky": { - "hooks": { - "pre-commit": "lint-staged" - } - }, "lint-staged": { "*.{js,mjs}": [ "eslint --fix", - "git add" + "git diff --exit-code" ] } } diff --git a/src/lib/ortep3d/cell3d.js b/src/lib/ortep3d/cell3d.js new file mode 100644 index 0000000..675ce6a --- /dev/null +++ b/src/lib/ortep3d/cell3d.js @@ -0,0 +1,187 @@ +import * as THREE from 'three'; +import { UnitCell } from '../structure/crystal'; + +/** + * Creates a 3D arrow visualization using a cylinder shaft and cone head. + * The arrow points in the direction of the provided vector from the origin. + * @param {THREE.Vector3} vector - The direction and magnitude vector for the arrow + * @param {THREE.Color|string|number} color - The color for both shaft and head (THREE.Color, hex string, or number) + * @param {number} headLength - The length/height of the cone arrowhead + * @param {number} headWidth - The radius of the cone arrowhead base + * @param {number} cylinderRadius - The radius of the cylinder shaft for the arrow + * @returns {THREE.Group} A THREE.Group containing the cylinder shaft and cone head meshes + */ +function createCylinderArrow(vector, color, headLength, headWidth, cylinderRadius) { + const direction = vector.clone().normalize(); + const magnitude = vector.length(); + const shaftLength = magnitude - headLength; + + // Create a cylinder geometry for the arrow shaft + const cylinderGeometry = new THREE.CylinderGeometry(cylinderRadius, cylinderRadius, shaftLength, 8); + const cylinderMaterial = new THREE.MeshBasicMaterial({ color: color }); + const cylinderMesh = new THREE.Mesh(cylinderGeometry, cylinderMaterial); + + // Create a cone geometry for the arrow head + const coneGeometry = new THREE.ConeGeometry(headWidth, headLength, 8); + const coneMaterial = new THREE.MeshBasicMaterial({ color: color }); + const coneMesh = new THREE.Mesh(coneGeometry, coneMaterial); + + // Default cylinder points along Y-axis (0, 1, 0) + // Default cone points along Y-axis (0, 1, 0) + const defaultDirection = new THREE.Vector3(0, 1, 0); + + // Create rotation quaternion to align with target direction + const quaternion = new THREE.Quaternion(); + quaternion.setFromUnitVectors(defaultDirection, direction); + + // Apply rotation to both meshes + cylinderMesh.applyQuaternion(quaternion); + coneMesh.applyQuaternion(quaternion); + + // Position cylinder at half its length along the direction + cylinderMesh.position.copy(direction.clone().multiplyScalar(shaftLength / 2)); + + // Position cone at the end of the shaft + coneMesh.position.copy(direction.clone().multiplyScalar(shaftLength + headLength / 2)); + + // Combine the cylinder and cone into a group + const arrowGroup = new THREE.Group(); + arrowGroup.add(cylinderMesh); + arrowGroup.add(coneMesh); + + return arrowGroup; +} + +/** + * Creates the wireframe edges of a unit cell using line segments. + * This creates a proper parallelepiped representation with 12 edges. + * @param {THREE.Matrix4} transformationMatrix - The fractional-to-cartesian transformation matrix + * @param {string|number} color - Color for the wireframe lines + * @param {number} opacity - Opacity of the wireframe lines + * @param {number} lineWidth - Width of the lines (note: may not work in all browsers) + * @returns {THREE.Group} A group containing all the wireframe edges + */ +function createUnitCellWireframe(transformationMatrix, color, opacity, lineWidth) { + const wireframeGroup = new THREE.Group(); + + // Define the 8 vertices of a unit cube in fractional coordinates + const vertices = [ + new THREE.Vector3(0, 0, 0), // 0: origin + new THREE.Vector3(1, 0, 0), // 1: along a + new THREE.Vector3(0, 1, 0), // 2: along b + new THREE.Vector3(0, 0, 1), // 3: along c + new THREE.Vector3(1, 1, 0), // 4: a+b + new THREE.Vector3(1, 0, 1), // 5: a+c + new THREE.Vector3(0, 1, 1), // 6: b+c + new THREE.Vector3(1, 1, 1), // 7: a+b+c + ]; + + // Transform vertices to cartesian coordinates + const cartesianVertices = vertices.map(vertex => { + return vertex.clone().applyMatrix4(transformationMatrix); + }); + + // Define the 12 edges of the parallelepiped + const edges = [ + // Bottom face (z=0) + [0, 1], [1, 4], [4, 2], [2, 0], + // Top face (z=1) + [3, 5], [5, 7], [7, 6], [6, 3], + // Vertical edges connecting bottom and top + [0, 3], [1, 5], [4, 7], [2, 6], + ]; + + // Create line material + const lineMaterial = new THREE.LineBasicMaterial({ + color: color, + transparent: opacity < 1.0, + opacity: opacity, + linewidth: 1.0 * lineWidth, // Note: may not work in WebGL + }); + + // Create each edge as a line segment + edges.forEach(([startIdx, endIdx]) => { + const geometry = new THREE.BufferGeometry().setFromPoints([ + cartesianVertices[startIdx], + cartesianVertices[endIdx], + ]); + + const line = new THREE.Line(geometry, lineMaterial); + wireframeGroup.add(line); + }); + + return wireframeGroup; +} + +/** + * Creates a 3D representation of a unit cell using Three.js with proper wireframe edges. + * @param {UnitCell} cell - The unit cell object containing dimensions and angles + * @param {object} cellSettings - Settings for the cell representation + * @param {string} cellSettings.color - Color of the cell wireframe + * @param {number} cellSettings.opacity - Opacity of the cell wireframe + * @param {string} cellSettings.colorA - Color for axis A + * @param {string} cellSettings.colorB - Color for axis B + * @param {string} cellSettings.colorC - Color for axis C + * @param {number} [cellSettings.headLengthMult] - Head length as fraction of smallest axis + * @param {number} [cellSettings.headWidthMult] - Head width as fraction of head length + * @param {number} [cellSettings.lineWidth] - Width of wireframe lines + * @returns {THREE.Group} A Three.js Group containing the cell wireframe and axes + */ +export function createCell3D(cell, cellSettings) { + const { + boxColor, + boxOpacity, + boxLineWidth, + arrowColorA, + arrowColorB, + arrowColorC, + arrowHeadLengthMult, + arrowHeadWidthMult, + arrowCylinderRadius, + } = cellSettings; + + const cellGroup = new THREE.Group(); + + // Create transformation matrix from the unit cell + const matrixArray = cell.fractToCartMatrix.toArray(); + const transformationMatrix = new THREE.Matrix4( + matrixArray[0][0], matrixArray[0][1], matrixArray[0][2], 0, + matrixArray[1][0], matrixArray[1][1], matrixArray[1][2], 0, + matrixArray[2][0], matrixArray[2][1], matrixArray[2][2], 0, + 0, 0, 0, 1, + ); + + // Create the wireframe using line segments + const wireframe = createUnitCellWireframe(transformationMatrix, boxColor, boxOpacity, boxLineWidth); + cellGroup.add(wireframe); + + // Extract basis vectors for the axes + const directionA = new THREE.Vector3(); + const directionB = new THREE.Vector3(); + const directionC = new THREE.Vector3(); + transformationMatrix.extractBasis(directionA, directionB, directionC); + + // Calculate arrow dimensions based on cell parameters + const { a, b, c } = cell; + const headLength = Math.max(a, b, c) * arrowHeadLengthMult; + const headWidth = headLength * arrowHeadWidthMult; + + // Create arrows to represent the cell axes + const arrowA = createCylinderArrow(directionA, arrowColorA, headLength, headWidth, arrowCylinderRadius); + const arrowB = createCylinderArrow(directionB, arrowColorB, headLength, headWidth, arrowCylinderRadius); + const arrowC = createCylinderArrow(directionC, arrowColorC, headLength, headWidth, arrowCylinderRadius); + + cellGroup.add(arrowA); + cellGroup.add(arrowB); + cellGroup.add(arrowC); + + // Add metadata + cellGroup.name = 'UnitCell'; + cellGroup.userData = { + selectable: false, + cellParameters: { a, b, c, alpha: cell.alpha, beta: cell.beta, gamma: cell.gamma }, + type: 'UnitCell', + }; + + return cellGroup; +} \ No newline at end of file diff --git a/src/lib/ortep3d/cell3d.test.js b/src/lib/ortep3d/cell3d.test.js new file mode 100644 index 0000000..72bd99a --- /dev/null +++ b/src/lib/ortep3d/cell3d.test.js @@ -0,0 +1,366 @@ +import * as THREE from 'three'; +import { createCell3D } from './cell3d.js'; + +// Mock the calcBondTransform import since it's not used in the current implementation +vitest.mock('../ortep.js', () => ({ + calcBondTransform: vitest.fn(), +})); + +describe('cell3d.js', () => { + let mockUnitCell; + let defaultCellSettings; + + beforeEach(() => { + // Create a mock unit cell with typical values + mockUnitCell = { + a: 10.0, + b: 12.0, + c: 8.0, + alpha: 90, + beta: 90, + gamma: 90, + fractToCartMatrix: { + toArray: () => [ + [10.0, 0.0, 0.0], + [0.0, 12.0, 0.0], + [0.0, 0.0, 8.0], + ], + }, + }; + + defaultCellSettings = { + color: '#CCCCCC', + opacity: 0.5, + colorA: '#FF0000', + colorB: '#00FF00', + colorC: '#0000FF', + headLengthMult: 0.1, + headWidthMult: 0.5, + lineWidth: 2, + }; + }); + + describe('createCell3D', () => { + test('creates a THREE.Group with correct name and userData', () => { + const result = createCell3D(mockUnitCell, defaultCellSettings); + + expect(result).toBeInstanceOf(THREE.Group); + expect(result.name).toBe('UnitCell'); + expect(result.userData.selectable).toBe(false); + expect(result.userData.type).toBe('UnitCell'); + expect(result.userData.cellParameters).toEqual({ + a: 10.0, + b: 12.0, + c: 8.0, + alpha: 90, + beta: 90, + gamma: 90, + }); + }); + + test('creates correct number of child objects (wireframe + 3 arrows)', () => { + const result = createCell3D(mockUnitCell, defaultCellSettings); + + // Should contain: 1 wireframe group + 3 arrow groups = 4 children + expect(result.children).toHaveLength(4); + }); + + test('creates wireframe with 12 line segments', () => { + const result = createCell3D(mockUnitCell, defaultCellSettings); + + // First child should be the wireframe group + const wireframe = result.children[0]; + expect(wireframe).toBeInstanceOf(THREE.Group); + expect(wireframe.children).toHaveLength(12); // 12 edges of parallelepiped + + // All children should be Line objects + wireframe.children.forEach(child => { + expect(child).toBeInstanceOf(THREE.Line); + expect(child.material).toBeInstanceOf(THREE.LineBasicMaterial); + }); + }); + + test('applies correct wireframe color and opacity', () => { + const customSettings = { + ...defaultCellSettings, + boxColor: '#FF5500', + boxOpacity: 0.7, + }; + + const result = createCell3D(mockUnitCell, customSettings); + const wireframe = result.children[0]; + const firstLine = wireframe.children[0]; + + expect(firstLine.material.color).toEqual(new THREE.Color('#FF5500')); + expect(firstLine.material.opacity).toBe(0.7); + expect(firstLine.material.transparent).toBe(true); + }); + + test('creates three arrow groups with correct colors', () => { + const result = createCell3D(mockUnitCell, defaultCellSettings); + + // Skip wireframe (index 0), check arrows (indices 1-3) + const arrows = result.children.slice(1); + expect(arrows).toHaveLength(3); + + arrows.forEach(arrow => { + expect(arrow).toBeInstanceOf(THREE.Group); + expect(arrow.children).toHaveLength(2); // cylinder + cone + }); + }); + + test('handles different unit cell shapes correctly', () => { + // Test with a non-orthogonal unit cell + const triclinicCell = { + a: 5.0, + b: 7.0, + c: 9.0, + alpha: 75, + beta: 85, + gamma: 95, + fractToCartMatrix: { + toArray: () => [ + [5.0, 1.2, 0.5], + [0.0, 6.8, 0.3], + [0.0, 0.0, 8.9], + ], + }, + }; + + const result = createCell3D(triclinicCell, defaultCellSettings); + + expect(result).toBeInstanceOf(THREE.Group); + expect(result.children).toHaveLength(4); + expect(result.userData.cellParameters.alpha).toBe(75); + }); + + test('calculates arrow dimensions correctly', () => { + // We need to test this indirectly by checking the head dimensions calculation + const smallCell = { + a: 2.0, + b: 4.0, + c: 3.0, + alpha: 90, + beta: 90, + gamma: 90, + fractToCartMatrix: { + toArray: () => [ + [2.0, 0.0, 0.0], + [0.0, 4.0, 0.0], + [0.0, 0.0, 3.0], + ], + }, + }; + + const customSettings = { + ...defaultCellSettings, + headLengthMult: 0.2, + headWidthMult: 0.6, + }; + + const result = createCell3D(smallCell, customSettings); + + // The smallest axis is 2.0, so headLength should be 2.0 * 0.2 = 0.4 + // headWidth should be 0.4 * 0.6 = 0.24 + expect(result.userData.cellParameters.a).toBe(2.0); + }); + + test('uses default values for optional parameters', () => { + const minimalSettings = { + color: '#FFFFFF', + opacity: 1.0, + colorA: '#FF0000', + colorB: '#00FF00', + colorC: '#0000FF', + }; + + const result = createCell3D(mockUnitCell, minimalSettings); + + expect(result).toBeInstanceOf(THREE.Group); + expect(result.children).toHaveLength(4); + }); + + test('handles edge case with very small unit cell', () => { + const smallCell = { + a: 0.1, + b: 0.1, + c: 0.1, + alpha: 90, + beta: 90, + gamma: 90, + fractToCartMatrix: { + toArray: () => [ + [0.1, 0.0, 0.0], + [0.0, 0.1, 0.0], + [0.0, 0.0, 0.1], + ], + }, + }; + + const result = createCell3D(smallCell, defaultCellSettings); + + expect(result).toBeInstanceOf(THREE.Group); + expect(result.children).toHaveLength(4); + }); + + test('handles edge case with very large unit cell', () => { + const largeCell = { + a: 1000.0, + b: 1000.0, + c: 1000.0, + alpha: 90, + beta: 90, + gamma: 90, + fractToCartMatrix: { + toArray: () => [ + [1000.0, 0.0, 0.0], + [0.0, 1000.0, 0.0], + [0.0, 0.0, 1000.0], + ], + }, + }; + + const result = createCell3D(largeCell, defaultCellSettings); + + expect(result).toBeInstanceOf(THREE.Group); + expect(result.children).toHaveLength(4); + }); + + test('wireframe vertices are correctly positioned', () => { + const result = createCell3D(mockUnitCell, defaultCellSettings); + const wireframe = result.children[0]; + + // Check that we have line geometries with correct point counts + wireframe.children.forEach(line => { + const positions = line.geometry.attributes.position; + expect(positions.count).toBe(2); // Each line has 2 points + expect(positions.itemSize).toBe(3); // 3D coordinates + }); + }); + + test('handles opacity correctly for transparent and opaque cases', () => { + // Test transparent case + const transparentSettings = { ...defaultCellSettings, boxOpacity: 0.3 }; + const transparentResult = createCell3D(mockUnitCell, transparentSettings); + const transparentWireframe = transparentResult.children[0]; + const transparentLine = transparentWireframe.children[0]; + + expect(transparentLine.material.transparent).toBe(true); + expect(transparentLine.material.opacity).toBe(0.3); + + // Test opaque case + const opaqueSettings = { ...defaultCellSettings, opacity: 1.0 }; + const opaqueResult = createCell3D(mockUnitCell, opaqueSettings); + const opaqueWireframe = opaqueResult.children[0]; + const opaqueLine = opaqueWireframe.children[0]; + + expect(opaqueLine.material.transparent).toBe(false); + expect(opaqueLine.material.opacity).toBe(1.0); + }); + + test('arrow colors are applied correctly', () => { + const customColors = { + ...defaultCellSettings, + colorA: '#FF5733', + colorB: '#33FF57', + colorC: '#3357FF', + }; + + const result = createCell3D(mockUnitCell, customColors); + const arrows = result.children.slice(1); // Skip wireframe + + // Note: Testing exact arrow colors requires access to the internal + // createCylinderArrow function, which creates meshes with materials + // This is a structural test to ensure arrows are created + arrows.forEach(arrow => { + expect(arrow.children).toHaveLength(2); // cylinder + cone + arrow.children.forEach(mesh => { + expect(mesh).toBeInstanceOf(THREE.Mesh); + expect(mesh.material).toBeInstanceOf(THREE.MeshBasicMaterial); + }); + }); + }); + }); + + describe('createUnitCellWireframe (indirectly tested)', () => { + test('creates correct wireframe structure', () => { + const result = createCell3D(mockUnitCell, defaultCellSettings); + const wireframe = result.children[0]; + + // Test the structure created by createUnitCellWireframe + expect(wireframe.children).toHaveLength(12); + + // Verify all edges are Line objects with LineBasicMaterial + wireframe.children.forEach(edge => { + expect(edge).toBeInstanceOf(THREE.Line); + expect(edge.material).toBeInstanceOf(THREE.LineBasicMaterial); + expect(edge.geometry).toBeInstanceOf(THREE.BufferGeometry); + }); + }); + }); + + describe('createCylinderArrow (indirectly tested)', () => { + test('creates correct arrow structure', () => { + const result = createCell3D(mockUnitCell, defaultCellSettings); + const arrows = result.children.slice(1); + + // Each arrow should be a group with 2 children (cylinder + cone) + arrows.forEach(arrow => { + expect(arrow).toBeInstanceOf(THREE.Group); + expect(arrow.children).toHaveLength(2); + + const [cylinder, cone] = arrow.children; + expect(cylinder).toBeInstanceOf(THREE.Mesh); + expect(cone).toBeInstanceOf(THREE.Mesh); + + // Check geometries + expect(cylinder.geometry).toBeInstanceOf(THREE.CylinderGeometry); + expect(cone.geometry).toBeInstanceOf(THREE.ConeGeometry); + + // Check materials + expect(cylinder.material).toBeInstanceOf(THREE.MeshBasicMaterial); + expect(cone.material).toBeInstanceOf(THREE.MeshBasicMaterial); + }); + }); + }); + + describe('Error handling and edge cases', () => { + test('handles missing optional cellSettings properties gracefully', () => { + const minimalSettings = { + color: '#FFFFFF', + opacity: 1.0, + colorA: '#FF0000', + colorB: '#00FF00', + colorC: '#0000FF', + }; + + expect(() => { + createCell3D(mockUnitCell, minimalSettings); + }).not.toThrow(); + }); + + test('handles zero-length axes gracefully', () => { + const degenerateCell = { + a: 0.0, + b: 1.0, + c: 1.0, + alpha: 90, + beta: 90, + gamma: 90, + fractToCartMatrix: { + toArray: () => [ + [0.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ], + }, + }; + + // This might create issues with arrow head calculations + // The function should handle this gracefully + expect(() => { + createCell3D(degenerateCell, defaultCellSettings); + }).not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/src/lib/ortep3d/crystal-viewer.js b/src/lib/ortep3d/crystal-viewer.js index d36299e..0e3fc9f 100644 --- a/src/lib/ortep3d/crystal-viewer.js +++ b/src/lib/ortep3d/crystal-viewer.js @@ -9,6 +9,7 @@ import { BondGenerator, AtomLabelFilter, IsolatedHydrogenFixer } from '../struct import { DisorderFilter, HydrogenFilter, SymmetryGrower } from '../structure/structure-modifiers/modes.js'; import { tryToFixCifBlock } from '../fix-cif/base.js'; import { createCameraController } from './camera-controllers.js'; +import { createCell3D } from './cell3d.js'; /** * Manages selections of atoms, bonds, and hydrogen bonds in the 3D structure. @@ -405,6 +406,10 @@ export class CrystalViewer { symmetryMode: options.symmetryMode || defaultSettings.symmetryMode, renderMode: options.renderMode || defaultSettings.renderMode, fixCifErrors: options.fixCifErrors || defaultSettings.fixCifErrors, + cell: { + ...defaultSettings.cell, + ...options.cell, + }, }; this.state = { @@ -592,8 +597,15 @@ export class CrystalViewer { update3DOrtep() { this.removeStructure(); let structure = this.state.baseStructure; + let drawCell = false; for (const modifier of Object.values(this.modifiers)) { structure = modifier.apply(structure); + drawCell = drawCell || modifier.drawCell; + } + + if (drawCell) { + const cell3D = createCell3D(structure.cell, this.options.cell); + this.moleculeContainer.add(cell3D); } const ortep = new ORTEP3JsStructure(structure, this.options); diff --git a/src/lib/ortep3d/ortep.js b/src/lib/ortep3d/ortep.js index 2cf57fa..47f5c33 100644 --- a/src/lib/ortep3d/ortep.js +++ b/src/lib/ortep3d/ortep.js @@ -3,8 +3,8 @@ import defaultSettings from './structure-settings.js'; import { inferElementFromLabel } from '../structure/crystal.js'; import { HBond, Bond } from '../structure/bonds.js'; import { UAnisoADP, UIsoADP } from '../structure/adp.js'; -import { SymmetryGrower } from '../structure/structure-modifiers/modes.js'; import { CrystalStructure, UnitCell, Atom } from '../structure/crystal.js'; +//import { createSymAtomLabel } from '../structure/structure-modifiers/growing/util.js'; /** * Examines a THREE.Object3D and its children for NaN values in position, rotation, scale, and matrix properties. @@ -414,13 +414,7 @@ export class ORTEP3JsStructure { // Handle regular bonds const drawnBonds = this.crystalStructure.bonds - .map(bond => new Bond( - bond.atom1Label, - SymmetryGrower.combineSymOpLabel(bond.atom2Label, bond.atom2SiteSymmetry), - bond.bondLength, - bond.bondLengthSU, - '.', - )) + .filter(bond => bond.atom2SiteSymmetry === null || bond.atom2SiteSymmetry === '.') .filter(bond => atomLabels.includes(bond.atom2Label)); for (const bond of drawnBonds) { @@ -440,23 +434,7 @@ export class ORTEP3JsStructure { // Handle hydrogen bonds const drawnHBonds = this.crystalStructure.hBonds - .map(hBond => new HBond( - hBond.donorAtomLabel, - hBond.hydrogenAtomLabel, - SymmetryGrower.combineSymOpLabel( - hBond.acceptorAtomLabel, - hBond.acceptorAtomSymmetry, - ), - hBond.donorHydrogenDistance, - hBond.donorHydrogenDistanceSU, - hBond.acceptorHydrogenDistance, - hBond.acceptorHydrogenDistanceSU, - hBond.donorAcceptorDistance, - hBond.donorAcceptorDistanceSU, - hBond.hBondAngle, - hBond.hBondAngleSU, - '.', - )) + .filter(hBond => hBond.acceptorAtomSymmetry === null || hBond.acceptorAtomSymmetry === '.') .filter(hBond => atomLabels.includes(hBond.acceptorAtomLabel)); for (const hbond of drawnHBonds) { diff --git a/src/lib/ortep3d/ortep.test.js b/src/lib/ortep3d/ortep.test.js index 1dd6698..9023cac 100644 --- a/src/lib/ortep3d/ortep.test.js +++ b/src/lib/ortep3d/ortep.test.js @@ -326,10 +326,10 @@ describe('ORTEP3JsStructure', () => { new UAnisoADP(0.01, 0.01, 0.01, 0, 0, 0)), ]; const bonds = [ - new Bond('C1', 'O1', 1.5, 0.01), + new Bond('C1', 'O1', 1.5, 0.01, '.'), ]; const hbonds = [ - new HBond('O1', 'H1', 'C1', 1.0, 0.01, 2.0, 0.02, 2.8, 0.03, 175, 1), + new HBond('O1', 'H1', 'C1', 1.0, 0.01, 2.0, 0.02, 2.8, 0.03, 175, 1, '.'), ]; mockCrystalStructure = new CrystalStructure(cell, atoms, bonds, hbonds); diff --git a/src/lib/ortep3d/structure-settings.js b/src/lib/ortep3d/structure-settings.js index 49d2f08..a7f3e0e 100644 --- a/src/lib/ortep3d/structure-settings.js +++ b/src/lib/ortep3d/structure-settings.js @@ -48,7 +48,7 @@ export default { // starting values for hydrogen, disorder and symmetry display 'hydrogenMode': 'none', 'disorderMode': 'all', - 'symmetryMode': 'bonds-no-hbonds-no', + 'symmetryMode': 'none', 'bondGrowToleranceFactor': 1.2, @@ -79,6 +79,19 @@ export default { 'hbondDashSegmentLength': 0.3, // Target length for each dash+gap segment 'hbondDashFraction': 0.6, // Fraction of segment that is solid (vs gap) + // Cell visualisation settings + 'cell': { + 'boxColor': '#000000', + 'boxOpacity': 0.8, + 'boxLineWidth': 2, + 'arrowColorA': '#E74C3C', // Red', + 'arrowColorB': '#2ECC71', // Green', + 'arrowColorC': '#3498DB', // Blue', + 'arrowHeadLengthMult': 0.05, + 'arrowHeadWidthMult': 0.25, + 'arrowCylinderRadius': 0.04, + }, + 'elementProperties': { 'H': { 'radius': 0.31, 'atomColor': '#ffffff', 'ringColor': '#000000' }, 'D': { 'radius': 0.31, 'atomColor': '#ffffff', 'ringColor': '#000000' }, diff --git a/src/lib/structure/bonds.js b/src/lib/structure/bonds.js index 50a61bf..9cd89fd 100644 --- a/src/lib/structure/bonds.js +++ b/src/lib/structure/bonds.js @@ -45,6 +45,13 @@ export class Bond { siteSymmetry2 = '.'; } + if (siteSymmetry2 === '?') { + siteSymmetry2 = '.'; + } + if (siteSymmetry2 !== '.' && !siteSymmetry2.includes('_')) { + siteSymmetry2 = `${siteSymmetry2}_555`; + } + return new Bond( bondLoop.getIndex(['_geom_bond.atom_site_label_1', '_geom_bond_atom_site_label_1'], bondIndex), bondLoop.getIndex(['_geom_bond.atom_site_label_2', '_geom_bond_atom_site_label_2'], bondIndex), diff --git a/src/lib/structure/cell-symmetry.js b/src/lib/structure/cell-symmetry.js index b4badf0..5ef5501 100644 --- a/src/lib/structure/cell-symmetry.js +++ b/src/lib/structure/cell-symmetry.js @@ -1,6 +1,6 @@ import { create, all } from 'mathjs'; -import { Atom } from './crystal.js'; +import { Atom, UnitCell } from './crystal.js'; import { FractPosition } from './position.js'; import { UAnisoADP, UIsoADP } from './adp.js'; import { CifLoop } from '../read-cif/loop.js'; @@ -215,7 +215,7 @@ export class SymmetryOperation { /** * Applies the symmetry operation to multiple atoms - * @param {object[]} atoms - Array of atom objects + * @param {Atom[]} atoms - Array of atom objects * @param {string} atoms[].label - Atom label * @param {string} atoms[].atomType - Chemical element symbol * @param {FractPosition} atoms[].position - Fractional position object @@ -314,6 +314,50 @@ export class CellSymmetry { return math.equal(symOp.rotMatrix, math.identity(3)) && math.equal(symOp.transVector, math.zeros(3)); })?.[0]; + + // Cache for combineSymmetryCodes results + this._combineSymmetryCodesCache = new Map(); + // Build rotation matrix index + this._rotationMatrixIndex = new Map(); + this._buildRotationIndex(); + } + + _buildRotationIndex() { + this.symmetryOperations.forEach((op, index) => { + const key = this._matrixToKey(op.rotMatrix); + if (!this._rotationMatrixIndex.has(key)) { + this._rotationMatrixIndex.set(key, []); + } + this._rotationMatrixIndex.get(key).push(index); + }); + } + + _matrixToKey(matrix) { + // Convert matrix to a string key for indexing + // Round to avoid floating point comparison issues + const rounded = matrix.map(row => + row.map(val => Math.round(val * 1000) / 1000), + ); + return JSON.stringify(rounded); + } + + // Instead of string concatenation, use a numeric hash + _getCacheKey(outerCode, innerCode) { + // Simple hash function for two strings + const hash1 = this._hashCode(outerCode); + const hash2 = this._hashCode(innerCode); + // Combine hashes using bit operations + return (hash1 << 16) | (hash2 & 0xFFFF); + } + + _hashCode(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return hash; } generateEquivalentPositions(point) { @@ -345,34 +389,103 @@ export class CellSymmetry { const symOp = this.symmetryOperations[symOpIndex]; return { symOp, transVector }; } + // Helper methods for fast 3x3 operations + _multiplyMatrices3x3(a, b) { + return [ + [ + a[0][0]*b[0][0] + a[0][1]*b[1][0] + a[0][2]*b[2][0], + a[0][0]*b[0][1] + a[0][1]*b[1][1] + a[0][2]*b[2][1], + a[0][0]*b[0][2] + a[0][1]*b[1][2] + a[0][2]*b[2][2], + ], + [ + a[1][0]*b[0][0] + a[1][1]*b[1][0] + a[1][2]*b[2][0], + a[1][0]*b[0][1] + a[1][1]*b[1][1] + a[1][2]*b[2][1], + a[1][0]*b[0][2] + a[1][1]*b[1][2] + a[1][2]*b[2][2], + ], + [ + a[2][0]*b[0][0] + a[2][1]*b[1][0] + a[2][2]*b[2][0], + a[2][0]*b[0][1] + a[2][1]*b[1][1] + a[2][2]*b[2][1], + a[2][0]*b[0][2] + a[2][1]*b[1][2] + a[2][2]*b[2][2], + ], + ]; + } + + _multiplyMatrixVector3x3(m, v) { + return [ + m[0][0]*v[0] + m[0][1]*v[1] + m[0][2]*v[2], + m[1][0]*v[0] + m[1][1]*v[1] + m[1][2]*v[2], + m[2][0]*v[0] + m[2][1]*v[1] + m[2][2]*v[2], + ]; + } /** * Combines two position codes to create a new position code - * @param {string} positionCodeOuter - Outer position code (applied second) - * @param {string} positionCodeInner - Inner position code (applied first) + * @param {string} symmetryCodeOuter - Outer position code (applied second) + * @param {string} symmetryCodeInner - Inner position code (applied first) * @returns {string} Combined position code * @throws {Error} If no matching symmetry operation is found */ - combinePositionCodes(positionCodeOuter, positionCodeInner) { - const { symOp: symOpOuter, transVector: transVecOuterArray } = this.parsePositionCode(positionCodeOuter); - const { symOp: symOpInner, transVector: transVecInnerArray } = this.parsePositionCode(positionCodeInner); - const transVecOuter = math.add(math.matrix(transVecOuterArray), math.matrix(symOpOuter.transVector)); - const transVecInner = math.add(math.matrix(transVecInnerArray), math.matrix(symOpInner.transVector)); - const combinedTransVector = math.add(math.multiply(symOpOuter.rotMatrix, transVecInner), transVecOuter); - const combinedRotMatrix = math.multiply(symOpOuter.rotMatrix, symOpInner.rotMatrix); - - for (let i = 0; i < this.symmetryOperations.length; i++) { + combineSymmetryCodes(symmetryCodeOuter, symmetryCodeInner) { + const cacheKey = this._getCacheKey(symmetryCodeOuter, symmetryCodeInner); + const cached = this._combineSymmetryCodesCache.get(cacheKey); + if (cached !== undefined) { + if (cached instanceof Error) { + throw cached; + } + return cached; + } + + // Original calculation starts here + const { symOp: symOpOuter, transVector: transVecOuterArray } = this.parsePositionCode(symmetryCodeOuter); + const { symOp: symOpInner, transVector: transVecInnerArray } = this.parsePositionCode(symmetryCodeInner); + const transVecOuter = [ + transVecOuterArray[0] + symOpOuter.transVector[0], + transVecOuterArray[1] + symOpOuter.transVector[1], + transVecOuterArray[2] + symOpOuter.transVector[2], + ]; + + const transVecInner = [ + transVecInnerArray[0] + symOpInner.transVector[0], + transVecInnerArray[1] + symOpInner.transVector[1], + transVecInnerArray[2] + symOpInner.transVector[2], + ]; + + const combinedRotMatrix = this._multiplyMatrices3x3(symOpOuter.rotMatrix, symOpInner.rotMatrix); + + const rotatedInner = this._multiplyMatrixVector3x3(symOpOuter.rotMatrix, transVecInner); + const combinedTransVector = [ + transVecOuter[0] + rotatedInner[0], + transVecOuter[1] + rotatedInner[1], + transVecOuter[2] + rotatedInner[2], + ]; + + // Look up matching operation using index + const rotKey = this._matrixToKey(combinedRotMatrix); + const candidateIndices = this._rotationMatrixIndex.get(rotKey); + + if (!candidateIndices) { + throw new Error( + 'No matching symmetry operation found for combined position codes: ' + + `${symmetryCodeOuter} and ${symmetryCodeInner}`, + ); + } + + // Check translation vectors for candidates + for (const i of candidateIndices) { const possibleSymOp = this.symmetryOperations[i]; - if (!math.deepEqual(combinedRotMatrix, possibleSymOp.rotMatrix)) { - continue; - }; - const remainderVector = math.subtract(combinedTransVector, math.matrix(possibleSymOp.transVector)); - const isIntegerTranslation = remainderVector.toArray().every(val => + const remainderVector = [ + combinedTransVector[0] - possibleSymOp.transVector[0], + combinedTransVector[1] - possibleSymOp.transVector[1], + combinedTransVector[2] - possibleSymOp.transVector[2], + ]; + + // Check if translation is integer + const isInteger = remainderVector.every(val => Math.abs(val - Math.round(val)) < 1e-10, ); - - if (isIntegerTranslation) { - // Find the ID for this symmetry operation + + if (isInteger) { + // Find the ID for this operation let symOpId = null; for (const [id, index] of this.operationIds.entries()) { if (index === i) { @@ -381,34 +494,63 @@ export class CellSymmetry { } } - const roundedDiff = remainderVector.toArray().map(val => Math.round(val) + 5); - const translationCode = roundedDiff.join(''); - - return `${symOpId}_${translationCode}`; + const roundedDiff = remainderVector.map(val => Math.round(val) + 5); + const combinedPositionCode = `${symOpId}_${roundedDiff.join('')}`; + this._combineSymmetryCodesCache.set(cacheKey, combinedPositionCode); + return combinedPositionCode; } } - - throw new Error('No matching symmetry operation found for combined position codes'); + + throw new Error( + 'No matching symmetry operation found for combined position codes: ' + + `${symmetryCodeOuter} and ${symmetryCodeInner}`, + ); } + /** + * Applies the symmetry operation of a position code to multiple atoms, but return atoms on special + * positions separately + * @param {string} positionCode - A valid position code. + * @param {Atom[]} atoms - Array of atom objects + * @returns {Atom[]} - Array of symmetry transformed atoms + */ applySymmetry(positionCode, atoms) { const { symOp, transVector } = this.parsePositionCode(positionCode); - if (Array.isArray(atoms)) { - const newAtoms = symOp.applyToAtoms(atoms); - newAtoms.forEach(newAtom => { - newAtom.position.x += transVector[0]; - newAtom.position.y += transVector[1]; - newAtom.position.z += transVector[2]; - }); - return newAtoms; - } + const newAtoms = symOp.applyToAtoms(atoms); + newAtoms.forEach(newAtom => { + newAtom.position.x += transVector[0]; + newAtom.position.y += transVector[1]; + newAtom.position.z += transVector[2]; + }); + return newAtoms; + } - const newAtom = symOp.applyToAtom(atoms); - newAtom.position.x += transVector[0]; - newAtom.position.y += transVector[1]; - newAtom.position.z += transVector[2]; - return newAtom; + /** + * Applies the symmetry operation of a position code to multiple atoms, but return atoms on special + * positions separately + * @param {string} positionCode - A valid position code. + * @param {Atom[]} atoms - Array of atom objects + * @param {UnitCell} unitCell - Unit cell object + * @returns {{atoms: Atom[], specialPositions: string[]}} - Array of unique symmetry transformed atoms + * and list of labels of atoms on special positions. + */ + applySymmetryNonSpecial(positionCode, atoms, unitCell) { + const newAtoms = this.applySymmetry(positionCode, atoms); + const specialPositions = []; + const filteredAtoms = []; + newAtoms.forEach((atom, i) => { + const isSpecial = Math.abs(atom.position.x - atoms[i].position.x) * unitCell.a < 1e-3 + && Math.abs(atom.position.y - atoms[i].position.y) * unitCell.b < 1e-3 + && Math.abs(atom.position.z - atoms[i].position.z) * unitCell.c < 1e-3; + + if (isSpecial) { + specialPositions.push(atom.label); + } else { + filteredAtoms.push(atom); + } + }); + return { atoms: filteredAtoms, specialPositions: specialPositions }; } static fromCIF(cifBlock) { diff --git a/src/lib/structure/cell-symmetry.test.js b/src/lib/structure/cell-symmetry.test.js index b68a1e0..a4dafff 100644 --- a/src/lib/structure/cell-symmetry.test.js +++ b/src/lib/structure/cell-symmetry.test.js @@ -374,13 +374,13 @@ describe('CellSymmetry', () => { test('combines identity operations', () => { const ops = [new SymmetryOperation('x,y,z')]; const sym = new CellSymmetry('P1', 1, ops); - expect(sym.combinePositionCodes('1_555', '1_555')).toBe('1_555'); + expect(sym.combineSymmetryCodes('1_555', '1_555')).toBe('1_555'); }); test('combines integer translations', () => { const ops = [new SymmetryOperation('x,y,z')]; const sym = new CellSymmetry('P1', 1, ops); - expect(sym.combinePositionCodes('1_655', '1_565')).toBe('1_665'); + expect(sym.combineSymmetryCodes('1_655', '1_565')).toBe('1_665'); }); test('combines operations with rotations', () => { @@ -390,7 +390,7 @@ describe('CellSymmetry', () => { ]; const operationIds = new Map([['1', 0], ['2', 1]]); const sym = new CellSymmetry('P-1', 2, ops, operationIds); - expect(sym.combinePositionCodes('2_555', '2_555')).toBe('1_555'); + expect(sym.combineSymmetryCodes('2_555', '2_555')).toBe('1_555'); }); test('combines rotation with translation', () => { @@ -401,7 +401,7 @@ describe('CellSymmetry', () => { ]; const operationIds = new Map([['1', 0], ['2', 1], ['3', 2]]); const sym = new CellSymmetry('P2/m', 10, ops, operationIds); - expect(sym.combinePositionCodes('2_555', '1_655')).toBe('2_455'); + expect(sym.combineSymmetryCodes('2_555', '1_655')).toBe('2_455'); }); test('throws error when no matching operation found', () => { @@ -419,7 +419,7 @@ describe('CellSymmetry', () => { transVector: [0, 0, 0], }); - expect(() => sym.combinePositionCodes('1_555', '1_555')).toThrow('No matching symmetry operation found'); + expect(() => sym.combineSymmetryCodes('1_555', '1_555')).toThrow('No matching symmetry operation found'); }); test('handles complex space group operations', () => { @@ -433,32 +433,49 @@ describe('CellSymmetry', () => { const sym = new CellSymmetry('P21/m', 11, ops, operationIds); // Combine a 2-fold rotation (2) with a translation - expect(sym.combinePositionCodes('2_555', '1_655')).toBe('2_455'); + expect(sym.combineSymmetryCodes('2_555', '1_655')).toBe('2_455'); // Combine inversion (3) with a glide plane (2) - expect(sym.combinePositionCodes('3_555', '2_555')).toBe('4_545'); + expect(sym.combineSymmetryCodes('3_555', '2_555')).toBe('4_545'); }); - }); - describe('applySymmetry', () => { - test('applies symmetry operation with translation', () => { + test('handles operations with x,y switch (dev from paracyclophane)', () => { const ops = [ new SymmetryOperation('x,y,z'), - new SymmetryOperation('-x,y,-z'), + new SymmetryOperation('-y+1/2,x+1/2,z+1/2'), + new SymmetryOperation('y+1/2,-x+1/2,z+1/2'), + new SymmetryOperation('x+1/2,-y+1/2,-z+1/2'), + new SymmetryOperation('-x+1/2,y+1/2,-z+1/2'), + new SymmetryOperation('-x,-y,z'), + new SymmetryOperation('y,x,-z'), + new SymmetryOperation('-y,-x,-z'), + new SymmetryOperation('-x,-y,-z'), + new SymmetryOperation('y-1/2,-x-1/2,-z-1/2'), + new SymmetryOperation('-y-1/2,x-1/2,-z-1/2'), + new SymmetryOperation('-x-1/2,y-1/2,z-1/2'), + new SymmetryOperation('x-1/2,-y-1/2,z-1/2'), + new SymmetryOperation('x,y,-z'), + new SymmetryOperation('-y,-x,z'), + new SymmetryOperation('y,x,z'), ]; - const sym = new CellSymmetry('P2/m', 10, ops); - const atom = new Atom( - 'C1', - 'C', - new FractPosition(0.5, 0.25, 0.75), - ); - - const result = sym.applySymmetry('2_456', atom); - expect(result.position.x).toBeCloseTo(-1.5); - expect(result.position.y).toBeCloseTo(0.25); - expect(result.position.z).toBeCloseTo(0.25); + const sym = new CellSymmetry('P 42/m n m', 136, ops); + + expect(sym.combineSymmetryCodes('15_665', '6_665')).toBe('16_555'); + expect(sym.combineSymmetryCodes('15_665', '7_556')).toBe('9_666'); + expect(sym.combineSymmetryCodes('15_665', '8_666')).toBe('14_556'); + expect(sym.combineSymmetryCodes('15_665', '16_555')).toBe('6_665'); + expect(sym.combineSymmetryCodes('15_665', '9_666')).toBe('7_556'); + expect(sym.combineSymmetryCodes('15_665', '14_556')).toBe('8_666'); + expect(sym.combineSymmetryCodes('14_556', '15_665')).toBe('8_666'); + expect(sym.combineSymmetryCodes('14_556', '9_666')).toBe('6_665'); + expect(sym.combineSymmetryCodes('14_556', '16_555')).toBe('7_556'); + expect(sym.combineSymmetryCodes('14_556', '8_666')).toBe('15_665'); + expect(sym.combineSymmetryCodes('14_556', '6_665')).toBe('9_666'); + expect(sym.combineSymmetryCodes('14_556', '7_556')).toBe('16_555'); }); + }); + describe('applySymmetry', () => { test('applies symmetry operation with translation to multiple atoms', () => { const ops = [ new SymmetryOperation('x,y,z'), @@ -580,14 +597,14 @@ loop_ }); describe('CellSymmetry ID handling', () => { - let mockAtom; + let mockAtoms; beforeEach(() => { - mockAtom = new Atom( + mockAtoms = [new Atom( 'C1', 'C', new FractPosition(0.5, 0.5, 0.5), - ); + )]; }); test('handles custom operation IDs from CIF', () => { @@ -610,7 +627,7 @@ b2 -x,-y,z`; expect(symmetry.identitySymOpId).toBe('a1'); // Test applying operation with translation - const transformed = symmetry.applySymmetry('b2_456', mockAtom); + const transformed = symmetry.applySymmetry('b2_456', mockAtoms)[0]; expect(transformed.position.x).toBeCloseTo(-1.5); expect(transformed.position.y).toBeCloseTo(-0.5); expect(transformed.position.z).toBeCloseTo(1.5); // 0.5 + 1 @@ -633,7 +650,7 @@ x,y,z expect(symmetry.operationIds.get('2')).toBe(1); // Test legacy numeric code still works - const transformed = symmetry.applySymmetry('2', mockAtom); + const transformed = symmetry.applySymmetry('2', mockAtoms)[0]; expect(transformed.position.x).toBeCloseTo(-0.5); expect(transformed.position.y).toBeCloseTo(-0.5); expect(transformed.position.z).toBeCloseTo(0.5); @@ -651,7 +668,7 @@ a1 x,y,z`; const block = cif.getBlock(0); const symmetry = CellSymmetry.fromCIF(block); - expect(() => symmetry.applySymmetry('invalid_555', mockAtom)) + expect(() => symmetry.applySymmetry('invalid_555', mockAtoms)) .toThrow('Invalid symmetry operation ID in string invalid_555: invalid'); }); }); diff --git a/src/lib/structure/crystal.js b/src/lib/structure/crystal.js index 737c72a..82e2cdc 100644 --- a/src/lib/structure/crystal.js +++ b/src/lib/structure/crystal.js @@ -79,7 +79,6 @@ export class CrystalStructure { this.atoms = atoms; this.bonds = bonds; this.hBonds = hBonds; - this.recalculateConnectedGroups(); this.symmetry = symmetry ? symmetry : new CellSymmetry('None', 0, [new SymmetryOperation('x,y,z')]); } @@ -149,8 +148,10 @@ export class CrystalStructure { /** * Groups atoms connected by bonds or H-bonds, excluding symmetry relationships * from the provided atoms and bonds + * @returns {Array} Array of connected groups, each containing atoms, bonds, and H-bonds + * @throws {Error} If atom with label not found */ - recalculateConnectedGroups() { + calculateConnectedGroups() { // Map to track which atoms have been assigned to a group const atomGroupMap = new Map(); const groups = []; @@ -247,7 +248,7 @@ export class CrystalStructure { groups.push(newGroup); }); // Convert Sets to Arrays for easier handling - this.connectedGroups = groups.map(group => ({ + return groups.map(group => ({ atoms: Array.from(group.atoms), bonds: Array.from(group.bonds), hBonds: Array.from(group.hBonds), diff --git a/src/lib/structure/crystal.test.js b/src/lib/structure/crystal.test.js index 0a5b2a5..fa64214 100644 --- a/src/lib/structure/crystal.test.js +++ b/src/lib/structure/crystal.test.js @@ -209,7 +209,7 @@ C4 C 0 0 0 .`; ]; const structure = new CrystalStructure(cell, atoms, bonds, hBonds); - const groups = structure.connectedGroups; + const groups = structure.calculateConnectedGroups(); expect(groups).toHaveLength(2); expect(groups[0].atoms).toHaveLength(3); @@ -240,7 +240,7 @@ C4 C 0 0 0 .`; ]; const structure = new CrystalStructure(cell, atoms, bonds, hBonds); - const groups = structure.connectedGroups; + const groups = structure.calculateConnectedGroups(); // Group 1: C1-O1-N1-H1 connected by regular bonds and H-bonds // Group 2: P1 alone (unconnected) @@ -290,7 +290,7 @@ C4 C 0 0 0 .`; ]; const structure = new CrystalStructure(cell, atoms, bonds, hbonds); - const groups = structure.connectedGroups; + const groups = structure.calculateConnectedGroups(); expect(groups).toHaveLength(1); const group = groups[0]; diff --git a/src/lib/structure/structure-modifiers/base.js b/src/lib/structure/structure-modifiers/base.js index 1911849..699829c 100644 --- a/src/lib/structure/structure-modifiers/base.js +++ b/src/lib/structure/structure-modifiers/base.js @@ -28,6 +28,10 @@ export class BaseFilter { return false; } + get drawCell() { + return false; + } + /** * Gets the current mode * @returns {string} Current mode diff --git a/src/lib/structure/structure-modifiers/base.test.js b/src/lib/structure/structure-modifiers/base.test.js index 4a4b31a..7125f5c 100644 --- a/src/lib/structure/structure-modifiers/base.test.js +++ b/src/lib/structure/structure-modifiers/base.test.js @@ -22,13 +22,14 @@ export class MockStructure { this.structure = baseStructure; } else { const cell = new UnitCell(10, 10, 10, 90, 90, 90); + // Operations for P2_1/m (No. 11), unique axis b, origin at -1 const symmetryOps = [ new SymmetryOperation('x,y,z'), - new SymmetryOperation('-x,y+1/2,-z'), - new SymmetryOperation('-x+1/2,y,-z+1/2'), - new SymmetryOperation('x+1/2,-y+1/2,z'), + new SymmetryOperation('-x, y+1/2, -z'), + new SymmetryOperation('-x,-y,-z'), + new SymmetryOperation('x, -y+1/2, z'), ]; - const symmetry = new CellSymmetry('Test', 1, symmetryOps); + const symmetry = new CellSymmetry('P 21/m', 11, symmetryOps); this.structure = new CrystalStructure(cell, [], [], [], symmetry); } @@ -172,8 +173,7 @@ export class MockStructure { } /** - * Finalizes and validates the mock structure - * Verifies that all references to atoms exist and recalculates connected groups + * Finalizes and validates the mock structure, verifies that all references to atoms exist * @returns {CrystalStructure} - The completed crystal structure * @throws {Error} If referenced atoms don't exist */ @@ -200,8 +200,6 @@ export class MockStructure { throw new Error(`H-bond references non-existent acceptor atom ${hbond.acceptorAtomLabel}`); } } - this.structure.recalculateConnectedGroups(); - return this.structure; } } diff --git a/src/lib/structure/structure-modifiers/fixers.js b/src/lib/structure/structure-modifiers/fixers.js index 22b0999..40bd678 100644 --- a/src/lib/structure/structure-modifiers/fixers.js +++ b/src/lib/structure/structure-modifiers/fixers.js @@ -1,3 +1,4 @@ +//import { atomLabelsMatch } from '../../fix-cif/reconcile-labels.js'; import { Bond } from '../bonds.js'; import { CrystalStructure, inferElementFromLabel } from '../crystal.js'; import { BaseFilter } from './base.js'; @@ -407,13 +408,15 @@ export class IsolatedHydrogenFixer extends BaseFilter { * @returns {Array} Array of isolated hydrogen atoms with their indices */ findIsolatedHydrogenAtoms(structure) { - const isolatedHydrogenAtoms = []; + const atomsInBonds = new Set(); + structure.bonds.forEach(b => { + atomsInBonds.add(b.atom1Label); + atomsInBonds.add(b.atom2Label); + }); - // Find connected groups with only a single hydrogen atom - structure.connectedGroups.forEach(group => { - if (group.atoms.length === 1 && group.atoms[0].atomType === 'H') { - const atom = group.atoms[0]; - const atomIndex = structure.atoms.findIndex(a => a.label === atom.label); + const isolatedHydrogenAtoms = []; + structure.atoms.forEach((atom, atomIndex) => { + if (!atomsInBonds.has(atom.label) && atom.atomType === 'H') { isolatedHydrogenAtoms.push({ atom, atomIndex }); } }); @@ -550,9 +553,7 @@ export class IsolatedHydrogenFixer extends BaseFilter { } // Check if there are isolated hydrogen atoms - const hasIsolatedHydrogens = structure.connectedGroups.some(group => - group.atoms.length === 1 && group.atoms[0].atomType === 'H', - ); + const hasIsolatedHydrogens = this.findIsolatedHydrogenAtoms(structure).length > 0; if (hasIsolatedHydrogens) { return [ diff --git a/src/lib/structure/structure-modifiers/fixers.test.js b/src/lib/structure/structure-modifiers/fixers.test.js index 26b12fb..50fd27f 100644 --- a/src/lib/structure/structure-modifiers/fixers.test.js +++ b/src/lib/structure/structure-modifiers/fixers.test.js @@ -461,38 +461,8 @@ describe('IsolatedHydrogenFixer', () => { new Bond('C1', 'H1', 1.0, null, '.'), ]; - // Create connected groups - const connectedGroups = [ - { - atoms: [atoms[0], atoms[2]], // C1 and H1 - bonds: [bonds[0]], - hBonds: [], - }, - { - atoms: [atoms[1]], // O1 - bonds: [], - hBonds: [], - }, - { - atoms: [atoms[3]], // H2 (isolated) - bonds: [], - hBonds: [], - }, - { - atoms: [atoms[4]], // H3 (isolated) - bonds: [], - hBonds: [], - }, - { - atoms: [atoms[5]], // H4 (isolated) - bonds: [], - hBonds: [], - }, - ]; - // Mock structure with connected groups mockStructure = new CrystalStructure(unitCell, atoms, bonds); - mockStructure.connectedGroups = connectedGroups; // Create fixer with default settings hydrogenFixer = new IsolatedHydrogenFixer(); @@ -519,22 +489,17 @@ describe('IsolatedHydrogenFixer', () => { [], [], ); - noBondsStructure.connectedGroups = mockStructure.connectedGroups; - + + const noBondModes = hydrogenFixer.getApplicableModes(noBondsStructure); + expect(noBondModes).toEqual(['off']); + + // Structure without isolated hydrogen atoms has only OFF const noIsolatedHStructure = new CrystalStructure( mockStructure.cell, - mockStructure.atoms, + mockStructure.atoms.slice(0, 3), mockStructure.bonds, [], ); - // Mock no isolated H atoms - noIsolatedHStructure.connectedGroups = [ - { - atoms: mockStructure.atoms, - bonds: mockStructure.bonds, - hBonds: [], - }, - ]; const noIsolatedModes = hydrogenFixer.getApplicableModes(noIsolatedHStructure); expect(noIsolatedModes).toEqual(['off']); diff --git a/src/lib/structure/structure-modifiers/growing/grow-cell.js b/src/lib/structure/structure-modifiers/growing/grow-cell.js new file mode 100644 index 0000000..d6fee0f --- /dev/null +++ b/src/lib/structure/structure-modifiers/growing/grow-cell.js @@ -0,0 +1,850 @@ +import { CellSymmetry, SymmetryOperation } from '../../cell-symmetry.js'; +import { Atom, CrystalStructure } from '../../crystal.js'; +import { Bond, HBond } from '../../bonds.js'; +import { create, all } from 'mathjs'; +import { createBondIdentifier, createHBondIdentifier } from './grow-fragment.js'; +import { combineSymAtomLabel } from './util.js'; + +const math = create(all, {}); + +/** + * @typedef {object} FractionalLimits + * @property {number} minX - Minimum fractional X coordinate. + * @property {number} maxX - Maximum fractional X coordinate. + * @property {number} minY - Minimum fractional Y coordinate. + * @property {number} maxY - Maximum fractional Y coordinate. + * @property {number} minZ - Minimum fractional Z coordinate. + * @property {number} maxZ - Maximum fractional Z coordinate. + */ + +/** + * Creates a set of symmetry elements that will fill the complete cell when executed subsequently + * on the fragment grown from previously grown sets. + * @param {CellSymmetry} symmetry - Cell symmetry for which the minimal set should be determined + * @param {string[]} preexistingSymOps - Symmetry elements already generated by growing of fragment + * @returns {Set} - Set containing the ids of the unique set + */ +export function minimalGrowthSet(symmetry, preexistingSymOps) { + const keepSet = new Set([symmetry.identitySymOpId]); + + const preexistingSet = new Set(); + + // Add all symmetry operations that can be generated from preexisting ones + for (const existId of preexistingSymOps) { + const combinedId = symmetry.combineSymmetryCodes(existId + '_555', symmetry.identitySymOpId + '_555'); + preexistingSet.add(combinedId.split('_')[0]); + } + + // Find minimal set needed to generate all symmetry operations + for (const [id] of symmetry.operationIds) { + if (keepSet.has(id) || preexistingSet.has(id)) { + continue; + } + + keepSet.add(id); + } + return keepSet; +} + +/** + * Calculates the minimum and maximum fractional coordinates for all atoms. + * @param {Atom[]} atoms - The atoms to analyze + * @returns {FractionalLimits} An object containing the min/max fractional coordinates + */ +export function getFragmentLimits(atoms) { + if (atoms.length === 0) { + return { minX: 0, maxX: 1, minY: 0, maxY: 1, minZ: 0, maxZ: 1 }; + } + + let minX = Infinity, maxX = -Infinity; + let minY = Infinity, maxY = -Infinity; + let minZ = Infinity, maxZ = -Infinity; + + for (const atom of atoms) { + const { x, y, z } = atom.position; + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + minZ = Math.min(minZ, z); + maxZ = Math.max(maxZ, z); + } + + return { minX, maxX, minY, maxY, minZ, maxZ }; +} + +/** + * Calculates the center of a fragment defined by its atoms. + * @param {Atom[]} atoms - The atoms defining the fragment + * @returns {math.Matrix} The center of the fragment as a 3D vector + */ +export function getFragmentCentre(atoms) { + const limits = getFragmentLimits(atoms); + return math.matrix([ + (limits.minX + limits.maxX) / 2, + (limits.minY + limits.maxY) / 2, + (limits.minZ + limits.maxZ) / 2, + ]); +} + +/** + * Calculates the symmetry-transformed centre. + * @param {math.Matrix} centre - Original centre + * @param {SymmetryOperation} symOp - Symmetry operation + * @returns {math.Matrix} The symmetry converted center of the fragment as a 3D vector + */ +export function getSymmetryCentre(centre, symOp) { + + const symmCentre = math.add( + math.multiply(symOp.rotMatrix, centre), + symOp.transVector, + ); + + return symmCentre; +} + +/** + * Extracts already applied symmetries from atom labels in a group. + * @param {object} group - Group of atoms + * @param {CrystalStructure} structure - Crystal structure containing symmetry information + * @param {Map} specialPositionMap - Map of special positions (duplicates + * @returns {string[]} Array of unique symmetry operation IDs for the group + */ +export function getGrownSymmetriesofGroup(group, structure, specialPositionMap) { + const identitySymOp = structure.symmetry.identitySymOpId; + const groupSymmetries = new Set(); + + for (const atom of group.atoms) { + const labelParts = atom.label.split('@'); + if (labelParts.length === 2) { + groupSymmetries.add(labelParts[1].split('_')[0]); + } else { + groupSymmetries.add(identitySymOp); + } + } + + const atomLabelsInGroup = new Set(group.atoms.map(atom => atom.label)); + + for (const atomLabel of specialPositionMap.keys()) { + const labelParts = atomLabel.split('@'); + let symOpId = identitySymOp; + if (labelParts.length === 2) { + symOpId = labelParts[1].split('_')[0]; + } + if (atomLabelsInGroup.has(specialPositionMap.get(atomLabel))) { + groupSymmetries.add(symOpId); + } + } + + return Array.from(groupSymmetries); +} + +/** + * Checks if an atom position is within the unit cell (0 ≤ x,y,z < 1). + * @param {Atom} atom - Atom to check + * @param {number} [tolerance] - Tolerance for boundary comparisons + * @returns {boolean} True if atom is within unit cell + */ +function isWithinUnitCell(atom, tolerance = 1e-6) { + const { x, y, z } = atom.position; + return x >= -tolerance && x < 1 + tolerance && + y >= -tolerance && y < 1 + tolerance && + z >= -tolerance && z < 1 + tolerance; +} + +/** + * Creates a unique key for atom position to detect duplicates/special positions. + * @param {Atom} atom - Atom to create key for + * @param {number} [precision] - Decimal places for rounding + * @returns {string} Position key + */ +function getAtomPositionKey(atom, precision = 4) { + const factor = Math.pow(10, precision); + const x = Math.round(atom.position.x * factor) / factor; + const y = Math.round(atom.position.y * factor) / factor; + const z = Math.round(atom.position.z * factor) / factor; + const labelParts = atom.label.split('@'); + return `${labelParts[0]}_x${x}_y${y}_z${z}`; +} + +/** + * Transforms a symmetry string to center it around a given symmetry centre. + * @param {CellSymmetry} symmetry - Cell symmetry object + * @param {string} symmString - Symmetry string to transform + * @param {object} symmCentre - Centre coordinates to center the symmetry string around + * @returns {string} Transformed symmetry string + */ +export function centreSymmetryString(symmetry, symmString, symmCentre) { + const { symOp, transVector } = symmetry.parsePositionCode(symmString); + + const symOpResult = math.add( + math.multiply(symOp.rotMatrix, math.matrix(symmCentre)), + symOp.transVector, + ); + + const transformedCentre = math.add(symOpResult, transVector); + + const offsetX = Math.floor(transformedCentre.get([0])); + const offsetY = Math.floor(transformedCentre.get([1])); + const offsetZ = Math.floor(transformedCentre.get([2])); + + const originalSymmetry = symmString.split('_')[0]; + + const originalTranslationString = symmString.split('_')[1] || '555'; + const translationParts = originalTranslationString.split('').map(part => { + return parseInt(part, 10); // Default to 5 if part is not a number + }); + + const newTranslationString = ( + `${translationParts[0] - offsetX}` + + `${translationParts[1] - offsetY}` + + `${translationParts[2] - offsetZ}` + ); + + const offsetCentre = math.subtract(transformedCentre, math.matrix([offsetX, offsetY, offsetZ])); + return { newCentre: offsetCentre, newString: `${originalSymmetry}_${newTranslationString}` }; +} + +/** + * @typedef {object} CreatedObjectTracker + * @property {Map} atomMap - Maps atom position keys to atom labels + * @property {Set} createdBonds - Set of created bond identifiers + * @property {Set} createdHBonds - Set of created hydrogen bond identifiers + * @property {Map} specialPositionMap - Maps original atom labels to kept atom labels for duplicates + * @property {Map} atomTranslations - Maps original atom labels to [new label, + * translation string] + */ + +/** + * @typedef {object} GrownGroup + * @property {Atom[]} atoms - Atoms in the group + * @property {Bond[]} internalBonds - Bonds in the group + * @property {HBond[]} internalHBonds - Hydrogen bonds in the group + * @property {Bond[]} externalBonds - External bonds to atoms outside the group + * @property {HBond[]} externalHBonds - External hydrogen bonds to atoms outside the group + * @property {string} symmString - Symmetry string for the group + * @property {string} groupCentre - Original center of the group (includes ommitted special position atoms) + */ + +/** + * Grows atoms in a group with the given symmetry and symmetry string. + * This function applies the symmetry operations to the atoms in the group, + * updates their labels, and checks for duplicates/special positions. It also + * updates the object tracker to keep track of created atoms and translations. + * @param {GrownGroup} grownGroup - The group of atoms to grow + * @param {CellSymmetry} symmetry - The symmetry object containing symmetry operations + * @param {string} symmString - The symmetry string to grow the atoms with + * @param {CreatedObjectTracker} objectTracker - Object tracker to keep track of created atoms. Used + * to avoid duplicates and track translations, as well as special positions. + * @param {boolean} moveAtomsInsideCell - Whether to move atoms inside the unit cell + * @returns {GrownGroup} New group with grown atoms and updated symmetry string + */ +export function growAtomsinGroup(grownGroup, symmetry, symmString, objectTracker, moveAtomsInsideCell) { + const newAtoms = []; + const transformedAtoms = symmetry.applySymmetry(symmString, grownGroup.atoms); + + // Process transformed atoms + for (const atom of transformedAtoms) { + // Update atom label with symmetry information + atom.label = combineSymAtomLabel(atom.label, symmString, symmetry); + + // Check if atom is within unit cell + if (moveAtomsInsideCell && !isWithinUnitCell(atom)) { + const atomOffsetX = Math.floor(atom.position.x); + const atomOffsetY = Math.floor(atom.position.y); + const atomOffsetZ = Math.floor(atom.position.z); + atom.position.x -= atomOffsetX; + atom.position.y -= atomOffsetY; + atom.position.z -= atomOffsetZ; + // Update label to reflect new position + const translationString = `${symmetry.identitySymOpId}_` + + `${5 + atomOffsetX}${5 + atomOffsetY}${5 + atomOffsetZ}`; + const newLabel = combineSymAtomLabel(atom.label, translationString, symmetry); + objectTracker.atomTranslations.set(atom.label, [newLabel, translationString]); + atom.label = newLabel; + } + + // Check for special positions (duplicates) + const posKey = getAtomPositionKey(atom); + const existingAtom = objectTracker.atomMap.get(posKey); + + if (existingAtom) { + // This is a special position - map to existing atom + objectTracker.specialPositionMap.set(atom.label, existingAtom); + } else { + // New unique position + objectTracker.atomMap.set(posKey, atom.label); + newAtoms.push(atom); + } + } + return newAtoms; +} + +/** + * Grows internal bonds in a group of atoms, with the symmetry string provided. + * Will check for already created bonds and avoid duplicates. Will also update + * the object tracker to keep track of created bonds. + * @param {GrownGroup} grownGroup - The group of atoms with internal bonds to grow + * @param {CellSymmetry} symmetry - The symmetry object used to combine symmetry codes + * @param {string} symmString - The symmetry string to use for growing the bonds + * @param {CreatedObjectTracker} objectTracker - Object tracker to keep track of created bonds and atom translations. + * The function will update the object tracker with new bond IDs. + * @returns {Bond[]} Grown bonds from the group + */ +export function growInternalBondsInGroup(grownGroup, symmetry, symmString, objectTracker) { + const grownBonds = []; + for (const bond of grownGroup.internalBonds) { + const potentialAtom1Label = combineSymAtomLabel(bond.atom1Label, symmString, symmetry); + const atom1Label = objectTracker.specialPositionMap.get(potentialAtom1Label) || potentialAtom1Label; + const potentialAtom2Label = combineSymAtomLabel(bond.atom2Label, symmString, symmetry); + const atom2Label = objectTracker.specialPositionMap.get(potentialAtom2Label) || potentialAtom2Label; + + // Skip self-bonds + if (atom1Label === atom2Label) { + continue; + } + + if (!objectTracker.atomTranslations.has(atom1Label) && !objectTracker.atomTranslations.has(atom2Label)) { + // If both atoms are not translated, use their original labels + + const bondId = createBondIdentifier(atom1Label, atom2Label); + if (!objectTracker.createdBonds.has(bondId)) { + const newBond = new Bond( + atom1Label, + atom2Label, + bond.bondLength, + bond.bondLengthSU, + '.', + ); + grownBonds.push(newBond); + objectTracker.createdBonds.add(bondId); + //grownBonds.push(newBond); + } + } else if (objectTracker.atomTranslations.has(atom1Label) && objectTracker.atomTranslations.has(atom2Label)) { + // If both atoms are translated, use their new labels + const [newAtom1Label, symm1] = objectTracker.atomTranslations.get(atom1Label); + const [newAtom2Label, symm2] = objectTracker.atomTranslations.get(atom2Label); + + if (symm1 === symm2) { + // Only create bond if atoms have been translated with the same symmetry + const bondId = createBondIdentifier(newAtom1Label, newAtom2Label); + if (!objectTracker.createdBonds.has(bondId)) { + const newBond = new Bond( + newAtom1Label, + newAtom2Label, + bond.bondLength, + bond.bondLengthSU, + '.', + ); + grownBonds.push(newBond); + objectTracker.createdBonds.add(bondId); + } + } + } + // if only one atom is translated, we skip the bond + } + return grownBonds; +} + +/** + * Grows hydrogen bonds in a group of atoms, with the symmetry string provided. + * Will check for already created hydrogen bonds and avoid duplicates. Will also update + * the object tracker to keep track of created hydrogen bonds. + * @param {GrownGroup} grownGroup - The group of atoms with hydrogen bonds to grow + * @param {CellSymmetry} symmetry - The symmetry object used to combine symmetry codes + * @param {string} symmString - The symmetry string to use for growing the group + * @param {CreatedObjectTracker} objectTracker - Object tracker to keep track of created hydrogen bonds and atom + * translations. The function will update the object tracker with new hydrogen bond IDs. + * @returns {HBond[]} Grown hydrogen bonds from the group + */ +export function growInternalHBondsInGroup(grownGroup, symmetry, symmString, objectTracker) { + const grownHBonds = []; + + for (const hbond of grownGroup.internalHBonds) { + const donorLabel = objectTracker.specialPositionMap.get( + combineSymAtomLabel(hbond.donorAtomLabel, symmString, symmetry), + ) || combineSymAtomLabel(hbond.donorAtomLabel, symmString, symmetry); + + const hydrogenLabel = objectTracker.specialPositionMap.get( + combineSymAtomLabel(hbond.hydrogenAtomLabel, symmString, symmetry), + ) || combineSymAtomLabel(hbond.hydrogenAtomLabel, symmString, symmetry); + + const acceptorLabel = objectTracker.specialPositionMap.get( + combineSymAtomLabel(hbond.acceptorAtomLabel, symmString, symmetry), + ) || combineSymAtomLabel(hbond.acceptorAtomLabel, symmString, symmetry); + + if ( + !objectTracker.atomTranslations.has(donorLabel) + && !objectTracker.atomTranslations.has(hydrogenLabel) + && !objectTracker.atomTranslations.has(acceptorLabel) + ) { + const hbondId = createHBondIdentifier(donorLabel, hydrogenLabel, acceptorLabel); + if (!objectTracker.createdHBonds.has(hbondId)) { + const newHBond = new HBond( + donorLabel, + hydrogenLabel, + acceptorLabel, + hbond.donorHydrogenDistance, + hbond.donorHydrogenDistanceSU, + hbond.acceptorHydrogenDistance, + hbond.acceptorHydrogenDistanceSU, + hbond.donorAcceptorDistance, + hbond.donorAcceptorDistanceSU, + hbond.hBondAngle, + hbond.hBondAngleSU, + '.', + ); + objectTracker.createdHBonds.add(hbondId); + grownHBonds.push(newHBond); + } + } else if ( + objectTracker.atomTranslations.has(donorLabel) && + objectTracker.atomTranslations.has(hydrogenLabel) && + objectTracker.atomTranslations.has(acceptorLabel) + ) { + // If all atoms are translated, use their new labels + const [newDonorLabel, symmD] = objectTracker.atomTranslations.get(donorLabel); + const [newHydrogenLabel, symmH] = objectTracker.atomTranslations.get(hydrogenLabel); + const [newAcceptorLabel, symmA] = objectTracker.atomTranslations.get(acceptorLabel); + + if (symmD === symmH && symmH === symmA) { + + const hbondId = createHBondIdentifier(newDonorLabel, newHydrogenLabel, newAcceptorLabel); + if (!objectTracker.createdHBonds.has(hbondId)) { + const newHBond = new HBond( + newDonorLabel, + newHydrogenLabel, + newAcceptorLabel, + hbond.donorHydrogenDistance, + hbond.donorHydrogenDistanceSU, + hbond.acceptorHydrogenDistance, + hbond.acceptorHydrogenDistanceSU, + hbond.donorAcceptorDistance, + hbond.donorAcceptorDistanceSU, + hbond.hBondAngle, + hbond.hBondAngleSU, + '.', + ); + objectTracker.createdHBonds.add(hbondId); + grownHBonds.push(newHBond); + } + } + } + // if only part of the atoms are translated, we skip the H-bond + } + return grownHBonds; +} + +/** + * Grows external bonds in a group of atoms, with the symmetry string provided. + * Will check for already created bonds and avoid duplicates. Will also update + * the object tracker to keep track of created bonds. + * @param {GrownGroup} grownGroup - The group of atoms with external bonds to + * grow + * @param {CellSymmetry} symmetry - The symmetry object used to combine symmetry codes + * @param {string} symmString - The symmetry string to use for growing the group/bonds + * @param {CreatedObjectTracker} objectTracker - Object tracker to keep track of created bonds. + * The function will update the object tracker with new bond IDs. + * @returns {Bond[]} Grown external bonds from the group + */ +export function growExternalBondsInGroup(grownGroup, symmetry, symmString, objectTracker) { + const grownExternalBonds = []; + for (const bond of grownGroup.externalBonds) { + let atom1Label = objectTracker.specialPositionMap.get( + combineSymAtomLabel(bond.atom1Label, symmString, symmetry), + ) || combineSymAtomLabel(bond.atom1Label, symmString, symmetry); + + let atom2Symm = symmetry.combineSymmetryCodes( + symmString, + bond.atom2SiteSymmetry, + ); + + let atom2Label = combineSymAtomLabel(bond.atom2Label, atom2Symm, symmetry); + atom2Label = objectTracker.specialPositionMap.get(atom2Label) || atom2Label; + + if (objectTracker.atomTranslations.has(atom1Label)) { + let atom1Symm; + [atom1Label, atom1Symm] = objectTracker.atomTranslations.get(atom1Label); + atom2Symm = symmetry.combineSymmetryCodes( + atom1Symm, + atom2Symm, + ); + } + + const bondId = createBondIdentifier(atom1Label, atom2Label); + if (!objectTracker.createdBonds.has(bondId)) { + + const newBond = new Bond( + atom1Label, + bond.atom2Label, + bond.bondLength, + bond.bondLengthSU, + atom2Symm, + ); + + grownExternalBonds.push(newBond); + objectTracker.createdBonds.add(bondId); + } + } + return grownExternalBonds; +} + +/** + * Grows external hydrogen bonds in a group of atoms, with the symmetry string provided. + * Will check for already created hydrogen bonds and avoid duplicates. Will also update + * the object tracker to keep track of created hydrogen bonds. + * @param {GrownGroup} grownGroup - The group of atoms with hydrogen bonds to grow + * @param {CellSymmetry} symmetry - The symmetry object used to combine symmetry codes + * @param {string} symmString - The symmetry string to use for growing the group + * @param {CreatedObjectTracker} objectTracker - Object tracker to keep track of created hydrogen bonds and atom + * translations. The function will update the object tracker with new hydrogen bond IDs. + * @returns {HBond[]} Grown external hydrogen bonds from the group + */ +export function growExternalHBondsInGroup(grownGroup, symmetry, symmString, objectTracker) { + const grownExternalHBonds = []; + for (const hBond of grownGroup.externalHBonds) { + let donorLabel = objectTracker.specialPositionMap.get( + combineSymAtomLabel(hBond.donorAtomLabel, symmString, symmetry), + ) || combineSymAtomLabel(hBond.donorAtomLabel, symmString, symmetry); + + let hydrogenLabel = objectTracker.specialPositionMap.get( + combineSymAtomLabel(hBond.hydrogenAtomLabel, symmString, symmetry), + ) || combineSymAtomLabel(hBond.hydrogenAtomLabel, symmString, symmetry); + + let acceptorSymm = symmetry.combineSymmetryCodes( + symmString, + hBond.acceptorAtomSymmetry, + ); + + const acceptorLabel = combineSymAtomLabel(hBond.acceptorAtomLabel, acceptorSymm, symmetry); + + if ( + objectTracker.atomTranslations.has(donorLabel) + && objectTracker.atomTranslations.has(hydrogenLabel) + ) { + let donorTransSymm; + [donorLabel, donorTransSymm] = objectTracker.atomTranslations.get(donorLabel); + acceptorSymm = symmetry.combineSymmetryCodes( + donorTransSymm, + acceptorSymm, + ); + const [hydrogenLabelr, hydrogenSymm] = objectTracker.atomTranslations.get(hydrogenLabel); + hydrogenLabel = hydrogenLabelr; + acceptorSymm = symmetry.combineSymmetryCodes( + hydrogenSymm, + acceptorSymm, + ); + } else if ( + objectTracker.atomTranslations.has(donorLabel) + || objectTracker.atomTranslations.has(hydrogenLabel) + ) { + // If only one atom is translated, we skip the hydrogen bond + continue; + } + + const hbondId = createHBondIdentifier(donorLabel, hydrogenLabel, acceptorLabel); + if (!objectTracker.createdHBonds.has(hbondId)) { + const newHBond = new HBond( + donorLabel, + hydrogenLabel, + hBond.acceptorAtomLabel, + hBond.donorHydrogenDistance, + hBond.donorHydrogenDistanceSU, + hBond.acceptorHydrogenDistance, + hBond.acceptorHydrogenDistanceSU, + hBond.donorAcceptorDistance, + hBond.donorAcceptorDistanceSU, + hBond.hBondAngle, + hBond.hBondAngleSU, + acceptorSymm, + ); + + grownExternalHBonds.push(newHBond); + objectTracker.createdHBonds.add(hbondId); + } + } + return grownExternalHBonds; +} + +/** + * Grows a group of atoms with the given symmetry and symmetry string. + * This function applies the symmetry operations to the atoms in the group, + * grows internal and external bonds, and returns a new group with the grown atoms and bonds. + * @param {GrownGroup} grownGroup - The group of atoms to grow + * @param {CellSymmetry} symmetry - The symmetry object containing symmetry operations + * @param {string} symmString - The symmetry string to use for the group + * @param {CreatedObjectTracker} objectTracker - Object tracker to keep track of created atoms and bonds + * @param {boolean} moveAtomsInsideCell - Whether to move atoms inside the unit cell + * @returns {GrownGroup} New group with grown atoms, internal and external bonds, and symmetry string + */ +export function growGroup(grownGroup, symmetry, symmString, objectTracker, moveAtomsInsideCell) { + const combinedSymmString = symmetry.combineSymmetryCodes( + symmString, + grownGroup.symmString, + ); + + const { newCentre: transformedCentre, newString: centredSymmString } = centreSymmetryString( + symmetry, + combinedSymmString, + grownGroup.groupCentre, + ); + + const grownAtoms = growAtomsinGroup( + grownGroup, + symmetry, + centredSymmString, + objectTracker, + moveAtomsInsideCell, + ); + + const grownInternalBonds = growInternalBondsInGroup( + grownGroup, + symmetry, + centredSymmString, + objectTracker, + ); + + const grownInternalHBonds = growInternalHBondsInGroup( + grownGroup, + symmetry, + centredSymmString, + objectTracker, + ); + + const grownExternalBonds = growExternalBondsInGroup( + grownGroup, + symmetry, + centredSymmString, + objectTracker, + ); + + const grownExternalHBonds = growExternalHBondsInGroup( + grownGroup, + symmetry, + centredSymmString, + objectTracker, + ); + + return { + atoms: grownAtoms, + internalBonds: grownInternalBonds, + internalHBonds: grownInternalHBonds, + externalBonds: grownExternalBonds, + externalHBonds: grownExternalHBonds, + symmString: centredSymmString, + groupCentre: transformedCentre, + }; + +} + +/** + * Grows a crystal structure to fill a complete unit cell. + * @param {CrystalStructure} structure - Input crystal structure + * @param {boolean} [moveAtomsInsideCell] - Whether to exclude atoms outside unit cell + * @param {Map} [startingSpecialPositions] - Optional special positions map to start with. + * @returns {CrystalStructure} New structure filling the unit cell + */ +export function growCell(structure, moveAtomsInsideCell = true, startingSpecialPositions = null) { + let specialPositionMap; + if (startingSpecialPositions !== null) { + // If a starting special position is provided, initialize the map with it + specialPositionMap = startingSpecialPositions; + } else { + specialPositionMap = new Map(); // duplicate label -> kept label + } + + // Early return for empty structures + if (structure.atoms.length === 0) { + return new CrystalStructure( + structure.cell, + [], + [], + [], + structure.symmetry, + ); + } + + // Analyze connected groups and their symmetry operations + const atomGroups = structure.calculateConnectedGroups(); + + // Determine minimal set of symmetry operations needed for each group + const growSymIdsPerGroup = atomGroups.map(group => { + const alreadyGrownSymmetries = getGrownSymmetriesofGroup( + group, + structure, + specialPositionMap, + ); + return Array.from(minimalGrowthSet(structure.symmetry, alreadyGrownSymmetries)); + }); + + const groupsExternalBonds = atomGroups.map(group => { + return structure.bonds.filter(bond => + (bond.atom2SiteSymmetry && bond.atom2SiteSymmetry !== '.') && + group.atoms.some(atom => atom.label === bond.atom1Label), + ); + }); + + const groupsExternalHBonds = atomGroups.map(group => { + return structure.hBonds.filter(hbond => + (hbond.acceptorAtomSymmetry && hbond.acceptorAtomSymmetry !== '.') && + group.atoms.some(atom => atom.label === hbond.donorAtomLabel), + ); + }); + + // Prepare collections for the grown structure + const objectTracker = { + atomMap: new Map(), // atomMap + createdBonds: new Set(), // createdBonds + createdHBonds: new Set(), // createdHBonds + specialPositionMap, // specialPositionMap + atomTranslations: new Map(), // atomTranslations + }; + + const grownAtomsGroups = new Array(); + + // Process each atom group with its required symmetry operations + for (let groupIdx = 0; groupIdx < atomGroups.length; groupIdx++) { + const group = atomGroups[groupIdx]; + const symOpsToApply = growSymIdsPerGroup[groupIdx]; + const originalCentre = getFragmentCentre(structure.atoms); + + const symId = symOpsToApply[0]; + + const baseGrownGroup = { + atoms: group.atoms, + internalBonds: group.bonds, + internalHBonds: group.hBonds, + symmString: `${symId}_555`, // Use the first symmetry operation with a placeholder + groupCentre: originalCentre, + externalBonds: groupsExternalBonds[groupIdx], + externalHBonds: groupsExternalHBonds[groupIdx], + }; + + for (const symId of symOpsToApply) { + + const nonTransSymmString = `${symId}_555`; + // Grow the group with the new symmetry operation + const newGrownGroup = growGroup( + baseGrownGroup, + structure.symmetry, + nonTransSymmString, + objectTracker, + moveAtomsInsideCell, + ); + grownAtomsGroups.push(newGrownGroup); + } + } + const finalAtoms = grownAtomsGroups.flatMap(group => group.atoms); + const finalBonds = grownAtomsGroups.flatMap(group => group.internalBonds); + const finalHBonds = grownAtomsGroups.flatMap(group => group.internalHBonds); + const finalAtomLabels = new Set(finalAtoms.map(atom => atom.label)); + + grownAtomsGroups.forEach(group => { + // Add external bonds and H-bonds to the potential maps + group.externalBonds.forEach(bond => { + const combinedAtom2Label = combineSymAtomLabel(bond.atom2Label, bond.atom2SiteSymmetry, structure.symmetry); + const specialPositionAtom2 = objectTracker.specialPositionMap.get(combinedAtom2Label) || combinedAtom2Label; + //const bondId = createBondIdentifier(bond.atom1Label, specialPositionAtom2); + + if (finalAtomLabels.has(bond.atom1Label) && finalAtomLabels.has(combinedAtom2Label)) { + // Both atoms exist in the grown structure -> create non-symm Bond + + const newBond = new Bond( + bond.atom1Label, + specialPositionAtom2, + bond.bondLength, + bond.bondLengthSU, + '.', + ); + finalBonds.push(newBond); + } else if (finalAtomLabels.has(bond.atom1Label)) { + // Only atom1 exists in the grown structure -> create symm Bond + const newBond = new Bond( + bond.atom1Label, + bond.atom2Label, + bond.bondLength, + bond.bondLengthSU, + bond.atom2SiteSymmetry, + ); + finalBonds.push(newBond); + } + + }); + + group.externalHBonds.forEach(hbond => { + let donorLookup = objectTracker.specialPositionMap.get(hbond.donorAtomLabel) || hbond.donorAtomLabel; + if (!finalAtomLabels.has(donorLookup)) { + donorLookup = objectTracker.specialPositionMap.get(donorLookup) || donorLookup; + donorLookup = objectTracker.atomTranslations.get(donorLookup) || donorLookup; + } + let hydrogenLookup = objectTracker.specialPositionMap.get( + hbond.hydrogenAtomLabel, + ) || hbond.hydrogenAtomLabel; + if (!finalAtomLabels.has(hydrogenLookup)) { + hydrogenLookup = objectTracker.specialPositionMap.get(hydrogenLookup) || hydrogenLookup; + hydrogenLookup = objectTracker.atomTranslations.get(hydrogenLookup) || hydrogenLookup; + } + let acceptorLookup = combineSymAtomLabel( + hbond.acceptorAtomLabel, + hbond.acceptorAtomSymmetry, + structure.symmetry, + ); + + if (!finalAtomLabels.has(acceptorLookup)) { + acceptorLookup = objectTracker.specialPositionMap.get(acceptorLookup) || acceptorLookup; + acceptorLookup = objectTracker.atomTranslations.get(acceptorLookup) || acceptorLookup; + } + + if ( + finalAtomLabels.has(donorLookup) && + finalAtomLabels.has(hydrogenLookup) && + finalAtomLabels.has(acceptorLookup) + ) { + // All atoms exist in the grown structure -> create non-symm HBond + const newHBond = new HBond( + donorLookup, + hydrogenLookup, + acceptorLookup, + hbond.donorHydrogenDistance, + hbond.donorHydrogenDistanceSU, + hbond.acceptorHydrogenDistance, + hbond.acceptorHydrogenDistanceSU, + hbond.donorAcceptorDistance, + hbond.donorAcceptorDistanceSU, + hbond.hBondAngle, + hbond.hBondAngleSU, + '.', + ); + finalHBonds.push(newHBond); + } else if ( + finalAtomLabels.has(donorLookup) && + finalAtomLabels.has(hydrogenLookup) + ) { + // Only donor and hydrogen exist in the grown structure -> create symm HBond + const newHBond = new HBond( + donorLookup, + hydrogenLookup, + hbond.acceptorAtomLabel, + hbond.donorHydrogenDistance, + hbond.donorHydrogenDistanceSU, + hbond.acceptorHydrogenDistance, + hbond.acceptorHydrogenDistanceSU, + hbond.donorAcceptorDistance, + hbond.donorAcceptorDistanceSU, + hbond.hBondAngle, + hbond.hBondAngleSU, + hbond.acceptorAtomSymmetry, + ); + finalHBonds.push(newHBond); + } + }); + }); + + return new CrystalStructure( + structure.cell, + finalAtoms, + finalBonds, + finalHBonds, + structure.symmetry, + ); +} diff --git a/src/lib/structure/structure-modifiers/growing/grow-cell.test.js b/src/lib/structure/structure-modifiers/growing/grow-cell.test.js new file mode 100644 index 0000000..b5a85c1 --- /dev/null +++ b/src/lib/structure/structure-modifiers/growing/grow-cell.test.js @@ -0,0 +1,1376 @@ +import { beforeEach, describe, test, expect } from 'vitest'; +import { MockStructure } from '../base.test.js'; +import { + minimalGrowthSet, + getFragmentLimits, + getSymmetryCentre, + getGrownSymmetriesofGroup, + centreSymmetryString, + growCell, + growAtomsinGroup, + growInternalBondsInGroup, + growInternalHBondsInGroup, + growExternalBondsInGroup, + growExternalHBondsInGroup, +} from './grow-cell.js'; + +import { createBondIdentifier, createHBondIdentifier } from './grow-fragment.js'; +import { CellSymmetry, SymmetryOperation } from '../../cell-symmetry.js'; +import { UnitCell, CrystalStructure, Atom } from '../../crystal.js'; +import { FractPosition } from '../../position.js'; +import { Bond, HBond } from '../../bonds.js'; +import { create, all } from 'mathjs'; +const math = create(all, {}); + +describe('growCell basic functions', () => { + let symmetry; + + beforeEach(() => { + // space group Cc (9) + const symmetryOps = [ + new SymmetryOperation('x,y,z'), // 1_555 (identity) + new SymmetryOperation('x,-y,z+1/2'), // 2_555 + new SymmetryOperation('x+1/2,y+1/2,z'), // 3_555 + new SymmetryOperation('x+1/2,-y+1/2,z+1/2'), // 4_555 + ]; + const operationIds = new Map([ + ['1', 0], ['2', 1], ['3', 2], ['4', 3], + ]); + symmetry = new CellSymmetry('Test', 1, symmetryOps, operationIds); + }); + + describe('minimalGrowthSet', () => { + test('returns identity for empty preexisting ops', () => { + const result = minimalGrowthSet(symmetry, []); + expect(result).toEqual(new Set(['1', '2', '3', '4'])); + }); + + test('handles preexisting symmetry operations', () => { + const result = minimalGrowthSet(symmetry, ['1', '2']); + expect(result.has('1')).toBe(true); // Identity always included + // Should determine minimal set to generate all ops + expect(result.size).toBeGreaterThanOrEqual(1); + }); + }); + + describe('getFragmentLimits', () => { + test('handles empty atoms array', () => { + const limits = getFragmentLimits([]); + expect(limits).toEqual({ + minX: 0, maxX: 1, minY: 0, maxY: 1, minZ: 0, maxZ: 1, + }); + }); + + test('calculates limits correctly for atoms', () => { + const atoms = [ + new Atom('C1', 'C', new FractPosition(0.1, 0.2, 0.3)), + new Atom('C2', 'C', new FractPosition(0.8, 0.9, 0.1)), + new Atom('O1', 'O', new FractPosition(0.5, 0.1, 0.7)), + ]; + + const limits = getFragmentLimits(atoms); + expect(limits.minX).toBeCloseTo(0.1); + expect(limits.maxX).toBeCloseTo(0.8); + expect(limits.minY).toBeCloseTo(0.1); + expect(limits.maxY).toBeCloseTo(0.9); + expect(limits.minZ).toBeCloseTo(0.1); + expect(limits.maxZ).toBeCloseTo(0.7); + }); + }); + + describe('getSymmetryCentre', () => { + test('calculates center for identity operation', () => { + const startCentre = math.matrix([0.1, 0.2, 0.3]); + const identityOp = symmetry.symmetryOperations[0]; // x,y,z + + const centre = getSymmetryCentre(startCentre, identityOp); + const centreArray = centre.toArray(); + expect(centreArray.length).toBe(3); + expect(centreArray[0]).toBeCloseTo(0.1); + expect(centreArray[1]).toBeCloseTo(0.2); + expect(centreArray[2]).toBeCloseTo(0.3); + }); + + test('calculates center for c-glide plane', () => { + const startCentre = math.matrix([0.1, 0.2, 0.3]); + const glidePlaneOp = symmetry.symmetryOperations[1]; // x,-y,z+1/2 + + const centre = getSymmetryCentre(startCentre, glidePlaneOp); + // Should apply the transformation to the center point + const centreArray = centre.toArray(); + expect(centreArray.length).toBe(3); + expect(centreArray[0]).toBeCloseTo(0.1); // x + expect(centreArray[1]).toBeCloseTo(-0.2); // - + expect(centreArray[2]).toBeCloseTo(0.8); // z + 1/2 + }); + }); + + describe('getGrownSymmetriesofGroup', () => { + let symmetry; + let group; + let specialPositionMap; + + beforeEach(() => { + // Set up symmetry operations (space group Cc - monoclinic) + const symmetryOps = [ + new SymmetryOperation('x,y,z'), // 1_555 (identity) + new SymmetryOperation('x,-y,z+1/2'), // 2_555 + new SymmetryOperation('x+1/2,y+1/2,z'), // 3_555 + new SymmetryOperation('x+1/2,-y+1/2,z+1/2'), // 4_555 + ]; + const operationIds = new Map([ + ['1', 0], ['2', 1], ['3', 2], ['4', 3], + ]); + symmetry = new CellSymmetry('Cc', 9, symmetryOps, operationIds); + + // Create test atoms for the group + group = { + atoms: [ + new Atom('C1', 'C', new FractPosition(0.1, 0.1, 0.1)), + new Atom('O1', 'O', new FractPosition(0.2, 0.2, 0.2)), + ], + }; + + specialPositionMap = new Map(); + }); + + test('extracts identity symmetry from unlabeled atoms', () => { + // Group contains atoms without symmetry labels (original atoms) + const result = getGrownSymmetriesofGroup(group, { symmetry }, specialPositionMap); + + expect(result).toEqual(['1']); // Should contain only identity + expect(result).toHaveLength(1); + }); + + test('extracts symmetry operations from atom labels with @ syntax', () => { + // Atoms with symmetry labels in format: originalLabel@symOpId_translationCode + group.atoms = [ + new Atom('C1@2_555', 'C', new FractPosition(0.1, 0.1, 0.1)), + new Atom('O1@3_666', 'O', new FractPosition(0.2, 0.2, 0.2)), + new Atom('N1@4_555', 'N', new FractPosition(0.3, 0.3, 0.3)), + ]; + + const result = getGrownSymmetriesofGroup(group, { symmetry }, specialPositionMap); + + expect(result).toEqual(expect.arrayContaining(['2', '3', '4'])); + expect(result).toHaveLength(3); + }); + + test('handles mixed labeled and unlabeled atoms', () => { + // Mix of original atoms and symmetry-generated atoms + group.atoms = [ + new Atom('C1', 'C', new FractPosition(0.1, 0.1, 0.1)), // Original + new Atom('O1@2_555', 'O', new FractPosition(0.2, 0.2, 0.2)), // Symmetry-generated + new Atom('N1', 'N', new FractPosition(0.3, 0.3, 0.3)), // Original + ]; + + const result = getGrownSymmetriesofGroup(group, { symmetry }, specialPositionMap); + + expect(result).toEqual(expect.arrayContaining(['1', '2'])); + expect(result).toHaveLength(2); + }); + + test('extracts unique symmetry operations from duplicate labels', () => { + // Multiple atoms with same symmetry operation + group.atoms = [ + new Atom('C1@2_555', 'C', new FractPosition(0.1, 0.1, 0.1)), + new Atom('O1@2_666', 'O', new FractPosition(0.2, 0.2, 0.2)), // Same symOp, different translation + new Atom('N1@2_555', 'N', new FractPosition(0.3, 0.3, 0.3)), // Same symOp + translation + ]; + + const result = getGrownSymmetriesofGroup(group, { symmetry }, specialPositionMap); + + expect(result).toEqual(['2']); // Should contain only unique symmetry operation + expect(result).toHaveLength(1); + }); + + test('processes special position map entries', () => { + // Special position map contains atom labels that map to atoms in this group + specialPositionMap.set('C1@2_555', 'C1'); // C1@2_555 is a special position of C1 + specialPositionMap.set('O1@3_666', 'O1'); // O1@3_666 is a special position of O1 + specialPositionMap.set('N1@4_777', 'X1'); // N1@4_777 maps to X1 (not in this group) + + const result = getGrownSymmetriesofGroup(group, { symmetry }, specialPositionMap); + + // Should include symmetries from special positions that map to atoms in this group + // 1 from group atoms, 2,3 from special positions + expect(result).toEqual(expect.arrayContaining(['1', '2', '3'])); + expect(result).not.toContain('4'); // N1@4_777 maps to X1 which is not in this group + }); + + test('handles special position map with identity symmetry', () => { + // Special position entry without @ symbol (identity operation) + specialPositionMap.set('C1_copy', 'C1'); // Maps to atom in group + specialPositionMap.set('O1_copy', 'O1'); // Maps to atom in group + + const result = getGrownSymmetriesofGroup(group, { symmetry }, specialPositionMap); + + expect(result).toEqual(['1']); // Should only contain identity + expect(result).toHaveLength(1); + }); + + test('handles empty group', () => { + group.atoms = []; + + const result = getGrownSymmetriesofGroup(group, { symmetry }, specialPositionMap); + + expect(result).toEqual([]); // No atoms means no symmetries + expect(result).toHaveLength(0); + }); + + test('handles empty special position map', () => { + group.atoms = [ + new Atom('C1@2_555', 'C', new FractPosition(0.1, 0.1, 0.1)), + ]; + const emptySpecialPositionMap = new Map(); + + const result = getGrownSymmetriesofGroup(group, { symmetry }, emptySpecialPositionMap); + + expect(result).toEqual(['2']); + expect(result).toHaveLength(1); + }); + + test('combines symmetries from both atom labels and special position map', () => { + // Group has atoms with some symmetry operations + group.atoms = [ + new Atom('C1', 'C', new FractPosition(0.1, 0.1, 0.1)), // Identity + new Atom('O1', 'O', new FractPosition(0.8, 0.8, 0.8)), // Identity + new Atom('O1@2_555', 'O', new FractPosition(0.2, 0.2, 0.2)), // Symmetry 2 + ]; + + // Special position map adds more symmetries for atoms in this group + specialPositionMap.set('C1@3_555', 'C1'); // Adds symmetry 3 + specialPositionMap.set('O1@4_666', 'O1'); // Adds symmetry 4 + specialPositionMap.set('X1@2_555', 'Y1'); // Maps to atom not in group - should be ignored + + const result = getGrownSymmetriesofGroup(group, { symmetry }, specialPositionMap); + + expect(result).toEqual(expect.arrayContaining(['1', '2', '3', '4'])); + expect(result).toHaveLength(4); + }); + + test('maintains unique symmetries across all sources', () => { + // Overlapping symmetries from atom labels and special position map + group.atoms = [ + new Atom('C1@2_555', 'C', new FractPosition(0.1, 0.1, 0.1)), + new Atom('O1@3_666', 'O', new FractPosition(0.2, 0.2, 0.2)), + ]; + + // Special position map has overlapping symmetries + specialPositionMap.set('C1@2_777', 'C1'); // Same symmetry 2 as atom label + specialPositionMap.set('O1@3_555', 'O1@3_666'); // Same symmetry 3 as atom label + specialPositionMap.set('N1@4_555', 'C1@2_555'); // New symmetry 4 + + const result = getGrownSymmetriesofGroup(group, { symmetry }, specialPositionMap); + + expect(result).toEqual(expect.arrayContaining(['2', '3', '4'])); + expect(result).toHaveLength(3); // Should deduplicate + }); + }); + + describe('centreSymmetryString', () => { + let symmetry; + + beforeEach(() => { + // Set up symmetry operations (space group Cc - monoclinic) + const symmetryOps = [ + new SymmetryOperation('x,y,z'), // 1_555 (identity) + new SymmetryOperation('x,-y,z+1/2'), // 2_555 + new SymmetryOperation('x+1/2,y+1/2,z'), // 3_555 + new SymmetryOperation('x+1/2,-y+1/2,z+1/2'), // 4_555 + ]; + const operationIds = new Map([ + ['1', 0], ['2', 1], ['3', 2], ['4', 3], + ]); + symmetry = new CellSymmetry('Cc', 9, symmetryOps, operationIds); + }); + + test('transforms normal symmetry string with translation', () => { + // Test with symmetry operation 2 (x,-y,z+1/2) and translation 666 (1,1,1) + const symmString = '2_666'; + const symmCentre = [0.25, 0.75, 0.1]; // Centre that will cause unit cell translations + + const result = centreSymmetryString(symmetry, symmString, symmCentre); + + // Verify return object structure + expect(result).toHaveProperty('newCentre'); + expect(result).toHaveProperty('newString'); + + // Check that newCentre is a mathjs matrix with 3 elements + expect(result.newCentre.size()).toEqual([3]); + const centreArray = result.newCentre.toArray(); + expect(centreArray).toHaveLength(3); + + // Check transformation: x,-y,z+1/2 applied to [0.25, 0.75, 0.1] with translation [1,1,1] + // Expected: [0.25 + 1, -0.75 + 1, 0.1 + 0.5 + 1] = [1.25, 0.25, 1.6] + // Floor offsets: [1, 0, 1] + // Adjusted centre: [0.25, 0.25, 0.6] + expect(centreArray[0]).toBeCloseTo(0.25, 6); + expect(centreArray[1]).toBeCloseTo(0.25, 6); + expect(centreArray[2]).toBeCloseTo(0.6, 6); + + // Check adjusted symmetry string: original translation was 666 (1,1,1), offsets [1,0,1] + // New translation should be [6-1, 6-0, 6-1] = [5,6,5] = '565' + expect(result.newString).toBe('2_565'); + }); + + test('handles symmetry string with missing translation', () => { + // Test with symmetry operation without explicit translation (should default to 555) + const symmString = '3'; // No translation part + const symmCentre = [0.1, 0.2, 0.8]; // Centre that causes some translation + + const result = centreSymmetryString(symmetry, symmString, symmCentre); + + // Verify return object structure + expect(result).toHaveProperty('newCentre'); + expect(result).toHaveProperty('newString'); + + // Check that newCentre is properly formatted + expect(result.newCentre.size()).toEqual([3]); + const centreArray = result.newCentre.toArray(); + expect(centreArray).toHaveLength(3); + + // Check transformation: x+1/2,y+1/2,z applied to [0.1, 0.2, 0.8] with default translation [0,0,0] + // Expected: [0.1 + 0.5, 0.2 + 0.5, 0.8] = [0.6, 0.7, 0.8] + // Floor offsets: [0, 0, 0] + // Adjusted centre: [0.6, 0.7, 0.8] + expect(centreArray[0]).toBeCloseTo(0.6, 6); + expect(centreArray[1]).toBeCloseTo(0.7, 6); + expect(centreArray[2]).toBeCloseTo(0.8, 6); + + // Check symmetry string: default translation 555 (0,0,0), offsets [0,0,0] + // New translation should be [5-0, 5-0, 5-0] = [5,5,5] = '555' + expect(result.newString).toBe('3_555'); + }); + }); +}); + +describe('Individual growing functions', () => { + + describe('growAtomsinGroup', () => { + let symmetry; + let grownGroup; + let objectTracker; + + beforeEach(() => { + // Set up symmetry operations for testing (space group Cc - 9) + const symmetryOps = [ + new SymmetryOperation('x,y,z'), // 1_555 (identity) + new SymmetryOperation('x,-y,z+1/2'), // 2_555 + new SymmetryOperation('x+1/2,y+1/2,z'), // 3_555 + new SymmetryOperation('x+1/2,-y+1/2,z+1/2'), // 4_555 + ]; + const operationIds = new Map([ + ['1', 0], ['2', 1], ['3', 2], ['4', 3], + ]); + symmetry = new CellSymmetry('Test', 1, symmetryOps, operationIds); + + // Set up a basic grown group with some test atoms + grownGroup = { + atoms: [ + new Atom('C1', 'C', new FractPosition(0.1, 0.2, 0.3)), + new Atom('O1', 'O', new FractPosition(0.8, 0.9, 0.1)), + ], + symmString: '1_555', + groupCentre: math.matrix([0.45, 0.55, 0.2]), + }; + + // Set up object tracker + objectTracker = { + atomMap: new Map(), + createdBonds: new Set(), + createdHBonds: new Set(), + specialPositionMap: new Map(), + atomTranslations: new Map(), + }; + }); + + describe('basic functionality', () => { + test('applies identity transformation correctly', () => { + const result = growAtomsinGroup(grownGroup, symmetry, '1_555', objectTracker, false); + + expect(result).toHaveLength(2); + expect(result[0].label).toBe('C1'); + expect(result[1].label).toBe('O1'); + + // Positions should remain the same for identity operation + expect(result[0].position.x).toBeCloseTo(0.1); + expect(result[0].position.y).toBeCloseTo(0.2); + expect(result[0].position.z).toBeCloseTo(0.3); + }); + + test('applies non-identity transformation correctly', () => { + const result = growAtomsinGroup(grownGroup, symmetry, '2_555', objectTracker, false); + + expect(result).toHaveLength(2); + expect(result[0].label).toBe('C1@2_555'); + expect(result[1].label).toBe('O1@2_555'); + + // For transformation x,-y,z+1/2: (0.1,0.2,0.3) -> (0.1,-0.2,0.8) + expect(result[0].position.x).toBeCloseTo(0.1); + expect(result[0].position.y).toBeCloseTo(-0.2); + expect(result[0].position.z).toBeCloseTo(0.8); + }); + + test('updates object tracker with atom positions', () => { + const _result = growAtomsinGroup(grownGroup, symmetry, '1_555', objectTracker, false); + + // Check that atomMap is populated + expect(objectTracker.atomMap.size).toBe(2); + + // Check that position keys are correctly generated + const atom1Key = Array.from(objectTracker.atomMap.keys())[0]; + const atom2Key = Array.from(objectTracker.atomMap.keys())[1]; + + expect(atom1Key).toMatch(/^[CO]1_x[\d.-]+_y[\d.-]+_z[\d.-]+$/); + expect(atom2Key).toMatch(/^[CO]1_x[\d.-]+_y[\d.-]+_z[\d.-]+$/); + }); + }); + + describe('moveAtomsInsideCell functionality', () => { + beforeEach(() => { + // Create atoms that will be outside unit cell after transformation + grownGroup.atoms = [ + new Atom('C1', 'C', new FractPosition(1.2, 0.2, 0.3)), // x > 1 + new Atom('O1', 'O', new FractPosition(0.1, -0.5, 0.9)), // y < 0 + new Atom('N1', 'N', new FractPosition(0.5, 0.5, 2.1)), // z > 1 + ]; + }); + + test('moves atoms inside cell when moveAtomsInsideCell is true', () => { + const result = growAtomsinGroup(grownGroup, symmetry, '1_555', objectTracker, true); + + expect(result).toHaveLength(3); + + // Check atoms are now within [0,1) range + result.forEach(atom => { + expect(atom.position.x).toBeGreaterThanOrEqual(-1e-6); + expect(atom.position.x).toBeLessThan(1 + 1e-6); + expect(atom.position.y).toBeGreaterThanOrEqual(-1e-6); + expect(atom.position.y).toBeLessThan(1 + 1e-6); + expect(atom.position.z).toBeGreaterThanOrEqual(-1e-6); + expect(atom.position.z).toBeLessThan(1 + 1e-6); + }); + }); + + test('does not move atoms when moveAtomsInsideCell is false', () => { + const result = growAtomsinGroup(grownGroup, symmetry, '1_555', objectTracker, false); + + expect(result).toHaveLength(3); + + // Atoms should retain original positions (possibly outside unit cell) + expect(result[0].position.x).toBeCloseTo(1.2); // C1 + expect(result[1].position.y).toBeCloseTo(-0.5); // O1 + expect(result[2].position.z).toBeCloseTo(2.1); // N1 + }); + + test('updates atomTranslations map when moving atoms', () => { + const _result = growAtomsinGroup(grownGroup, symmetry, '1_555', objectTracker, true); + + // Check that translations are recorded + expect(objectTracker.atomTranslations.size).toBeGreaterThan(0); + + // Each translation should map original label to [new label, translation string] + for (const [originalLabel, [newLabel, translationString]] of objectTracker.atomTranslations) { + expect(typeof originalLabel).toBe('string'); + expect(typeof newLabel).toBe('string'); + expect(typeof translationString).toBe('string'); + expect(translationString).toMatch(/^1_\d{3}$/); // Format: 1_xyz where xyz are digits + } + }); + + test('correctly calculates translation strings', () => { + grownGroup.atoms = [ + new Atom('C1', 'C', new FractPosition(2.3, -1.7, 3.9)), // Large offsets + ]; + + const result = growAtomsinGroup(grownGroup, symmetry, '1_555', objectTracker, true); + + expect(result).toHaveLength(1); + expect(result[0].position.x).toBeCloseTo(0.3); // 2.3 - 2 + expect(result[0].position.y).toBeCloseTo(0.3); // -1.7 - (-2) = 0.3 + expect(result[0].position.z).toBeCloseTo(0.9); // 3.9 - 3 + + // Check translation string format: 1_abc where a=5+offsetX, b=5+offsetY, c=5+offsetZ + const translation = objectTracker.atomTranslations.get('C1'); + expect(translation).toBeDefined(); + expect(translation[1]).toBe('1_738'); // 5-(2), 5-(2), 5-(-3) = 7,3,8 + }); + }); + + describe('special positions and duplicates', () => { + test('detects and handles duplicate atom positions', () => { + // Create a scenario where symmetry operation creates duplicate position + grownGroup.atoms = [ + new Atom('C1', 'C', new FractPosition(0.5, 0.5, 0.5)), // Center position + ]; + + // First call - should create atom + const result1 = growAtomsinGroup(grownGroup, symmetry, '1_555', objectTracker, false); + expect(result1).toHaveLength(1); + expect(objectTracker.atomMap.size).toBe(1); + + // Second call with same position - should detect duplicate + const result2 = growAtomsinGroup(grownGroup, symmetry, '2_555', objectTracker, false); + + // If the symmetry operation produces the same position, it should be detected as special position + if (result2.length === 0) { + expect(objectTracker.specialPositionMap.size).toBeGreaterThan(0); + } else { + // Different position was created + expect(result2).toHaveLength(1); + } + }); + + test('maps duplicate atoms to existing atoms in special position map', () => { + // Manually set up a special position scenario + const existingAtomLabel = 'C1'; + const duplicatePosition = 'C1_x0.5_y-0.5_z1'; + + // Pre-populate the atomMap to simulate existing atom + objectTracker.atomMap.set(duplicatePosition, existingAtomLabel); + + grownGroup.atoms = [ + new Atom('C1', 'C', new FractPosition(0.5, 0.5, 0.5)), // Same position as existing + ]; + + const result = growAtomsinGroup(grownGroup, symmetry, '2_555', objectTracker, false); + + // Should return no new atoms since position already exists + expect(result).toHaveLength(0); + + // Should map the duplicate to the existing atom + expect(objectTracker.specialPositionMap.has('C1@2_555')).toBe(true); + expect(objectTracker.specialPositionMap.get('C1@2_555')).toBe(existingAtomLabel); + }); + }); + + describe('edge cases', () => { + test('handles empty atom group', () => { + grownGroup.atoms = []; + + const result = growAtomsinGroup(grownGroup, symmetry, '1_555', objectTracker, false); + + expect(result).toHaveLength(0); + expect(objectTracker.atomMap.size).toBe(0); + }); + + test('handles atoms with existing symmetry labels', () => { + grownGroup.atoms = [ + new Atom('C1@3_666', 'C', new FractPosition(0.1, 0.2, 0.3)), + ]; + + const result = growAtomsinGroup(grownGroup, symmetry, '2_555', objectTracker, false); + + expect(result).toHaveLength(1); + // Label should be updated to combine both symmetry operations + expect(result[0].label).toMatch(/C1@.*_\d{3}/); + }); + + test('handles atoms at unit cell boundaries', () => { + grownGroup.atoms = [ + new Atom('C1', 'C', new FractPosition(0.0, 0.0, 0.0)), // At origin + new Atom('O1', 'O', new FractPosition(1.0, 1.0, 1.0)), // At opposite corner + new Atom('N1', 'N', new FractPosition(0.5, 0.0, 1.0)), // On boundaries + ]; + + const result = growAtomsinGroup(grownGroup, symmetry, '1_555', objectTracker, true); + + expect(result).toHaveLength(3); + + // Check that boundary atoms are handled correctly + result.forEach(atom => { + expect(atom.position.x).toBeGreaterThanOrEqual(-1e-6); + expect(atom.position.x).toBeLessThan(1 + 1e-6); + expect(atom.position.y).toBeGreaterThanOrEqual(-1e-6); + expect(atom.position.y).toBeLessThan(1 + 1e-6); + expect(atom.position.z).toBeGreaterThanOrEqual(-1e-6); + expect(atom.position.z).toBeLessThan(1 + 1e-6); + }); + }); + + test('handles complex symmetry operations with translations', () => { + const result = growAtomsinGroup(grownGroup, symmetry, '4_555', objectTracker, false); + + expect(result).toHaveLength(2); + expect(result[0].label).toBe('C1@4_555'); + expect(result[1].label).toBe('O1@4_555'); + + // Verify that complex transformation x+1/2,-y+1/2,z+1/2 is applied correctly + // Original C1: (0.1, 0.2, 0.3) -> (0.1+0.5, -0.2+0.5, 0.3+0.5) = (0.6, 0.3, 0.8) + expect(result[0].position.x).toBeCloseTo(0.6); + expect(result[0].position.y).toBeCloseTo(0.3); + expect(result[0].position.z).toBeCloseTo(0.8); + }); + }); + + describe('precision and tolerance', () => { + + test('correctly rounds positions for boundary detection', () => { + grownGroup.atoms = [ + new Atom('C1', 'C', new FractPosition(0.9999999, 0.0000001, 0.5)), + ]; + + const result = growAtomsinGroup(grownGroup, symmetry, '1_555', objectTracker, true); + + expect(result).toHaveLength(1); + // Position should be considered as within bounds and not moved + expect(result[0].position.x).toBeCloseTo(0.9999999, 6); + expect(result[0].position.y).toBeCloseTo(0.0000001, 6); + }); + }); + + describe('object tracker interactions', () => { + test('correctly populates all object tracker maps', () => { + const _result = growAtomsinGroup(grownGroup, symmetry, '2_555', objectTracker, true); + + // AtomMap should be populated + expect(objectTracker.atomMap.size).toBeGreaterThan(0); + + // Other maps should be initialized but may be empty for this function + expect(objectTracker.createdBonds).toBeInstanceOf(Set); + expect(objectTracker.createdHBonds).toBeInstanceOf(Set); + expect(objectTracker.specialPositionMap).toBeInstanceOf(Map); + expect(objectTracker.atomTranslations).toBeInstanceOf(Map); + }); + + test('preserves existing object tracker state', () => { + // Pre-populate object tracker + objectTracker.atomMap.set('existing_key', 'existing_value'); + objectTracker.specialPositionMap.set('existing_special', 'existing_atom'); + + const _result = growAtomsinGroup(grownGroup, symmetry, '1_555', objectTracker, false); + + // Should preserve existing entries + expect(objectTracker.atomMap.get('existing_key')).toBe('existing_value'); + expect(objectTracker.specialPositionMap.get('existing_special')).toBe('existing_atom'); + + // Should also add new entries + expect(objectTracker.atomMap.size).toBeGreaterThan(1); + }); + }); + }); + describe('Bond Growing Functions', () => { + let symmetry; + let grownGroup; + let objectTracker; + + beforeEach(() => { + // Set up symmetry operations for testing (space group Cc - 9) + const symmetryOps = [ + new SymmetryOperation('x,y,z'), // 1_555 (identity) + new SymmetryOperation('x,-y,z+1/2'), // 2_555 + new SymmetryOperation('x+1/2,y+1/2,z'), // 3_555 + new SymmetryOperation('x+1/2,-y+1/2,z+1/2'), // 4_555 + ]; + const operationIds = new Map([ + ['1', 0], ['2', 1], ['3', 2], ['4', 3], + ]); + symmetry = new CellSymmetry('Test', 1, symmetryOps, operationIds); + + // Set up object tracker + objectTracker = { + atomMap: new Map(), + createdBonds: new Set(), + createdHBonds: new Set(), + specialPositionMap: new Map(), + atomTranslations: new Map(), + }; + }); + + describe('growInternalBondsInGroup', () => { + beforeEach(() => { + grownGroup = { + atoms: [ + new Atom('C1', 'C', new FractPosition(0.1, 0.2, 0.3)), + new Atom('O1', 'O', new FractPosition(0.8, 0.9, 0.1)), + new Atom('C2', 'C', new FractPosition(0.4, 0.5, 0.6)), + ], + internalBonds: [ + new Bond('C1', 'O1', 1.5, 0.01, '.'), + new Bond('C1', 'C2', 1.4, 0.02, '.'), + ], + symmString: '1_555', + groupCentre: math.matrix([0.45, 0.55, 0.2]), + }; + }); + + test('grows internal bonds with identity transformation', () => { + const result = growInternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result).toHaveLength(2); + expect(result[0].atom1Label).toBe('C1'); // Identity keeps original label + expect(result[0].atom2Label).toBe('O1'); // Identity keeps original label + expect(result[0].bondLength).toBe(1.5); + expect(result[0].bondLengthSU).toBe(0.01); + expect(result[0].atom2SiteSymmetry).toBe('.'); + + expect(result[1].atom1Label).toBe('C1'); + expect(result[1].atom2Label).toBe('C2'); + }); + + test('grows internal bonds with non-identity transformation', () => { + const result = growInternalBondsInGroup(grownGroup, symmetry, '2_555', objectTracker); + + expect(result).toHaveLength(2); + expect(result[0].atom1Label).toBe('C1@2_555'); // Non-identity gets symmetry code + expect(result[0].atom2Label).toBe('O1@2_555'); + expect(result[0].bondLength).toBe(1.5); + expect(result[0].atom2SiteSymmetry).toBe('.'); + + expect(result[1].atom1Label).toBe('C1@2_555'); + expect(result[1].atom2Label).toBe('C2@2_555'); + }); + + test('avoids duplicate bonds', () => { + // First call + const result1 = growInternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + expect(result1).toHaveLength(2); + + // Second call with same bonds - should create no new bonds + const result2 = growInternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + expect(result2).toHaveLength(0); + expect(objectTracker.createdBonds.size).toBe(2); + }); + + test('skips self-bonds', () => { + grownGroup.internalBonds = [ + new Bond('C1', 'C1', 1.5, 0.01, '.'), // Self-bond + new Bond('C1', 'O1', 1.5, 0.01, '.'), // Normal bond + ]; + + const result = growInternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result).toHaveLength(1); + expect(result[0].atom1Label).toBe('C1'); + expect(result[0].atom2Label).toBe('O1'); + }); + + test('handles special position mapping', () => { + // Set up special position mapping + objectTracker.specialPositionMap.set('C1@2_555', 'C1'); // Maps to identity atom + + const result = growInternalBondsInGroup(grownGroup, symmetry, '2_555', objectTracker); + + expect(result).toHaveLength(2); + // Should use mapped atom label from special positions + expect(result[0].atom1Label).toBe('C1'); // Mapped from C1@2_555 to identity + }); + + test('handles atom translations correctly', () => { + // Set up atom translations - both atoms translated with same symmetry + objectTracker.atomTranslations.set('C1', ['C1_trans@1_444', '1_444']); + objectTracker.atomTranslations.set('O1', ['O1_trans@1_444', '1_444']); + + const result = growInternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result).toHaveLength(1); // Only one bond because C2 not translated + expect(result[0].atom1Label).toBe('C1_trans@1_444'); + expect(result[0].atom2Label).toBe('O1_trans@1_444'); + }); + + test('skips bonds when only one atom is translated', () => { + // Set up atom translations - only one atom translated + objectTracker.atomTranslations.set('C1', ['C1_trans@1_444', '1_444']); + + const result = growInternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result).toHaveLength(0); // No bonds because only one atom is translated + }); + + test('skips bonds when atoms have different translation symmetries', () => { + // Set up atom translations with different symmetries + objectTracker.atomTranslations.set('C1', ['C1@1_444', '1_444']); + objectTracker.atomTranslations.set('C2', ['C2@1_444', '1_444']); + objectTracker.atomTranslations.set('O1', ['O1@1_555', '1_555']); + + const result = growInternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result).toHaveLength(1); // Only C1-C2 bond (C2 not translated) + expect(result[0].atom2Label).toBe('C2@1_444'); // C2 has translation symmetry + }); + + test('handles empty internal bonds', () => { + grownGroup.internalBonds = []; + + const result = growInternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result).toHaveLength(0); + expect(objectTracker.createdBonds.size).toBe(0); + }); + }); + + describe('growExternalBondsInGroup', () => { + beforeEach(() => { + grownGroup = { + atoms: [ + new Atom('C1', 'C', new FractPosition(0.1, 0.2, 0.3)), + new Atom('O1', 'O', new FractPosition(0.8, 0.9, 0.1)), + ], + externalBonds: [ + new Bond('C1', 'N1', 1.5, 0.01, '2_555'), // External bond + new Bond('O1', 'S1', 1.8, 0.02, '3_666'), // External bond + ], + symmString: '1_555', + groupCentre: math.matrix([0.45, 0.55, 0.2]), + }; + }); + + test('grows external bonds with identity transformation', () => { + const result = growExternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result).toHaveLength(2); + + expect(result[0].atom1Label).toBe('C1'); // Identity keeps original label + expect(result[0].atom2Label).toBe('N1'); // Original atom2Label preserved + expect(result[0].bondLength).toBe(1.5); + expect(result[0].atom2SiteSymmetry).toBe('2_555'); // Combined symmetry + + expect(result[1].atom1Label).toBe('O1'); + expect(result[1].atom2Label).toBe('S1'); + expect(result[1].atom2SiteSymmetry).toBe('3_666'); + }); + + test('grows external bonds with non-identity transformation', () => { + const result = growExternalBondsInGroup(grownGroup, symmetry, '2_555', objectTracker); + + expect(result).toHaveLength(2); + // Non-identity transformation should add symmetry codes + expect(result[0].atom1Label).toBe('C1@2_555'); + expect(result[1].atom1Label).toBe('O1@2_555'); + // Symmetry should be combined: symmString (2_555) + atom2SiteSymmetry + expect(result[0].atom2SiteSymmetry).toBeDefined(); + expect(result[1].atom2SiteSymmetry).toBeDefined(); + }); + + test('handles both atoms translated scenario', () => { + // Set up translations for both atoms + objectTracker.atomTranslations.set('C1', ['C1@1_444', '1_444']); + objectTracker.atomTranslations.set('N1', ['N1@1_444', '1_444']); + + const result = growExternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + // Should handle complex translation logic + expect(result.length).toBeGreaterThanOrEqual(0); + }); + + test('take into account of only one atom translated', () => { + // Set up translation for only atom1 + objectTracker.atomTranslations.set('C1', ['C1@1_444', '1_444']); + + const result = growExternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + // Should skip bonds where only one atom is translated + expect(result).toHaveLength(2); + expect(result[0].atom1Label).toBe('C1@1_444'); + expect(result[0].atom2Label).toBe('N1'); // N1 no applied translatetion + expect(result[0].atom2SiteSymmetry).toBe('2_444'); // Should be combined symmetry + expect(result[1].atom1Label).toBe('O1'); + + }); + + test('updates objectTracker createdBonds set', () => { + const _result = growExternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(objectTracker.createdBonds.size).toBe(2); + expect(objectTracker.createdBonds.has(createBondIdentifier('C1', 'N1@2_555'))).toBe(true); + expect(objectTracker.createdBonds.has(createBondIdentifier('O1', 'S1@3_666'))).toBe(true); + }); + + test('avoids duplicate external bonds', () => { + // First call + const result1 = growExternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + expect(result1).toHaveLength(2); + + // Second call - should create no duplicates + const result2 = growExternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + expect(result2).toHaveLength(0); + }); + + test('handles special position mapping for external bonds', () => { + objectTracker.specialPositionMap.set('C1', 'C1_mapped'); + + const result = growExternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result[0].atom1Label).toBe('C1_mapped'); + }); + + test('handles empty external bonds', () => { + grownGroup.externalBonds = []; + + const result = growExternalBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result).toHaveLength(0); + expect(objectTracker.createdBonds.size).toBe(0); + }); + }); + + describe('growInternalHBondsInGroup', () => { + beforeEach(() => { + grownGroup = { + atoms: [ + new Atom('O1', 'O', new FractPosition(0.1, 0.2, 0.3)), + new Atom('H1', 'H', new FractPosition(0.2, 0.2, 0.3)), + new Atom('N1', 'N', new FractPosition(0.8, 0.9, 0.1)), + ], + internalHBonds: [ + new HBond('O1', 'H1', 'N1', 1.0, 0.01, 2.0, 0.02, 2.8, 0.03, 175, 1, '.'), + new HBond('N1', 'H2', 'O2', 1.1, 0.02, 1.9, 0.03, 2.9, 0.04, 178, 2, '.'), + ], + symmString: '1_555', + groupCentre: math.matrix([0.45, 0.55, 0.2]), + }; + }); + + test('grows internal hydrogen bonds with identity transformation', () => { + const result = growInternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result).toHaveLength(2); + + expect(result[0].donorAtomLabel).toBe('O1'); // Identity keeps original labels + expect(result[0].hydrogenAtomLabel).toBe('H1'); + expect(result[0].acceptorAtomLabel).toBe('N1'); + expect(result[0].donorHydrogenDistance).toBe(1.0); + expect(result[0].acceptorHydrogenDistance).toBe(2.0); + expect(result[0].hBondAngle).toBe(175); + expect(result[0].acceptorAtomSymmetry).toBe('.'); + + expect(result[1].donorAtomLabel).toBe('N1'); + expect(result[1].hydrogenAtomLabel).toBe('H2'); + expect(result[1].acceptorAtomLabel).toBe('O2'); + }); + + test('updates objectTracker createdHBonds set', () => { + const _result = growInternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(objectTracker.createdHBonds.size).toBe(2); + expect(objectTracker.createdHBonds.has( + createHBondIdentifier('O1', 'H1', 'N1'), + )).toBe(true); + expect(objectTracker.createdHBonds.has( + createHBondIdentifier('N1', 'H2', 'O2'), + )).toBe(true); + }); + + test('avoids duplicate hydrogen bonds', () => { + // First call + const result1 = growInternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + expect(result1).toHaveLength(2); + + // Second call - should create no duplicates + const result2 = growInternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + expect(result2).toHaveLength(0); + expect(objectTracker.createdHBonds.size).toBe(2); + }); + + test('handles special position mapping for hydrogen bonds', () => { + objectTracker.specialPositionMap.set('O1', 'O1_mapped'); + objectTracker.specialPositionMap.set('H1', 'H1_mapped'); + + const result = growInternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result[0].donorAtomLabel).toBe('O1_mapped'); + expect(result[0].hydrogenAtomLabel).toBe('H1_mapped'); + }); + + test('handles all atoms translated with same symmetry', () => { + // Set up translations for all three atoms with same symmetry + objectTracker.atomTranslations.set('O1', ['O1@1_444', '1_444']); + objectTracker.atomTranslations.set('H1', ['H1@1_444', '1_444']); + objectTracker.atomTranslations.set('N1', ['N1@1_444', '1_444']); + + const result = growInternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result).toHaveLength(1); // Only first H-bond (all atoms translated) + expect(result[0].donorAtomLabel).toBe('O1@1_444'); + expect(result[0].hydrogenAtomLabel).toBe('H1@1_444'); + expect(result[0].acceptorAtomLabel).toBe('N1@1_444'); + }); + + test('skips hydrogen bonds when atoms have different translation symmetries', () => { + // Set up translations with different symmetries + objectTracker.atomTranslations.set('O1', ['O1@1_444', '1_444']); + objectTracker.atomTranslations.set('H1', ['H1@1_555', '1_444']); + objectTracker.atomTranslations.set('N1', ['N1@1_555', '1_555']); + const result = growInternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result).toHaveLength(0); // Only H-bond where no atoms are translated + }); + + test('skips hydrogen bonds when only some atoms are translated', () => { + // Only donor atom translated + objectTracker.atomTranslations.set('O1', ['O1@1_444', '1_444']); + + const result = growInternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result).toHaveLength(1); // Only second H-bond (no translated atoms) + expect(result[0].donorAtomLabel).toBe('N1'); + }); + + test('preserves all hydrogen bond properties', () => { + const result = growInternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + const hbond = result[0]; + expect(hbond.donorHydrogenDistance).toBe(1.0); + expect(hbond.donorHydrogenDistanceSU).toBe(0.01); + expect(hbond.acceptorHydrogenDistance).toBe(2.0); + expect(hbond.acceptorHydrogenDistanceSU).toBe(0.02); + expect(hbond.donorAcceptorDistance).toBe(2.8); + expect(hbond.donorAcceptorDistanceSU).toBe(0.03); + expect(hbond.hBondAngle).toBe(175); + expect(hbond.hBondAngleSU).toBe(1); + expect(hbond.acceptorAtomSymmetry).toBe('.'); + }); + + test('handles empty internal hydrogen bonds', () => { + grownGroup.internalHBonds = []; + + const result = growInternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result).toHaveLength(0); + expect(objectTracker.createdHBonds.size).toBe(0); + }); + }); + + describe('growExternalHBondsInGroup', () => { + beforeEach(() => { + grownGroup = { + atoms: [ + new Atom('O1', 'O', new FractPosition(0.1, 0.2, 0.3)), + new Atom('H1', 'H', new FractPosition(0.2, 0.2, 0.3)), + ], + externalHBonds: [ + new HBond('O1', 'H1', 'N1', 1.0, 0.01, 2.0, 0.02, 2.8, 0.03, 175, 1, '2_555'), + new HBond('O2', 'H2', 'S1', 1.1, 0.02, 1.9, 0.03, 2.9, 0.04, 178, 2, '3_666'), + ], + symmString: '1_555', + groupCentre: math.matrix([0.45, 0.55, 0.2]), + }; + }); + + test('grows external hydrogen bonds with symmetry transformations', () => { + const result = growExternalHBondsInGroup(grownGroup, symmetry, '3_555', objectTracker); + + expect(result).toHaveLength(2); + + expect(result[0].donorAtomLabel).toBe('O1@3_555'); // Non-identity gets symmetry code + expect(result[0].hydrogenAtomLabel).toBe('H1@3_555'); + expect(result[0].acceptorAtomLabel).toBe('N1'); // Original acceptor label + expect(result[0].acceptorAtomSymmetry).toBe('4_555'); // Combined symmetry + + expect(result[1].donorAtomLabel).toBe('O2@3_555'); + expect(result[1].hydrogenAtomLabel).toBe('H2@3_555'); + expect(result[1].acceptorAtomLabel).toBe('S1'); + expect(result[1].acceptorAtomSymmetry).toBe('1_776'); + }); + + test('combines acceptor symmetry strings correctly', () => { + const result = growExternalHBondsInGroup(grownGroup, symmetry, '2_555', objectTracker); + + expect(result).toHaveLength(2); + // Acceptor symmetry should be combined: symmString + original acceptor symmetry + expect(result[0].acceptorAtomSymmetry).toBeDefined(); + expect(result[1].acceptorAtomSymmetry).toBeDefined(); + // Note: exact values depend on symmetry.combineSymmetryCodes implementation + }); + + test('handles all atoms translated scenario', () => { + // Set up translations - all atoms translated with same symmetry + objectTracker.atomTranslations.set('O1', ['O1@1_444', '1_444']); + objectTracker.atomTranslations.set('H1', ['H1@1_444', '1_444']); + objectTracker.atomTranslations.set('N1', ['N1@1_444', '1_444']); + + const result = growExternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + // Should handle complex translation and symmetry combination logic + expect(result.length).toBeGreaterThanOrEqual(0); + }); + + test('skips hydrogen bonds when only some atoms are translated', () => { + // Set up translation for only donor + objectTracker.atomTranslations.set('O1', ['O1@1_444', '1_444']); + + const result = growExternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + // Should skip H-bonds where only some atoms are translated + expect(result).toHaveLength(1); // Only O2-H2...S1 bond should remain + expect(result[0].donorAtomLabel).toBe('O2'); + }); + + test('updates objectTracker createdHBonds set', () => { + const _result = growExternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + expect(objectTracker.createdHBonds.size).toBe(2); + expect(objectTracker.createdHBonds.has( + createHBondIdentifier('O1', 'H1', 'N1@2_555'), + )).toBe(true); + expect(objectTracker.createdHBonds.has( + createHBondIdentifier('O2', 'H2', 'S1@3_666'), + )).toBe(true); + }); + + test('avoids duplicate external hydrogen bonds', () => { + // First call + const result1 = growExternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + expect(result1).toHaveLength(2); + + // Second call - should create no duplicates + const result2 = growExternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + expect(result2).toHaveLength(0); + }); + + test('handles special position mapping for external hydrogen bonds', () => { + objectTracker.specialPositionMap.set('O1', 'O1_mapped'); + + const result = growExternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result[0].donorAtomLabel).toBe('O1_mapped'); + }); + + test('preserves all external hydrogen bond properties', () => { + const result = growExternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + const hbond = result[0]; + expect(hbond.donorHydrogenDistance).toBe(1.0); + expect(hbond.donorHydrogenDistanceSU).toBe(0.01); + expect(hbond.acceptorHydrogenDistance).toBe(2.0); + expect(hbond.acceptorHydrogenDistanceSU).toBe(0.02); + expect(hbond.donorAcceptorDistance).toBe(2.8); + expect(hbond.donorAcceptorDistanceSU).toBe(0.03); + expect(hbond.hBondAngle).toBe(175); + expect(hbond.hBondAngleSU).toBe(1); + }); + + test('handles empty external hydrogen bonds', () => { + grownGroup.externalHBonds = []; + + const result = growExternalHBondsInGroup(grownGroup, symmetry, '1_555', objectTracker); + + expect(result).toHaveLength(0); + expect(objectTracker.createdHBonds.size).toBe(0); + }); + }); + + describe('cross-function integration', () => { + test('bond and hbond identifiers do not conflict', () => { + const bondId = createBondIdentifier('C1@1_555', 'O1@1_555'); + const hbondId = createHBondIdentifier('O1@1_555', 'H1@1_555', 'N1@1_555'); + + objectTracker.createdBonds.add(bondId); + objectTracker.createdHBonds.add(hbondId); + + expect(objectTracker.createdBonds.has(bondId)).toBe(true); + expect(objectTracker.createdHBonds.has(hbondId)).toBe(true); + expect(objectTracker.createdBonds.size).toBe(1); + expect(objectTracker.createdHBonds.size).toBe(1); + }); + + test('object tracker state persists across function calls', () => { + const bondGroup = { + atoms: [new Atom('C1', 'C', new FractPosition(0.1, 0.2, 0.3))], + internalBonds: [new Bond('C1', 'O1', 1.5, 0.01, '.')], + externalBonds: [new Bond('C1', 'N1', 1.4, 0.02, '2_555')], + internalHBonds: [], + externalHBonds: [], + symmString: '1_555', + groupCentre: math.matrix([0.1, 0.2, 0.3]), + }; + + // Call both bond growing functions + const internalBonds = growInternalBondsInGroup(bondGroup, symmetry, '1_555', objectTracker); + const externalBonds = growExternalBondsInGroup(bondGroup, symmetry, '1_555', objectTracker); + + // Verify state persistence + expect(objectTracker.createdBonds.size).toBe(2); + expect(internalBonds).toHaveLength(1); + expect(externalBonds).toHaveLength(1); + }); + + test('functions handle shared special position mapping', () => { + // Set up shared special position + objectTracker.specialPositionMap.set('C1', 'C1_shared'); + + const bondGroup = { + atoms: [new Atom('C1', 'C', new FractPosition(0.1, 0.2, 0.3))], + internalBonds: [new Bond('C1', 'O1', 1.5, 0.01, '.')], + externalBonds: [new Bond('C1', 'N1', 1.4, 0.02, '2_555')], + internalHBonds: [new HBond('C1', 'H1', 'O1', 1.0, 0.01, 2.0, 0.02, 2.8, 0.03, 175, 1, '.')], + externalHBonds: [new HBond('C1', 'H1', 'N1', 1.0, 0.01, 2.0, 0.02, 2.8, 0.03, 175, 1, '2_555')], + symmString: '1_555', + groupCentre: math.matrix([0.1, 0.2, 0.3]), + }; + + // All functions should use the same mapped atom + const internalBonds = growInternalBondsInGroup(bondGroup, symmetry, '1_555', objectTracker); + const externalBonds = growExternalBondsInGroup(bondGroup, symmetry, '1_555', objectTracker); + const internalHBonds = growInternalHBondsInGroup(bondGroup, symmetry, '1_555', objectTracker); + const externalHBonds = growExternalHBondsInGroup(bondGroup, symmetry, '1_555', objectTracker); + + expect(internalBonds[0].atom1Label).toBe('C1_shared'); + expect(externalBonds[0].atom1Label).toBe('C1_shared'); + expect(internalHBonds[0].donorAtomLabel).toBe('C1_shared'); + expect(externalHBonds[0].donorAtomLabel).toBe('C1_shared'); + }); + }); + }); +}); + +describe('growCell integration tests', () => { + describe('simple structures', () => { + test('handles empty structure', () => { + const cell = new UnitCell(10, 10, 10, 90, 90, 90); + const symmetryOps = [new SymmetryOperation('x,y,z')]; + const symmetry = new CellSymmetry('P1', 1, symmetryOps); + const structure = new CrystalStructure(cell, [], [], [], symmetry); + + const result = growCell(structure); + expect(result.atoms).toHaveLength(0); + expect(result.bonds).toHaveLength(0); + expect(result.hBonds).toHaveLength(0); + }); + + test('handles single atom with identity symmetry', () => { + const cell = new UnitCell(10, 10, 10, 90, 90, 90); + const atoms = [new Atom('C1', 'C', new FractPosition(0.5, 0.5, 0.5))]; + const symmetryOps = [new SymmetryOperation('x,y,z')]; + const symmetry = new CellSymmetry('P1', 1, symmetryOps); + const structure = new CrystalStructure(cell, atoms, [], [], symmetry); + + const result = growCell(structure); + expect(result.atoms).toHaveLength(1); + expect(result.atoms[0].label).toBe('C1'); + expect(result.bonds).toHaveLength(0); + }); + + test('grows simple structure with multiple symmetry operations', () => { + const mockStructure = MockStructure.createDefault({ + hasMultipleSymmetry: true, + }).build(); + + const result = growCell(mockStructure); + + // Should have more atoms than the original + expect(result.atoms.length).toBeGreaterThan(mockStructure.atoms.length); + + // Check that we have some symmetry-generated atoms + const symmetryAtoms = result.atoms.filter(atom => atom.label.includes('@')); + expect(symmetryAtoms.length).toBeGreaterThan(0); + }); + }); + + describe('fragment cutting', () => { + test('respects cutFragments parameter', () => { + const mockStructure = MockStructure.createDefault({ + hasMultipleSymmetry: true, + }).build(); + + const resultWithCutting = growCell(mockStructure, true); + const resultWithoutCutting = growCell(mockStructure, false); + + // Without cutting should potentially have more atoms + expect(resultWithoutCutting.atoms.length).toBeGreaterThanOrEqual(resultWithCutting.atoms.length); + }); + }); + + describe('special positions handling', () => { + test('handles special positions correctly', () => { + // Create a structure where symmetry operations might generate duplicate atoms + const cell = new UnitCell(10, 10, 10, 90, 90, 90); + const atoms = [ + new Atom('C1', 'C', new FractPosition(0.0, 0.0, 0.0)), // At origin + new Atom('O1', 'O', new FractPosition(0.5, 0.5, 0.5)), // At center + ]; + const bonds = [new Bond('C1', 'O1', 1.5, 0.01, '.')]; + + const symmetryOps = [ + new SymmetryOperation('x,y,z'), // Identity + new SymmetryOperation('-x,-y,-z'), // Inversion - should duplicate atoms at origin/center + ]; + const operationIds = new Map([['1', 0], ['2', 1]]); + const symmetry = new CellSymmetry('P-1', 2, symmetryOps, operationIds); + + const structure = new CrystalStructure(cell, atoms, bonds, [], symmetry); + + const result = growCell(structure); + + expect(result.atoms.length).toEqual(3); + expect(result.atoms[0].label).toBe('C1'); + expect(result.atoms[1].label).toBe('O1'); + + }); + }); + + describe('external bonds with ungrown target atoms', () => { + test('handles external bonds where target atoms are not generated by symmetry operations', () => { + // Test case: External bond pointing to atom that would exist outside unit cell + // but symmetry operations don't generate it within the unit cell + const cell = new UnitCell(10, 10, 10, 90, 90, 90); + const atoms = [ + new Atom('C1', 'C', new FractPosition(0.1, 0.1, 0.1)), + new Atom('N1', 'N', new FractPosition(0.9, 0.9, 0.9)), // Near cell boundary + new Atom('O1', 'O', new FractPosition(0.5, 0.5, 0.5)), // Center atom + new Atom('S1', 'S', new FractPosition(0.8, 0.8, 0.8)), // Near cell boundary + ]; + const bonds = [ + new Bond('C1', 'N1', 1.5, 0.01, '.'), // Internal bond + new Bond('C1', 'O1', 1.4, 0.02, '2_556'), // External bond to ungrown atom + new Bond('N1', 'S1', 1.8, 0.03, '2_556'), // External bond to ungrown atom + new Bond('N1', 'S1', 1.8, 0.03, '2_344'), // External bond to ungrown atom + ]; + + // Symmetry that won't generate the target atoms within unit cell + const symmetryOps = [ + new SymmetryOperation('x,y,z'), // Identity only + new SymmetryOperation('-x,-y,-z'), // Inversion (won't help for these bonds) + ]; + const operationIds = new Map([['1', 0], ['2', 1]]); + const symmetry = new CellSymmetry('Test', 1, symmetryOps, operationIds); + + const structure = new CrystalStructure(cell, atoms, bonds, [], symmetry); + const result = growCell(structure); + + // Should preserve external bonds even if target atoms aren't grown + const externalBonds = result.bonds.filter(bond => + bond.atom2SiteSymmetry && bond.atom2SiteSymmetry !== '.', + ); + expect(externalBonds.length).toBeGreaterThan(0); + + // Target atoms should remain with original labels (not grown) + const ungrown02Bond = externalBonds.find(bond => + bond.atom1Label === 'C1' && bond.atom2Label === 'O1', + ); + expect(ungrown02Bond).toBeDefined(); + expect(ungrown02Bond.atom2SiteSymmetry).toBe('2_556'); + }); + }); + + describe('external HBonds with ungrown target atoms', () => { + test('handles external HBonds where acceptor atoms are not generated by symmetry', () => { + const cell = new UnitCell(10, 10, 10, 90, 90, 90); + const atoms = [ + new Atom('O1', 'O', new FractPosition(0.1, 0.1, 0.1)), + new Atom('H1', 'H', new FractPosition(0.15, 0.1, 0.1)), + new Atom('N1', 'N', new FractPosition(0.9, 0.9, 0.9)), + ]; + const hBonds = [ + new HBond('O1', 'H1', 'N1', 1.0, 0.01, 2.0, 0.02, 2.8, 0.03, 175, 1, '.'), // Internal + new HBond('O1', 'H1', 'N1', 1.1, 0.02, 2.1, 0.03, 2.9, 0.04, 170, 2, '1_333'), // External to ungrown + ]; + + const symmetryOps = [new SymmetryOperation('x,y,z')]; // Identity only + const operationIds = new Map([['1', 0]]); + const symmetry = new CellSymmetry('P1', 1, symmetryOps, operationIds); + + const structure = new CrystalStructure(cell, atoms, [], hBonds, symmetry); + const result = growCell(structure); + + // Should preserve external HBond even if acceptor atom isn't grown + const externalHBonds = result.hBonds.filter(hbond => + hbond.acceptorAtomSymmetry && hbond.acceptorAtomSymmetry !== '.', + ); + expect(externalHBonds.length).toBe(1); + + const preservedHBond = externalHBonds[0]; + expect(preservedHBond.acceptorAtomLabel).toBe('N1'); + expect(preservedHBond.acceptorAtomSymmetry).toBe('1_333'); + expect(preservedHBond.donorAcceptorDistance).toBe(2.9); + }); + }); +}); diff --git a/src/lib/structure/structure-modifiers/growing/grow-fragment.js b/src/lib/structure/structure-modifiers/growing/grow-fragment.js new file mode 100644 index 0000000..0b90b29 --- /dev/null +++ b/src/lib/structure/structure-modifiers/growing/grow-fragment.js @@ -0,0 +1,823 @@ +import { CrystalStructure } from '../../crystal.js'; +import { Bond, HBond } from '../../bonds.js'; +import { createSymAtomLabel } from './util.js'; + +/** + * @typedef {object} SeedConnection + * @property {number} targetIndex - Index of the target atom group. + * @property {string} targetSymmetry - The symmetry operation code needed to reach the target group from the + * origin group at identity. + * @property {Array} bonds - Specific bonds forming this connection. + */ + +/** + * @typedef {object} ExplorationState + * @property {Array} danglingConnections - Queue of connection groups to process. + * @property {Set} processedConnections - Set of unique keys for connections already processed or queued. + */ + +/** + * @typedef {object} ExplorationStepResult + * @property {ConnectedGroup} newConnectedGroup - The new group instance discovered in this step. + * @property {Array} newDanglingConnections - New connections found that need further + * exploration. + * @property {Array} foundTranslations - Connections found that lead to translational + * duplicates. + */ + +/** + * @typedef {object} ConnectivityAnalysisResult + * @property {Array} networkConnections - Bond groups forming the core connected network. + * @property {Array} translationLinks - Bond groups leading to translational duplicates + * (potential infinite growth). + * @property {Array>} discoveredGroups - All unique group instances found, grouped by their + * original asymmetric unit group index. + */ + +/** + * @typedef {object} InterGroupBondInfo + * @property {string} originSymmAtom - Label of the atom in the origin group instance (includes symmetry code + * if not identity). + * @property {string} targetSymmAtom - Label of the atom in the target group instance (includes symmetry code). + * @property {number} bondLength - The length of the bond. + * @property {number} bondLengthSU - Standard uncertainty of the bond length. + */ + +/** + * @typedef {object} SymmetryRequirements + * @property {Set} requiredSymmetryInstances - Set of unique group@symmetry strings that need to be generated. + * @property {Array} interGroupBonds - List of bonds connecting different symmetry instances. + */ + +/** + * Creates a unique identifier string for a bond between two atom labels. + * Ensures consistent ordering for duplicate checking. + * @param {string} atom1Label - Label of the first atom (e.g., 'C1@1_555'). + * @param {string} atom2Label - Label of the second atom (e.g., 'O2@2_565'). + * @returns {string} A unique, ordered string representing the bond (e.g., 'C1@1_555->O2@2_565'). + */ +export function createBondIdentifier(atom1Label, atom2Label) { + // Ensure consistent order for Set comparison + return atom1Label < atom2Label ? `${atom1Label}->${atom2Label}` : `${atom2Label}->${atom1Label}`; +} + +/** + * Creates a unique identifier string for a hydrogen bond. + * @param {string} donorAtomLabel - Label of the donor atom. + * @param {string} hydrogenAtomLabel - Label of the hydrogen atom. + * @param {string} acceptorAtomLabel - Label of the acceptor atom. + * @returns {string} A unique string representing the hydrogen bond. + */ +export function createHBondIdentifier(donorAtomLabel, hydrogenAtomLabel, acceptorAtomLabel) { + return `${donorAtomLabel}-${hydrogenAtomLabel}...${acceptorAtomLabel}`; +} + +/** + * Represents a group of atoms in a specific symmetry position + * @class + * @property {number} groupIndex - Index of the group in the original structure + * @property {string} symmetryId - Symmetry operation ID part of the symmetry code + * @property {string} translationId - Translation ID part of the symmetry code (e.g., '555') + */ +export class ConnectedGroup { + /** + * Creates a new connected group + * @param {number} groupIndex - Index of the group in the original structure + * @param {string} symmetry - Full symmetry operation code (e.g., '2_565') + */ + constructor(groupIndex, symmetry) { + this.groupIndex = groupIndex; + // Split symm code for efficient comparison later + const [symmetryId, translationId] = symmetry.split('_'); + this.symmetryId = symmetryId; + this.translationId = translationId; + } + + /** + * Checks if this group instance is a translational duplicate of another. + * @param {ConnectedGroup} other - Group to compare with + * @returns {boolean} True if groups are equivalent (same group index and symmetry operation ID) but have + * different translations. + */ + isTranslationalDuplicateOf(other) { + // Check if only the translation part differs + return this.groupIndex === other.groupIndex && + this.symmetryId === other.symmetryId && + this.translationId !== other.translationId; + } + + /** + * Gets the full symmetry string. + * @returns {string} The combined symmetry and translation ID string. + */ + getSymmetryString() { + return `${this.symmetryId}_${this.translationId}`; + } +} + +/** + * Represents the specific atoms involved in a symmetry connection. + * @class + * @property {string} originAtom - Label of the atom in the origin group + * @property {string} targetAtom - Label of the atom in the target group (before symmetry) + */ +export class ConnectingBond { + constructor(originAtom, targetAtom, bondLength, bondLengthSU) { + this.originAtom = originAtom; + this.targetAtom = targetAtom; + this.bondLength = bondLength; + this.bondLengthSU = bondLengthSU; + } +} + +/** + * Represents a bond group that crosses symmetry operations + * @class + * @property {number} originIndex - Index of the origin group + * @property {string} originSymmetry - Symmetry operation of origin group + * @property {number} targetIndex - Index of the target group + * @property {string} targetSymmetry - Direct symmetry operation for the target group + * @property {ConnectingBond[]} connectingBonds - All bonds that form the connection between the two fragments + * @property {number} creationOriginIndex - Index of the group within the asym. unit this bond originates from + */ +export class ConnectingBondGroup { + /** + * Represents a connection between two molecular fragments via symmetry + * @param {number} originIndex - Index of the origin group + * @param {string} originSymmetry - Symmetry operation of origin group + * @param {number} targetIndex - Index of the target group + * @param {string} targetSymmetry - Direct symmetry operation for the target + * @param {ConnectingBond[]} connectingBonds - All bonds that form the connection between the two fragments + * @param {number} creationOriginIndex - Index of the group within the asym. unit this bond originates from. Used to + * track which groups belong together when checking for translational duplicates. + */ + constructor(originIndex, originSymmetry, targetIndex, targetSymmetry, connectingBonds, creationOriginIndex) { + this.originIndex = originIndex; + this.originSymmetry = originSymmetry; + this.targetIndex = targetIndex; + this.targetSymmetry = targetSymmetry; // Direct symmetry operation for the target + this.connectingBonds = connectingBonds; + this.creationOriginIndex = creationOriginIndex; + } + + /** + * Gets a key that uniquely identifies this bond connection, respecting symmetry and order. + * Ensures that the connection A->B with symm S is the same key as B->A with inverse symm S'. + * @returns {string} Unique identifier for the bond connection. + */ + getKey() { + // Ensure consistent ordering for the key regardless of bond direction + if (this.originIndex === this.targetIndex) { + // Intra-group connection across symmetry + if (this.originSymmetry < this.targetSymmetry) { + return `${this.originIndex}_${this.originSymmetry}_${this.targetIndex}_${this.targetSymmetry}`; + } else { + return `${this.targetIndex}_${this.targetSymmetry}_${this.originIndex}_${this.originSymmetry}`; + } + } else if (this.originIndex < this.targetIndex) { + // Inter-group connection + return `${this.originIndex}_${this.originSymmetry}_${this.targetIndex}_${this.targetSymmetry}`; + } else { + // Inter-group connection (reversed order) + return `${this.targetIndex}_${this.targetSymmetry}_${this.originIndex}_${this.originSymmetry}`; + } + } +} + +/** + * Extracts the initial symmetry connections based on the structure's bond list. + * These are the starting points for the connectivity exploration. + * @param {CrystalStructure} structure - Crystal structure to analyze. + * @param {Array} atomGroups - Array of atom groups (from structure.connectedGroups). + * @param {Map} atomGroupMap - Map from atom label to group index. + * @returns {Array>} An array where each index corresponds to an atom group, + * originating from that group: { targetIndex, targetSymmetry: connectingSymOp, bonds }. + */ +export function getSeedConnections(structure, atomGroups, atomGroupMap) { + // Used to group bonds between the same groups/symm ops + const seedConnectionsKeys = new Map(); + const seedConnectionsInGroup = atomGroups.map(() => []); + + structure.bonds + .filter(bond => bond.atom2SiteSymmetry !== '.') // Only consider bonds crossing symmetry + .forEach(bond => { + const atom1Group = atomGroupMap.get(bond.atom1Label); + const atom2Group = atomGroupMap.get(bond.atom2Label); + // Key identifies the specific connection: origin group -> target group @ identity symm -> target symm + const targetKey = `${atom1Group}->${atom2Group}@.@${bond.atom2SiteSymmetry}`; + + if (seedConnectionsKeys.has(targetKey)) { + // Add bond to existing connection object + const index = seedConnectionsKeys.get(targetKey); + seedConnectionsInGroup[atom1Group][index].bonds.push( + new ConnectingBond(bond.atom1Label, bond.atom2Label, bond.bondLength, bond.bondLengthSU), + ); + } else { + // Create new connection object + seedConnectionsKeys.set(targetKey, seedConnectionsInGroup[atom1Group].length); + seedConnectionsInGroup[atom1Group].push({ + targetIndex: atom2Group, + targetSymmetry: bond.atom2SiteSymmetry, // Symmetry op needed to reach atom 2 + bonds: [new ConnectingBond(bond.atom1Label, bond.atom2Label, bond.bondLength, bond.bondLengthSU)], + }); + } + }); + return seedConnectionsInGroup; +} + +/** + * Initializes the queue of bond groups (connections) to process and the set of processed connections. + * @param {Array>} seedConnectionsPerGroup - Connections extracted by getSeedConnections. + * @param {string} identSymmString - The string representing the identity symmetry operation ('1_555'). + * @returns {ExplorationState} An object containing the initial queue and the set of processed connection keys. + */ +export function initializeExploration(seedConnectionsPerGroup, identSymmString) { + const danglingConnections = []; + const processedConnections = new Set(); + + seedConnectionsPerGroup.forEach((groupConnections, groupIndex) => { + for (const connection of groupConnections) { + // Initial bond groups start from the identity symmetry + const initialBondGroup = new ConnectingBondGroup( + groupIndex, // Origin group index + identSymmString, // Origin symmetry is identity + connection.targetIndex, // Target group index + connection.targetSymmetry, // Symmetry op to reach target from origin + connection.bonds, // Specific atom bonds + groupIndex, // The creation origin is the group itself initially + ); + + // Calculate the key based on the target symmetry + const bondKey = initialBondGroup.getKey(); + + if (!processedConnections.has(bondKey)) { + danglingConnections.push(initialBondGroup); + processedConnections.add(bondKey); // Mark this initial connection as processed + } + } + }); + + return { danglingConnections, processedConnections }; +} + +/** + * Processes a single connection group from the queue, determines the resulting group's symmetry, + * finds new connections, and checks for translations. + * @param {ConnectingBondGroup} currentConnection - The connection group to process. + * @param {CrystalStructure} structure - The crystal structure. + * @param {Array>} discoveredGroups - Current list of discovered group instances for each + * creation origin. + * @param {Array>} seedConnectionsPerGroup - The initial connections for each group type. + * @param {Set} processedConnections - Set of unique keys for connections already processed or queued. This + * function adds new connection keys to this set as they are encountered. + * @returns {ExplorationStepResult} Results of processing the step. + */ +export function exploreConnection( + currentConnection, + structure, + discoveredGroups, + seedConnectionsPerGroup, + processedConnections, +) { + const newDanglingConnections = []; + const foundTranslations = []; + + const newConnectedGroup = new ConnectedGroup(currentConnection.targetIndex, currentConnection.targetSymmetry); + + // Find connections originating from the *type* of group we just reached (targetIndex) + const targetGroupConnections = seedConnectionsPerGroup[currentConnection.targetIndex]; + + // Process each connection from the target group + for (const connection of targetGroupConnections) { + // Calculate the absolute symmetry operation for the *next* group + const nextTargetSymmetryAbsolute = structure.symmetry.combineSymmetryCodes( + currentConnection.targetSymmetry, // Absolute symmetry of the group we just reached) + connection.targetSymmetry, // Symmetry to apply to the next target (relative to targetIndex@identity) + ); + + // Create the prospective bond representing the next step + const prospectiveConnection = new ConnectingBondGroup( + currentConnection.targetIndex, // Origin is the group we just reached + currentConnection.targetSymmetry, // Symmetry of this origin + connection.targetIndex, // Target group index for the *next* step + nextTargetSymmetryAbsolute, // Direct symmetry needed to get to the target + connection.bonds, // Specific atom bonds for this connection type + currentConnection.creationOriginIndex, // Propagate the original creation index + ); + + // Check if this connection path has already been processed or queued + const connectionKey = prospectiveConnection.getKey(); + if (processedConnections.has(connectionKey)) { + continue; + } + processedConnections.add(connectionKey); // Mark as processed *now* to prevent duplicates in queue + + const resultingGroup = new ConnectedGroup(connection.targetIndex, nextTargetSymmetryAbsolute); + + // Check if this resulting group is a translational duplicate of an existing group + // within the same creationOriginIndex set + const translationPresent = discoveredGroups[currentConnection.creationOriginIndex].some(existing => { + return resultingGroup.isTranslationalDuplicateOf(existing); + }); + + // Add to the appropriate list based on translation check + if (translationPresent) { + foundTranslations.push(prospectiveConnection); + } else { + newDanglingConnections.push(prospectiveConnection); + } + } + + return { newConnectedGroup, newDanglingConnections, foundTranslations }; +} + +/** + * Analyzes the connectivity of a crystal structure including symmetry operations. + * This function performs a breadth-first search starting from the asymmetric unit, + * exploring connections across symmetry operations. It identifies unique symmetry-related + * groups and flags connections that only involve translation (potential infinite growth). + * @param {CrystalStructure} structure - Crystal structure to analyze. + * @param {Array} atomGroups - Created distinct groups of interconnected atoms. + * @returns {ConnectivityAnalysisResult} - Object containing the list of bond groups used to build the connected + * network, bond groups leading to translational duplicates, and the discovered group instances. + */ +export function createConnectivity(structure, atomGroups) { + const atomGroupMap = new Map(); + atomGroups.forEach((group, i) => { + group.atoms.forEach(atom => atomGroupMap.set(atom.label, i)); + }); + + const identSymmString = structure.symmetry.identitySymOpId + '_555'; + + // Find all initial connections defined in the bond list + const seedConnectionsPerGroup = getSeedConnections(structure, atomGroups, atomGroupMap); + + // Set up the initial processing queue and processed set + const { danglingConnections, processedConnections } = initializeExploration( + seedConnectionsPerGroup, + identSymmString, + ); + + const networkConnections = []; // Bonds successfully processed and added to the network + const translationLinks = []; // Bonds leading to translational duplicates + + // Tracks all symmetry instances found for each original group index + const discoveredGroups = atomGroups.map(() => []); + + // Add the initial identity group for each original group index + atomGroups.forEach((_, i) => { + discoveredGroups[i].push(new ConnectedGroup(i, identSymmString)); + }); + + let safetyCounter = 0; + const MAXITER = 10000; // Safety limit + + // Process the queue iteratively using breadth-first search + while (danglingConnections.length > 0) { + if (safetyCounter++ > MAXITER) { + console.error( + 'Max iterations reached in createConnectivity. Possible infinite loop orvery complex structure.', + ); + break; // Exit loop to prevent freezing + } + + const currentConnection = danglingConnections.shift(); + + // Process this connection group to find the next connected group and any new bonds + const stepResult = exploreConnection( + currentConnection, + structure, + discoveredGroups, + seedConnectionsPerGroup, + processedConnections, // Pass the set to be mutated + ); + + // Add the newly found connected group to our tracking list + discoveredGroups[currentConnection.creationOriginIndex].push(stepResult.newConnectedGroup); + + // Add newly found dangling connections to the queue for further processing + danglingConnections.push(...stepResult.newDanglingConnections); + + // Add bonds leading to translations to the separate list + translationLinks.push(...stepResult.foundTranslations); + + // Record the bond group that was successfully processed + networkConnections.push(currentConnection); + } + + if (danglingConnections.length > 0) { + console.warn( + `Connectivity processing stopped due to iteration limit. ${danglingConnections.length} ` + + 'connections remain unprocessed.', + ); + } + + return { networkConnections, translationLinks, discoveredGroups }; +} + +/** + * Collects required symmetry instances and creates inter-group bonds from network connections. + * @param {Array} networkConnections - The network connections from createConnectivity. + * @param {CrystalStructure} structure - The crystal structure. + * @param {string} identSymmString - The identity symmetry operation string. + * @returns {SymmetryRequirements} The required symmetry instances and inter-group bonds. + */ +export function collectSymmetryRequirements(networkConnections, structure, identSymmString) { + const requiredSymmetryInstances = new Set(); + const interGroupBonds = []; + + // Collect all unique group@symmetry instances needed + networkConnections.forEach((group) => { + requiredSymmetryInstances.add(`${group.originIndex}@.@${group.originSymmetry}`); + requiredSymmetryInstances.add(`${group.targetIndex}@.@${group.targetSymmetry}`); + + group.connectingBonds.forEach(conBond => { + const atom1 = group.originSymmetry === identSymmString + ? conBond.originAtom + : createSymAtomLabel(conBond.originAtom, group.originSymmetry); + const atom2 = group.targetSymmetry === identSymmString + ? conBond.targetAtom + : createSymAtomLabel(conBond.targetAtom, group.targetSymmetry); + interGroupBonds.push({ + originSymmAtom: atom1, + targetSymmAtom: atom2, // TODO: This should be the label *before* special position remapping + bondLength: conBond.bondLength, + bondLengthSU: conBond.bondLengthSU, + }); + }); + }); + + return { requiredSymmetryInstances, interGroupBonds }; +} + +/** + * Generates symmetry-related atoms based on the required symmetry instances. + * @param {Set} requiredSymmetryInstances - Set of required symmetry instances. + * @param {Array} atomGroups - The atom groups from structure.connectedGroups. + * @param {CrystalStructure} structure - The crystal structure. + * @param {string} identSymmString - The identity symmetry operation string. + * @returns {{specialPositionAtoms: Map, newAtoms: Array}} Map of special position atoms + * (from -> to) and the generated atoms. + */ +export function generateSymmetryAtoms(requiredSymmetryInstances, atomGroups, structure, identSymmString) { + // Store atom groups for each symmetry: [groupIndex][symmInstanceIndex][atomIndex] + const atomsByGroupAndSymmetry = atomGroups.map(g => [[...g.atoms]]); // Start with identity atoms + + // --- Special position handling --- + // This logic identifies atoms generated by symmetry that occupy the same + // position as another atom (either the original or from another symm op). + // It maps the duplicate atom's label to the label of the atom being kept. + const specialPositionAtoms = new Map(); + const newAtoms = []; + + // Generate atoms for all required symmetry instances + requiredSymmetryInstances.forEach(g => { + const [idxStr, symOp] = g.split('@.@'); + if (symOp === identSymmString) { + return; // Skip identity operation + } + const groupIndex = Number(idxStr); + // Ensure atoms are cloned/copied properly if modified later + const originalAtoms = atomGroups[groupIndex].atoms; + const newGroupAtoms = structure.symmetry.applySymmetry(symOp, originalAtoms); + newGroupAtoms.forEach(atom => { + // Modify label to indicate symmetry instance + atom.label = createSymAtomLabel(atom.label, symOp); + }); + atomsByGroupAndSymmetry[groupIndex].push(newGroupAtoms); + }); + + // Process all symmetry atoms to handle special positions + atomsByGroupAndSymmetry.forEach(g => { + // Compare atoms across different symmetry instances *of the same original atom* + if (g.length > 0 && g[0].length > 0) { // Check if there are atoms to compare + const numOriginalAtoms = g[0].length; + for (let atomIdx = 0; atomIdx < numOriginalAtoms; ++atomIdx) { + const atomsForOriginal = g.map(symmGroup => symmGroup[atomIdx]); // Get atom from all symmetry instances + const keptSymmAtoms = []; // Track unique positions found for this original atom + for (const symmAtom of atomsForOriginal) { + let specPos = false; // Assume not special position initially + for (const keptSymmAtom of keptSymmAtoms) { + // Use a small tolerance for floating point comparisons + specPos = Math.abs(keptSymmAtom.position.x - symmAtom.position.x) * structure.cell.a < 1e-4 && + Math.abs(keptSymmAtom.position.y - symmAtom.position.y) * structure.cell.b < 1e-4 && + Math.abs(keptSymmAtom.position.z - symmAtom.position.z) * structure.cell.c < 1e-4; + if (specPos) { + // Map the duplicate atom label to the kept atom label + specialPositionAtoms.set(symmAtom.label, keptSymmAtom.label); + break; // Found a match, no need to check further kept atoms + } + } + if (!specPos) { + // This atom represents a unique position for this original atom, keep it + keptSymmAtoms.push(symmAtom); + newAtoms.push(symmAtom); + } + } + } + } + }); + + return { specialPositionAtoms, newAtoms }; +} + +/** + * Generates bonds for symmetry instances and handles special positions. + * @param {Array} atomGroups - The atom groups from structure.connectedGroups. + * @param {Set} requiredSymmetryInstances - Set of required symmetry instances. + * @param {Array} interGroupBonds - Inter-group bonds from collectSymmetryRequirements. + * @param {Map} specialPositionAtoms - Map of special position atoms. + * @param {Array} newAtoms - The generated atoms. + * @param {string} identSymmString - The identity symmetry operation string. + * @returns {{newBonds: Array, atomLabels: Set}} New bonds and set of atom labels. + */ +export function generateSymmetryBonds( + atomGroups, requiredSymmetryInstances, interGroupBonds, specialPositionAtoms, newAtoms, identSymmString, +) { + // Initialize with the original intra-group bonds + const newBonds = []; + atomGroups.forEach(g => { + newBonds.push(...g.bonds); + }); + + const existingBonds = new Set(); + + // Track existing bonds to avoid duplicates + newBonds.forEach(b => { + existingBonds.add(createBondIdentifier(b.atom1Label, b.atom2Label)); + }); + + // Generate symmetry-related intra-group bonds + requiredSymmetryInstances.forEach(g => { + const [idxStr, symOp] = g.split('@.@'); + if (symOp === identSymmString) { + return; // Skip identity operation + } + const groupIndex = Number(idxStr); + const originalBonds = atomGroups[groupIndex].bonds; + originalBonds.forEach(b => { + const atom1Label = createSymAtomLabel(b.atom1Label, symOp); + const atom2Label = createSymAtomLabel(b.atom2Label, symOp); + const atom1 = specialPositionAtoms.get(atom1Label) || atom1Label; + const atom2 = specialPositionAtoms.get(atom2Label) || atom2Label; + + const bondString = createBondIdentifier(atom1, atom2); + if (!existingBonds.has(bondString)) { + existingBonds.add(bondString); + newBonds.push(new Bond( + atom1, atom2, b.bondLength, b.bondLengthSU, '.', + )); + } + }); + }); + + //Add inter-group bonds + interGroupBonds.forEach(b => { + const atom1 = specialPositionAtoms.get(b.originSymmAtom) || b.originSymmAtom; + const atom2 = specialPositionAtoms.get(b.targetSymmAtom) || b.targetSymmAtom; + const bondString = createBondIdentifier(atom1, atom2); + if (!existingBonds.has(bondString)) { + existingBonds.add(bondString); + newBonds.push(new Bond( + atom1, atom2, b.bondLength, b.bondLengthSU, '.', + )); + } + }); + + // Create set of atom labels for lookup + const atomLabels = new Set(newAtoms.map(a => a.label)); + + return { newBonds, atomLabels }; +} + +/** + * Generates hydrogen bonds for symmetry instances and handles special positions. + * @param {CrystalStructure} structure - The crystal structure. + * @param {Array} atomGroups - The atom groups from structure.connectedGroups. + * @param {Map} atomGroupMap - Map from atom label to group index. + * @param {Set} requiredSymmetryInstances - Set of required symmetry instances (e.g. '0@.@2_655'). + * @param {Map} specialPositionAtoms - Map from a duplicate symmetry-generated atom label to the + * label of the atom instance that is kept (representing the same spatial position). + * @param {Set} atomLabels - Set of atom labels. + * @param {string} identSymmString - The identity symmetry operation string. + * @returns {Array} New hydrogen bonds. + */ +export function generateSymmetryHBonds( + structure, atomGroups, atomGroupMap, requiredSymmetryInstances, specialPositionAtoms, atomLabels, identSymmString, +) { + const finalHBonds = []; + const finalHBondIdentifiers = new Set(); + + // Add all original H-bonds first, ensuring their identifiers are correctly stored + // to prevent adding duplicates later if they are re-generated. + structure.hBonds.forEach(hb => { + let identifier; + if (hb.acceptorAtomSymmetry === '.' || hb.acceptorAtomSymmetry === identSymmString) { // Internal H-bond + identifier = createHBondIdentifier(hb.donorAtomLabel, hb.hydrogenAtomLabel, hb.acceptorAtomLabel); + } else { // External H-bond + const noSymIdentifier = createHBondIdentifier( + hb.donorAtomLabel, + hb.hydrogenAtomLabel, + hb.acceptorAtomLabel, + ); + identifier = `${noSymIdentifier}@${hb.acceptorAtomSymmetry}`; + } + // Only add if not already present (e.g. if input structure.hBonds had duplicates) + if (!finalHBondIdentifiers.has(identifier)) { + finalHBondIdentifiers.add(identifier); + finalHBonds.push(hb); + } + }); + + // Get definitions of external H-bonds originating from each group in the ASU + const externalHBondDefinitions = atomGroups.map(() => []); + structure.hBonds + .filter(hb => hb.acceptorAtomSymmetry !== '.') + .forEach(hb => { + const donorGroupIndex = atomGroupMap.get(hb.donorAtomLabel); + if (donorGroupIndex !== undefined) { + externalHBondDefinitions[donorGroupIndex].push(hb); + } + }); + + // Process required symmetry instances to generate new H-bonds + requiredSymmetryInstances.forEach(gInstance => { + const [idxStr, symOp] = gInstance.split('@.@'); + if (symOp === identSymmString) { + // Potentially, if atoms involved in an original H-bond were remapped by specialPositionAtoms + // even for identity, we might need to re-evaluate. However, the current logic + // of adding originals first and then checking identifiers should handle this. + // If C1 -> C1_sp, then original O1-H1..C1 becomes O1-H1..C1_sp if we re-evaluate. + // For now, this early return is kept as per original logic, assuming `specialPositionAtoms` + // primarily remaps non-identity generated atoms. + // The tests pass with this, but it's a subtle area. + return; + } + + const groupIndex = Number(idxStr); + + // Handle intra-group H-bonds (generate symmetry copies) + const originalIntraGroupHBonds = atomGroups[groupIndex].hBonds; + originalIntraGroupHBonds.forEach(hb => { + const sDonor = createSymAtomLabel(hb.donorAtomLabel, symOp); + const sH = createSymAtomLabel(hb.hydrogenAtomLabel, symOp); + const sAcceptor = createSymAtomLabel(hb.acceptorAtomLabel, symOp); + + const finalDonor = specialPositionAtoms.get(sDonor) || sDonor; + const finalH = specialPositionAtoms.get(sH) || sH; + const finalAcceptor = specialPositionAtoms.get(sAcceptor) || sAcceptor; + + const hBondIdentifier = createHBondIdentifier(finalDonor, finalH, finalAcceptor); + if (!finalHBondIdentifiers.has(hBondIdentifier)) { + finalHBondIdentifiers.add(hBondIdentifier); + finalHBonds.push(new HBond( + finalDonor, finalH, finalAcceptor, + hb.donorHydrogenDistance, hb.donorHydrogenDistanceSU, + hb.acceptorHydrogenDistance, hb.acceptorHydrogenDistanceSU, + hb.donorAcceptorDistance, hb.donorAcceptorDistanceSU, + hb.hBondAngle, hb.hBondAngleSU, + '.', // Generated intra-group H-bonds are internal + )); + } + }); + + // Handle external H-bonds (generate symmetry copies) + externalHBondDefinitions[groupIndex].forEach(hb => { + const sDonor = createSymAtomLabel(hb.donorAtomLabel, symOp); + const sH = createSymAtomLabel(hb.hydrogenAtomLabel, symOp); + + const finalDonor = specialPositionAtoms.get(sDonor) || sDonor; + const finalH = specialPositionAtoms.get(sH) || sH; + + const newAcceptorOverallSymmetry = structure.symmetry.combineSymmetryCodes( + symOp, hb.acceptorAtomSymmetry, + ); + const potentialAcceptorFullLabel = createSymAtomLabel(hb.acceptorAtomLabel, newAcceptorOverallSymmetry); + const finalAcceptorLabelForLookup = specialPositionAtoms.get( + potentialAcceptorFullLabel, + ) || potentialAcceptorFullLabel; + + let newHBondToAdd; + let hBondIdentifier; + + if (atomLabels.has(finalAcceptorLabelForLookup)) { + // Acceptor is generated and becomes internal + newHBondToAdd = new HBond( + finalDonor, finalH, finalAcceptorLabelForLookup, + hb.donorHydrogenDistance, hb.donorHydrogenDistanceSU, + hb.acceptorHydrogenDistance, hb.acceptorHydrogenDistanceSU, + hb.donorAcceptorDistance, hb.donorAcceptorDistanceSU, + hb.hBondAngle, hb.hBondAngleSU, + '.', // Acceptor is now internal + ); + hBondIdentifier = createHBondIdentifier(finalDonor, finalH, finalAcceptorLabelForLookup); + } else { + // Acceptor remains external + newHBondToAdd = new HBond( + finalDonor, finalH, hb.acceptorAtomLabel, // Use base acceptor label + hb.donorHydrogenDistance, hb.donorHydrogenDistanceSU, + hb.acceptorHydrogenDistance, hb.acceptorHydrogenDistanceSU, + hb.donorAcceptorDistance, hb.donorAcceptorDistanceSU, + hb.hBondAngle, hb.hBondAngleSU, + newAcceptorOverallSymmetry, // New external symmetry + ); + const symmHBIdentifier = createHBondIdentifier(finalDonor, finalH, hb.acceptorAtomLabel); + hBondIdentifier = `${symmHBIdentifier}@${newAcceptorOverallSymmetry}`; + } + + if (!finalHBondIdentifiers.has(hBondIdentifier)) { + finalHBondIdentifiers.add(hBondIdentifier); + finalHBonds.push(newHBondToAdd); + } + }); + }); + + return finalHBonds; +} + +/** + * Processes translational links to generate additional bonds. + * @param {Array} translationLinks - The translation links from createConnectivity. + * @param {CrystalStructure} structure - The crystal structure. + * @param {Map} specialPositionAtoms - Map of special position atoms. + * @param {Set} existingBonds - Set of unique bond identifiers. This function adds identifiers of newly + * created bonds to this set. + * @returns {Array} Additional bonds from translation links. + */ +export function processTranslationLinks(translationLinks, structure, specialPositionAtoms, existingBonds) { + const additionalBonds = []; + translationLinks.forEach(tl => { + for (const conBond of tl.connectingBonds) { + const atom1Label = createSymAtomLabel(conBond.originAtom, tl.originSymmetry); + const atom2Label = createSymAtomLabel(conBond.targetAtom, tl.targetSymmetry); + const atom1 = specialPositionAtoms.get(atom1Label) || atom1Label; + const atom2 = specialPositionAtoms.get(atom2Label) || atom2Label; + + const bondString = createBondIdentifier(atom1, atom2); + if (!existingBonds.has(bondString)) { + existingBonds.add(bondString); + additionalBonds.push( + new Bond(atom1, conBond.targetAtom, conBond.bondLength, conBond.bondLengthSU, tl.targetSymmetry), + ); + } + } + }); + + return additionalBonds; +} + +/** + * Grows a crystal structure by applying symmetry operations based on connectivity. + * @param {CrystalStructure} structure - The crystal structure to grow. + * @returns {CrystalStructure} New structure with symmetry-expanded atoms and bonds. + */ +export function growFragment(structure) { + const atomGroups = structure.calculateConnectedGroups(); + + // Map atoms to their group indices for faster lookup + const atomGroupMap = new Map(); + atomGroups.forEach((group, i) => { + group.atoms.forEach(atom => atomGroupMap.set(atom.label, i)); + }); + + const identSymmString = structure.symmetry.identitySymOpId + '_555'; + + // Step 1: Analyze connectivity to find all necessary symmetry operations + const { networkConnections, translationLinks } = createConnectivity(structure, atomGroups); + + // Step 2: Collect required symmetry instances and inter-group bonds + const { requiredSymmetryInstances, interGroupBonds } = collectSymmetryRequirements( + networkConnections, structure, identSymmString, + ); + + // Step 3: Generate symmetry-related atoms and handle special positions + const { specialPositionAtoms, newAtoms } = generateSymmetryAtoms( + requiredSymmetryInstances, atomGroups, structure, identSymmString, + ); + + // Step 4: Generate bonds for symmetry instances + const { newBonds, atomLabels } = generateSymmetryBonds( + atomGroups, requiredSymmetryInstances, interGroupBonds, + specialPositionAtoms, newAtoms, identSymmString, + ); + + // Step 5: Generate hydrogen bonds for symmetry instances + const newHBonds = generateSymmetryHBonds( + structure, atomGroups, atomGroupMap, requiredSymmetryInstances, + specialPositionAtoms, atomLabels, identSymmString, + ); + + // Step 6: Process translation links to add any remaining bonds + const existingBondIds = new Set(newBonds.map(b => createBondIdentifier(b.atom1Label, b.atom2Label))); + const translationBonds = processTranslationLinks( + translationLinks, structure, specialPositionAtoms, existingBondIds, + ); + + newBonds.push(...translationBonds); + + // Step 7: Create the new structure with grown symmetry + return { + grownStructure: new CrystalStructure(structure.cell, newAtoms, newBonds, newHBonds, structure.symmetry), + specialPositionAtoms: specialPositionAtoms, + }; +} \ No newline at end of file diff --git a/src/lib/structure/structure-modifiers/growing/grow-fragment.test.js b/src/lib/structure/structure-modifiers/growing/grow-fragment.test.js new file mode 100644 index 0000000..4f3a8e0 --- /dev/null +++ b/src/lib/structure/structure-modifiers/growing/grow-fragment.test.js @@ -0,0 +1,1565 @@ +import { beforeEach, describe, test } from 'vitest'; +import { MockStructure as MockStructureHelper } from '../base.test.js'; +import { Bond } from '../../bonds.js'; + +import { + createBondIdentifier, + createHBondIdentifier, + ConnectedGroup, + ConnectingBond, + ConnectingBondGroup, + getSeedConnections, + initializeExploration, + exploreConnection, + generateSymmetryAtoms, + generateSymmetryBonds, + generateSymmetryHBonds, + createConnectivity, + collectSymmetryRequirements, + processTranslationLinks, + growFragment, +} from './grow-fragment.js'; + +describe('Helper Functions', () => { + test('createBondIdentifier orders labels consistently', () => { + expect(createBondIdentifier('C1@1_555', 'O2@2_565')).toBe('C1@1_555->O2@2_565'); + expect(createBondIdentifier('O2@2_565', 'C1@1_555')).toBe('C1@1_555->O2@2_565'); // Reversed input + expect(createBondIdentifier('A', 'B')).toBe('A->B'); + expect(createBondIdentifier('B', 'A')).toBe('A->B'); + }); + + test('createHBondIdentifier correctly formats labels', () => { + expect(createHBondIdentifier('D@1', 'H@1', 'A@2')).toBe('D@1-H@1...A@2'); + expect(createHBondIdentifier('O1', 'H1A', 'N2@2_655')).toBe('O1-H1A...N2@2_655'); + }); +}); + +describe('ConnectedGroup', () => { + const group = new ConnectedGroup(0, '2_565'); + + test('constructor splits symmetry correctly', () => { + expect(group.groupIndex).toBe(0); + expect(group.symmetryId).toBe('2'); + expect(group.translationId).toBe('565'); + }); + + test('getSymmetryString returns the full string', () => { + expect(group.getSymmetryString()).toBe('2_565'); + }); + + test('isTranslationalDuplicateOf identifies translational duplicates', () => { + const sameGroupSameSymm = new ConnectedGroup(0, '2_565'); + const sameGroupDiffTrans = new ConnectedGroup(0, '2_555'); + const sameGroupDiffSymm = new ConnectedGroup(0, '3_565'); + const diffGroupSameSymm = new ConnectedGroup(1, '2_565'); + + expect(group.isTranslationalDuplicateOf(sameGroupSameSymm)).toBe(false); // Identical + expect(group.isTranslationalDuplicateOf(sameGroupDiffTrans)).toBe(true); // Translational duplicate + expect(group.isTranslationalDuplicateOf(sameGroupDiffSymm)).toBe(false); // Different symmetry op + expect(group.isTranslationalDuplicateOf(diffGroupSameSymm)).toBe(false); // Different group index + }); +}); + +describe('ConnectingBondGroup', () => { + const connectingBonds = [new ConnectingBond('C1', 'O1', 1.4, 0.01)]; + const bondGroup = new ConnectingBondGroup(0, '1_555', 0, '2_555', connectingBonds, 0); + + test('constructor sets properties correctly', () => { + expect(bondGroup.originIndex).toBe(0); + expect(bondGroup.originSymmetry).toBe('1_555'); + expect(bondGroup.targetIndex).toBe(0); + expect(bondGroup.targetSymmetry).toBe('2_555'); + expect(bondGroup.connectingBonds).toEqual(connectingBonds); + expect(bondGroup.creationOriginIndex).toBe(0); + }); + + test('getKey generates consistent keys for intra-group connections', () => { + // Origin symm < Target symm + expect(bondGroup.getKey()).toBe('0_1_555_0_2_555'); + + // Origin symm > Target symm (create reversed group for test) + const reversedBondGroup = new ConnectingBondGroup(0, '2_555', 0, '1_555', connectingBonds, 0); + expect(reversedBondGroup.getKey()).toBe('0_1_555_0_2_555'); + }); + + test('getKey generates consistent keys for inter-group connections', () => { + // Origin index < Target index + const interGroup = new ConnectingBondGroup(0, '1_555', 1, '3_555', connectingBonds, 0); + expect(interGroup.getKey()).toBe('0_1_555_1_3_555'); + + // Origin index > Target index (create reversed group for test) + const reversedInterGroup = new ConnectingBondGroup(1, '3_555', 0, '1_555', connectingBonds, 1); + expect(reversedInterGroup.getKey()).toBe('0_1_555_1_3_555'); + }); +}); + +describe('Structure dependent methods', () => { + let structureHelper; // Helper to build structures + let structure; // The built CrystalStructure + let atomGroups; // Result of structure.calculateConnectedGroups() + let atomGroupMap; // Map from atom label to group index + + beforeEach(() => { + // Default setup for many tests, can be overridden + structureHelper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) + .addAtom('C2', 'C', 0.2, 0.2, 0.2) + .addAtom('O1', 'O', 0.3, 0.3, 0.3) + .addAtom('N1', 'N', 0.4, 0.4, 0.4) + .addAtom('S1', 'S', 0.5, 0.5, 0.5) + .addBond('C1', 'C2') + .addBond('C2', 'O1'); + // Group 0: C1, C2, O1 + // Group 1: N1 + // Group 2: S1 + }); + + describe('getSeedConnections', () => { + /** + * Set up the necessary objects for the tests after the structure has been modified + * for the individual test. + */ + function setupStructure() { + structure = structureHelper.build(); + atomGroups = structure.calculateConnectedGroups(); + atomGroupMap = new Map(); + atomGroups.forEach((group, i) => { + group.atoms.forEach(atom => atomGroupMap.set(atom.label, i)); + }); + } + + test('should correctly identify a single symmetry bond', () => { + structureHelper.addBond('O1', 'N1', '2_565', 2.0, 0.02); // O1 (group 0) -> N1 (group 1) + setupStructure(); + + const seedConnectionsPerGroup = getSeedConnections(structure, atomGroups, atomGroupMap); + + expect(seedConnectionsPerGroup.length).toBe(3); // C1-C2-O1, N1, S1 + + // Group 0 (C1,C2,O1) should have one connection + expect(seedConnectionsPerGroup[0].length).toBe(1); + const connection0 = seedConnectionsPerGroup[0][0]; + expect(connection0.targetIndex).toBe(1); // Target is group 1 (N1) + expect(connection0.targetSymmetry).toBe('2_565'); + expect(connection0.bonds.length).toBe(1); + expect(connection0.bonds[0]).toBeInstanceOf(ConnectingBond); + expect(connection0.bonds[0].originAtom).toBe('O1'); + expect(connection0.bonds[0].targetAtom).toBe('N1'); + expect(connection0.bonds[0].bondLength).toBe(2.0); + expect(connection0.bonds[0].bondLengthSU).toBe(0.02); + + // Other groups should have no outgoing symmetry connections + expect(seedConnectionsPerGroup[1].length).toBe(0); + expect(seedConnectionsPerGroup[2].length).toBe(0); + }); + + test('should return empty connections if no symmetry bonds exist', () => { + structureHelper.addBond('C1', 'N1', '.'); // Non-symmetry bond + setupStructure(); + const seedConnectionsPerGroup = getSeedConnections(structure, atomGroups, atomGroupMap); + + expect(seedConnectionsPerGroup.length).toBe(2); // C1-C2-O1-N1, S1 + seedConnectionsPerGroup.forEach(connections => { + expect(connections.length).toBe(0); + }); + }); + + test('should return empty connections for a structure with no bonds at all', () => { + structureHelper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1); + setupStructure(); + const seedConnectionsPerGroup = getSeedConnections(structure, atomGroups, atomGroupMap); + + expect(seedConnectionsPerGroup.length).toBe(1); + expect(seedConnectionsPerGroup[0].length).toBe(0); + }); + + test('should group multiple atomic bonds defining the same symmetry connection', () => { + // C1 (group 0) -> N1 (group 1) via 2_565 + // O1 (group 0) -> N1 (group 1) via 2_565 (N1 is a single-atom group here) + structureHelper.addBond('C1', 'N1', '2_565', 1.8, 0.01); + structureHelper.addBond('O1', 'N1', '2_565', 1.9, 0.02); + setupStructure(); + + const seedConnectionsPerGroup = getSeedConnections(structure, atomGroups, atomGroupMap); + + expect(seedConnectionsPerGroup.length).toBe(3); // C1-C2-O1, N1, S1 + expect(seedConnectionsPerGroup[0].length).toBe(1); // Only one connection type from group 0 + + const connection0 = seedConnectionsPerGroup[0][0]; + expect(connection0.targetIndex).toBe(1); // Target is group 1 (N1) + expect(connection0.targetSymmetry).toBe('2_565'); + expect(connection0.bonds.length).toBe(2); // Two atomic bonds form this connection + + expect(connection0.bonds).toEqual( + expect.arrayContaining([ + expect.objectContaining({ originAtom: 'C1', targetAtom: 'N1', bondLength: 1.8 }), + expect.objectContaining({ originAtom: 'O1', targetAtom: 'N1', bondLength: 1.9 }), + ]), + ); + }); + + test('should handle multiple distinct symmetry connections from the same origin group', () => { + // O1 (group 0) -> N1 (group 1) via 2_565 + // C1 (group 0) -> S1 (group 2) via 3_444 + structureHelper.addBond('O1', 'N1', '2_565', 2.0, 0.02); + structureHelper.addBond('C1', 'S1', '3_444', 2.1, 0.03); + setupStructure(); + + const seedConnectionsPerGroup = getSeedConnections(structure, atomGroups, atomGroupMap); + expect(seedConnectionsPerGroup.length).toBe(3); + expect(seedConnectionsPerGroup[0].length).toBe(2); // Two distinct connections from group 0 + + const connToN1 = seedConnectionsPerGroup[0].find(c => c.targetIndex === 1); + expect(connToN1).toBeDefined(); + expect(connToN1.targetSymmetry).toBe('2_565'); + expect(connToN1.bonds.length).toBe(1); + expect(connToN1.bonds[0].originAtom).toBe('O1'); + expect(connToN1.bonds[0].targetAtom).toBe('N1'); + + const connToS1 = seedConnectionsPerGroup[0].find(c => c.targetIndex === 2); + expect(connToS1).toBeDefined(); + expect(connToS1.targetSymmetry).toBe('3_444'); + expect(connToS1.bonds.length).toBe(1); + expect(connToS1.bonds[0].originAtom).toBe('C1'); + expect(connToS1.bonds[0].targetAtom).toBe('S1'); + }); + + test('should handle symmetry bonds from different origin groups', () => { + // Group 0: C1, C2, O1 + // Group 1: N1 + // Group 2: S1 + structureHelper.addBond('O1', 'N1', '2_565'); // Group 0 -> Group 1 + structureHelper.addAtom('P1', 'P', 0.6, 0.6, 0.6); // Group 3: P1 + structureHelper.addBond('S1', 'P1', '4_555'); // Group 2 -> Group 3 + setupStructure(); + + const seedConnectionsPerGroup = getSeedConnections(structure, atomGroups, atomGroupMap); + expect(seedConnectionsPerGroup.length).toBe(4); // C1-C2-O1, N1, S1, P1 + + // Connection from Group 0 + expect(seedConnectionsPerGroup[0].length).toBe(1); + expect(seedConnectionsPerGroup[0][0].targetIndex).toBe(1); // N1 + expect(seedConnectionsPerGroup[0][0].targetSymmetry).toBe('2_565'); + + // No connections from Group 1 (N1) + expect(seedConnectionsPerGroup[1].length).toBe(0); + + // Connection from Group 2 (S1) + expect(seedConnectionsPerGroup[2].length).toBe(1); + expect(seedConnectionsPerGroup[2][0].targetIndex).toBe(3); // P1 + expect(seedConnectionsPerGroup[2][0].targetSymmetry).toBe('4_555'); + + // No connections from Group 3 (P1) + expect(seedConnectionsPerGroup[3].length).toBe(0); + }); + }); + + describe('initializeExploration', () => { + const identSymmString = '1_555'; // Standard identity for these tests + let b1, b2; + + beforeEach(() => { + b1 = [new ConnectingBond('C1', 'N1', 1.5, 0.01)]; + b2 = [new ConnectingBond('O1', 'S1', 1.8, 0.02)]; + }); + + test('should return empty results for no seed connections', () => { + const seedConnectionsPerGroup = []; + const { danglingConnections, processedConnections } = initializeExploration( + seedConnectionsPerGroup, identSymmString, + ); + expect(danglingConnections).toEqual([]); + expect(processedConnections.size).toBe(0); + }); + + test('should return empty results for empty groups in seed connections', () => { + const seedConnectionsPerGroup = [[], []]; // Two groups, no connections + const { danglingConnections, processedConnections } = initializeExploration( + seedConnectionsPerGroup, + identSymmString, + ); + expect(danglingConnections).toEqual([]); + expect(processedConnections.size).toBe(0); + }); + + test('should process a single seed connection correctly', () => { + const seedConnectionsPerGroup = [ + [{ targetIndex: 1, targetSymmetry: '2_565', bonds: b1 }], + [], // Group 1 has no outgoing seeds + ]; + const { danglingConnections, processedConnections } = initializeExploration( + seedConnectionsPerGroup, + identSymmString, + ); + + expect(danglingConnections.length).toBe(1); + const bondGroup = danglingConnections[0]; + expect(bondGroup).toBeInstanceOf(ConnectingBondGroup); + expect(bondGroup.originIndex).toBe(0); + expect(bondGroup.originSymmetry).toBe(identSymmString); + expect(bondGroup.targetIndex).toBe(1); + expect(bondGroup.targetSymmetry).toBe('2_565'); + expect(bondGroup.connectingBonds).toEqual(b1); + expect(bondGroup.creationOriginIndex).toBe(0); + + expect(processedConnections.size).toBe(1); + // Key for CBG(originIdx=0, originSym='1_555', targetIdx=1, targetSymmetry='2_565') + expect(processedConnections.has('0_1_555_1_2_565')).toBe(true); + }); + + test('should process multiple seed connections from the same group', () => { + const seedConnectionsPerGroup = [ + [ + { targetIndex: 1, targetSymmetry: '2_565', bonds: b1 }, + { targetIndex: 2, targetSymmetry: '3_444', bonds: b2 }, + ], + ]; + const { danglingConnections, processedConnections } = initializeExploration( + seedConnectionsPerGroup, + identSymmString, + ); + + expect(danglingConnections.length).toBe(2); + expect(danglingConnections[0].creationOriginIndex).toBe(0); + expect(danglingConnections[1].creationOriginIndex).toBe(0); + expect(danglingConnections[0].targetSymmetry).toBe('2_565'); + expect(danglingConnections[1].targetSymmetry).toBe('3_444'); + + expect(processedConnections.size).toBe(2); + expect(processedConnections.has('0_1_555_1_2_565')).toBe(true); // 0 -> 1 via 2_565 + expect(processedConnections.has('0_1_555_2_3_444')).toBe(true); // 0 -> 2 via 3_444 + }); + + test('should process seed connections from different groups', () => { + const seedConnectionsPerGroup = [ + [{ targetIndex: 1, targetSymmetry: '2_565', bonds: b1 }], // From group 0 + [{ targetIndex: 0, targetSymmetry: '3_565', bonds: b2 }], // From group 1 + ]; + const { danglingConnections, processedConnections } = initializeExploration( + seedConnectionsPerGroup, + identSymmString, + ); + + expect(danglingConnections.length).toBe(2); + const bondGroup0 = danglingConnections.find(bg => bg.creationOriginIndex === 0); + const bondGroup1 = danglingConnections.find(bg => bg.creationOriginIndex === 1); + + expect(bondGroup0).toBeDefined(); + expect(bondGroup0.originIndex).toBe(0); + expect(bondGroup0.targetIndex).toBe(1); + expect(bondGroup0.targetSymmetry).toBe('2_565'); + + expect(bondGroup1).toBeDefined(); + expect(bondGroup1.originIndex).toBe(1); + expect(bondGroup1.targetIndex).toBe(0); + expect(bondGroup1.targetSymmetry).toBe('3_565'); + + expect(processedConnections.size).toBe(2); + expect(processedConnections.has('0_1_555_1_2_565')).toBe(true); + // Key for CBG(originIdx=1, originSym='1_555', targetIdx=0, targetSymmetry='3_565') + expect(processedConnections.has('0_3_565_1_1_555')).toBe(true); + }); + + test('should skip duplicate initial connections based on key', () => { + // This simulates if getSeedConnections somehow produced a duplicate entry + const seedConnectionsPerGroup = [ + [ + { targetIndex: 1, targetSymmetry: '2_565', bonds: b1 }, + { targetIndex: 1, targetSymmetry: '2_565', bonds: b1 }], + ]; + const { danglingConnections, processedConnections } = initializeExploration( + seedConnectionsPerGroup, + identSymmString, + ); + expect(danglingConnections.length).toBe(1); // Only one should be added + expect(processedConnections.size).toBe(1); + }); + }); + + describe('exploreConnection', () => { + let currentConnection; + let discoveredGroups; + let seedConnectionsPerGroup; + let processedConnections; + const identSymmString = '1_555'; + + beforeEach(() => { + // Default structure: + // Group 0: C1, C2, O1 + // Group 1: N1 + // Group 2: S1 + structureHelper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) // Group 0 + .addAtom('C2', 'C', 0.2, 0.2, 0.2) // Group 0 + .addAtom('O1', 'O', 0.3, 0.3, 0.3) // Group 0 + .addBond('C1', 'C2') + .addBond('C2', 'O1') + .addAtom('N1', 'N', 0.4, 0.4, 0.4) // Group 1 + .addAtom('S1', 'S', 0.5, 0.5, 0.5); // Group 2 + + // Setup structure and dependent objects + structure = structureHelper.build(); + atomGroups = structure.calculateConnectedGroups(); + atomGroupMap = new Map(); + atomGroups.forEach((group, i) => { + group.atoms.forEach(atom => atomGroupMap.set(atom.label, i)); + }); + + // Initialize discoveredGroups with identity for each group + discoveredGroups = atomGroups.map((_, i) => [new ConnectedGroup(i, identSymmString)]); + processedConnections = new Set(); + }); + + test('should explore a simple connection leading to a new group and new dangling connections', () => { + // Current connection: Group 0 (ident) -> Group 1 via '2_565' + const bondsForCurrent = [new ConnectingBond('O1', 'N1', 1.5, 0.01)]; + currentConnection = new ConnectingBondGroup(0, identSymmString, 1, '2_565', bondsForCurrent, 0); + + // Seed connections: Group 1 has an outgoing connection to Group 2 via '3_444' + const bondsForSeed = [new ConnectingBond('N1', 'S1', 1.6, 0.01)]; + seedConnectionsPerGroup = [ + [], // Group 0 seeds (not relevant for this step) + [{ targetIndex: 2, targetSymmetry: '3_444', bonds: bondsForSeed }], // Group 1 seeds + [], // Group 2 seeds + ]; + + const result = exploreConnection( + currentConnection, + structure, + discoveredGroups, + seedConnectionsPerGroup, + processedConnections, + ); + + // New connected group should be Group 1 @ 2_565 (target symmetry) + expect(result.newConnectedGroup).toBeInstanceOf(ConnectedGroup); + expect(result.newConnectedGroup.groupIndex).toBe(1); // Target was N1 (group 1) + expect(result.newConnectedGroup.getSymmetryString()).toBe('2_565'); + + // New dangling connections: from Group 1 @ 2_565 -> Group 2 via combined symmetry + expect(result.newDanglingConnections.length).toBe(1); + const newDangling = result.newDanglingConnections[0]; + expect(newDangling.originIndex).toBe(1); // Origin is the group we just reached + expect(newDangling.originSymmetry).toBe('2_565'); + expect(newDangling.targetIndex).toBe(2); // Target from seed + // targetSymmetry should be the result of combining '3_444' (seed) with '2_565' (current origin) + expect(newDangling.connectingBonds).toEqual(bondsForSeed); + expect(newDangling.creationOriginIndex).toBe(0); // Propagated + + expect(result.foundTranslations.length).toBe(0); + + // Check processedConnections was updated (exact check depends on symmetry combination) + expect(processedConnections.size).toBe(1); // One new connection was processed and added + }); + + test('should find no new dangling connections if target group has no outgoing symmetry bonds', () => { + const bondsForCurrent = [new ConnectingBond('O1', 'N1', 1.5, 0.01)]; + currentConnection = new ConnectingBondGroup(0, identSymmString, 1, '2_565', bondsForCurrent, 0); + seedConnectionsPerGroup = [[], [], []]; // No outgoing connections from any group + + const result = exploreConnection( + currentConnection, + structure, + discoveredGroups, + seedConnectionsPerGroup, + processedConnections, + ); + + expect(result.newConnectedGroup.groupIndex).toBe(1); + expect(result.newConnectedGroup.getSymmetryString()).toBe('2_565'); + expect(result.newDanglingConnections.length).toBe(0); + expect(result.foundTranslations.length).toBe(0); + expect(processedConnections.size).toBe(0); // No *new* connections were processed from the target + }); + + test('should identify a connection leading to a translational duplicate', () => { + const bondsForCurrent = [new ConnectingBond('O1', 'N1', 1.5, 0.01)]; + currentConnection = new ConnectingBondGroup(0, identSymmString, 1, '2_555', bondsForCurrent, 0); + + // Seed connection from Group 1 that leads back to Group 0 with a target symmetry that + // represents a translational duplicate + const bondsForSeed = [new ConnectingBond('N1', 'C1', 1.5, 0.01)]; + seedConnectionsPerGroup = [ + [], + [{ targetIndex: 0, targetSymmetry: '2_555', bonds: bondsForSeed }], + // This would be a translational duplicate (twice applied screw axis) + [], + ]; + + const result = exploreConnection( + currentConnection, + structure, + discoveredGroups, + seedConnectionsPerGroup, + processedConnections, + ); + + expect(result.newConnectedGroup.groupIndex).toBe(1); // N1 + expect(result.newConnectedGroup.getSymmetryString()).toBe('2_555'); + expect(result.newDanglingConnections.length).toBe(0); + + expect(result.foundTranslations.length).toBe(1); + const translation = result.foundTranslations[0]; + expect(translation.originIndex).toBe(1); // From N1 + expect(translation.originSymmetry).toBe('2_555'); + expect(translation.targetIndex).toBe(0); // To C1 (group 0) + expect(translation.targetSymmetry).toBe('1_565'); // Direct target symmetry + expect(translation.creationOriginIndex).toBe(0); + + expect(processedConnections.size).toBe(1); // The translational link was added to processed + }); + + test('should skip connections if their key is already in processedConnections', () => { + const bondsForCurrent = [new ConnectingBond('O1', 'N1', 1.5, 0.01)]; + currentConnection = new ConnectingBondGroup(0, identSymmString, 1, '2_555', bondsForCurrent, 0); + + const bondsForSeed = [new ConnectingBond('N1', 'S1', 1.6, 0.01)]; + seedConnectionsPerGroup = [ + [], + [{ targetIndex: 2, targetSymmetry: '2_555', bonds: bondsForSeed }], + [], + ]; + + // Manually add the key for the prospective connection to processedConnections + // Prospective: origin=1@2_565, target=2, targetSymmetry='3_444' + const prospectiveKey = new ConnectingBondGroup(1, '2_555', 2, '1_565', bondsForSeed, 0) + .getKey(); + processedConnections.add(prospectiveKey); + + const result = exploreConnection( + currentConnection, + structure, + discoveredGroups, + seedConnectionsPerGroup, + processedConnections, + ); + + expect(result.newDanglingConnections.length).toBe(0); // Should be skipped + expect(result.foundTranslations.length).toBe(0); + expect(processedConnections.size).toBe(1); // No new keys added + }); + }); + + describe('createConnectivity', () => { + test('should correctly identify network connections and translational links', () => { + // Setup a specific structure for this test + // MockStructureHelper uses P 21/m by default. + // Identity operation is 1 (x,y,z) + // Symmetry operation 2 is (-x, y+1/2, -z) + structureHelper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) // Group 0 + .addAtom('N1', 'N', 0.4, 0.4, 0.4); // Group 1 + + // Bond 1: C1 (G0) -> N1 (G1) via symm op '2_555' + // '2_555' means: apply symmetry operation #2, and the standard unit cell origin (555). + // This will be a network connection. + structureHelper.addBond('C1', 'N1', '2_555'); + + // Bond 2: N1 (G1) -> C1 (G0) via symm op '2_565' + // '2_565' means: apply symmetry operation #2, and translate by (0,1,0) from standard origin. + // When exploring from N1 (already at symm '2_555' due to the first bond), + // applying this '2_565' (as direct target symmetry) to C1 will result in C1@2_565. + // C1@2_565 is a translational duplicate of C1@1_555 (the ASU C1). + // So, this path should lead to a translationLink. + structureHelper.addBond('N1', 'C1', '2_556'); + + structure = structureHelper.build(); + atomGroups = structure.calculateConnectedGroups(); // Important: calculate before passing + + const identSymmString = structure.symmetry.identitySymOpId + '_555'; // Expected: '1_555' + + const { networkConnections, translationLinks, discoveredGroups } = createConnectivity( + structure, atomGroups, + ); + + // Assertions for networkConnections + // Two initial connections are made from the asymmetric unit. + expect(networkConnections.length).toBe(2); + + const cbg_C1_to_N1_symm = networkConnections.find( + bg => bg.originIndex === 0 && bg.targetIndex === 1 && bg.targetSymmetry === '2_555', + ); + expect(cbg_C1_to_N1_symm).toBeDefined(); + expect(cbg_C1_to_N1_symm.originSymmetry).toBe(identSymmString); + expect(cbg_C1_to_N1_symm.creationOriginIndex).toBe(0); + expect(cbg_C1_to_N1_symm.connectingBonds[0].originAtom).toBe('C1'); + expect(cbg_C1_to_N1_symm.connectingBonds[0].targetAtom).toBe('N1'); + + const cbg_N1_to_C1_symm = networkConnections.find( + bg => bg.originIndex === 1 && bg.targetIndex === 0 && bg.targetSymmetry === '2_556', + ); + expect(cbg_N1_to_C1_symm).toBeDefined(); + expect(cbg_N1_to_C1_symm.originSymmetry).toBe(identSymmString); + expect(cbg_N1_to_C1_symm.creationOriginIndex).toBe(1); + expect(cbg_N1_to_C1_symm.connectingBonds[0].originAtom).toBe('N1'); + expect(cbg_N1_to_C1_symm.connectingBonds[0].targetAtom).toBe('C1'); + + // Assertions for translationLinks + // Each network connection will explore one step further, leading to a translational link. + expect(translationLinks.length).toBe(2); + + // Path: C1@1_555 -> N1@2_555 (network). From N1@2_555, seed N1->C1@2_565 leads to C1@1_575. + // This C1@1_575 is a translation of C1@1_555. + const tl_from_N1_at_2_555 = translationLinks.find( + bg => bg.originIndex === 1 && bg.originSymmetry === '2_555' && // From N1@2_555 + bg.targetIndex === 0 && bg.targetSymmetry === '1_564', // To C1 via direct target symmetry + ); + expect(tl_from_N1_at_2_555).toBeDefined(); + expect(tl_from_N1_at_2_555.creationOriginIndex).toBe(0); // Path started from C1 + expect(tl_from_N1_at_2_555.connectingBonds[0].originAtom).toBe('N1'); + expect(tl_from_N1_at_2_555.connectingBonds[0].targetAtom).toBe('C1'); + + // Path: N1@1_555 -> C1@2_565 (network). From C1@2_565, seed C1->N1@2_555 leads to N1@1_575. + // This N1@1_575 is a translation of N1@1_555. + const tl_from_C1_at_2_556 = translationLinks.find( + bg => bg.originIndex === 0 && bg.originSymmetry === '2_556' && // From C1@2_556 + bg.targetIndex === 1 && bg.targetSymmetry === '1_566', // To N1 via direct target symmetry + ); + expect(tl_from_C1_at_2_556).toBeDefined(); + expect(tl_from_C1_at_2_556.creationOriginIndex).toBe(1); // Path started from N1 + expect(tl_from_C1_at_2_556.connectingBonds[0].originAtom).toBe('C1'); + expect(tl_from_C1_at_2_556.connectingBonds[0].targetAtom).toBe('N1'); + + // Assertions for discoveredGroups + expect(discoveredGroups.length).toBe(2); // For Group 0 (C1) and Group 1 (N1) + + // Discovered groups for creationOriginIndex 0 (path starting from C1@1_555) + // Should contain C1@1_555 (identity) and N1@2_555 (from C1 -> N1@2_555 connection) + expect(discoveredGroups[0].length).toBe(2); + expect(discoveredGroups[0]).toEqual(expect.arrayContaining([ + expect.objectContaining({ groupIndex: 0, symmetryId: '1', translationId: '555' }), // C1@1_555 + expect.objectContaining({ groupIndex: 1, symmetryId: '2', translationId: '555' }), // N1@2_555 + ])); + + // Discovered groups for creationOriginIndex 1 (path starting from N1@1_555) + // Should contain N1@1_555 (identity) and C1@2_565 (from N1 -> C1@2_565 connection) + expect(discoveredGroups[1].length).toBe(2); + expect(discoveredGroups[1]).toEqual(expect.arrayContaining([ + expect.objectContaining({ groupIndex: 1, symmetryId: '1', translationId: '555' }), // N1@1_555 + expect.objectContaining({ groupIndex: 0, symmetryId: '2', translationId: '556' }), // C1@2_565 + ])); + }); + + test('should handle structure with no symmetry bonds', () => { + structureHelper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) + .addAtom('N1', 'N', 0.4, 0.4, 0.4) + .addBond('C1', 'N1', '.'); // No symmetry + structure = structureHelper.build(); + atomGroups = structure.calculateConnectedGroups(); + const identSymmString = structure.symmetry.identitySymOpId + '_555'; + + const { networkConnections, translationLinks, discoveredGroups } = createConnectivity( + structure, atomGroups, + ); + + expect(networkConnections.length).toBe(0); + expect(translationLinks.length).toBe(0); + expect(discoveredGroups.length).toBe(1); // Only one group C1-N1 + expect(discoveredGroups[0].length).toBe(1); // Only identity instance + expect(discoveredGroups[0][0].getSymmetryString()).toBe(identSymmString); + }); + + test('should complete for a simple case without hitting iteration limits', () => { + structureHelper = new MockStructureHelper() + .addAtom('C1', 'C').addAtom('N1', 'N').addBond('C1', 'N1', '2_555'); + structure = structureHelper.build(); + atomGroups = structure.calculateConnectedGroups(); + + const { networkConnections, translationLinks, discoveredGroups } = createConnectivity( + structure, atomGroups, + ); + expect(networkConnections.length).toBe(1); // C1@1_555 -> N1@2_555 + expect(translationLinks.length).toBe(0); + // Discovered for C1's path: C1@1_555 (initial), N1@2_555 (new) + expect(discoveredGroups[0].length).toBe(2); + // Discovered for N1's path: N1@1_555 (initial) + expect(discoveredGroups[1].length).toBe(1); + }); + }); + + describe('collectSymmetryRequirements', () => { + let structure; + // let atomGroups; // Not directly used in tests, but calculated by setupConnectivity + // let atomGroupMap; // Not directly used in tests, but calculated by setupConnectivity + let identSymmString; + + /** + * Helper to build structure, calculate groups, and get connectivity results. + * @param {MockStructureHelper} helper - The configured helper. + * @returns {object} Results from createConnectivity. + */ + function setupConnectivity(helper) { + structure = helper.build(); + const atomGroups = structure.calculateConnectedGroups(); + const atomGroupMap = new Map(); + atomGroups.forEach((group, i) => { + group.atoms.forEach(atom => atomGroupMap.set(atom.label, i)); + }); + identSymmString = structure.symmetry.identitySymOpId + '_555'; + return createConnectivity(structure, atomGroups); + } + + test('should handle origin=identity, finalTarget=symmetry (original case)', () => { + // Scenario 1: atom1 = originAtom, atom2 = targetAtom@symm + // Group 0: C1, C2 (identity symmetry) + // Group 1: N1 with symmetry '2_555' + // The connection between them is: C1 (group 0, 1_555) -> N1 (group 1, 2_555) + const helper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) + .addAtom('C2', 'C', 0.2, 0.2, 0.2) + .addAtom('N1', 'N', 0.4, 0.4, 0.4) + .addBond('C1', 'C2', '.') + .addBond('C1', 'N1', '2_555', 1.5, 0.01); + + const { networkConnections } = setupConnectivity(helper); + + const { requiredSymmetryInstances, interGroupBonds } = collectSymmetryRequirements( + networkConnections, structure, identSymmString, + ); + + expect(requiredSymmetryInstances.size).toBe(2); + expect(requiredSymmetryInstances).toContain('0@.@1_555'); // Identity for C1, C2 + expect(requiredSymmetryInstances).toContain('1@.@2_555'); // Symmetry for N1 + + expect(interGroupBonds.length).toBe(1); + expect(interGroupBonds[0].originSymmAtom).toBe('C1'); // Should be identity by default + expect(interGroupBonds[0].targetSymmAtom).toBe('N1@2_555'); + expect(interGroupBonds[0].bondLength).toBe(1.5); + expect(interGroupBonds[0].bondLengthSU).toBe(0.01); + }); + + test('should handle origin=identity, finalTarget=identity (identity bond)', () => { + // Scenario 2: atom1 = originAtom, atom2 = targetAtom + // Group 0: C1 + // Group 1: N1 + // Connection: C1 (G0, 1_555) -> N1 (G1, 1_555) via explicit identity '1_555' + const helper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) // Group 0 + .addAtom('N1', 'N', 0.4, 0.4, 0.4) // Group 1 + .addBond('C1', 'N1', '1_555', 1.4, 0.02); // Explicit identity symmetry + + const { networkConnections } = setupConnectivity(helper); + // Expected networkConnections: one entry for C1@1_555 -> N1 via direct targetSymmetry '1_555' + // originSymmetry = '1_555', targetSymmetry = '1_555' + + const { requiredSymmetryInstances, interGroupBonds } = collectSymmetryRequirements( + networkConnections, structure, identSymmString, + ); + + expect(requiredSymmetryInstances.size).toBe(2); + expect(requiredSymmetryInstances).toContain('0@.@1_555'); // C1 at identity + expect(requiredSymmetryInstances).toContain('1@.@1_555'); // N1 at identity + + expect(interGroupBonds.length).toBe(1); + expect(interGroupBonds[0].originSymmAtom).toBe('C1'); + expect(interGroupBonds[0].targetSymmAtom).toBe('N1'); + expect(interGroupBonds[0].bondLength).toBe(1.4); + }); + + test('should handle origin=symmetry, finalTarget=symmetry (chain connection)', () => { + // Scenario 3: atom1 = originAtom@symm, atom2 = targetAtom@symm_combined + // Path: A@1_555 -> B@2_555. Then from B@2_555, connect to D via '3_555' (relative to ASU B). + // Results in B@2_555 -> D@4_555 (using P21/m: op3(op2(X)) = op4 with y-translation) + const helper = new MockStructureHelper() + .addAtom('A1', 'C', 0.1, 0.1, 0.1) // Group 0 + .addAtom('B1', 'N', 0.2, 0.2, 0.2) // Group 1 + .addAtom('D1', 'O', 0.3, 0.3, 0.3) // Group 2 + .addBond('A1', 'B1', '2_555', 1.5, 0.01) // A1 -> B1@2_555 + .addBond('B1', 'D1', '3_555', 1.6, 0.02); // B1(ASU) -> D1@3_555 + + const { networkConnections } = setupConnectivity(helper); + // networkConnections will include: + // 1. A1@1_555 -> B1 via 2_555 (results in B1@2_555) + // 2. B1@1_555 -> D1 via 3_555 (results in D1@3_555) + // 3. B1@2_555 -> D1 via 3_555 (results in D1@4_555) + + const { requiredSymmetryInstances, interGroupBonds } = collectSymmetryRequirements( + networkConnections, structure, identSymmString, + ); + + expect(requiredSymmetryInstances.size).toBe(5); // A1@1, B1@1, D1@1, B1@2_555, D1@3_555, D1@4_555 + expect(requiredSymmetryInstances).toContain('0@.@1_555'); // A1 + expect(requiredSymmetryInstances).toContain('1@.@1_555'); // B1 + expect(requiredSymmetryInstances).toContain('1@.@2_555'); // B1@2_555 + expect(requiredSymmetryInstances).toContain('2@.@3_555'); // D1@3_555 + expect(requiredSymmetryInstances).toContain('2@.@4_555'); // D1@4_555 + + expect(interGroupBonds.length).toBe(3); + expect(interGroupBonds).toEqual(expect.arrayContaining([ + expect.objectContaining({ originSymmAtom: 'A1', targetSymmAtom: 'B1@2_555' }), + expect.objectContaining({ originSymmAtom: 'B1', targetSymmAtom: 'D1@3_555' }), + expect.objectContaining({ originSymmAtom: 'B1@2_555', targetSymmAtom: 'D1@4_555' }), + ])); + }); + + test('should handle origin=symmetry, finalTarget=identity (return to identity)', () => { + // Scenario 4: atom1 = originAtom@symm, atom2 = targetAtom + // Path: A@1_555 -> B@2_555. Then from B@2_555, connect to A via '2_555' (relative to ASU B). + // Results in B@2_555 -> A@1_555 (using P21/m: op2(op2(X)) = op1) + const helper = new MockStructureHelper() + .addAtom('A1', 'C', 0.1, 0.1, 0.1) // Group 0 + .addAtom('B1', 'N', 0.2, 0.2, 0.2) // Group 1 + .addBond('A1', 'B1', '2_555', 1.5, 0.01) // A1 -> B1@2_555 + .addBond('B1', 'A1', '2_555', 1.5, 0.01); // B1(ASU) -> A1@2_555 + + const { networkConnections } = setupConnectivity(helper); + + const { requiredSymmetryInstances, interGroupBonds } = collectSymmetryRequirements( + networkConnections, structure, identSymmString, + ); + + expect(requiredSymmetryInstances.size).toBe(4); // A1@1, B1@1, B1@2_555, A1@2_555 + expect(requiredSymmetryInstances).toContain('0@.@1_555'); // A1 + expect(requiredSymmetryInstances).toContain('1@.@1_555'); // B1 + expect(requiredSymmetryInstances).toContain('1@.@2_555'); // B1@2_555 + expect(requiredSymmetryInstances).toContain('0@.@2_555'); // A1@2_555 + + expect(interGroupBonds.length).toBe(2); // A1->B1@S2, B1->A1@S2, B1@S2->A1@S1 + expect(interGroupBonds).toEqual(expect.arrayContaining([ + expect.objectContaining({ originSymmAtom: 'A1', targetSymmAtom: 'B1@2_555' }), + expect.objectContaining({ originSymmAtom: 'B1', targetSymmAtom: 'A1@2_555' }), + ])); + }); + + test('should collect requirements for multiple inter-group connections from the same origin group', () => { + // Group 0: C1 + // Group 1: N1 + // Group 2: S1 + // Connections: C1 (G0) -> N1 (G1) via '2_555', C1 (G0) -> S1 (G2) via '3_555' + const helper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) // Group 0 + .addAtom('N1', 'N', 0.4, 0.4, 0.4) // Group 1 + .addAtom('S1', 'S', 0.5, 0.5, 0.5) // Group 2 + .addBond('C1', 'N1', '2_555', 1.5, 0.01) + .addBond('C1', 'S1', '3_555', 1.8, 0.02); + + const { networkConnections } = setupConnectivity(helper); + + const { requiredSymmetryInstances, interGroupBonds } = collectSymmetryRequirements( + networkConnections, structure, identSymmString, + ); + + expect(requiredSymmetryInstances.size).toBe(3); + expect(requiredSymmetryInstances).toContain('0@.@1_555'); // C1 identity + expect(requiredSymmetryInstances).toContain('1@.@2_555'); // N1 symm + expect(requiredSymmetryInstances).toContain('2@.@3_555'); // S1 symm + + expect(interGroupBonds.length).toBe(2); + expect(interGroupBonds).toEqual(expect.arrayContaining([ + expect.objectContaining({ originSymmAtom: 'C1', targetSymmAtom: 'N1@2_555', bondLength: 1.5 }), + expect.objectContaining({ originSymmAtom: 'C1', targetSymmAtom: 'S1@3_555', bondLength: 1.8 }), + ])); + }); + + test('should collect requirements for multiple inter-group connections from different origin groups', () => { + // Group 0: C1 + // Group 1: N1 + // Group 2: S1 + // Group 3: P1 + // Connections: C1 (G0) -> N1 (G1) via '2_555', S1 (G2) -> P1 (G3) via '4_555' + const helper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) // Group 0 + .addAtom('N1', 'N', 0.4, 0.4, 0.4) // Group 1 + .addAtom('S1', 'S', 0.5, 0.5, 0.5) // Group 2 + .addAtom('P1', 'P', 0.6, 0.6, 0.6) // Group 3 + .addBond('C1', 'N1', '2_555', 1.5, 0.01) + .addBond('S1', 'P1', '4_555', 2.0, 0.03); + + const { networkConnections } = setupConnectivity(helper); + + const { requiredSymmetryInstances, interGroupBonds } = collectSymmetryRequirements( + networkConnections, structure, identSymmString, + ); + + expect(requiredSymmetryInstances.size).toBe(4); + expect(requiredSymmetryInstances).toContain('0@.@1_555'); // C1 identity + expect(requiredSymmetryInstances).toContain('1@.@2_555'); // N1 symm + expect(requiredSymmetryInstances).toContain('2@.@1_555'); // S1 identity + expect(requiredSymmetryInstances).toContain('3@.@4_555'); // P1 symm + + expect(interGroupBonds.length).toBe(2); + expect(interGroupBonds).toEqual(expect.arrayContaining([ + expect.objectContaining({ originSymmAtom: 'C1', targetSymmAtom: 'N1@2_555', bondLength: 1.5 }), + expect.objectContaining({ originSymmAtom: 'S1', targetSymmAtom: 'P1@4_555', bondLength: 2.0 }), + ])); + }); + + test('should collect requirements for an intra-group symmetry connection', () => { + // Group 0: C1 + // Connection: C1 (G0) -> C1 (G0) via '2_555' + const helper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) // Group 0 + .addBond('C1', 'C1', '2_555', 2.5, 0.05); + + const { networkConnections } = setupConnectivity(helper); + + const { requiredSymmetryInstances, interGroupBonds } = collectSymmetryRequirements( + networkConnections, structure, identSymmString, + ); + + expect(requiredSymmetryInstances.size).toBe(2); + expect(requiredSymmetryInstances).toContain('0@.@1_555'); // C1 identity + expect(requiredSymmetryInstances).toContain('0@.@2_555'); // C1 symm + + expect(interGroupBonds.length).toBe(1); + expect(interGroupBonds[0]).toEqual(expect.objectContaining({ + originSymmAtom: 'C1', + targetSymmAtom: 'C1@2_555', + bondLength: 2.5, + bondLengthSU: 0.05, + })); + }); + + test('should collect requirements for a connection group with multiple connecting bonds', () => { + // Group 0: C1, C2 + // Group 1: N1 + // Connections: C1 (G0) -> N1 (G1) via '2_555', C2 (G0) -> N1 (G1) via '2_555' + const helper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) // Group 0 + .addAtom('C2', 'C', 0.2, 0.2, 0.2) // Group 0 + .addAtom('N1', 'N', 0.4, 0.4, 0.4) // Group 1 + .addBond('C1', 'C2', '.') + .addBond('C1', 'N1', '2_555', 1.5, 0.01) + .addBond('C2', 'N1', '2_555', 1.6, 0.02); + + const { networkConnections } = setupConnectivity(helper); + + const { requiredSymmetryInstances, interGroupBonds } = collectSymmetryRequirements( + networkConnections, structure, identSymmString, + ); + + expect(requiredSymmetryInstances.size).toBe(2); + expect(requiredSymmetryInstances).toContain('0@.@1_555'); // Identity for C1, C2 + expect(requiredSymmetryInstances).toContain('1@.@2_555'); // Symmetry for N1 + + expect(interGroupBonds.length).toBe(2); + expect(interGroupBonds[0].originSymmAtom).toBe('C1'); // Should be identity by default + expect(interGroupBonds[0].targetSymmAtom).toBe('N1@2_555'); + expect(interGroupBonds[0].bondLength).toBe(1.5); + expect(interGroupBonds[0].bondLengthSU).toBe(0.01); + }); + + // Note: Test cases involving combinations of inter/intra group bonds, + // and connections from different origin groups are implicitly covered + // by the tests above and the createConnectivity tests which ensure + // the networkConnections array contains all necessary connections. + }); + + describe('generateSymmetryAtoms', () => { + test('should generate symmetry atoms and handle special positions', () => { + // Start with C1 and apply '2_555' symmetry, it should generate C1@2_555 + structureHelper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1); + structure = structureHelper.build(); + atomGroups = structure.calculateConnectedGroups(); + const identSymmString = structure.symmetry.identitySymOpId + '_555'; + const requiredSymmetryInstances = new Set(['0@.@2_555']); + + const { specialPositionAtoms, newAtoms } = generateSymmetryAtoms( + requiredSymmetryInstances, atomGroups, structure, identSymmString, + ); + + expect(specialPositionAtoms.size).toBe(0); + expect(newAtoms.length).toBe(2); + expect(newAtoms[0].label).toBe('C1'); + expect(newAtoms[1].label).toBe('C1@2_555'); + }); + }); + + describe('generateSymmetryBonds', () => { + test('should generate symmetry bonds and handle special positions', () => { + // C1-C2 with identity and requires C1@2_555-C2@2_555 + structureHelper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) + .addAtom('C2', 'C', 0.2, 0.2, 0.2) + .addBond('C1', 'C2', '.', 1.5, 0.01); + structure = structureHelper.build(); + atomGroups = structure.calculateConnectedGroups(); + const identSymmString = structure.symmetry.identitySymOpId + '_555'; + const requiredSymmetryInstances = new Set(['0@.@2_555']); + const interGroupBonds = []; + const { specialPositionAtoms, newAtoms } = generateSymmetryAtoms( + requiredSymmetryInstances, atomGroups, structure, identSymmString, + ); + const { newBonds, atomLabels } = generateSymmetryBonds( + atomGroups, requiredSymmetryInstances, interGroupBonds, specialPositionAtoms, newAtoms, identSymmString, + ); + + // Should retain the original bond and add the symmetry-equivalent bond + expect(newBonds.length).toBe(2); + expect(newBonds).toEqual( + expect.arrayContaining([ + expect.objectContaining({ atom1Label: 'C1', atom2Label: 'C2' }), // Original + expect.objectContaining({ atom1Label: 'C1@2_555', atom2Label: 'C2@2_555' }), // Symmetry + ]), + ); + expect(atomLabels.size).toBe(4); + }); test('should correctly add inter-group bonds', () => { + const atomGroups = [ + { atoms: [{ label: 'C1', atomType: 'C' }], bonds: [], hBonds: [] }, // Group 0 + { atoms: [{ label: 'N1', atomType: 'N' }], bonds: [], hBonds: [] }, // Group 1 + ]; + const requiredSymmetryInstances = new Set([ + '0@.@1_555', // C1 at identity + '1@.@2_555', // N1 at symm '2_555' + ]); + const interGroupBonds = [ + { originSymmAtom: 'C1', targetSymmAtom: 'N1@2_555', bondLength: 1.6, bondLengthSU: 0.02 }, + ]; + const specialPositionAtoms = new Map(); + // Simulating atoms that would be in the final structure + const newAtomsList = [ + { label: 'C1', atomType: 'C' }, { label: 'N1@2_555', atomType: 'N' }, + ]; + const identSymmString = '1_555'; + + const { newBonds, atomLabels } = generateSymmetryBonds( + atomGroups, requiredSymmetryInstances, interGroupBonds, + specialPositionAtoms, newAtomsList, identSymmString, + ); + + expect(newBonds.length).toBe(1); // Only the inter-group bond + expect(newBonds[0]).toEqual(expect.objectContaining({ + atom1Label: 'C1', + atom2Label: 'N1@2_555', + bondLength: 1.6, + bondLengthSU: 0.02, + atom2SiteSymmetry: '.', // Inter-group bonds are created as direct connections + })); + expect(atomLabels.size).toBe(2); + expect(atomLabels).toContain('C1'); + expect(atomLabels).toContain('N1@2_555'); + }); + + test('should handle special positions in inter-group bonds', () => { + const atomGroups = [ + { atoms: [{ label: 'C1', atomType: 'C' }], bonds: [], hBonds: [] }, // Group 0 + { atoms: [{ label: 'N1', atomType: 'N' }], bonds: [], hBonds: [] }, // Group 1 + ]; + const requiredSymmetryInstances = new Set([ + // These are used for intra-group bond generation, not directly for inter-group here + '0@.@1_555', + '1@.@2_555', + '0@.@3_555', + ]); + const interGroupBonds = [ + { originSymmAtom: 'C1@3_555', targetSymmAtom: 'N1@2_555', bondLength: 1.7, bondLengthSU: 0.03 }, + ]; + const specialPositionAtoms = new Map([ + ['C1@3_555', 'C1X'], // C1@3_555 is mapped to C1X + ['N1@2_555', 'N1Y'], // N1@2_555 is mapped to N1Y + ]); + const newAtomsList = [ // Atoms that are kept after special position resolution + { label: 'C1X', atomType: 'C' }, { label: 'N1Y', atomType: 'N' }, + ]; + const identSymmString = '1_555'; + + const { newBonds, atomLabels } = generateSymmetryBonds( + atomGroups, requiredSymmetryInstances, interGroupBonds, + specialPositionAtoms, newAtomsList, identSymmString, + ); + + expect(newBonds.length).toBe(1); + expect(newBonds[0]).toEqual(expect.objectContaining({ + atom1Label: 'C1X', // Resolved from C1@3_555 + atom2Label: 'N1Y', // Resolved from N1@2_555 + bondLength: 1.7, + })); + expect(atomLabels.size).toBe(2); + expect(atomLabels).toContain('C1X'); + expect(atomLabels).toContain('N1Y'); + }); + + test('should not add duplicate inter-group bonds (after special position resolution)', () => { + const atomGroups = [ + { atoms: [{ label: 'C1', atomType: 'C' }], bonds: [], hBonds: [] }, + { atoms: [{ label: 'N1', atomType: 'N' }], bonds: [], hBonds: [] }, + ]; + const requiredSymmetryInstances = new Set(); // Not relevant for this specific test focus + const interGroupBonds = [ + { originSymmAtom: 'C1', targetSymmAtom: 'N1@2_555', bondLength: 1.6, bondLengthSU: 0.02 }, + // This bond, after special position mapping, will be identical to the first one + { originSymmAtom: 'C1@3_555', targetSymmAtom: 'N1@4_555', bondLength: 1.6, bondLengthSU: 0.02 }, + ]; + const specialPositionAtoms = new Map([ + ['C1@3_555', 'C1'], // Map C1@3_555 back to C1 + ['N1@4_555', 'N1@2_555'], // Map N1@4_555 back to N1@2_555 + ]); + const newAtomsList = [ + { label: 'C1', atomType: 'C' }, { label: 'N1@2_555', atomType: 'N' }, + ]; + const identSymmString = '1_555'; + + const { newBonds } = generateSymmetryBonds( + atomGroups, requiredSymmetryInstances, interGroupBonds, + specialPositionAtoms, newAtomsList, identSymmString, + ); + + expect(newBonds.length).toBe(1); // Only one bond should be present + expect(newBonds[0].atom1Label).toBe('C1'); + expect(newBonds[0].atom2Label).toBe('N1@2_555'); + }); + + test( + 'should not add inter-group bond if it duplicates an existing intra-group bond' + + ' (after symm & special pos)', () => { + const atomGroups = [ + { // Group 0 + atoms: [{ label: 'C1', atomType: 'C' }, { label: 'C2', atomType: 'C' }], + bonds: [new Bond('C1', 'C2', 1.5, 0.01, '.')], // Original intra-group bond + hBonds: [], + }, + { // Group 1 (dummy) + atoms: [{ label: 'N1', atomType: 'N' }], bonds: [], hBonds: [], + }, + ]; + // C1@symm_A will be mapped to C1, N1@symm_B (from group 1) will be mapped to C2 (in group 0) + // The inter-group bond C1@symm_A -- N1@symm_B becomes C1--C2, duplicating the intra-group bond. + const requiredSymmetryInstances = new Set([ + // '0@.@1_555' for C1, C2 (implicitly handled by initial bonds) + // '0@.@symm_A' for C1@symm_A (used in interGroupBonds) + // '1@.@symm_B' for N1@symm_B (used in interGroupBonds) + ]); + const interGroupBonds = [ + { originSymmAtom: 'C1@symm_A', targetSymmAtom: 'N1@symm_B', bondLength: 1.5, bondLengthSU: 0.01 }, + ]; + const specialPositionAtoms = new Map([ + ['C1@symm_A', 'C1'], + ['N1@symm_B', 'C2'], + ]); + const newAtomsList = [ + { label: 'C1', atomType: 'C' }, { label: 'C2', atomType: 'C' }, { label: 'N1', atomType: 'N' }, + ]; + const identSymmString = '1_555'; + + const { newBonds } = generateSymmetryBonds( + atomGroups, requiredSymmetryInstances, interGroupBonds, + specialPositionAtoms, newAtomsList, identSymmString, + ); + + expect(newBonds.length).toBe(1); // Only the original C1-C2 bond + expect(newBonds[0].atom1Label).toBe('C1'); + expect(newBonds[0].atom2Label).toBe('C2'); + }); + }); + + describe('generateSymmetryHBonds', () => { + test('should generate symmetry h-bonds and handle special positions', () => { + structureHelper = new MockStructureHelper() + .addAtom('O1', 'O', 0.1, 0.1, 0.1) + .addAtom('H1', 'H', 0.2, 0.2, 0.2) + .addAtom('N1', 'N', 0.3, 0.3, 0.3) + .addHBond('O1', 'H1', 'N1', '.', { daDist: 2.5, angle: 170 }); // Original H-bond + structure = structureHelper.build(); + atomGroups = structure.calculateConnectedGroups(); + const identSymmString = structure.symmetry.identitySymOpId + '_555'; + const requiredSymmetryInstances = new Set(['0@.@2_555']); + const atomGroupMap = new Map(); + atomGroups.forEach((group, i) => { + group.atoms.forEach(atom => atomGroupMap.set(atom.label, i)); + }); + const { specialPositionAtoms, newAtoms } = generateSymmetryAtoms( + requiredSymmetryInstances, atomGroups, structure, identSymmString, + ); + const atomLabels = new Set(newAtoms.map(a => a.label)); + const newHBonds = generateSymmetryHBonds( + structure, atomGroups, atomGroupMap, requiredSymmetryInstances, + specialPositionAtoms, atomLabels, identSymmString, + ); + // Should keep the original h-bond and add the symmetry-equivalent one + expect(newHBonds.length).toBe(2); + expect(newHBonds).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + donorAtomLabel: 'O1', hydrogenAtomLabel: 'H1', acceptorAtomLabel: 'N1', + }), // Original + expect.objectContaining({ + donorAtomLabel: 'O1@2_555', + hydrogenAtomLabel: 'H1@2_555', + acceptorAtomLabel: 'N1@2_555', + }), // Symmetry + ]), + ); + }); + + test('should generate external H-bonds where acceptor is generated and becomes internal', () => { + structureHelper = new MockStructureHelper() + .addAtom('O1', 'O', 0.1, 0.1, 0.1) // Group 0 + .addAtom('H1', 'H', 0.15, 0.15, 0.15) // Group 0 + .addAtom('N1', 'N', 0.4, 0.4, 0.4) // Group 1 + .addBond('O1', 'H1', '.') + // External H-bond: O1-H1 ... N1@2_555 + .addHBond('O1', 'H1', 'N1', '2_555', { daDist: 2.8, angle: 160 }); + structure = structureHelper.build(); + atomGroups = structure.calculateConnectedGroups(); + const identSymmString = structure.symmetry.identitySymOpId + '_555'; // '1_555' + const atomGroupMap = new Map(); + atomGroups.forEach((group, i) => { + group.atoms.forEach(atom => atomGroupMap.set(atom.label, i)); + }); + + // Required symmetry: + // - Group 0 (O1, H1) at symm '3_555' + // - Group 1 (N1) at symm combine('3_555', '2_555') -> '4_545' (from P21/m default) + const requiredSymmetryInstances = new Set([ + '0@.@3_555', // For O1@3_555, H1@3_555 + '1@.@4_545', // For N1@4_545 (target acceptor) + ]); + const { specialPositionAtoms, newAtoms } = generateSymmetryAtoms( + requiredSymmetryInstances, atomGroups, structure, identSymmString, + ); + const atomLabels = new Set(newAtoms.map(a => a.label)); // Will contain N1@4_545 + + const newHBonds = generateSymmetryHBonds( + structure, atomGroups, atomGroupMap, requiredSymmetryInstances, + specialPositionAtoms, atomLabels, identSymmString, + ); + + // Expected: + // 1. Original ASU H-bond: O1-H1...N1@2_555 (remains as is, acceptor is external) + // 2. Symm-generated external H-bond: O1@3_555 - H1@3_555 ... N1@4_545 (acceptor is internal) + expect(newHBonds.length).toBe(2); + expect(newHBonds).toEqual( + expect.arrayContaining([ + expect.objectContaining({ // Original external H-bond + donorAtomLabel: 'O1', hydrogenAtomLabel: 'H1', acceptorAtomLabel: 'N1', + acceptorAtomSymmetry: '2_555' }), + expect.objectContaining({ // Symmetry generated, acceptor now internal + donorAtomLabel: 'O1@3_555', hydrogenAtomLabel: 'H1@3_555', acceptorAtomLabel: 'N1@4_545', + acceptorAtomSymmetry: '.' }), + ]), + ); + }); + + test('should generate external H-bonds where acceptor remains external', () => { + structureHelper = new MockStructureHelper() + .addAtom('O1', 'O', 0.1, 0.1, 0.1) // Group 0 + .addAtom('H1', 'H', 0.15, 0.15, 0.15) // Group 0 + .addAtom('N1', 'N', 0.4, 0.4, 0.4) // Group 1 + .addBond('O1', 'H1', '.') + .addHBond('O1', 'H1', 'N1', '2_555', { daDist: 2.8, angle: 160 }); // O1-H1 ... N1@2_555 + structure = structureHelper.build(); + atomGroups = structure.calculateConnectedGroups(); + const identSymmString = structure.symmetry.identitySymOpId + '_555'; + const atomGroupMap = new Map(); + atomGroups.forEach((group, i) => { + group.atoms.forEach(atom => atomGroupMap.set(atom.label, i)); + }); + + // Required symmetry: + // - Group 0 (O1, H1) at symm '3_555' + // - Group 1 (N1) *not* generated at combined symm '4_545'. + const requiredSymmetryInstances = new Set(['0@.@3_555']); // Only O1, H1 are symm-generated + const { specialPositionAtoms, newAtoms } = generateSymmetryAtoms( + requiredSymmetryInstances, atomGroups, structure, identSymmString, + ); + const atomLabels = new Set(newAtoms.map(a => a.label)); // Will NOT contain N1@4_545 + + const newHBonds = generateSymmetryHBonds( + structure, atomGroups, atomGroupMap, requiredSymmetryInstances, + specialPositionAtoms, atomLabels, identSymmString, + ); + + // Expected: + // 1. Original ASU H-bond: O1-H1...N1@2_555 + // 2. Symm-generated external H-bond: O1@3_555 - H1@3_555 ... N1 (acceptor is N1, symm is '4_545') + expect(newHBonds.length).toBe(2); + expect(newHBonds).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + donorAtomLabel: 'O1', hydrogenAtomLabel: 'H1', acceptorAtomLabel: 'N1', + acceptorAtomSymmetry: '2_555' }), + expect.objectContaining({ + donorAtomLabel: 'O1@3_555', hydrogenAtomLabel: 'H1@3_555', acceptorAtomLabel: 'N1', + acceptorAtomSymmetry: '4_545' }), // Combined symm of '3_555' and '2_555' + ]), + ); + }); + + test('should handle special positions for acceptors in external H-bonds', () => { + structureHelper = new MockStructureHelper() + .addAtom('O1', 'O').addAtom('H1', 'H').addAtom('N1', 'N') // G0: O1,H1; G1: N1 + .addBond('O1', 'H1', '.') + .addHBond('O1', 'H1', 'N1', '2_555'); // O1-H1 ... N1@2_555 + structure = structureHelper.build(); + atomGroups = structure.calculateConnectedGroups(); + const identSymmString = '1_555'; + const atomGroupMap = new Map(); + atomGroups.forEach((g, i) => g.atoms.forEach(a => atomGroupMap.set(a.label, i))); + + const requiredSymmetryInstances = new Set(['0@.@3_555', '1@.@4_545']); // O1@3_555, H1@3_555, N1@4_545 + const specialPositionAtoms = new Map([['N1@4_545', 'N1_mapped']]); // N1@4_545 maps to N1_mapped + const { newAtoms } = generateSymmetryAtoms( + requiredSymmetryInstances, atomGroups, structure, identSymmString, + ); + const atomLabels = new Set([...newAtoms.map(a => a.label), 'N1_mapped']); // Ensure N1_mapped is "present" + + const newHBonds = generateSymmetryHBonds( + structure, atomGroups, atomGroupMap, requiredSymmetryInstances, specialPositionAtoms, + atomLabels, identSymmString, + ); + expect(newHBonds.find(hb => { + return hb.donorAtomLabel === 'O1@3_555' + && hb.acceptorAtomLabel === 'N1_mapped' + && hb.acceptorAtomSymmetry === '.'; + })).toBeDefined(); + }); + }); + + describe('processTranslationLinks', () => { + test('should process translation links correctly', () => { + structureHelper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) + .addAtom('N1', 'N', 0.4, 0.4, 0.4) + .addBond('C1', 'N1', '2_555'); + structure = structureHelper.build(); + atomGroups = structure.calculateConnectedGroups(); + + const translationLinks = [ + new ConnectingBondGroup(0, '1_555', 1, '2_565', [ + new ConnectingBond('C1', 'N1', 1.5, 0.01), + ], + 0), + ]; + const specialPositionAtoms = new Map(); + const existingBonds = new Set(); + + const additionalBonds = processTranslationLinks( + translationLinks, structure, specialPositionAtoms, existingBonds, + ); + + expect(additionalBonds.length).toBe(1); + expect(additionalBonds[0].atom1Label).toBe('C1@1_555'); + expect(additionalBonds[0].atom2Label).toBe('N1'); + expect(additionalBonds[0].atom2SiteSymmetry).toBe('2_565'); + }); + }); + + describe('growSymmetry', () => { + test('should return the same structure if no symmetry growth is needed', () => { + structureHelper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) + .addAtom('N1', 'N', 0.2, 0.2, 0.2) + .addBond('C1', 'N1', '.'); // No symmetry operation + structure = structureHelper.build(); + + const { grownStructure } = growFragment(structure); + + expect(grownStructure.atoms.length).toBe(2); + expect(grownStructure.atoms.map(a => a.label)).toEqual(expect.arrayContaining(['C1', 'N1'])); + expect(grownStructure.bonds.length).toBe(1); + expect(grownStructure.bonds[0].atom1Label).toBe('C1'); + expect(grownStructure.bonds[0].atom2Label).toBe('N1'); + expect(grownStructure.bonds[0].atom2SiteSymmetry).toBe('.'); + }); + + test('should perform simple symmetry growth for one inter-group bond', () => { + // C1 (Group 0) bonds to N1 (Group 1) via symmetry '2_555' + structureHelper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) // Group 0 + .addAtom('N1', 'N', 0.4, 0.4, 0.4) // Group 1 + .addBond('C1', 'N1', '2_555'); // C1 -> N1@2_555 + structure = structureHelper.build(); + + const { grownStructure } = growFragment(structure); + // Expected atoms: C1 (ASU), N1 (ASU), N1@2_555 + // Expected bonds: C1 - N1@2_555 (direct bond after growth) + + expect(grownStructure.atoms.length).toBe(3); + expect(grownStructure.atoms.map(a => a.label)).toEqual(expect.arrayContaining(['C1', 'N1', 'N1@2_555'])); + + expect(grownStructure.bonds.length).toBe(1); + const bond = grownStructure.bonds[0]; + expect(bond.atom1Label).toBe('C1'); + expect(bond.atom2Label).toBe('N1@2_555'); + expect(bond.atom2SiteSymmetry).toBe('.'); // Bond is now direct + }); + + test('should handle intra-group symmetry bond (atom to its own image)', () => { + // C1 (Group 0) bonds to its own image C1 via symmetry '2_555' + structureHelper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) // Group 0 + .addBond('C1', 'C1', '2_555'); // C1 -> C1@2_555 + structure = structureHelper.build(); + + const { grownStructure } = growFragment(structure); + // Expected atoms: C1 (ASU), C1@2_555 + // Expected bonds: C1 - C1@2_555 (direct bond after growth) + + expect(grownStructure.atoms.length).toBe(2); + expect(grownStructure.atoms.map(a => a.label)).toEqual(expect.arrayContaining(['C1', 'C1@2_555'])); + + expect(grownStructure.bonds.length).toBe(2); + const bond = grownStructure.bonds[0]; + // Order might vary due to createBondIdentifier + const bondLabels = [bond.atom1Label, bond.atom2Label].sort(); + expect(bondLabels).toEqual(['C1', 'C1@2_555'].sort()); + expect(bond.atom2SiteSymmetry).toBe('.'); + }); + + test('should handle chain growth A-B@S1, B@S1-C@S_combined', () => { + structureHelper = new MockStructureHelper() + .addAtom('A1', 'C', 0.1, 0.1, 0.1) // Group 0 + .addAtom('B1', 'N', 0.2, 0.2, 0.2) // Group 1 + .addAtom('C1', 'O', 0.3, 0.3, 0.3) // Group 2 + .addBond('A1', 'B1', '2_555') // A1 -> B1@2_555 + .addBond('B1', 'C1', '3_555'); // B1 -> C1@3_555 (from ASU B1) + structure = structureHelper.build(); + + const { grownStructure } = growFragment(structure); + + // Expected atoms: A1, B1, C1 (ASU) + // B1@2_555 (from A1-B1@S1) + // C1_symm (from B1@2_555 connecting to C1 via '3_555' relative to ASU B1) + // Symmetry of B1@2_555 is '2_555'. Connecting op to C1 is '3_555'. + // Final symm for C1 is combine('3_555', '2_555'). + // P21/m: op2 = (-x,y+1/2,-z), op3 = (-x,-y,-z) + // op3(op2(X)) = op3(-x,y+1/2,-z) = (x, -(y+1/2), z) = (x, -y-1/2, z). This is op4 with y-translation. + // So C1_symm should be C1@4_545 (approx, depends on exact combination logic for translations) + // Let's verify with actual combination: combine('3_555','2_555') -> '4_545' + const combinedSymm_B1_to_C1 = structure.symmetry.combineSymmetryCodes( + '2_555', '3_555', + ); // Should be '4_545' + + expect(grownStructure.atoms.length).toBe(6); + expect(grownStructure.atoms.map(a => a.label)).toEqual(expect.arrayContaining([ + 'A1', 'B1', 'C1', 'B1@2_555', `C1@${combinedSymm_B1_to_C1}`, + ])); + + expect(grownStructure.bonds.length).toBe(3); + expect(grownStructure.bonds).toEqual(expect.arrayContaining([ + expect.objectContaining({ atom1Label: 'A1', atom2Label: 'B1@2_555', atom2SiteSymmetry: '.' }), + expect.objectContaining( + { atom1Label: 'B1@2_555', atom2Label: `C1@${combinedSymm_B1_to_C1}`, atom2SiteSymmetry: '.' }, + ), + ])); + }); + + test('should correctly handle translational links', () => { + structureHelper = new MockStructureHelper() + .addAtom('C1', 'C', 0.1, 0.1, 0.1) // Group 0 + .addAtom('N1', 'N', 0.4, 0.4, 0.4); // Group 1 + structureHelper.addBond('C1', 'N1', '2_555'); // C1@1_555 -> N1@2_555 + structureHelper.addBond('N1', 'C1', '2_565'); // N1@1_555 -> C1@2_565 + // This setup leads to translational links as detailed in thought process. + structure = structureHelper.build(); + + const { grownStructure } = growFragment(structure); + + // Expected atoms: C1, N1 (ASU), N1@2_555, C1@2_565 + expect(grownStructure.atoms.length).toBe(4); + expect(grownStructure.atoms.map(a => a.label)).toEqual(expect.arrayContaining([ + 'C1', 'N1', 'N1@2_555', 'C1@2_565', + ])); + + // Expected bonds: + // 1. C1 - N1@2_555 (symm '.') from network connection + // 2. N1 - C1@2_565 (symm '.') from network connection + // 3. N1@2_555 - C1 (symm '1_565') from translationLink + // 4. C1@2_565 - N1 (symm '1_545') from translationLink + expect(grownStructure.bonds.length).toBe(4); + expect(grownStructure.bonds).toEqual(expect.arrayContaining([ + expect.objectContaining({ + atom1Label: 'C1', + atom2Label: 'N1@2_555', + atom2SiteSymmetry: '.', + bondLength: 1.5, + bondLengthSU: 0.01, + }), + expect.objectContaining({ + atom1Label: 'N1', + atom2Label: 'C1@2_565', + atom2SiteSymmetry: '.', + bondLength: 1.5, + bondLengthSU: 0.01, + }), + expect.objectContaining({ + atom1Label: 'N1@2_555', + atom2Label: 'C1', + atom2SiteSymmetry: '1_575', + bondLength: 1.5, + bondLengthSU: 0.01, + }), + expect.objectContaining({ + atom1Label: 'C1@2_565', + atom2Label: 'N1', + atom2SiteSymmetry: '1_575', + bondLength: 1.5, + bondLengthSU: 0.01, + }), + ])); + }); + + test('should handle special positions correctly', () => { + // C1 at (0,0,0) - an inversion center for P21/m with origin at -1. + // C2 at (0.1,0.1,0.1) + // Bond C1-C2 (intra-group) + // Bond C2 to C1 via symm '3_555' (op3 is inversion -x,-y,-z) + // C1@3_555 should map to C1. + structureHelper = new MockStructureHelper() + .addAtom('C1', 'C', 0, 0, 0) // Group 0 + .addAtom('C2', 'C', 0.1, 0.1, 0.1) // Group 0 + .addBond('C1', 'C2', '.') // Intra-group bond + .addBond('C2', 'C1', '3_555'); // C2 connects to C1@3_555 + structure = structureHelper.build(); + + const { grownStructure } = growFragment(structure); + + // Expected atoms: C1, C2 (ASU), C2@3_555. (C1@3_555 maps to C1) + expect(grownStructure.atoms.length).toBe(3); + expect(grownStructure.atoms.map(a => a.label)).toEqual(expect.arrayContaining([ + 'C1', 'C2', 'C2@3_555', + ])); + + // Expected bonds: + // 1. C1-C2 (original ASU bond) + // 2. C1-C2@3_555 (symm copy of C1-C2, where C1@3_555 became C1) + // The inter-group bond C2 - C1@3_555 becomes C2-C1, which is a duplicate of #1 and should be skipped. + expect(grownStructure.bonds.length).toBe(2); + const bond1 = grownStructure.bonds.find(b => + (b.atom1Label === 'C1' && b.atom2Label === 'C2') || (b.atom1Label === 'C2' && b.atom2Label === 'C1'), + ); + const bond2 = grownStructure.bonds.find(b => + (b.atom1Label === 'C1' && b.atom2Label === 'C2@3_555') + || (b.atom1Label === 'C2@3_555' && b.atom2Label === 'C1'), + ); + expect(bond1).toBeDefined(); + expect(bond1.atom2SiteSymmetry).toBe('.'); + expect(bond2).toBeDefined(); + expect(bond2.atom2SiteSymmetry).toBe('.'); + }); + + test('should keep hydrogen bonds if symmetry not grown in that direction', () => { + structureHelper = new MockStructureHelper() + .addAtom('O1', 'O', 0.1, 0.1, 0.1) // Group 0 + .addAtom('H1', 'H', 0.15, 0.15, 0.15) // Group 0 + .addAtom('N1', 'N', 0.4, 0.4, 0.4) // Group 1 + .addBond('O1', 'H1', '.') // O1-H1 bond + .addHBond('O1', 'H1', 'N1', '2_555'); // O1-H1 ... N1@2_555 + structure = structureHelper.build(); + + const { grownStructure } = growFragment(structure); + + // Expected atoms: O1, H1, N1 (ASU), N1@2_555 + expect(grownStructure.atoms.length).toBe(3); + expect(grownStructure.atoms.map(a => a.label)).toEqual(expect.arrayContaining([ + 'O1', 'H1', 'N1', + ])); + + // Expected bonds: O1-H1 + expect(grownStructure.bonds.length).toBe(1); + expect(grownStructure.bonds[0].atom1Label).toBe('O1'); + expect(grownStructure.bonds[0].atom2Label).toBe('H1'); + + // Expected H-Bonds: O1-H1...N1@2_555 + expect(grownStructure.hBonds.length).toBe(1); + const hbond = grownStructure.hBonds[0]; + expect(hbond.donorAtomLabel).toBe('O1'); + expect(hbond.hydrogenAtomLabel).toBe('H1'); + expect(hbond.acceptorAtomLabel).toBe('N1'); + expect(hbond.acceptorAtomSymmetry).toBe('2_555'); // HBond is now direct + }); + }); +}); \ No newline at end of file diff --git a/src/lib/structure/structure-modifiers/growing/grow-hbonds.js b/src/lib/structure/structure-modifiers/growing/grow-hbonds.js new file mode 100644 index 0000000..78af3a4 --- /dev/null +++ b/src/lib/structure/structure-modifiers/growing/grow-hbonds.js @@ -0,0 +1,120 @@ + +import { HBond, Bond } from '../../bonds.js'; +import { CrystalStructure } from '../../crystal.js'; +import { combineSymAtomLabel } from './util.js'; + +/** + * Grows external hydrogen bonds (HBonds) in a crystal structure by applying symmetry operations + * to connected groups and generating new atoms, bonds, and HBonds as needed. + * + * This function identifies HBonds that cross symmetry boundaries (i.e., those with a non-'.' + * acceptorAtomSymmetry), applies the corresponding symmetry operation to the connected group, + * and adds the resulting atoms, bonds, and HBonds to the structure. It ensures that each group + * is only grown once per symmetry operation to avoid duplication. + * @param {CrystalStructure} structure - The crystal structure to grow HBonds for. + * @returns {CrystalStructure} A new CrystalStructure instance with the grown atoms, bonds, and HBonds. + * @throws {Error} If an HBond references a non-existing acceptor atom. + */ +export function growExternalHBonds(structure) { + const groups = structure.calculateConnectedGroups(); + + const growableHBonds = []; + const finalHBonds = []; + structure.hBonds.forEach(hBond => { + if (hBond.acceptorAtomSymmetry === '.') { + finalHBonds.push(hBond); + } else { + growableHBonds.push(hBond); + } + }); + + const alreadyGrownGroups = new Set(); + + const finalAtoms = [...structure.atoms]; + const finalBonds = [...structure.bonds]; + + for (const hBond of growableHBonds) { + // add the hBond to final hBonds + finalHBonds.push(new HBond( + hBond.donorAtomLabel, + hBond.hydrogenAtomLabel, + combineSymAtomLabel(hBond.acceptorAtomLabel, hBond.acceptorAtomSymmetry, structure.symmetry), + hBond.donorHydrogenDistance, + hBond.donorHydrogenDistanceSU, + hBond.acceptorHydrogenDistance, + hBond.acceptorHydrogenDistanceSU, + hBond.donorAcceptorDistance, + hBond.donorAcceptorDistanceSU, + hBond.hBondAngle, + hBond.hBondAngleSU, + '.', + )); + + // find group index of acceptor atom, should always be possible because + // of checks in structure.calculateConnectedGroups() + const acceptorGroupIndex = groups.findIndex( + group => group.atoms.some(atom => atom.label === hBond.acceptorAtomLabel), + ); + + const symOpLabel = hBond.acceptorAtomSymmetry; + + // Make sure we are not growing a group twice if connected by two HBonds + const growLabel = `${acceptorGroupIndex}@${symOpLabel}`; + if (alreadyGrownGroups.has(growLabel)) { + continue; + } else { + alreadyGrownGroups.add(growLabel); + } + + // Get the acceptor group + const acceptorGroup = groups[acceptorGroupIndex]; + + // Create new atoms + const symmetryAtoms = structure.symmetry.applySymmetry(symOpLabel, acceptorGroup.atoms); + symmetryAtoms.forEach(atom => { + atom.label = combineSymAtomLabel(atom.label, symOpLabel, structure.symmetry); + finalAtoms.push(atom); + }); + + // Create new bonds + acceptorGroup.bonds + .filter(({ atom2SiteSymmetry }) => atom2SiteSymmetry === '.') + .forEach(bond => { + finalBonds.push(new Bond( + combineSymAtomLabel(bond.atom1Label, symOpLabel, structure.symmetry), + combineSymAtomLabel(bond.atom2Label, symOpLabel, structure.symmetry), + bond.bondLength, + bond.bondLengthSU, + '.', + )); + }); + + // Create new HBonds + acceptorGroup.hBonds + .filter(({ acceptorAtomSymmetry }) => acceptorAtomSymmetry === '.') + .forEach(hBond => { + finalHBonds.push(new HBond( + combineSymAtomLabel(hBond.donorAtomLabel, symOpLabel, structure.symmetry), + combineSymAtomLabel(hBond.hydrogenAtomLabel, symOpLabel, structure.symmetry), + combineSymAtomLabel(hBond.acceptorAtomLabel, symOpLabel, structure.symmetry), + hBond.donorHydrogenDistance, + hBond.donorHydrogenDistanceSU, + hBond.acceptorHydrogenDistance, + hBond.acceptorHydrogenDistanceSU, + hBond.donorAcceptorDistance, + hBond.donorAcceptorDistanceSU, + hBond.hBondAngle, + hBond.hBondAngleSU, + '.', + )); + }); + } + + return new CrystalStructure( + structure.cell, + finalAtoms, + finalBonds, + finalHBonds, + structure.symmetry, + ); +} \ No newline at end of file diff --git a/src/lib/structure/structure-modifiers/growing/grow-hbonds.test.js b/src/lib/structure/structure-modifiers/growing/grow-hbonds.test.js new file mode 100644 index 0000000..1af36c5 --- /dev/null +++ b/src/lib/structure/structure-modifiers/growing/grow-hbonds.test.js @@ -0,0 +1,221 @@ +import { CrystalStructure } from '../../crystal.js'; +import { growExternalHBonds } from './grow-hbonds.js'; +import { MockStructure } from '../base.test.js'; + +describe('growExternalHBonds', () => { + let basicStructure; + + beforeEach(() => { + // Create a basic structure with hydrogen bonds that have symmetry + basicStructure = MockStructure.createDefault({ + hasHydrogens: true, + hasMultipleSymmetry: true, + }) + .addHBond('O1', 'H1', 'N1', '2_555') // External HBond with symmetry + .addHBond('O1', 'H2', 'N2', '.') // Internal HBond without symmetry + .build(); + }); + + test('handles structures without HBonds', () => { + const structure = MockStructure.createDefault().build(); + const grown = growExternalHBonds(structure); + + expect(grown.atoms.length).toBe(structure.atoms.length); + expect(grown.bonds.length).toBe(structure.bonds.length); + expect(grown.hBonds.length).toBe(0); + }); + + test('preserves internal HBonds (no symmetry)', () => { + const structure = MockStructure.createDefault({ hasHydrogens: true }) + .addHBond('O1', 'H1', 'N1', '.') + .build(); + + const grown = growExternalHBonds(structure); + + expect(grown.hBonds.length).toBe(3); // 2 from default + 1 added = 3 total + expect(grown.hBonds.every(hb => hb.acceptorAtomSymmetry === '.')).toBe(true); + expect(grown.hBonds.some(hb => hb.donorAtomLabel === 'O1' && hb.acceptorAtomLabel === 'N1')).toBe(true); + }); + + test('grows external HBonds with symmetry operations', () => { + const grown = growExternalHBonds(basicStructure); + + // Should have both the original internal HBond and the grown external one + expect(grown.hBonds.length).toBeGreaterThan(basicStructure.hBonds.length); + + // Check for the grown HBond with updated labels + const grownHBond = grown.hBonds.find(hb => + hb.donorAtomLabel === 'O1@2_555' && + hb.acceptorAtomLabel === 'N1@2_555' && + hb.acceptorAtomSymmetry === '.', + ); + expect(grownHBond).toBeDefined(); + }); + + test('grows connected atoms, bonds, and HBonds for acceptor group', () => { + const grown = growExternalHBonds(basicStructure); + + // Should have grown atoms from the acceptor group + expect(grown.atoms.length).toBeGreaterThan(basicStructure.atoms.length); + + // Check for grown atoms with symmetry labels + const grownAtoms = grown.atoms.filter(atom => atom.label.includes('@2_555')); + expect(grownAtoms.length).toBeGreaterThan(0); + + // Should have grown bonds within the acceptor group + expect(grown.bonds.length).toBeGreaterThan(basicStructure.bonds.length); + }); + + test('avoids growing the same group twice for different HBonds', () => { + const structure = MockStructure.createDefault({ + hasHydrogens: true, + hasMultipleSymmetry: true, + }) + .addHBond('O1', 'H1', 'N1', '2_555') // First HBond to same acceptor group + .addHBond('C1', 'H2', 'N1', '2_555') // Second HBond to same acceptor group + .build(); + + const grown = growExternalHBonds(structure); + + // Count how many times N1@2_555 appears (should be once for each HBond but group grown only once) + const grownN1Atoms = grown.atoms.filter(atom => atom.label === 'N1@2_555'); + expect(grownN1Atoms.length).toBe(1); // Group should only be grown once + }); + + test('handles multiple symmetry operations', () => { + const structure = MockStructure.createDefault({ + hasHydrogens: true, + hasMultipleSymmetry: true, + }) + .addHBond('O1', 'H1', 'N1', '2_555') + .addHBond('O1', 'H2', 'N2', '3_565') + .build(); + + const grown = growExternalHBonds(structure); + + // Should have grown atoms for both symmetry operations + const grown2_555 = grown.atoms.filter(atom => atom.label.includes('@2_555')); + const grown3_565 = grown.atoms.filter(atom => atom.label.includes('@3_565')); + + expect(grown2_555.length).toBeGreaterThan(0); + expect(grown3_565.length).toBeGreaterThan(0); + }); + + test('preserves bond and HBond properties through growth', () => { + const structure = MockStructure.createDefault({ + hasHydrogens: true, + hasMultipleSymmetry: true, + }) + .addHBond('N2', 'H2', 'S1', '2_555', { + dhDist: 1.1, dhDistSU: 0.02, haDist: 2.1, haDistSU: 0.03, + daDist: 2.9, daDistSU: 0.04, angle: 176.0, angleSU: 2.0, + }) + .build(); + + const grown = growExternalHBonds(structure); + + const grownHBond = grown.hBonds.find(hb => + hb.donorAtomLabel === 'N2' && + hb.acceptorAtomLabel === 'S1@2_555', + ); + + expect(grownHBond.donorHydrogenDistance).toBe(1.1); + expect(grownHBond.donorHydrogenDistanceSU).toBe(0.02); + expect(grownHBond.acceptorHydrogenDistance).toBe(2.1); + expect(grownHBond.acceptorHydrogenDistanceSU).toBe(0.03); + expect(grownHBond.donorAcceptorDistance).toBe(2.9); + expect(grownHBond.donorAcceptorDistanceSU).toBe(0.04); + expect(grownHBond.hBondAngle).toBe(176); + expect(grownHBond.hBondAngleSU).toBe(2); + }); + + test('filters and grows only internal bonds within acceptor group', () => { + const structure = MockStructure.createDefault({ + hasHydrogens: true, + hasMultipleSymmetry: true, + }) + .addHBond('O1', 'H1', 'N1', '2_555') + .build(); + + const grown = growExternalHBonds(structure); + + // All grown bonds should have atom2SiteSymmetry of '.' + const grownBonds = grown.bonds.filter(bond => + bond.atom1Label.includes('@2_555') || bond.atom2Label.includes('@2_555'), + ); + + grownBonds.forEach(bond => { + expect(bond.atom2SiteSymmetry).toBe('.'); + }); + }); + + test('filters and grows only internal HBonds within acceptor group', () => { + const structure = MockStructure.createDefault({ + hasHydrogens: true, + hasMultipleSymmetry: true, + }) + .addHBond('O1', 'H1', 'N1', '2_555') + .addHBond('N1', 'H2', 'C1', '.') // Internal HBond within acceptor group + .build(); + + const grown = growExternalHBonds(structure); + + // Should find grown internal HBonds with updated labels + const grownInternalHBond = grown.hBonds.find(hb => + hb.donorAtomLabel === 'N1@2_555' && + hb.acceptorAtomLabel === 'C1@2_555' && + hb.acceptorAtomSymmetry === '.', + ); + + expect(grownInternalHBond).toBeDefined(); + }); + + test('returns new CrystalStructure with correct structure', () => { + const grown = growExternalHBonds(basicStructure); + + expect(grown).toBeInstanceOf(CrystalStructure); + expect(grown.cell).toBe(basicStructure.cell); + expect(grown.symmetry).toBe(basicStructure.symmetry); + expect(grown.atoms.length).toBeGreaterThan(basicStructure.atoms.length); + expect(grown.bonds.length).toBeGreaterThan(basicStructure.bonds.length); + expect(grown.hBonds.length).toBeGreaterThan(basicStructure.hBonds.length); + }); + + test('handles empty growable HBonds list', () => { + const structure = MockStructure.createDefault({ hasHydrogens: true }) + .addHBond('O1', 'H1', 'N1', '.') // Only internal HBonds + .build(); + + const grown = growExternalHBonds(structure); + + // Should be identical to original since no external HBonds to grow + expect(grown.atoms.length).toBe(structure.atoms.length); + expect(grown.bonds.length).toBe(structure.bonds.length); + expect(grown.hBonds.length).toBe(structure.hBonds.length); + }); + + test('preserves atom properties during growth', () => { + const structure = MockStructure.createDefault({ + hasHydrogens: true, + hasMultipleSymmetry: true, + hasAnisoHydrogens: true, + disorderGroups: [1], + }) + .addHBond('O1', 'H1', 'A0', '2_555') // A0 has disorder group 1 + .build(); + + const grown = growExternalHBonds(structure); + + const originalA0 = structure.atoms.find(atom => atom.label === 'A0'); + const grownA0 = grown.atoms.find(atom => atom.label === 'A0@2_555'); + + expect(grownA0).toBeDefined(); + expect(grownA0.atomType).toBe(originalA0.atomType); + expect(grownA0.disorderGroup).toBe(originalA0.disorderGroup); + + if (originalA0.adp) { + expect(grownA0.adp).toBeDefined(); + expect(grownA0.adp.constructor.name).toBe(originalA0.adp.constructor.name); + } + }); +}); diff --git a/src/lib/structure/structure-modifiers/growing/util.js b/src/lib/structure/structure-modifiers/growing/util.js new file mode 100644 index 0000000..9199704 --- /dev/null +++ b/src/lib/structure/structure-modifiers/growing/util.js @@ -0,0 +1,36 @@ +import { CellSymmetry } from '../../cell-symmetry.js'; + +/** + * Creates a unique identifier string for an atom including its symmetry code. + * @param {string} atomLabel - The base label of the atom (e.g., 'C1'). + * @param {string} symOpLabel - The symmetry code (e.g., '1_555'). + * @returns {string} The combined label (e.g., 'C1@1_555'). + */ +export function createSymAtomLabel(atomLabel, symOpLabel) { + return `${atomLabel}@${symOpLabel}`; +} + +/** + * Combines a new symmetry operation with an existing atom label that may already contain a symmetry code. + * If the atomLabel already has a symmetry code (e.g., 'C1@1_555'), it combines the new symOpLabel + * with the existing one using the `symmetry.combineSymmetryCodes` method. + * Otherwise, it creates a new symmetry atom label using the provided atomLabel and symOpLabel. + * @param {string} atomLabel - The base label of the atom, which may or may not include an existing symmetry code + * (e.g., 'C1' or 'C1@1_555'). + * @param {string} symOpLabel - The new symmetry code to apply or combine (e.g., '2_655'). + * @param {CellSymmetry} symmetry - An instance of CellSymmetry, used to combine symmetry codes. + * @returns {string} The combined atom label with the new or combined symmetry code + * (e.g., 'C1@2_655' or 'C1@combined_555'). + */ +export function combineSymAtomLabel(atomLabel, symOpLabel, symmetry) { + const labelParts = atomLabel.split('@'); + if (labelParts.length === 2) { + const combinedSymm = symmetry.combineSymmetryCodes(symOpLabel, labelParts[1]); + return createSymAtomLabel(labelParts[0], combinedSymm); + } else { + if (symOpLabel === `${symmetry.identitySymOpId}_555`) { + return atomLabel; // No change needed for identity operation + } + return createSymAtomLabel(atomLabel, symOpLabel); + } +} diff --git a/src/lib/structure/structure-modifiers/growing/util.test.js b/src/lib/structure/structure-modifiers/growing/util.test.js new file mode 100644 index 0000000..ac05a37 --- /dev/null +++ b/src/lib/structure/structure-modifiers/growing/util.test.js @@ -0,0 +1,6 @@ +import { createSymAtomLabel } from './util.js'; + +test('createSymAtomLabel correctly formats labels', () => { + expect(createSymAtomLabel('C1', '1_555')).toBe('C1@1_555'); + expect(createSymAtomLabel('O12', '2_654')).toBe('O12@2_654'); +}); \ No newline at end of file diff --git a/src/lib/structure/structure-modifiers/modes.js b/src/lib/structure/structure-modifiers/modes.js index 78266f1..a88d7c5 100644 --- a/src/lib/structure/structure-modifiers/modes.js +++ b/src/lib/structure/structure-modifiers/modes.js @@ -1,9 +1,11 @@ -import { Bond, HBond } from '../bonds.js'; import { Atom, CrystalStructure } from '../crystal.js'; import { BaseFilter } from './base.js'; import { UAnisoADP } from '../adp.js'; import { create, all } from 'mathjs'; +import { growFragment } from './growing/grow-fragment.js'; +import { growCell } from './growing/grow-cell.js'; +import { growExternalHBonds } from './growing/grow-hbonds.js'; export const math = create(all); /** @@ -41,6 +43,10 @@ export class HydrogenFilter extends BaseFilter { apply(structure) { this.ensureValidMode(structure); + if (this.mode === HydrogenFilter.MODES.ANISOTROPIC) { + return structure; + } + const filteredAtoms = structure.atoms .filter(atom => atom.atomType !== 'H' || this.mode !== HydrogenFilter.MODES.NONE) .map(atom => (new Atom( @@ -55,6 +61,17 @@ export class HydrogenFilter extends BaseFilter { const filteredBonds = structure.bonds .filter(bond => { if (this.mode === HydrogenFilter.MODES.NONE) { + if (bond.atom2SiteSymmetry !== '.') { + try { + // With cell growing the base atoms might not be present + // anymore, so we need to handle that gracefully + const atom1 = structure.getAtomByLabel(bond.atom1Label); + const atom2 = structure.getAtomByLabel(bond.atom2Label); + return !(atom1.atomType === 'H' || atom2.atomType === 'H'); + } catch { + return true; // Keep bond if there's an error + } + } const atom1 = structure.getAtomByLabel(bond.atom1Label); const atom2 = structure.getAtomByLabel(bond.atom2Label); return !(atom1.atomType === 'H' || atom2.atomType === 'H'); @@ -213,35 +230,27 @@ export class DisorderFilter extends BaseFilter { return modes; } } -/** - * Grows a crystal structure by applying symmetry operations to create symmetry-equivalent atoms and bonds. - * Can selectively grow based on regular bonds and/or hydrogen bonds. - * @augments BaseFilter - */ export class SymmetryGrower extends BaseFilter { static MODES = Object.freeze({ - BONDS_YES_HBONDS_YES: 'bonds-yes-hbonds-yes', - BONDS_YES_HBONDS_NO: 'bonds-yes-hbonds-no', - BONDS_YES_HBONDS_NONE: 'bonds-yes-hbonds-none', - BONDS_NO_HBONDS_NO: 'bonds-no-hbonds-no', - BONDS_NO_HBONDS_NONE: 'bonds-no-hbonds-none', - BONDS_NONE_HBONDS_YES: 'bonds-none-hbonds-yes', - BONDS_NONE_HBONDS_NO: 'bonds-none-hbonds-no', - BONDS_NONE_HBONDS_NONE: 'bonds-none-hbonds-none', + NONE: 'none', + HBONDS: 'hbonds', + FRAGMENT: 'fragment', + FRAGMENT_HBONDS: 'fragment-hbonds', + CELL: 'cell', + FRAGMENT_CELL: 'fragment-cell', }); static PREFERRED_FALLBACK_ORDER = [ - SymmetryGrower.MODES.BONDS_NO_HBONDS_NO, - SymmetryGrower.MODES.BONDS_NO_HBONDS_NONE, - SymmetryGrower.MODES.BONDS_NONE_HBONDS_NO, + SymmetryGrower.MODES.FRAGMENT, + SymmetryGrower.MODES.CELL, ]; /** * Creates a new symmetry grower * @param {SymmetryGrower.MODES} [mode] - Initial mode for growing symmetry */ - constructor(mode = SymmetryGrower.MODES.BONDS_NO_HBONDS_NO) { + constructor(mode = SymmetryGrower.MODES.NONE) { super(SymmetryGrower.MODES, mode, 'SymmetryGrower', SymmetryGrower.PREFERRED_FALLBACK_ORDER); } @@ -249,206 +258,64 @@ export class SymmetryGrower extends BaseFilter { return true; } - /** - * Combines an atom label with a symmetry operation code to create a unique identifier - * @param {string} atomLabel - Original atom label - * @param {string} symOp - Symmetry operation code (e.g., "2_555") - * @returns {string} Combined label or original label if no symmetry operation - */ - static combineSymOpLabel(atomLabel, symOp) { - return (!symOp || symOp === '.') ? atomLabel : `${atomLabel}@${symOp}`; + get drawCell() { + return this.mode === SymmetryGrower.MODES.CELL || this.mode === SymmetryGrower.MODES.FRAGMENT_CELL; } /** - * Finds atoms that can be grown through symmetry operations - * @param {CrystalStructure} structure - Structure to analyze - * @returns {object} Atoms that can be grown through bonds and hydrogen bonds - */ - findGrowableAtoms(structure) { - const bondAtoms = structure.bonds - .filter(({ atom2SiteSymmetry }) => atom2SiteSymmetry && atom2SiteSymmetry !== '.') - .map(({ atom2Label, atom2SiteSymmetry }) => [atom2Label, atom2SiteSymmetry]); - - const hBondAtoms = structure.hBonds - .filter(({ acceptorAtomSymmetry }) => acceptorAtomSymmetry && acceptorAtomSymmetry !== '.', - ) - .map(({ acceptorAtomLabel, acceptorAtomSymmetry }) => [acceptorAtomLabel, acceptorAtomSymmetry], - ); - - return { bondAtoms, hBondAtoms }; - } - - /** - * Grows a set of atoms and their connected groups using symmetry operations - * @param {CrystalStructure} structure - Original structure containing atoms to grow - * @param {Array<[string, string]>} atomsToGrow - Array of [atomLabel, symmetryOperation] pairs - * @param {object} growthState - Current state of structure growth - * @returns {object} Updated growth state including new atoms and bonds - * @throws {Error} If an atom is not found in any connected group - */ - growAtomArray(structure, atomsToGrow, growthState) { - for (const [atomLabel, symOp] of atomsToGrow) { - const newLabel = SymmetryGrower.combineSymOpLabel(atomLabel, symOp); - if (growthState.labels.has(newLabel)) { - continue; - } - - const group = structure.connectedGroups.find(group => group.atoms.some(atom => atom.label === atomLabel), - ); - - if (!group) { - throw new Error( - `Atom ${atomLabel} is not in any group. Typo or structure.recalculateConnectedGroups()?`, - ); - } - - const symmetryAtoms = structure.symmetry.applySymmetry(symOp, group.atoms); - const specialPositionMap = new Map(); - symmetryAtoms.forEach(atom => { - atom.label = SymmetryGrower.combineSymOpLabel(atom.label, symOp); - growthState.labels.add(atom.label); - growthState.atoms.add(atom); - }); - - group.bonds - .filter(({ atom2SiteSymmetry }) => atom2SiteSymmetry === '.') - .forEach(bond => { - growthState.bonds.add(new Bond( - SymmetryGrower.combineSymOpLabel(bond.atom1Label, symOp), - SymmetryGrower.combineSymOpLabel(bond.atom2Label, symOp), - bond.bondLength, - bond.bondLengthSU, - '.', - )); - }); - - group.hBonds - .filter(({ acceptorAtomSymmetry }) => acceptorAtomSymmetry === '.') - .forEach(hBond => { - growthState.hBonds.add(new HBond( - SymmetryGrower.combineSymOpLabel(hBond.donorAtomLabel, symOp), - SymmetryGrower.combineSymOpLabel(hBond.hydrogenAtomLabel, symOp), - SymmetryGrower.combineSymOpLabel(hBond.acceptorAtomLabel, symOp), - hBond.donorHydrogenDistance, - hBond.donorHydrogenDistanceSU, - hBond.acceptorHydrogenDistance, - hBond.acceptorHydrogenDistanceSU, - hBond.donorAcceptorDistance, - hBond.donorAcceptorDistanceSU, - hBond.hBondAngle, - hBond.hBondAngleSU, - '.', - )); - }); - } - return growthState; - } - /** - * Grows the structure according to the current mode. Switches mode with a warning if - * current mode is not applicable. + * Applies symmetry growth according to current mode * @param {CrystalStructure} structure - Structure to grow - * @returns {CrystalStructure} New structure with grown atoms and bonds + * @returns {CrystalStructure} New structure with grown symmetry */ apply(structure) { this.ensureValidMode(structure); - - const growableAtoms = this.findGrowableAtoms(structure); - - const growthState = { - atoms: new Set(structure.atoms), - bonds: new Set(structure.bonds), - hBonds: new Set(structure.hBonds), - labels: new Set(structure.atoms.map(({ label }) => label)), - }; - - if (this.mode.startsWith('bonds-yes')) { - this.growAtomArray(structure, growableAtoms.bondAtoms, growthState); + let workStructure = structure; + if (this.mode === SymmetryGrower.MODES.FRAGMENT || this.mode === SymmetryGrower.MODES.FRAGMENT_HBONDS) { + const growthResult = growFragment(structure); + workStructure = growthResult.grownStructure; } - - if (this.mode.includes('hbonds-yes')) { - this.growAtomArray(structure, growableAtoms.hBondAtoms, growthState); + if (this.mode === SymmetryGrower.MODES.CELL) { + workStructure = growCell(structure); + } else if (this.mode === SymmetryGrower.MODES.FRAGMENT_CELL) { + const { grownStructure, specialPositionAtoms } = growFragment(workStructure); + workStructure = growCell(grownStructure, false, specialPositionAtoms); } - const atomArray = Array.from(growthState.atoms); - - for (const bond of structure.bonds) { - if (bond.atom2SiteSymmetry === '.') { - continue; - } - const symmLabel = SymmetryGrower.combineSymOpLabel(bond.atom2Label, bond.atom2SiteSymmetry); - if (atomArray.some(a => a.label === symmLabel)) { - growthState.bonds.add( - new Bond(bond.atom1Label, symmLabel, bond.bondLength, bond.bondLengthSU, '.'), - ); - } - } - for (const hBond of structure.hBonds) { - if (hBond.acceptorAtomSymmetry === '.') { - continue; - } - const symmLabel = SymmetryGrower.combineSymOpLabel(hBond.acceptorAtomLabel, hBond.acceptorAtomSymmetry); - if (atomArray.some(a => a.label === symmLabel)) { - growthState.hBonds.add( - new HBond( - hBond.donorAtomLabel, hBond.hydrogenAtomLabel, symmLabel, - hBond.donorHydrogenDistance, hBond.donorHydrogenDistanceSU, - hBond.acceptorHydrogenDistance, hBond.acceptorHydrogenDistanceSU, - hBond.donorAcceptorDistance, hBond.donorAcceptorDistanceSU, - hBond.hBondAngle, hBond.hBondAngleSU, - '.', - ), - ); - } + if (this.mode === SymmetryGrower.MODES.HBONDS || this.mode === SymmetryGrower.MODES.FRAGMENT_HBONDS) { + workStructure = growExternalHBonds(workStructure); } - const hbondArray = Array.from(growthState.hBonds) - .filter(({ acceptorAtomLabel, hydrogenAtomLabel, donorAtomLabel }) => { - const condition1 = growthState.labels.has(acceptorAtomLabel); - const condition2 = growthState.labels.has(hydrogenAtomLabel); - const condition3 = growthState.labels.has(donorAtomLabel); - return condition1 && condition2 && condition3; - }); - - return new CrystalStructure( - structure.cell, - atomArray, - Array.from(growthState.bonds), - hbondArray, - structure.symmetry, - ); + return workStructure; } + /** - * Gets the modes that can be applied to the structure based on content + * Gets applicable modes based on structure symmetry and bonds * @param {CrystalStructure} structure - Structure to analyze * @returns {Array} Array of applicable mode names */ getApplicableModes(structure) { - const growableAtoms = this.findGrowableAtoms(structure); - const hasGrowableBonds = growableAtoms.bondAtoms.length > 0; - const hasGrowableHBonds = growableAtoms.hBondAtoms.length > 0; + const modes = [SymmetryGrower.MODES.NONE, SymmetryGrower.MODES.CELL, SymmetryGrower.MODES.FRAGMENT_CELL]; + const hasSymmetry = structure.symmetry && structure.symmetry.symmetryOperations.length > 0; - if (!hasGrowableBonds && !hasGrowableHBonds) { - return [SymmetryGrower.MODES.BONDS_NONE_HBONDS_NONE]; + if (!hasSymmetry) { + return modes; } - if (!hasGrowableBonds) { - return [ - SymmetryGrower.MODES.BONDS_NONE_HBONDS_YES, - SymmetryGrower.MODES.BONDS_NONE_HBONDS_NO, - ]; + const hasGrowableBonds = structure.bonds.some(bond => bond.atom2SiteSymmetry !== '.'); + if (hasGrowableBonds) { + modes.push(SymmetryGrower.MODES.FRAGMENT); } - if (!hasGrowableHBonds) { - return [ - SymmetryGrower.MODES.BONDS_YES_HBONDS_NONE, - SymmetryGrower.MODES.BONDS_NO_HBONDS_NONE, - ]; + const hasGrowableHBonds = structure.hBonds.some(hbond => hbond.acceptorAtomSymmetry !== '.'); + + if (hasGrowableHBonds) { + if (hasGrowableBonds) { + modes.push(SymmetryGrower.MODES.FRAGMENT_HBONDS); + } else { + modes.push(SymmetryGrower.MODES.HBONDS); + } } - return [ - SymmetryGrower.MODES.BONDS_YES_HBONDS_YES, - SymmetryGrower.MODES.BONDS_YES_HBONDS_NO, - SymmetryGrower.MODES.BONDS_NO_HBONDS_NO, - ]; + return modes; } -} +} \ No newline at end of file diff --git a/src/lib/structure/structure-modifiers/modes.test.js b/src/lib/structure/structure-modifiers/modes.test.js index 4e5e518..af86011 100644 --- a/src/lib/structure/structure-modifiers/modes.test.js +++ b/src/lib/structure/structure-modifiers/modes.test.js @@ -1,10 +1,12 @@ -import { HBond, Bond } from '../bonds.js'; -import { CrystalStructure, UnitCell, Atom } from '../crystal.js'; -import { FractPosition } from '../position.js'; +import { CrystalStructure, UnitCell } from '../crystal.js'; +import { CellSymmetry, SymmetryOperation } from '../cell-symmetry.js'; import { HydrogenFilter, DisorderFilter, SymmetryGrower, } from './modes.js'; -import { MockStructure, checkSymmetryGrowth } from './base.test.js'; +import { MockStructure } from './base.test.js'; +import { growFragment } from './growing/grow-fragment.js'; +import { growCell } from './growing/grow-cell.js'; +import { growExternalHBonds } from './growing/grow-hbonds.js'; describe('HydrogenFilter', () => { test('handles structures without hydrogens', () => { @@ -153,375 +155,319 @@ describe('DisorderFilter', () => { )).toBe(false); }); }); -describe('SymmetryGrower', () => { - describe('combineSymOpLabel', () => { - test('combine with symmmetry', () => { - const withSymmLabel = SymmetryGrower.combineSymOpLabel('B1', '2_643'); - expect(withSymmLabel).toBe('B1@2_643'); - }); - - test('combine with no symmetry', () => { - const noSymmLabel = SymmetryGrower.combineSymOpLabel('B1', '.'); - expect(noSymmLabel).toBe('B1'); - }); - }); - - describe('findGrowableAtoms', () => { - test('finds atoms with symmetry references in bonds', () => { - const structure = MockStructure.createDefault({ - hasMultipleSymmetry: true, - hasHydrogens: false, - }).build(); +describe('SymmetryGrower', () => { + describe('constructor and basic properties', () => { + test('initializes with default mode NONE', () => { const grower = new SymmetryGrower(); - const { bondAtoms } = grower.findGrowableAtoms(structure); - - expect(bondAtoms).toContainEqual(['N2', '2_545']); - expect(bondAtoms).toContainEqual(['N1', '3_565']); - expect(bondAtoms).toContainEqual(['N2', '4_655']); + expect(grower.mode).toBe(SymmetryGrower.MODES.NONE); + expect(grower.filterName).toBe('SymmetryGrower'); }); - test('finds atoms with symmetry references in hbonds', () => { - const structure = new CrystalStructure( - new UnitCell(10, 10, 10, 90, 90, 90), - [ - new Atom('O1', 'O', new FractPosition(0.1, 0.1, 0.1)), - new Atom('H1', 'H', new FractPosition(0.2, 0.2, 0.2)), - new Atom('N1', 'N', new FractPosition(0.3, 0.3, 0.3)), - ], - [], - [new HBond('O1', 'H1', 'N1', 1.0, 0.01, 2.0, 0.02, 2.8, 0.03, 175, 1, '2_555')], - ); - - const grower = new SymmetryGrower(); - const { hBondAtoms } = grower.findGrowableAtoms(structure); - expect(hBondAtoms).toContainEqual(['N1', '2_555']); + test('initializes with specified mode', () => { + const grower = new SymmetryGrower(SymmetryGrower.MODES.FRAGMENT); + expect(grower.mode).toBe(SymmetryGrower.MODES.FRAGMENT); }); - test('ignores non-symmetry references', () => { - const structure = new CrystalStructure( - new UnitCell(10, 10, 10, 90, 90, 90), - [ - new Atom('C1', 'C', new FractPosition(0.1, 0.1, 0.1)), - new Atom('C2', 'C', new FractPosition(0.2, 0.2, 0.2)), - ], - [new Bond('C1', 'C2', 1.5, 0.01, '.')], - [], - ); - + test('requiresCameraUpdate returns true', () => { const grower = new SymmetryGrower(); - const { bondAtoms, hBondAtoms } = grower.findGrowableAtoms(structure); - - expect(bondAtoms).toHaveLength(0); - expect(hBondAtoms).toHaveLength(0); + expect(grower.requiresCameraUpdate).toBe(true); }); - test('handles multiple symmetry references to same atom', () => { - const structure = new CrystalStructure( - new UnitCell(10, 10, 10, 90, 90, 90), - [ - new Atom('N1', 'N', new FractPosition(0.1, 0.1, 0.1)), - new Atom('C1', 'C', new FractPosition(0.2, 0.2, 0.2)), - ], - [ - new Bond('C1', 'N1', 1.5, 0.01, '2_555'), - new Bond('C1', 'N1', 1.5, 0.01, '3_555'), - ], - [], - ); - + test('drawCell property depends on mode', () => { const grower = new SymmetryGrower(); - const { bondAtoms } = grower.findGrowableAtoms(structure); - - expect(bondAtoms).toContainEqual(['N1', '2_555']); - expect(bondAtoms).toContainEqual(['N1', '3_555']); + + grower.mode = SymmetryGrower.MODES.NONE; + expect(grower.drawCell).toBe(false); + + grower.mode = SymmetryGrower.MODES.FRAGMENT; + expect(grower.drawCell).toBe(false); + + grower.mode = SymmetryGrower.MODES.HBONDS; + expect(grower.drawCell).toBe(false); + + grower.mode = SymmetryGrower.MODES.FRAGMENT_HBONDS; + expect(grower.drawCell).toBe(false); + + grower.mode = SymmetryGrower.MODES.CELL; + expect(grower.drawCell).toBe(true); + + grower.mode = SymmetryGrower.MODES.FRAGMENT_CELL; + expect(grower.drawCell).toBe(true); }); }); - describe('growAtomArray', () => { - test('grows atoms with their connected atoms and bonds', () => { - const structure = MockStructure.createDefault({ - hasMultipleSymmetry: true, - }).build(); - - const grower = new SymmetryGrower(SymmetryGrower.MODES.BONDS_YES_HBONDS_YES); - let growthState = { - atoms: new Set(structure.atoms), - bonds: new Set(structure.bonds), - hBonds: new Set(structure.hBonds), - labels: new Set(), - }; - - const atomsToGrow = [['N2', '2_555']]; - growthState = grower.growAtomArray(structure, atomsToGrow, growthState); - - // Check all atoms in connected group were grown - const grownLabels = Array.from(growthState.labels); - expect(grownLabels).toContain('N2@2_555'); - expect(grownLabels).toContain('C1@2_555'); - - // Check bonds were grown with correct labels - const grownBonds = Array.from(growthState.bonds); - const bondLabels = grownBonds.map(b => [b.atom1Label, b.atom2Label]); - expect(bondLabels).toContainEqual(['C1@2_555', 'C2@2_555']); + describe('getApplicableModes', () => { + test('returns only basic modes for structure without symmetry', () => { + const structure = MockStructure.createDefault().build(); + const grower = new SymmetryGrower(); + + const modes = grower.getApplicableModes(structure); + expect(modes).toEqual([ + SymmetryGrower.MODES.NONE, + SymmetryGrower.MODES.CELL, + SymmetryGrower.MODES.FRAGMENT_CELL, + ]); }); - test('skips already grown atoms', () => { - const structure = MockStructure.createDefault({ - hasMultipleSymmetry: true, - }).build(); - + test('includes fragment mode when growable bonds exist', () => { + const structure = MockStructure.createDefault({ hasMultipleSymmetry: true }) + .addBond('C1', 'C2', '2_555') + .build(); const grower = new SymmetryGrower(); - const growthState = { - atoms: new Set(), - bonds: new Set(), - hBonds: new Set(), - labels: new Set(['N1@2_555']), - }; - - const atomsToGrow = [['N1', '2_555']]; - grower.growAtomArray(structure, atomsToGrow, growthState); - - expect(growthState.atoms.size).toBe(0); - expect(growthState.bonds.size).toBe(0); + + const modes = grower.getApplicableModes(structure); + expect(modes).toContain(SymmetryGrower.MODES.FRAGMENT); }); - test('grows h-bonds within connected groups', () => { - const structure = MockStructure.createDefault({ - hasMultipleSymmetry: true, + test('includes hbonds mode when only growable HBonds exist', () => { + const structure = MockStructure.createDefault({ hasHydrogens: true, - }).build(); - + }) + .addHBond('O1', 'H1', 'N1', '2_555') + .build(); const grower = new SymmetryGrower(); - const growthState = { - atoms: new Set(), - bonds: new Set(), - hBonds: new Set(), - labels: new Set(), - }; - - const atomsToGrow = [['N2', '2_555']]; - grower.growAtomArray(structure, atomsToGrow, growthState); - - const grownHBonds = Array.from(growthState.hBonds); - const hbondLabels = grownHBonds.map(h => [ - h.donorAtomLabel, - h.hydrogenAtomLabel, - h.acceptorAtomLabel, - ]); - expect(hbondLabels).toContainEqual(['O1@2_555', 'H1@2_555', 'N1@2_555']); + + const modes = grower.getApplicableModes(structure); + expect(modes).toContain(SymmetryGrower.MODES.HBONDS); + expect(modes).not.toContain(SymmetryGrower.MODES.FRAGMENT_HBONDS); }); - test('preserves atom properties through growth', () => { - const structure = MockStructure.createDefault({ + test('includes fragment-hbonds mode when both growable bonds and HBonds exist', () => { + const structure = MockStructure.createDefault({ hasMultipleSymmetry: true, - hasAnisoHydrogens: true, - }).build(); - + hasHydrogens: true, + }) + .addBond('C1', 'C2', '2_555') + .addHBond('O1', 'H1', 'N1', '3_565') + .build(); const grower = new SymmetryGrower(); - const growthState = { - atoms: new Set(), - bonds: new Set(), - hBonds: new Set(), - labels: new Set(), - }; - - const atomsToGrow = [['H3', '2_555']]; - grower.growAtomArray(structure, atomsToGrow, growthState); - - const grownH3 = Array.from(growthState.atoms).find(a => a.label === 'H3@2_555'); - expect(grownH3.adp.constructor.name).toBe('UAnisoADP'); - expect(grownH3.adp.u11).toBe(0.01); + + const modes = grower.getApplicableModes(structure); + expect(modes).toContain(SymmetryGrower.MODES.FRAGMENT_HBONDS); + expect(modes).not.toContain(SymmetryGrower.MODES.HBONDS); }); - test('handles missing connected groups with error', () => { - const structure = MockStructure.createDefault({ - hasMultipleSymmetry: true, - }).build(); - + test('handles structures with internal bonds/HBonds only', () => { + const structure = MockStructure.createDefault({ + hasMultipleSymmetry: false, + hasHydrogens: true, + }) + .addBond('C1', 'C2', '.') // Internal bond + .addHBond('O1', 'H1', 'N1', '.') // Internal HBond + .build(); const grower = new SymmetryGrower(); - const growthState = { - atoms: new Set(), - bonds: new Set(), - hBonds: new Set(), - labels: new Set(), - }; - - const atomsToGrow = [['NonExistentAtom', '2_555']]; - expect(() => grower.growAtomArray(structure, atomsToGrow, growthState)) - .toThrow('Atom NonExistentAtom is not in any group. Typo or structure.recalculateConnectedGroups()?'); - + + const modes = grower.getApplicableModes(structure); + expect(modes).not.toContain(SymmetryGrower.MODES.FRAGMENT); + expect(modes).not.toContain(SymmetryGrower.MODES.HBONDS); + expect(modes).not.toContain(SymmetryGrower.MODES.FRAGMENT_HBONDS); }); }); - describe('apply', () => { - test('grows nothing in BONDS_NONE_HBONDS_NONE mode', () => { - const structure = MockStructure.createDefault({ + describe('apply method', () => { + let structure; + + beforeEach(() => { + structure = MockStructure.createDefault({ hasMultipleSymmetry: true, hasHydrogens: true, - }).build(); - - const grower = new SymmetryGrower(SymmetryGrower.MODES.BONDS_NO_HBONDS_NO); - const grown = grower.apply(structure); - - expect(grown.atoms.length).toBe(structure.atoms.length); - expect(grown.bonds.length).toBe(structure.bonds.length); - expect(grown.hBonds.length).toBe(structure.hBonds.length); + }) + .addBond('C1', 'C2', '2_555') + .addHBond('O1', 'H1', 'N1', '3_565') + .build(); }); - test('grows only bond symmetry in BONDS_YES_HBONDS_NO mode', () => { - const structure = MockStructure.createDefault({ - hasMultipleSymmetry: true, - hasHydrogens: true, - }).build(); - - const grower = new SymmetryGrower(SymmetryGrower.MODES.BONDS_YES_HBONDS_NO); - const grown = grower.apply(structure); - - const errors = checkSymmetryGrowth(grown, { - checkSymmetries: ['2_545', '3_565', '4_655'], - excludeSymmetries: ['2_555', '3_568'], - }); - expect(errors).toEqual([]); - - // check that original connecting bond is in set - expect(grown.bonds.some(b => b.atom1Label === 'N1' && b.atom2Label === 'N2@2_545')).toBe(true); + test('returns unchanged structure in NONE mode', () => { + const grower = new SymmetryGrower(SymmetryGrower.MODES.NONE); + const result = grower.apply(structure); + + expect(result).toBe(structure); + expect(result.atoms.length).toBe(structure.atoms.length); + expect(result.bonds.length).toBe(structure.bonds.length); + expect(result.hBonds.length).toBe(structure.hBonds.length); }); - test('grows both bond and hbond symmetry in BONDS_YES_HBONDS_YES mode', () => { - const structure = MockStructure.createDefault({ - hasMultipleSymmetry: true, - hasHydrogens: true, - }).build(); - - const grower = new SymmetryGrower(SymmetryGrower.MODES.BONDS_YES_HBONDS_YES); - const grown = grower.apply(structure); - - const errors = checkSymmetryGrowth(grown, { - checkSymmetries: ['3_568', '2_545', '3_565', '4_655'], - excludeSymmetries: [ - '2_555', // S1 is not connected to group - ], - }); - expect(errors).toEqual([]); - - expect(grown.atoms.some(a => a.label === 'S1@2_555')).toBe(true); - expect( - grown.hBonds.some( - hb => hb.donorAtomLabel === 'N2' - && hb.hydrogenAtomLabel === 'H2' - && hb.acceptorAtomLabel === 'O1@3_568', - )).toBe(true); - expect(grown.bonds.some(b => b.atom1Label === 'N1' && b.atom2Label === 'N2@2_545')).toBe(true); + test('grows fragment in FRAGMENT mode', () => { + const grower = new SymmetryGrower(SymmetryGrower.MODES.FRAGMENT); + const result = grower.apply(structure); + + // Compare with direct growFragment call + const { grownStructure: expected } = growFragment(structure); + expect(result.atoms.length).toBe(expected.atoms.length); + expect(result.bonds.length).toBe(expected.bonds.length); }); - test('maintains all properties in grown structure', () => { - const structure = MockStructure.createDefault({ - hasMultipleSymmetry: true, - hasHydrogens: true, - hasAnisoHydrogens: true, - disorderGroups: [1, 2], - }).build(); - - const grower = new SymmetryGrower(SymmetryGrower.MODES.BONDS_YES_HBONDS_YES); - const grown = grower.apply(structure); - - // Check ADP preservation - const originalH3 = structure.atoms.find(a => a.label === 'H3'); - const grownH3 = grown.atoms.find(a => a.label === 'H3@2_545'); - expect(grownH3.adp.constructor.name).toBe('UAnisoADP'); - expect(grownH3.adp.u11).toBe(originalH3.adp.u11); - - // Check disorder group preservation - const originalA0 = structure.atoms.find(a => a.label === 'A0'); - const grownA0 = grown.atoms.find(a => a.label === 'A0@2_545'); - expect(grownA0.disorderGroup).toBe(originalA0.disorderGroup); - - // Check bond properties - const originalBond = structure.bonds.find(b => b.atom1Label === 'N1' && b.atom2Label === 'N2'); - const grownBond = grown.bonds.find(b => b.atom1Label === 'N2@2_545' && b.atom2Label === 'C1@2_545', - ); - expect(grownBond.bondLength).toBe(originalBond.bondLength); - expect(grownBond.bondLengthSU).toBe(originalBond.bondLengthSU); + test('grows cell in CELL mode', () => { + const grower = new SymmetryGrower(SymmetryGrower.MODES.CELL); + const result = grower.apply(structure); + + // Compare with direct growCell call + const expected = growCell(structure); + expect(result.atoms.length).toBe(expected.atoms.length); + expect(result.bonds.length).toBe(expected.bonds.length); }); - test('ensures valid mode before growing', () => { - const structure = MockStructure.createDefault({ - hasMultipleSymmetry: true, - hasHydrogens: true, - }).build(); - - // Start with invalid mode for structure - const grower = new SymmetryGrower(SymmetryGrower.MODES.BONDS_NONE_HBONDS_NONE); - grower.apply(structure); - - expect(grower.mode).toBe(SymmetryGrower.MODES.BONDS_NO_HBONDS_NO); + test('combines fragment and HBonds growth in FRAGMENT_HBONDS mode', () => { + const grower = new SymmetryGrower(SymmetryGrower.MODES.FRAGMENT_HBONDS); + const result = grower.apply(structure); + + // Should first grow fragment then HBonds + const { grownStructure: fragmentGrown } = growFragment(structure); + const expected = growExternalHBonds(fragmentGrown); + + expect(result.atoms.length).toBe(expected.atoms.length); + expect(result.bonds.length).toBe(expected.bonds.length); + expect(result.hBonds.length).toBe(expected.hBonds.length); }); - }); - describe('getApplicableModes', () => { - test('returns BONDS_NONE_HBONDS_NONE for structure with no symmetry', () => { - const structure = MockStructure.createDefault().build(); - const grower = new SymmetryGrower(); - - expect(grower.getApplicableModes(structure)) - .toEqual([SymmetryGrower.MODES.BONDS_NONE_HBONDS_NONE]); + test('combines fragment and cell growth in FRAGMENT_CELL mode', () => { + const grower = new SymmetryGrower(SymmetryGrower.MODES.FRAGMENT_CELL); + const result = grower.apply(structure); + + // Should first grow fragment then cell without cutting + const { grownStructure: fragmentGrown } = growFragment(structure); + const expected = growCell(fragmentGrown, false); + + expect(result.atoms.length).toBe(expected.atoms.length); + expect(result.bonds.length).toBe(expected.bonds.length); }); - test('returns bond-only modes for structure with only bond symmetry', () => { - const structure = MockStructure.createDefault({ - hasMultipleSymmetry: true, - hasHydrogens: false, - }).build(); - - const grower = new SymmetryGrower(); - const modes = grower.getApplicableModes(structure); - - expect(modes).toContain(SymmetryGrower.MODES.BONDS_YES_HBONDS_NONE); - expect(modes).toContain(SymmetryGrower.MODES.BONDS_NO_HBONDS_NONE); - expect(modes).not.toContain(SymmetryGrower.MODES.BONDS_YES_HBONDS_YES); + test('validates mode before applying', () => { + // Create structure that only supports NONE and CELL modes + const simpleStructure = MockStructure.createDefault().build(); + const grower = new SymmetryGrower(SymmetryGrower.MODES.FRAGMENT); + + // Should fallback to CELL mode (first in preferred order that's applicable) + const result = grower.apply(simpleStructure); + expect(grower.mode).toBe(SymmetryGrower.MODES.CELL); + + // Should apply cell growth + const expected = growCell(simpleStructure); + expect(result.atoms.length).toBe(expected.atoms.length); }); - test('returns hbond-only modes for structure with only hbond symmetry', () => { - const structure = MockStructure.createDefault({ + test('grows external HBonds in HBONDS mode', () => { + const nonSymmetricStructure = MockStructure.createDefault({ hasHydrogens: true, }) .addHBond('O1', 'H1', 'N1', '2_555') .build(); - - const grower = new SymmetryGrower(); - const modes = grower.getApplicableModes(structure); - - expect(modes).toContain(SymmetryGrower.MODES.BONDS_NONE_HBONDS_YES); - expect(modes).toContain(SymmetryGrower.MODES.BONDS_NONE_HBONDS_NO); - expect(modes).not.toContain(SymmetryGrower.MODES.BONDS_YES_HBONDS_YES); + const expected = growExternalHBonds(nonSymmetricStructure); + const grower = new SymmetryGrower(SymmetryGrower.MODES.HBONDS); + const result = grower.apply(nonSymmetricStructure); + console.log(grower.getApplicableModes(nonSymmetricStructure)); + + // Compare with direct growExternalHBonds call + //const expected = growExternalHBonds(growFragment(structure)); + expect(result.atoms.length).toBe(expected.atoms.length); + expect(result.hBonds.length).toBe(expected.hBonds.length); }); + }); - test('returns all modes for structure with both bond and hbond symmetry', () => { - const structure = MockStructure.createDefault({ + describe('mode cycling', () => { + test('cycles through applicable modes correctly', () => { + const structure = MockStructure.createDefault({ hasMultipleSymmetry: true, hasHydrogens: true, - }).build(); + }) + .addBond('C1', 'C2', '2_555') + .addHBond('O1', 'H1', 'N1', '3_565') + .build(); + + const grower = new SymmetryGrower(SymmetryGrower.MODES.NONE); + + // Get all applicable modes + const applicableModes = grower.getApplicableModes(structure); + expect(applicableModes.length).toBeGreaterThan(3); + + // Cycle through all modes + const seenModes = [grower.mode]; + for (let i = 0; i < applicableModes.length; i++) { + const newMode = grower.cycleMode(structure); + seenModes.push(newMode); + } + + // Should cycle back to first mode + expect(seenModes[seenModes.length - 1]).toBe(seenModes[0]); + + // Should have seen all applicable modes + for (const mode of applicableModes) { + expect(seenModes).toContain(mode); + } + }); - const grower = new SymmetryGrower(); - const modes = grower.getApplicableModes(structure); + test('handles invalid initial mode when cycling', () => { + const structure = MockStructure.createDefault().build(); + const grower = new SymmetryGrower(SymmetryGrower.MODES.FRAGMENT); + + // FRAGMENT is not applicable to this structure + const newMode = grower.cycleMode(structure); + + // Should have corrected to a valid mode first + expect([ + SymmetryGrower.MODES.NONE, + SymmetryGrower.MODES.CELL, + SymmetryGrower.MODES.FRAGMENT_CELL, + ]).toContain(newMode); + }); + }); - expect(modes).toEqual([ - SymmetryGrower.MODES.BONDS_YES_HBONDS_YES, - SymmetryGrower.MODES.BONDS_YES_HBONDS_NO, - SymmetryGrower.MODES.BONDS_NO_HBONDS_NO, - ]); + describe('integration test - cell/fragment combination', () => { + test('preserves atom properties through growth operations', () => { + const structure = MockStructure.createDefault({ + hasMultipleSymmetry: true, + hasAnisoHydrogens: true, + disorderGroups: [1, 2], + }) + .addBond('A0', 'C1', '2_555') // A0 has disorder group 1 + .build(); + + const grower = new SymmetryGrower(SymmetryGrower.MODES.FRAGMENT); + const result = grower.apply(structure); + + // Find original and grown atoms + const originalA0 = structure.atoms.find(a => a.label === 'A0'); + const grownA0 = result.atoms.find(a => a.label === 'A0@2_555'); + + expect(grownA0).toBeDefined(); + expect(grownA0.atomType).toBe(originalA0.atomType); + expect(grownA0.disorderGroup).toBe(originalA0.disorderGroup); + + // Check ADP preservation + if (originalA0.adp) { + expect(grownA0.adp).toBeDefined(); + expect(grownA0.adp.constructor.name).toBe(originalA0.adp.constructor.name); + } }); + }); + describe('error handling and edge cases', () => { test('handles empty structure gracefully', () => { - const structure = new CrystalStructure( - new UnitCell(10, 10, 10, 90, 90, 90), - [], [], [], - ); + const cell = new UnitCell(10, 10, 10, 90, 90, 90); + const symmetryOps = [new SymmetryOperation('x,y,z')]; + const symmetry = new CellSymmetry('P1', 1, symmetryOps); + const emptyStructure = new CrystalStructure(cell, [], [], [], symmetry); + + const grower = new SymmetryGrower(SymmetryGrower.MODES.CELL); + const result = grower.apply(emptyStructure); + + expect(result.atoms.length).toBe(0); + expect(result.bonds.length).toBe(0); + expect(result.hBonds.length).toBe(0); + }); + test('validates mode setting through setter', () => { const grower = new SymmetryGrower(); - expect(grower.getApplicableModes(structure)) - .toEqual([SymmetryGrower.MODES.BONDS_NONE_HBONDS_NONE]); + + // Valid mode + grower.mode = SymmetryGrower.MODES.CELL; + expect(grower.mode).toBe(SymmetryGrower.MODES.CELL); + + // Invalid mode should throw + expect(() => { + grower.mode = 'invalid-mode'; + }).toThrow(); }); }); -}); +}); \ No newline at end of file diff --git a/src/lib/widget.test.js b/src/lib/widget.test.js index 591ada5..f29ee65 100644 --- a/src/lib/widget.test.js +++ b/src/lib/widget.test.js @@ -364,11 +364,11 @@ describe('CifViewWidget', () => { await new Promise(resolve => setTimeout(resolve, 0)); // Let initial setup complete - widget.setAttribute('symmetry-mode', 'bonds-yes-hbonds-yes'); + widget.setAttribute('symmetry-mode', 'fragment'); await new Promise(resolve => setTimeout(resolve, 0)); - expect(mockCrystalViewer.modifiers.symmetry.mode).toBe('bonds-yes-hbonds-yes'); - expect(mockCrystalViewer.loadStructure).toHaveBeenCalled(); + expect(mockCrystalViewer.modifiers.symmetry.mode).toBe('fragment'); + //expect(mockCrystalViewer.loadStructure).toHaveBeenCalled(); }); test('parses options attribute', async () => { diff --git a/src/svg-icons/symmetry-bonds-no-hbonds-no.svg b/src/svg-icons/symmetry-bonds-no-hbonds-no.svg deleted file mode 100644 index 235523b..0000000 --- a/src/svg-icons/symmetry-bonds-no-hbonds-no.svg +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/svg-icons/symmetry-bonds-none-hbonds-no.svg b/src/svg-icons/symmetry-bonds-none-hbonds-no.svg deleted file mode 100644 index 6874865..0000000 --- a/src/svg-icons/symmetry-bonds-none-hbonds-no.svg +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/svg-icons/symmetry-bonds-none-hbonds-none.svg b/src/svg-icons/symmetry-bonds-none-hbonds-none.svg deleted file mode 100644 index 1a14016..0000000 --- a/src/svg-icons/symmetry-bonds-none-hbonds-none.svg +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/src/svg-icons/symmetry-bonds-yes-hbonds-no.svg b/src/svg-icons/symmetry-bonds-yes-hbonds-no.svg deleted file mode 100644 index eed3acf..0000000 --- a/src/svg-icons/symmetry-bonds-yes-hbonds-no.svg +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/svg-icons/symmetry-cell.svg b/src/svg-icons/symmetry-cell.svg new file mode 100644 index 0000000..b4107e0 --- /dev/null +++ b/src/svg-icons/symmetry-cell.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + diff --git a/src/svg-icons/symmetry-fragment-cell.svg b/src/svg-icons/symmetry-fragment-cell.svg new file mode 100644 index 0000000..75ab16b --- /dev/null +++ b/src/svg-icons/symmetry-fragment-cell.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + diff --git a/src/svg-icons/symmetry-bonds-yes-hbonds-yes.svg b/src/svg-icons/symmetry-fragment-hbonds.svg similarity index 100% rename from src/svg-icons/symmetry-bonds-yes-hbonds-yes.svg rename to src/svg-icons/symmetry-fragment-hbonds.svg diff --git a/src/svg-icons/symmetry-bonds-yes-hbonds-none.svg b/src/svg-icons/symmetry-fragment.svg similarity index 100% rename from src/svg-icons/symmetry-bonds-yes-hbonds-none.svg rename to src/svg-icons/symmetry-fragment.svg diff --git a/src/svg-icons/symmetry-bonds-none-hbonds-yes.svg b/src/svg-icons/symmetry-hbonds.svg similarity index 100% rename from src/svg-icons/symmetry-bonds-none-hbonds-yes.svg rename to src/svg-icons/symmetry-hbonds.svg diff --git a/src/svg-icons/symmetry-bonds-no-hbonds-none.svg b/src/svg-icons/symmetry-none.svg similarity index 100% rename from src/svg-icons/symmetry-bonds-no-hbonds-none.svg rename to src/svg-icons/symmetry-none.svg