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 Ok(())
255}
256
257pub(crate) fn atom_name_field(name: &str) -> String {
262 if name.len() >= 4 {
263 name.to_owned()
264 } else {
265 format!(" {name:<3}")
266 }
267}
268
269pub(crate) fn alt_field<'a>(structure: &'a Structure, atom: &AtomRef<'a>) -> &'a str {
274 let Some(alt) = atom.alt_id() else { return " " };
275 let Some(symbol) = alt.symbol() else {
276 return " ";
277 };
278 match structure.resolve(symbol) {
279 Some(label) => label,
280 None => " ",
281 }
282}
283
284pub(crate) fn serial_field(serial: i64, options: &PdbOptions) -> String {
285 match options
286 .hybrid36
287 .then(|| hybrid36::encode(serial, 5))
288 .flatten()
289 {
290 Some(field) => field,
291 None => format!("{serial:>5}"),
292 }
293}
294
295pub(crate) fn residue_field(seq: i64, options: &PdbOptions) -> String {
296 match options.hybrid36.then(|| hybrid36::encode(seq, 4)).flatten() {
297 Some(field) => field,
298 None => format!("{seq:>4}"),
299 }
300}
301
302pub(crate) fn chain_label<'a>(
303 chain: &'a ChainRef<'a>,
304 namespace: PdbIdentifierNamespace,
305) -> &'a str {
306 let Some(name) = chain_name(*chain, namespace) else {
307 return "";
308 };
309 name
310}
311
312pub(crate) fn insertion_code(residue: ResidueRef<'_>) -> &str {
313 let Some(code) = residue.ins_code() else {
314 return " ";
315 };
316 code
317}
318
319#[derive(Clone, Copy)]
320pub(crate) enum RequiredAtomFields {
321 Pdb,
322 Pqr,
323 Pdbqt,
324}
325
326pub(crate) fn check_capacity(
328 structure: &Structure,
329 options: &PdbOptions,
330 select: &impl Select,
331 required: RequiredAtomFields,
332) -> Vec<Diagnostic> {
333 let data = structure.data();
334 let mut refusals = Vec::new();
335
336 for chain in data.chains() {
337 if !select.accept_chain(chain.index()) {
338 continue;
339 }
340 let original = chain_label(&chain, options.namespace);
341 let written = options.label_for(original);
342 if written.chars().count() > 1 {
343 refusals.push(
344 Diagnostic::new(Code::E4102)
345 .with_context("chain", original.to_string())
346 .with_context("written as", written.to_string()),
347 );
348 }
349 for residue in chain.residues() {
350 if !select.accept_residue(residue.index()) {
351 continue;
352 }
353 check_residue(&residue, options, select, required, &mut refusals);
354 }
355 }
356
357 let atoms = i64::from(structure.atom_count());
358 if !options.hybrid36 && hybrid36::needs_hybrid36(atoms, 5) {
359 refusals.push(
360 Diagnostic::new(Code::E4101)
361 .with_context("atoms", atoms.to_string())
362 .with_context("field holds", "99999"),
363 );
364 }
365 refusals
366}
367
368fn check_residue(
369 residue: &ResidueRef<'_>,
370 options: &PdbOptions,
371 select: &impl Select,
372 required: RequiredAtomFields,
373 refusals: &mut Vec<Diagnostic>,
374) {
375 if let Some(seq) = residue_sequence(*residue, options.namespace)
376 && !options.hybrid36
377 && hybrid36::needs_hybrid36(i64::from(seq), 4)
378 {
379 refusals.push(
380 Diagnostic::new(Code::E4103)
381 .with_context("residue", seq.to_string())
382 .with_context("field holds", "9999"),
383 );
384 }
385 for atom in residue.atoms() {
386 if !select.accept_atom(atom.index()) {
387 continue;
388 }
389 check_required_atom_fields(atom, *residue, options, required, refusals);
390 let Some(position) = atom.position() else {
391 continue;
392 };
393 if position.iter().any(|value| {
394 let value = f64::from(*value);
395 !value.is_finite() || !(MIN_COORDINATE..MAX_COORDINATE).contains(&value)
396 }) {
397 refusals.push(
398 Diagnostic::new(Code::E4104)
399 .with_context("atom", atom.index().to_string())
400 .with_context("position", format!("{position:?}")),
401 );
402 }
403 }
404}
405
406fn check_required_atom_fields(
407 atom: AtomRef<'_>,
408 residue: ResidueRef<'_>,
409 options: &PdbOptions,
410 required: RequiredAtomFields,
411 refusals: &mut Vec<Diagnostic>,
412) {
413 let common = [
414 (atom.position().is_some(), "coordinates"),
415 (
416 atom_name(atom, options.namespace).is_some(),
417 "atom identifier",
418 ),
419 (
420 component_name(atom, residue, options.namespace).is_some(),
421 "component identifier",
422 ),
423 (
424 residue_sequence(residue, options.namespace).is_some(),
425 "residue sequence identifier",
426 ),
427 ];
428 for (present, field) in common {
429 if !present {
430 refusals.push(missing_field(field, atom, options));
431 }
432 }
433 if matches!(
434 required,
435 RequiredAtomFields::Pdb | RequiredAtomFields::Pdbqt
436 ) {
437 for (present, field) in [
438 (atom.occupancy().is_some(), "occupancy"),
439 (atom.b_factor().is_some(), "B factor"),
440 ] {
441 if !present {
442 refusals.push(missing_field(field, atom, options));
443 }
444 }
445 }
446}
447
448fn required_text<'a>(
449 value: Option<&'a str>,
450 field: &'static str,
451 atom: AtomRef<'_>,
452 options: &PdbOptions,
453) -> Result<&'a str, Diagnostic> {
454 value.ok_or_else(|| missing_field(field, atom, options))
455}
456
457fn required_value<T>(
458 value: Option<T>,
459 field: &'static str,
460 atom: AtomRef<'_>,
461 options: &PdbOptions,
462) -> Result<T, Diagnostic> {
463 value.ok_or_else(|| missing_field(field, atom, options))
464}
465
466fn missing_field(field: &'static str, atom: AtomRef<'_>, options: &PdbOptions) -> Diagnostic {
467 Diagnostic::new(Code::E4105)
468 .with_context("required field", field)
469 .with_context("atom", atom.index().to_string())
470 .with_context("namespace", options.namespace.as_str())
471}
472
473#[cfg(test)]
474#[path = "pdb_tests.rs"]
475mod tests;