Skip to main content

chematic_wasm/
mol_3d.rs

1//! 3D geometry, conformer, and force-field (MMFF94/UFF) bindings.
2
3use crate::{MolHandle, WASM_MAX_ATOMS, WASM_MAX_INPUT_BYTES, escape_json_string};
4use wasm_bindgen::prelude::*;
5
6/// Optimize molecular geometry using DREIDING force field.
7///
8/// Performs geometry minimization with DREIDING force field parameters.
9/// Returns minimized coordinate PDB.
10///
11/// # Arguments
12/// * `mol` - Molecule to optimize
13///
14/// # Returns
15/// PDB format string with optimized coordinates
16#[wasm_bindgen]
17pub fn minimize_dreiding_json(mol: &MolHandle) -> String {
18    let coords = chematic_3d::generate_coords(&mol.inner);
19    let min_coords = chematic_3d::minimize_dreiding(&mol.inner, coords);
20    chematic_3d::write_pdb(&mol.inner, &min_coords)
21}
22
23/// Compute WHIM descriptors (Weighted Holistic Invariant Molecular) from 3D coordinates.
24/// Returns JSON array of 22 values: 11 unit-weight descriptors followed by 11 mass-weight
25/// descriptors. Each 11-element block is [λ₁, λ₂, λ₃, ν₁, ν₂, ν₃, T, A, V, K, D].
26#[wasm_bindgen]
27pub fn whim_descriptors_json(mol: &MolHandle) -> String {
28    let coords = chematic_3d::generate_coords(&mol.inner);
29    let desc = chematic_3d::whim_descriptors(&mol.inner, &coords);
30    let parts: Vec<String> = desc.iter().map(|&v| v.to_string()).collect();
31    format!("[{}]", parts.join(","))
32}
33
34/// Compute GETAWAY descriptors (GEometry, Topology and Atom-Weights AssemblY) from 3D coords.
35///
36/// Returns a JSON array of **19** values:
37/// - `[0..7]`  H[1..8]  — leverage autocorrelation at topological lags 1–8
38/// - `[8..15]` R[1..8]  — H[k] normalised by pair count W_k
39/// - `[16]` Hmax, `[17]` Hmean, `[18]` Htot — per-atom leverage statistics
40///
41/// Note: requires 3D coordinates (non-planar); for flat/2D structures the hat matrix
42/// is degenerate and descriptors reflect squared centroid distances, not true leverage.
43#[wasm_bindgen]
44pub fn getaway_descriptors_json(mol: &MolHandle) -> String {
45    let coords = chematic_3d::generate_coords(&mol.inner);
46    let desc = chematic_3d::getaway_descriptors(&mol.inner, &coords);
47    let parts: Vec<String> = desc.iter().map(|&v| v.to_string()).collect();
48    format!("[{}]", parts.join(","))
49}
50
51/// Compute combined WHIM + GETAWAY descriptors (**41** values total) as JSON array.
52///
53/// Returns WHIM[0..21] (22 values) followed by GETAWAY[0..18] (19 values) = 41 total.
54/// Useful for ML pipelines requiring both shape and topologic features.
55#[wasm_bindgen]
56pub fn whim_getaway_combined_json(mol: &MolHandle) -> String {
57    let coords = chematic_3d::generate_coords(&mol.inner);
58    let desc = chematic_3d::whim_getaway_combined(&mol.inner, &coords);
59    let parts: Vec<String> = desc.iter().map(|&v| v.to_string()).collect();
60    format!("[{}]", parts.join(","))
61}
62
63/// Gasteiger-Marsili PEOE partial charges as a JSON array of f64.
64#[wasm_bindgen]
65pub fn gasteiger_charges_json(mol: &MolHandle) -> String {
66    let q = chematic_chem::gasteiger_charges(&mol.inner);
67    let parts: Vec<String> = q.iter().map(|v| format!("{v:.4}")).collect();
68    format!("[{}]", parts.join(","))
69}
70
71/// MMFF94 partial charges (BCI table, ±0.1e accuracy) as a JSON array of f64.
72///
73/// Uses Bond Charge Increment (BCI) model (Halgren 1996) for 25 common bond types.
74/// Returns `[q0, q1, ..., qN]` — one value per heavy atom.
75/// Total charge equals the sum of formal charges (charge conserved).
76#[wasm_bindgen]
77pub fn mmff94_charges_json(mol: &MolHandle) -> String {
78    let q = chematic_chem::mmff94_charges(&mol.inner);
79    let parts: Vec<String> = q.iter().map(|v| format!("{v:.6}")).collect();
80    format!("[{}]", parts.join(","))
81}
82
83/// Compute MMFF94-style atom-typed partial charges (improved over element-pair BCI).
84/// Returns JSON: {"charges":[f64,...]} or {"error":"..."}.
85/// Uses atom-type classification (Csp3/Ccarbonyl/Ohydroxyl/Oester/Nar/NarH etc.)
86/// for better accuracy (~±0.02e) vs element-pair BCI (~±0.05e).
87#[wasm_bindgen]
88pub fn mmff94_charges_typed_json(mol: &MolHandle) -> String {
89    if mol.inner.atom_count() > WASM_MAX_ATOMS {
90        return format!(
91            r#"{{"error":"molecule too large (max {} atoms)"}}"#,
92            WASM_MAX_ATOMS
93        );
94    }
95    let q = chematic_chem::mmff94_charges_typed(&mol.inner);
96    let vals: Vec<String> = q.iter().map(|x| format!("{x:.6}")).collect();
97    format!(r#"{{"charges":[{}]}}"#, vals.join(","))
98}
99
100// ── MMFF94 Full Force Field (v0.2.10) ───────────────────────────────────────
101
102/// Minimize geometry using MMFF94 steepest descent (Halgren 1996 full parameters).
103/// Generates 3D coords internally if needed.
104/// Returns JSON: {"energy":E,"rmsd":R,"converged":true,"iterations":N} or {"error":"..."}.
105#[wasm_bindgen]
106pub fn minimize_mmff94_json(mol: &MolHandle, max_iter: u32) -> String {
107    if mol.inner.atom_count() > WASM_MAX_ATOMS {
108        return format!(
109            r#"{{"error":"molecule too large (max {} atoms)"}}"#,
110            WASM_MAX_ATOMS
111        );
112    }
113    let conf = chematic_3d::generate_coords(&mol.inner);
114    let n = mol.inner.atom_count();
115    let mut coords: Vec<[f64; 3]> = (0..n)
116        .map(|i| {
117            let p = conf.get(chematic_core::AtomIdx(i as u32));
118            [p.x, p.y, p.z]
119        })
120        .collect();
121    match chematic_ff::minimize_mmff94_full(&mol.inner, &mut coords, max_iter as usize) {
122        Ok(r) => format!(
123            r#"{{"energy":{:.4},"rmsd":{:.4},"converged":{},"iterations":{}}}"#,
124            r.energy, r.rmsd, r.converged, r.iterations
125        ),
126        Err(e) => format!(r#"{{"error":"{}"}}"#, e),
127    }
128}
129
130/// Minimize geometry using MMFF94 L-BFGS (faster convergence than steepest descent).
131/// Returns JSON: {"energy":E,"rmsd":R,"converged":true,"iterations":N} or {"error":"..."}.
132#[wasm_bindgen]
133pub fn minimize_mmff94_lbfgs_json(mol: &MolHandle, max_iter: u32) -> String {
134    if mol.inner.atom_count() > WASM_MAX_ATOMS {
135        return format!(
136            r#"{{"error":"molecule too large (max {} atoms)"}}"#,
137            WASM_MAX_ATOMS
138        );
139    }
140    let conf = chematic_3d::generate_coords(&mol.inner);
141    let n = mol.inner.atom_count();
142    let mut coords: Vec<[f64; 3]> = (0..n)
143        .map(|i| {
144            let p = conf.get(chematic_core::AtomIdx(i as u32));
145            [p.x, p.y, p.z]
146        })
147        .collect();
148    match chematic_ff::minimize_mmff94_lbfgs(&mol.inner, &mut coords, max_iter as usize) {
149        Ok(r) => format!(
150            r#"{{"energy":{:.4},"rmsd":{:.4},"converged":{},"iterations":{}}}"#,
151            r.energy, r.rmsd, r.converged, r.iterations
152        ),
153        Err(e) => format!(r#"{{"error":"{}"}}"#, e),
154    }
155}
156
157/// Compute MMFF94 energy breakdown for current rule-based 3D geometry.
158/// Returns JSON: {"bond":B,"angle":A,"torsion":T,"vdw":V,"elec":E,"total":X} or {"error":"..."}.
159#[wasm_bindgen]
160pub fn mmff94_energy_breakdown_json(mol: &MolHandle) -> String {
161    if mol.inner.atom_count() > WASM_MAX_ATOMS {
162        return format!(
163            r#"{{"error":"molecule too large (max {} atoms)"}}"#,
164            WASM_MAX_ATOMS
165        );
166    }
167    let conf = chematic_3d::generate_coords(&mol.inner);
168    let n = mol.inner.atom_count();
169    let coords: Vec<[f64; 3]> = (0..n)
170        .map(|i| {
171            let p = conf.get(chematic_core::AtomIdx(i as u32));
172            [p.x, p.y, p.z]
173        })
174        .collect();
175    match chematic_ff::mmff94_energy_breakdown(&mol.inner, &coords) {
176        Ok(bd) => format!(
177            r#"{{"bond":{:.4},"angle":{:.4},"torsion":{:.4},"vdw":{:.4},"elec":{:.4},"total":{:.4}}}"#,
178            bd.bond, bd.angle, bd.torsion, bd.vdw, bd.electrostatic, bd.total
179        ),
180        Err(e) => format!(r#"{{"error":"{}"}}"#, e),
181    }
182}
183
184// torsion_scan_json removed to reduce WASM bundle size (force-field loop; rarely used in browser).
185
186/// Compute MMFF94 partial charges using numeric atom types (Halgren 1996 eq. 15).
187/// Returns JSON: {"charges":[-0.28,0.15,...]} or {"error":"..."}.
188#[wasm_bindgen]
189pub fn mmff94_partial_charges_json(mol: &MolHandle) -> String {
190    if mol.inner.atom_count() > WASM_MAX_ATOMS {
191        return format!(
192            r#"{{"error":"molecule too large (max {} atoms)"}}"#,
193            WASM_MAX_ATOMS
194        );
195    }
196    match chematic_ff::mmff94_charges_numeric(&mol.inner) {
197        Ok(q) => {
198            let vals: Vec<String> = q.iter().map(|x| format!("{x:.4}")).collect();
199            format!(r#"{{"charges":[{}]}}"#, vals.join(","))
200        }
201        Err(e) => format!(r#"{{"error":"{}"}}"#, e),
202    }
203}
204
205/// Compute 3D pharmacophore fingerprint from generated 3D coordinates.
206/// Returns simplified JSON with feature type counts (3D-aware version).
207#[wasm_bindgen]
208pub fn pharmacophore_fp_3d_summary(mol: &MolHandle) -> String {
209    let _coords = chematic_3d::generate_coords(&mol.inner);
210    let features = chematic_perception::detect_features(&mol.inner);
211    let mut counts = [0; 6];
212    for feat in features {
213        let idx = match feat.ftype {
214            chematic_perception::FeatureType::Donor => 0,
215            chematic_perception::FeatureType::Acceptor => 1,
216            chematic_perception::FeatureType::Aromatic => 2,
217            chematic_perception::FeatureType::Hydrophobic => 3,
218            chematic_perception::FeatureType::Positive => 4,
219            chematic_perception::FeatureType::Negative => 5,
220        };
221        counts[idx] += 1;
222    }
223    format!(
224        "{{\"Donor\":{},\"Acceptor\":{},\"Aromatic\":{},\"Hydrophobic\":{},\"Positive\":{},\"Negative\":{}}}",
225        counts[0], counts[1], counts[2], counts[3], counts[4], counts[5]
226    )
227}
228
229/// AutoCorr3D descriptor (8 values: Euclidean distance bins 1-8 Å).
230/// Requires 3D coordinates (generated automatically).
231#[wasm_bindgen]
232pub fn autocorr_3d_json(mol: &MolHandle) -> String {
233    let coords = chematic_3d::generate_coords(&mol.inner);
234    let ac = chematic_3d::autocorr_3d(&mol.inner, &coords);
235    format!(
236        "[{:.4},{:.4},{:.4},{:.4},{:.4},{:.4},{:.4},{:.4}]",
237        ac[0], ac[1], ac[2], ac[3], ac[4], ac[5], ac[6], ac[7]
238    )
239}
240
241/// Generate 3D coordinates as raw JSON array [[x,y,z], ...].
242///
243/// Unlike `generate_3d_pdb`, this returns coordinates that can be passed
244/// to descriptor functions like `whim_descriptors_json` or `shape_descriptors_json`.
245#[wasm_bindgen]
246pub fn generate_3d_coords_json(mol: &MolHandle) -> String {
247    let coords = chematic_3d::generate_and_minimize_dreiding(&mol.inner);
248    let parts: Vec<String> = coords
249        .points
250        .iter()
251        .map(|p| format!("[{:.4},{:.4},{:.4}]", p.x, p.y, p.z))
252        .collect();
253    format!("[{}]", parts.join(","))
254}
255
256/// Generate 3D coordinates using ETKDG as raw JSON array [[x,y,z], ...].
257#[wasm_bindgen]
258pub fn generate_3d_etkdg_coords_json(mol: &MolHandle) -> String {
259    let coords = chematic_3d::generate_coords_etkdg(&mol.inner);
260    let parts: Vec<String> = coords
261        .points
262        .iter()
263        .map(|p| format!("[{:.4},{:.4},{:.4}]", p.x, p.y, p.z))
264        .collect();
265    format!("[{}]", parts.join(","))
266}
267
268/// Generate multiple conformers with RMSD-based pruning.
269/// Returns JSON: `{"conformers": [[[x,y,z],...], ...], "count": int}`.
270#[wasm_bindgen]
271pub fn conformer_ensemble_json(mol: &MolHandle, n: u32, rmsd_threshold: f64) -> String {
272    let smiles = chematic_smiles::canonical_smiles(&mol.inner);
273    let fresh = match chematic_smiles::parse(&smiles) {
274        Ok(m) => m,
275        Err(_) => return r#"{"conformers":[],"count":0}"#.to_string(),
276    };
277    let config = chematic_3d::ConformerConfig {
278        count: n as usize,
279        rmsd_threshold,
280        ..chematic_3d::ConformerConfig::default()
281    };
282    match chematic_3d::generate_conformer_ensemble_with_config(fresh, &config) {
283        Ok(ensemble) => {
284            let conf_jsons: Vec<String> = (0..ensemble.conformer_count())
285                .filter_map(|i| ensemble.get_conformer(i))
286                .map(|c| {
287                    let pts: Vec<String> = c
288                        .points
289                        .iter()
290                        .map(|p| format!("[{:.4},{:.4},{:.4}]", p.x, p.y, p.z))
291                        .collect();
292                    format!("[{}]", pts.join(","))
293                })
294                .collect();
295            let count = conf_jsons.len();
296            format!(
297                r#"{{"conformers":[{}],"count":{count}}}"#,
298                conf_jsons.join(",")
299            )
300        }
301        Err(_) => r#"{"conformers":[],"count":0}"#.to_string(),
302    }
303}
304
305/// 3D shape descriptors as a JSON object.
306///
307/// Keys: `pmi1`, `pmi2`, `pmi3`, `npr1`, `npr2`, `asphericity`, `eccentricity`,
308/// `radiusOfGyration`, `planeOfBestFit`.  Non-finite values (e.g. single-atom
309/// molecules where pmi3 = 0) are serialised as JSON `null`.
310#[wasm_bindgen]
311pub fn shape_descriptors_json(mol: &MolHandle) -> String {
312    let coords = chematic_3d::generate_coords(&mol.inner);
313    let m = &*mol.inner;
314
315    fn fmt(v: f64) -> String {
316        if v.is_finite() {
317            format!("{v:.4}")
318        } else {
319            "null".to_string()
320        }
321    }
322
323    format!(
324        "{{\"pmi1\":{},\"pmi2\":{},\"pmi3\":{},\"npr1\":{},\"npr2\":{},\
325\"asphericity\":{},\"eccentricity\":{},\"radiusOfGyration\":{},\"planeOfBestFit\":{}}}",
326        fmt(chematic_3d::pmi1(m, &coords)),
327        fmt(chematic_3d::pmi2(m, &coords)),
328        fmt(chematic_3d::pmi3(m, &coords)),
329        fmt(chematic_3d::npr1(m, &coords)),
330        fmt(chematic_3d::npr2(m, &coords)),
331        fmt(chematic_3d::asphericity(m, &coords)),
332        fmt(chematic_3d::eccentricity(m, &coords)),
333        fmt(chematic_3d::radius_of_gyration(m, &coords)),
334        fmt(chematic_3d::plane_of_best_fit(m, &coords)),
335    )
336}
337
338/// A conformer ensemble: one molecule geometry with multiple 3D coordinate sets.
339///
340/// Create with `new(smiles)`, then add conformers with `add_generated_conformer`
341/// or `add_minimized_conformer`.  Retrieve coordinates as PDB strings via
342/// `get_conformer_pdb(idx)`.  Compare conformers with `conformer_rmsd`.
343#[wasm_bindgen]
344pub struct ConformerHandle {
345    inner: chematic_3d::ConformerEnsemble,
346}
347
348#[wasm_bindgen]
349impl ConformerHandle {
350    /// Create a new empty ensemble for the molecule given by `smiles`.
351    ///
352    /// Returns a JS error on SMILES parse failure.
353    #[wasm_bindgen(constructor)]
354    pub fn new(smiles: &str) -> Result<ConformerHandle, JsValue> {
355        let mol = chematic_smiles::parse(smiles).map_err(|e| JsValue::from_str(&e.to_string()))?;
356        Ok(ConformerHandle {
357            inner: chematic_3d::ConformerEnsemble::new(mol),
358        })
359    }
360
361    /// Number of conformers currently stored.
362    pub fn conformer_count(&self) -> usize {
363        self.inner.conformer_count()
364    }
365
366    /// The ensemble's molecule as a `MolHandle`.
367    pub fn mol(&self) -> MolHandle {
368        let smi = chematic_smiles::canonical_smiles(self.inner.mol());
369        let mol = chematic_smiles::parse(&smi)
370            .unwrap_or_else(|_| chematic_core::MoleculeBuilder::new().build());
371        MolHandle {
372            inner: std::rc::Rc::new(mol),
373        }
374    }
375
376    /// Generate a new 3D conformer using distance-geometry and add it to the ensemble.
377    ///
378    /// Returns the index of the newly added conformer.
379    pub fn add_generated_conformer(&mut self) -> usize {
380        let coords = chematic_3d::generate_coords(self.inner.mol());
381        self.inner.add_conformer(coords).unwrap_or(usize::MAX)
382    }
383
384    /// Generate a new 3D conformer, run force-field minimization, and add it.
385    ///
386    /// Returns the index of the newly added conformer.
387    pub fn add_minimized_conformer(&mut self) -> usize {
388        let coords = chematic_3d::generate_coords(self.inner.mol());
389        let minimized = chematic_3d::minimize(self.inner.mol(), coords);
390        self.inner.add_conformer(minimized).unwrap_or(usize::MAX)
391    }
392
393    /// Return conformer `idx` as a PDB string, or `null` if `idx` is out of range.
394    pub fn get_conformer_pdb(&self, idx: usize) -> Option<String> {
395        self.inner
396            .get_conformer(idx)
397            .map(|coords| chematic_3d::write_pdb(self.inner.mol(), coords))
398    }
399
400    /// Kabsch-aligned RMSD (Å) between conformers `a` and `b`.
401    ///
402    /// Returns `NaN` if either index is out of range.
403    pub fn conformer_rmsd(&self, a: usize, b: usize) -> f64 {
404        self.inner.conformer_rmsd(a, b).unwrap_or(f64::NAN)
405    }
406
407    /// Un-aligned (translation + rotation NOT removed) RMSD (Å) between conformers `a` and `b`.
408    ///
409    /// Returns `NaN` if either index is out of range.
410    pub fn conformer_rmsd_no_align(&self, a: usize, b: usize) -> f64 {
411        self.inner.conformer_rmsd_no_align(a, b).unwrap_or(f64::NAN)
412    }
413
414    /// Remove conformer `idx` and return `true`, or `false` if `idx` is out of range.
415    pub fn remove_conformer(&mut self, idx: usize) -> bool {
416        self.inner.remove_conformer(idx).is_some()
417    }
418
419    /// Cluster conformers by Kabsch-aligned RMSD and return a JSON object
420    /// describing which conformers to keep.
421    ///
422    /// Uses greedy leader-linkage: conformers are visited in index order; each
423    /// is compared against existing cluster representatives. If the RMSD to any
424    /// representative is < `rms_threshold`, the conformer is discarded; otherwise
425    /// it starts a new cluster and is kept.
426    ///
427    /// Returns `{"kept_indices":[0,3,7,...],"removed_count":5}` on success.
428    pub fn cluster_conformers_json(&self, rms_threshold: f64) -> String {
429        let kept = self.inner.cluster_conformers_by_rms(rms_threshold);
430        let total = self.inner.conformer_count();
431        let removed = total.saturating_sub(kept.len());
432        let indices_str: Vec<String> = kept.iter().map(|i| i.to_string()).collect();
433        format!(
434            r#"{{"kept_indices":[{}],"removed_count":{}}}"#,
435            indices_str.join(","),
436            removed
437        )
438    }
439}
440
441// ---------------------------------------------------------------------------
442// Mutable Molecule API
443// ---------------------------------------------------------------------------
444
445/// Compute structured depiction data using caller-supplied 2D coordinates.
446///
447/// `coords_json` — JSON array of `[x, y]` pairs, one per atom in order.
448///
449/// Returns the same JSON format as `depict_data_json`.
450#[wasm_bindgen]
451pub fn depict_data_with_coords_json(mol: &MolHandle, coords_json: &str) -> String {
452    // Parse [[x,y],...] — simple format, no full JSON parser needed.
453    let coords: Vec<(f64, f64)> = {
454        let mut result = Vec::new();
455        let inner = coords_json.trim().trim_matches(|c| c == '[' || c == ']');
456        // Split on "]," pattern to get individual [x,y] pairs.
457        for pair in inner.split("],[") {
458            let clean = pair.trim().trim_matches(|c| c == '[' || c == ']');
459            let nums: Vec<f64> = clean
460                .split(',')
461                .filter_map(|s| s.trim().parse::<f64>().ok())
462                .collect();
463            if nums.len() >= 2 {
464                result.push((nums[0], nums[1]));
465            }
466        }
467        result
468    };
469
470    let data = chematic_depict::depict_data_with_coords(&mol.inner, &coords);
471
472    let atoms: Vec<String> = data
473        .atoms
474        .iter()
475        .map(|a| {
476            let label = match &a.label {
477                Some(s) => format!("\"{}\"", escape_json_string(s)),
478                None => "null".to_string(),
479            };
480            let charge_suffix = if a.charge != 0 {
481                format!(",\"charge\":{}", a.charge)
482            } else {
483                String::new()
484            };
485            let label_prefix = format!("{label},");
486            format!(
487                r#"{{"idx":{},"element":"{}","x":{:.4},"y":{:.4},"label":{}"color":"{}"{}}}"#,
488                a.idx.0,
489                a.element.symbol(),
490                a.pos.x,
491                a.pos.y,
492                label_prefix,
493                escape_json_string(&a.color),
494                charge_suffix,
495            )
496        })
497        .collect();
498
499    let bond_kind = |k: &chematic_depict::DepictBondKind| match k {
500        chematic_depict::DepictBondKind::Single => "Single",
501        chematic_depict::DepictBondKind::Double => "Double",
502        chematic_depict::DepictBondKind::Triple => "Triple",
503        chematic_depict::DepictBondKind::Aromatic => "Aromatic",
504        chematic_depict::DepictBondKind::Up => "Up",
505        chematic_depict::DepictBondKind::Down => "Down",
506    };
507
508    let bonds: Vec<String> = data
509        .bonds
510        .iter()
511        .map(|b| {
512            format!(
513                r#"{{"idx":{},"atom1":{},"atom2":{},"kind":"{}"}}"#,
514                b.idx.0,
515                b.atom1.0,
516                b.atom2.0,
517                bond_kind(&b.kind)
518            )
519        })
520        .collect();
521
522    format!(
523        r#"{{"atoms":[{}],"bonds":[{}]}}"#,
524        atoms.join(","),
525        bonds.join(",")
526    )
527}
528
529// ---------------------------------------------------------------------------
530// Sprint CC: MMP analysis (Matched Molecular Pairs)
531// ---------------------------------------------------------------------------
532
533/// Minimise a molecule's geometry using the Universal Force Field (UFF).
534///
535/// `coords_json` — JSON array of `[x,y,z]` arrays (Å), one per atom.
536/// `max_iter` — maximum iterations (0 = default 500).
537///
538/// Returns JSON: `{"coords":[[x,y,z],...], "energy":float, "iterations":int, "converged":bool}`
539/// or `{"error":"<msg>"}` on failure.
540#[wasm_bindgen]
541pub fn minimize_uff_json(smiles: &str, coords_json: &str, max_iter: u32) -> String {
542    let mol = match chematic_smiles::parse(smiles) {
543        Ok(m) => m,
544        Err(e) => return format!("{{\"error\":\"{e}\"}}"),
545    };
546    let coords_arr: Vec<[f64; 3]> = match serde_json::from_str(coords_json) {
547        Ok(v) => v,
548        Err(e) => return format!("{{\"error\":\"coords parse error: {e}\"}}"),
549    };
550    let types = chematic_ff::assign_uff_types(&mol);
551    let iters = if max_iter == 0 {
552        500
553    } else {
554        max_iter as usize
555    };
556    let result = chematic_ff::minimize_uff(&mol, &types, coords_arr, iters);
557    let coords_out: Vec<[f64; 3]> = result.coords;
558    let coords_str = coords_out
559        .iter()
560        .map(|c| format!("[{:.4},{:.4},{:.4}]", c[0], c[1], c[2]))
561        .collect::<Vec<_>>()
562        .join(",");
563    format!(
564        "{{\"coords\":[{coords_str}],\"energy\":{:.4},\"iterations\":{},\"converged\":{}}}",
565        result.energy, result.iterations, result.converged
566    )
567}
568
569// ---------------------------------------------------------------------------
570// InChI I/O
571// ---------------------------------------------------------------------------
572
573/// Get bond length in Ångströms between two atoms from a SMILES string.
574/// Returns -1.0 if parsing fails or atom indices are out of range.
575///
576/// # Arguments
577/// - `smiles`: SMILES string
578/// - `a`: first atom index
579/// - `b`: second atom index
580///
581/// # Example
582/// ```javascript
583/// const len = get_bond_length_json("CC", 0, 1);  // C-C single bond ≈ 1.54 Å
584/// ```
585#[wasm_bindgen]
586pub fn get_bond_length_json(smiles: &str, a: u32, b: u32) -> f64 {
587    if smiles.len() > WASM_MAX_INPUT_BYTES {
588        return -1.0;
589    }
590    let mol = match chematic_smiles::parse(smiles) {
591        Ok(m) => m,
592        Err(_) => return -1.0,
593    };
594    let coords = chematic_3d::generate_coords(&mol);
595    let a_idx = chematic_core::AtomIdx(a);
596    let b_idx = chematic_core::AtomIdx(b);
597    if a >= mol.atom_count() as u32 || b >= mol.atom_count() as u32 {
598        return -1.0;
599    }
600    chematic_3d::get_bond_length(&coords, a_idx, b_idx)
601}
602
603/// Get dihedral angle A—B—C—D in degrees from a SMILES string.
604/// Returns null (JSON null) if any atom index is out of range or atoms are collinear.
605///
606/// # Arguments
607/// - `smiles`: SMILES string
608/// - `a`, `b`, `c`, `d`: atom indices
609///
610/// # Example
611/// ```javascript
612/// const dihedral = get_dihedral_json("CCCC", 0, 1, 2, 3);  // A-B-C-D
613/// ```
614#[wasm_bindgen]
615pub fn get_dihedral_json(smiles: &str, a: u32, b: u32, c: u32, d: u32) -> JsValue {
616    if smiles.len() > WASM_MAX_INPUT_BYTES {
617        return JsValue::NULL;
618    }
619    let mol = match chematic_smiles::parse(smiles) {
620        Ok(m) => m,
621        Err(_) => return JsValue::NULL,
622    };
623    if a >= mol.atom_count() as u32
624        || b >= mol.atom_count() as u32
625        || c >= mol.atom_count() as u32
626        || d >= mol.atom_count() as u32
627    {
628        return JsValue::NULL;
629    }
630    let coords = chematic_3d::generate_coords(&mol);
631    let a_idx = chematic_core::AtomIdx(a);
632    let b_idx = chematic_core::AtomIdx(b);
633    let c_idx = chematic_core::AtomIdx(c);
634    let d_idx = chematic_core::AtomIdx(d);
635    match chematic_3d::get_dihedral_deg(&coords, a_idx, b_idx, c_idx, d_idx) {
636        Some(angle) => JsValue::from_f64(angle),
637        None => JsValue::NULL,
638    }
639}
640
641/// Set dihedral angle A—B—C—D and return PDB block with modified coordinates.
642/// Rotates the D-side subtree around the B—C bond.
643/// Returns a JS error if parsing fails or atom indices are invalid.
644///
645/// # Arguments
646/// - `smiles`: SMILES string
647/// - `a`, `b`, `c`, `d`: atom indices
648/// - `angle_deg`: target dihedral angle in degrees
649///
650/// # Example
651/// ```javascript
652/// const pdbBlock = set_dihedral_json("CCCC", 0, 1, 2, 3, 120.0);
653/// ```
654#[wasm_bindgen]
655pub fn set_dihedral_json(
656    smiles: &str,
657    a: u32,
658    b: u32,
659    c: u32,
660    d: u32,
661    angle_deg: f64,
662) -> Result<String, String> {
663    if smiles.len() > WASM_MAX_INPUT_BYTES {
664        return Err("Input SMILES too long".to_string());
665    }
666    let mol = chematic_smiles::parse(smiles).map_err(|e| format!("Parse error: {}", e))?;
667    if a >= mol.atom_count() as u32
668        || b >= mol.atom_count() as u32
669        || c >= mol.atom_count() as u32
670        || d >= mol.atom_count() as u32
671    {
672        return Err("Atom index out of range".to_string());
673    }
674    let coords = chematic_3d::generate_coords(&mol);
675    let a_idx = chematic_core::AtomIdx(a);
676    let b_idx = chematic_core::AtomIdx(b);
677    let c_idx = chematic_core::AtomIdx(c);
678    let d_idx = chematic_core::AtomIdx(d);
679    let angle_rad = angle_deg.to_radians();
680    let new_coords =
681        chematic_3d::set_dihedral(&coords, &mol, a_idx, b_idx, c_idx, d_idx, angle_rad);
682    // Return PDB block with modified coordinates
683    Ok(chematic_3d::write_pdb(&mol, &new_coords))
684}
685
686// ---------------------------------------------------------------------------
687// random_smiles (SMILES augmentation)
688// ---------------------------------------------------------------------------