1use crate::hybrid36;
14use crate::{PDB_HEADERS_EXTENSION, PdbHeaders};
15use molframe_core::diagnostic::{Code, Diagnostic};
16use molframe_core::io::{Select, SelectAll};
17use molframe_core::structure::{AtomRef, ChainRef, ResidueRef, Structure};
18use std::collections::HashMap;
19use std::fmt;
20
21#[path = "identity.rs"]
22mod identity;
23pub use identity::PdbIdentifierNamespace;
24pub(crate) use identity::{atom_name, chain_name, component_name, residue_sequence};
25
26const MIN_COORDINATE: f64 = -999.999_5;
29const MAX_COORDINATE: f64 = 9_999.999_5;
30
31#[derive(Clone, Debug)]
33pub struct PdbOptions {
34 chain_map: HashMap<Box<str>, Box<str>>,
37 hybrid36: bool,
42 namespace: PdbIdentifierNamespace,
44}
45
46impl Default for PdbOptions {
47 fn default() -> Self {
48 Self {
49 chain_map: HashMap::new(),
50 hybrid36: false,
51 namespace: PdbIdentifierNamespace::Label,
52 }
53 }
54}
55
56impl PdbOptions {
57 #[must_use]
59 pub fn new() -> Self {
60 Self::default()
61 }
62
63 #[must_use]
65 pub fn chain_map(mut self, from: impl Into<Box<str>>, to: impl Into<Box<str>>) -> Self {
66 self.chain_map.insert(from.into(), to.into());
67 self
68 }
69
70 #[must_use]
72 pub const fn hybrid36(mut self, enabled: bool) -> Self {
73 self.hybrid36 = enabled;
74 self
75 }
76
77 #[must_use]
79 pub const fn namespace(mut self, namespace: PdbIdentifierNamespace) -> Self {
80 self.namespace = namespace;
81 self
82 }
83
84 pub(crate) const fn identifier_namespace(&self) -> PdbIdentifierNamespace {
85 self.namespace
86 }
87
88 pub(crate) fn label_for<'a>(&'a self, label: &'a str) -> &'a str {
90 match self.chain_map.get(label) {
91 Some(replacement) => replacement,
92 None => label,
93 }
94 }
95}
96
97pub fn write(structure: &Structure, options: &PdbOptions) -> Result<String, Vec<Diagnostic>> {
104 write_selected(structure, options, &SelectAll)
105}
106
107pub fn write_selected(
113 structure: &Structure,
114 options: &PdbOptions,
115 select: &impl Select,
116) -> Result<String, Vec<Diagnostic>> {
117 let mut out = String::with_capacity(structure.atom_count() as usize * 81);
118 render_selected(&mut out, structure, options, select)?;
119 Ok(out)
120}
121
122pub(super) fn render_selected(
123 out: &mut impl fmt::Write,
124 structure: &Structure,
125 options: &PdbOptions,
126 select: &impl Select,
127) -> Result<(), Vec<Diagnostic>> {
128 let refusals = check_capacity(structure, options, select, RequiredAtomFields::Pdb);
129 if !refusals.is_empty() {
130 return Err(refusals);
131 }
132
133 let data = structure.data();
134
135 if let Some(headers) = data.extensions.get::<PdbHeaders>(PDB_HEADERS_EXTENSION) {
136 for record in headers.records() {
137 let _ = writeln!(out, "{}", record.line());
138 }
139 } else {
140 write_generated_metadata(out, structure);
141 }
142
143 let mut serial = 1i64;
144 for chain in data.chains() {
145 if !select.accept_chain(chain.index()) {
146 continue;
147 }
148 let label = chain_label(&chain, options.namespace);
149 let label = options.label_for(label);
150
151 for residue in chain.residues() {
152 if !select.accept_residue(residue.index()) {
153 continue;
154 }
155 for atom in residue.atoms() {
156 if !select.accept_atom(atom.index()) {
157 continue;
158 }
159 if let Err(finding) =
160 write_atom(out, structure, &atom, &residue, label, serial, options)
161 {
162 return Err(vec![finding]);
163 }
164 serial += 1;
165 }
166 }
167 let _ = writeln!(out, "TER {:>5}", serial_field(serial, options));
168 serial += 1;
169 }
170 let _ = out.write_str("END\n");
171 Ok(())
172}
173
174fn write_generated_metadata(out: &mut impl fmt::Write, structure: &Structure) {
175 let data = structure.data();
176 if let Some(id) = &data.entry.id {
177 let _ = writeln!(out, "HEADER {:<40}{:<9} {:>4}", "", "", id);
178 }
179 if let Some(title) = &data.entry.title {
180 let _ = writeln!(out, "TITLE {title}");
181 }
182 if let Some(method) = &data.entry.method {
183 let _ = writeln!(out, "EXPDTA {method}");
184 }
185 if let Some(cell) = data.cell {
186 let _ = writeln!(
187 out,
188 "CRYST1{:9.3}{:9.3}{:9.3}{:7.2}{:7.2}{:7.2} {:<11}",
189 cell.lengths[0],
190 cell.lengths[1],
191 cell.lengths[2],
192 cell.angles[0],
193 cell.angles[1],
194 cell.angles[2],
195 match data.entry.space_group.as_deref() {
197 Some(symbol) => symbol,
198 None => "P 1",
199 },
200 );
201 }
202}
203
204fn write_atom(
205 out: &mut impl fmt::Write,
206 structure: &Structure,
207 atom: &AtomRef<'_>,
208 residue: &ResidueRef<'_>,
209 chain: &str,
210 serial: i64,
211 options: &PdbOptions,
212) -> Result<(), Diagnostic> {
213 let record = if residue.is_het() { "HETATM" } else { "ATOM " };
214 let name = required_text(
215 atom_name(*atom, options.namespace),
216 "atom identifier",
217 *atom,
218 options,
219 )?;
220 let element = match atom.element() {
221 Some(element) => element.symbol(),
222 None => "",
223 };
224 let position = required_value(atom.position(), "coordinates", *atom, options)?;
228 let seq = i64::from(required_value(
229 residue_sequence(*residue, options.namespace),
230 "residue sequence identifier",
231 *atom,
232 options,
233 )?);
234 let component = required_text(
235 component_name(*atom, *residue, options.namespace),
236 "component identifier",
237 *atom,
238 options,
239 )?;
240 let occupancy = required_value(atom.occupancy(), "occupancy", *atom, options)?;
241 let b_factor = required_value(atom.b_factor(), "B factor", *atom, options)?;
242
243 let _ = writeln!(
244 out,
245 "{record}{serial:>5} {name:<4}{alt:1}{comp:>3} {chain:>1}{seq}{ins:1} \
246 {x:8.3}{y:8.3}{z:8.3}{occ:6.2}{b:6.2} {element:>2}",
247 serial = serial_field(serial, options),
248 name = atom_name_field(name),
249 alt = alt_field(structure, atom),
250 comp = component,
251 seq = residue_field(seq, options),
252 ins = insertion_code(*residue),
253 x = f64::from(position[0]),
254 y = f64::from(position[1]),
255 z = f64::from(position[2]),
256 occ = f64::from(occupancy),
257 b = f64::from(b_factor),
258 );
259 if let Some(u) = structure.data().anisotropy.for_atom(atom.index()) {
262 super::aniso::Record {
263 atom,
264 structure,
265 residue,
266 name,
267 component,
268 chain,
269 seq: &residue_field(seq, options),
270 serial: &serial_field(serial, options),
271 u: &u,
272 }
273 .write(out);
274 }
275 Ok(())
276}
277
278pub(crate) fn atom_name_field(name: &str) -> String {
283 if name.len() >= 4 {
284 name.to_owned()
285 } else {
286 format!(" {name:<3}")
287 }
288}
289
290pub(crate) fn alt_field<'a>(structure: &'a Structure, atom: &AtomRef<'a>) -> &'a str {
295 let Some(alt) = atom.alt_id() else { return " " };
296 let Some(symbol) = alt.symbol() else {
297 return " ";
298 };
299 match structure.resolve(symbol) {
300 Some(label) => label,
301 None => " ",
302 }
303}
304
305pub(crate) fn serial_field(serial: i64, options: &PdbOptions) -> String {
306 match options
307 .hybrid36
308 .then(|| hybrid36::encode(serial, 5))
309 .flatten()
310 {
311 Some(field) => field,
312 None => format!("{serial:>5}"),
313 }
314}
315
316pub(crate) fn residue_field(seq: i64, options: &PdbOptions) -> String {
317 match options.hybrid36.then(|| hybrid36::encode(seq, 4)).flatten() {
318 Some(field) => field,
319 None => format!("{seq:>4}"),
320 }
321}
322
323pub(crate) fn chain_label<'a>(
324 chain: &'a ChainRef<'a>,
325 namespace: PdbIdentifierNamespace,
326) -> &'a str {
327 let Some(name) = chain_name(*chain, namespace) else {
328 return "";
329 };
330 name
331}
332
333pub(crate) fn insertion_code(residue: ResidueRef<'_>) -> &str {
334 let Some(code) = residue.ins_code() else {
335 return " ";
336 };
337 code
338}
339
340#[derive(Clone, Copy)]
341pub(crate) enum RequiredAtomFields {
342 Pdb,
343 Pqr,
344 Pdbqt,
345}
346
347pub(crate) fn check_capacity(
349 structure: &Structure,
350 options: &PdbOptions,
351 select: &impl Select,
352 required: RequiredAtomFields,
353) -> Vec<Diagnostic> {
354 let data = structure.data();
355 let mut refusals = Vec::new();
356
357 for chain in data.chains() {
358 if !select.accept_chain(chain.index()) {
359 continue;
360 }
361 let original = chain_label(&chain, options.namespace);
362 let written = options.label_for(original);
363 if written.chars().count() > 1 {
364 refusals.push(
365 Diagnostic::new(Code::E4102)
366 .with_context("chain", original.to_string())
367 .with_context("written as", written.to_string()),
368 );
369 }
370 for residue in chain.residues() {
371 if !select.accept_residue(residue.index()) {
372 continue;
373 }
374 check_residue(&residue, options, select, required, &mut refusals);
375 }
376 }
377
378 let atoms = i64::from(structure.atom_count());
379 if !options.hybrid36 && hybrid36::needs_hybrid36(atoms, 5) {
380 refusals.push(
381 Diagnostic::new(Code::E4101)
382 .with_context("atoms", atoms.to_string())
383 .with_context("field holds", "99999"),
384 );
385 }
386 refusals
387}
388
389fn check_residue(
390 residue: &ResidueRef<'_>,
391 options: &PdbOptions,
392 select: &impl Select,
393 required: RequiredAtomFields,
394 refusals: &mut Vec<Diagnostic>,
395) {
396 if let Some(seq) = residue_sequence(*residue, options.namespace)
397 && !options.hybrid36
398 && hybrid36::needs_hybrid36(i64::from(seq), 4)
399 {
400 refusals.push(
401 Diagnostic::new(Code::E4103)
402 .with_context("residue", seq.to_string())
403 .with_context("field holds", "9999"),
404 );
405 }
406 for atom in residue.atoms() {
407 if !select.accept_atom(atom.index()) {
408 continue;
409 }
410 check_required_atom_fields(atom, *residue, options, required, refusals);
411 let Some(position) = atom.position() else {
412 continue;
413 };
414 if position.iter().any(|value| {
415 let value = f64::from(*value);
416 !value.is_finite() || !(MIN_COORDINATE..MAX_COORDINATE).contains(&value)
417 }) {
418 refusals.push(
419 Diagnostic::new(Code::E4104)
420 .with_context("atom", atom.index().to_string())
421 .with_context("position", format!("{position:?}")),
422 );
423 }
424 }
425}
426
427fn check_required_atom_fields(
428 atom: AtomRef<'_>,
429 residue: ResidueRef<'_>,
430 options: &PdbOptions,
431 required: RequiredAtomFields,
432 refusals: &mut Vec<Diagnostic>,
433) {
434 let common = [
435 (atom.position().is_some(), "coordinates"),
436 (
437 atom_name(atom, options.namespace).is_some(),
438 "atom identifier",
439 ),
440 (
441 component_name(atom, residue, options.namespace).is_some(),
442 "component identifier",
443 ),
444 (
445 residue_sequence(residue, options.namespace).is_some(),
446 "residue sequence identifier",
447 ),
448 ];
449 for (present, field) in common {
450 if !present {
451 refusals.push(missing_field(field, atom, options));
452 }
453 }
454 if matches!(
455 required,
456 RequiredAtomFields::Pdb | RequiredAtomFields::Pdbqt
457 ) {
458 for (present, field) in [
459 (atom.occupancy().is_some(), "occupancy"),
460 (atom.b_factor().is_some(), "B factor"),
461 ] {
462 if !present {
463 refusals.push(missing_field(field, atom, options));
464 }
465 }
466 }
467}
468
469fn required_text<'a>(
470 value: Option<&'a str>,
471 field: &'static str,
472 atom: AtomRef<'_>,
473 options: &PdbOptions,
474) -> Result<&'a str, Diagnostic> {
475 value.ok_or_else(|| missing_field(field, atom, options))
476}
477
478fn required_value<T>(
479 value: Option<T>,
480 field: &'static str,
481 atom: AtomRef<'_>,
482 options: &PdbOptions,
483) -> Result<T, Diagnostic> {
484 value.ok_or_else(|| missing_field(field, atom, options))
485}
486
487fn missing_field(field: &'static str, atom: AtomRef<'_>, options: &PdbOptions) -> Diagnostic {
488 Diagnostic::new(Code::E4105)
489 .with_context("required field", field)
490 .with_context("atom", atom.index().to_string())
491 .with_context("namespace", options.namespace.as_str())
492}
493
494#[cfg(test)]
495#[path = "pdb_tests.rs"]
496mod tests;