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}",
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 );
196 }
197}
198
199fn write_atom(
200 out: &mut impl fmt::Write,
201 structure: &Structure,
202 atom: &AtomRef<'_>,
203 residue: &ResidueRef<'_>,
204 chain: &str,
205 serial: i64,
206 options: &PdbOptions,
207) -> Result<(), Diagnostic> {
208 let record = if residue.is_het() { "HETATM" } else { "ATOM " };
209 let name = required_text(
210 atom_name(*atom, options.namespace),
211 "atom identifier",
212 *atom,
213 options,
214 )?;
215 let element = match atom.element() {
216 Some(element) => element.symbol(),
217 None => "",
218 };
219 let position = required_value(atom.position(), "coordinates", *atom, options)?;
223 let seq = i64::from(required_value(
224 residue_sequence(*residue, options.namespace),
225 "residue sequence identifier",
226 *atom,
227 options,
228 )?);
229 let component = required_text(
230 component_name(*atom, *residue, options.namespace),
231 "component identifier",
232 *atom,
233 options,
234 )?;
235 let occupancy = required_value(atom.occupancy(), "occupancy", *atom, options)?;
236 let b_factor = required_value(atom.b_factor(), "B factor", *atom, options)?;
237
238 let _ = writeln!(
239 out,
240 "{record}{serial:>5} {name:<4}{alt:1}{comp:>3} {chain:>1}{seq}{ins:1} \
241 {x:8.3}{y:8.3}{z:8.3}{occ:6.2}{b:6.2} {element:>2}",
242 serial = serial_field(serial, options),
243 name = atom_name_field(name),
244 alt = alt_field(structure, atom),
245 comp = component,
246 seq = residue_field(seq, options),
247 ins = insertion_code(*residue),
248 x = f64::from(position[0]),
249 y = f64::from(position[1]),
250 z = f64::from(position[2]),
251 occ = f64::from(occupancy),
252 b = f64::from(b_factor),
253 );
254 if let Some(u) = structure.data().anisotropy.for_atom(atom.index()) {
257 super::aniso::Record {
258 atom,
259 structure,
260 residue,
261 name,
262 component,
263 chain,
264 seq: &residue_field(seq, options),
265 serial: &serial_field(serial, options),
266 u: &u,
267 }
268 .write(out);
269 }
270 Ok(())
271}
272
273pub(crate) fn atom_name_field(name: &str) -> String {
278 if name.len() >= 4 {
279 name.to_owned()
280 } else {
281 format!(" {name:<3}")
282 }
283}
284
285pub(crate) fn alt_field<'a>(structure: &'a Structure, atom: &AtomRef<'a>) -> &'a str {
290 let Some(alt) = atom.alt_id() else { return " " };
291 let Some(symbol) = alt.symbol() else {
292 return " ";
293 };
294 match structure.resolve(symbol) {
295 Some(label) => label,
296 None => " ",
297 }
298}
299
300pub(crate) fn serial_field(serial: i64, options: &PdbOptions) -> String {
301 match options
302 .hybrid36
303 .then(|| hybrid36::encode(serial, 5))
304 .flatten()
305 {
306 Some(field) => field,
307 None => format!("{serial:>5}"),
308 }
309}
310
311pub(crate) fn residue_field(seq: i64, options: &PdbOptions) -> String {
312 match options.hybrid36.then(|| hybrid36::encode(seq, 4)).flatten() {
313 Some(field) => field,
314 None => format!("{seq:>4}"),
315 }
316}
317
318pub(crate) fn chain_label<'a>(
319 chain: &'a ChainRef<'a>,
320 namespace: PdbIdentifierNamespace,
321) -> &'a str {
322 let Some(name) = chain_name(*chain, namespace) else {
323 return "";
324 };
325 name
326}
327
328pub(crate) fn insertion_code(residue: ResidueRef<'_>) -> &str {
329 let Some(code) = residue.ins_code() else {
330 return " ";
331 };
332 code
333}
334
335#[derive(Clone, Copy)]
336pub(crate) enum RequiredAtomFields {
337 Pdb,
338 Pqr,
339 Pdbqt,
340}
341
342pub(crate) fn check_capacity(
344 structure: &Structure,
345 options: &PdbOptions,
346 select: &impl Select,
347 required: RequiredAtomFields,
348) -> Vec<Diagnostic> {
349 let data = structure.data();
350 let mut refusals = Vec::new();
351
352 for chain in data.chains() {
353 if !select.accept_chain(chain.index()) {
354 continue;
355 }
356 let original = chain_label(&chain, options.namespace);
357 let written = options.label_for(original);
358 if written.chars().count() > 1 {
359 refusals.push(
360 Diagnostic::new(Code::E4102)
361 .with_context("chain", original.to_string())
362 .with_context("written as", written.to_string()),
363 );
364 }
365 for residue in chain.residues() {
366 if !select.accept_residue(residue.index()) {
367 continue;
368 }
369 check_residue(&residue, options, select, required, &mut refusals);
370 }
371 }
372
373 let atoms = i64::from(structure.atom_count());
374 if !options.hybrid36 && hybrid36::needs_hybrid36(atoms, 5) {
375 refusals.push(
376 Diagnostic::new(Code::E4101)
377 .with_context("atoms", atoms.to_string())
378 .with_context("field holds", "99999"),
379 );
380 }
381 refusals
382}
383
384fn check_residue(
385 residue: &ResidueRef<'_>,
386 options: &PdbOptions,
387 select: &impl Select,
388 required: RequiredAtomFields,
389 refusals: &mut Vec<Diagnostic>,
390) {
391 if let Some(seq) = residue_sequence(*residue, options.namespace)
392 && !options.hybrid36
393 && hybrid36::needs_hybrid36(i64::from(seq), 4)
394 {
395 refusals.push(
396 Diagnostic::new(Code::E4103)
397 .with_context("residue", seq.to_string())
398 .with_context("field holds", "9999"),
399 );
400 }
401 for atom in residue.atoms() {
402 if !select.accept_atom(atom.index()) {
403 continue;
404 }
405 check_required_atom_fields(atom, *residue, options, required, refusals);
406 let Some(position) = atom.position() else {
407 continue;
408 };
409 if position.iter().any(|value| {
410 let value = f64::from(*value);
411 !value.is_finite() || !(MIN_COORDINATE..MAX_COORDINATE).contains(&value)
412 }) {
413 refusals.push(
414 Diagnostic::new(Code::E4104)
415 .with_context("atom", atom.index().to_string())
416 .with_context("position", format!("{position:?}")),
417 );
418 }
419 }
420}
421
422fn check_required_atom_fields(
423 atom: AtomRef<'_>,
424 residue: ResidueRef<'_>,
425 options: &PdbOptions,
426 required: RequiredAtomFields,
427 refusals: &mut Vec<Diagnostic>,
428) {
429 let common = [
430 (atom.position().is_some(), "coordinates"),
431 (
432 atom_name(atom, options.namespace).is_some(),
433 "atom identifier",
434 ),
435 (
436 component_name(atom, residue, options.namespace).is_some(),
437 "component identifier",
438 ),
439 (
440 residue_sequence(residue, options.namespace).is_some(),
441 "residue sequence identifier",
442 ),
443 ];
444 for (present, field) in common {
445 if !present {
446 refusals.push(missing_field(field, atom, options));
447 }
448 }
449 if matches!(
450 required,
451 RequiredAtomFields::Pdb | RequiredAtomFields::Pdbqt
452 ) {
453 for (present, field) in [
454 (atom.occupancy().is_some(), "occupancy"),
455 (atom.b_factor().is_some(), "B factor"),
456 ] {
457 if !present {
458 refusals.push(missing_field(field, atom, options));
459 }
460 }
461 }
462}
463
464fn required_text<'a>(
465 value: Option<&'a str>,
466 field: &'static str,
467 atom: AtomRef<'_>,
468 options: &PdbOptions,
469) -> Result<&'a str, Diagnostic> {
470 value.ok_or_else(|| missing_field(field, atom, options))
471}
472
473fn required_value<T>(
474 value: Option<T>,
475 field: &'static str,
476 atom: AtomRef<'_>,
477 options: &PdbOptions,
478) -> Result<T, Diagnostic> {
479 value.ok_or_else(|| missing_field(field, atom, options))
480}
481
482fn missing_field(field: &'static str, atom: AtomRef<'_>, options: &PdbOptions) -> Diagnostic {
483 Diagnostic::new(Code::E4105)
484 .with_context("required field", field)
485 .with_context("atom", atom.index().to_string())
486 .with_context("namespace", options.namespace.as_str())
487}
488
489#[cfg(test)]
490#[path = "pdb_tests.rs"]
491mod tests;