Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
59f5e4b
feat: initial implement for molecule
anko9801 Sep 8, 2025
b38de8a
style: move to elements feature
anko9801 Sep 8, 2025
cb6a426
fix: fix public API name
anko9801 Sep 8, 2025
1a3c3e5
feat: ring prefix with "@" instead of "*"
anko9801 Sep 8, 2025
fd866dd
feat: improve connection point process and fragment process in ring
anko9801 Sep 8, 2025
69a3550
feat: use absolute angle instead of relative angle
anko9801 Sep 8, 2025
7221244
feat: replace an existing molecule element
anko9801 Sep 10, 2025
7e0382f
feat: introduce the conceptual design of a syntax for reaction schemes
anko9801 Sep 10, 2025
6df85c7
refactor: update reaction syntax to use operators and enhance fragmen…
anko9801 Sep 10, 2025
883fca2
feat: refine remote connection syntax
anko9801 Sep 10, 2025
7cae597
refactor: reimplement parser with parser combinator
anko9801 Sep 11, 2025
06db4a3
feat: update tranformer to follow parser
anko9801 Sep 11, 2025
223ada6
refactor: update angle calculation to follow parser
anko9801 Sep 11, 2025
5929af5
refactor: Improve clarity and remove redundant comments
anko9801 Sep 11, 2025
46314ea
feat: Implement simple error handling
anko9801 Sep 11, 2025
98b8c40
feat: Add edge and common integration test cases
anko9801 Sep 12, 2025
33c6c00
feat: Implement validator parser combinator
anko9801 Sep 12, 2025
1cbcf58
feat: Implement connecting points with labeling by merging label
anko9801 Sep 12, 2025
2b31604
feat: Optimize calling depth in parser
anko9801 Sep 13, 2025
d44eb38
feat: Add deeply nested structure test
anko9801 Sep 13, 2025
2f6c2aa
fix: fix incorrect angle calculation
anko9801 Sep 15, 2025
d429dee
refactor: transform by 1 recursion pass
anko9801 Sep 18, 2025
f5a9ebb
feat: initial connecting points
anko9801 Sep 22, 2025
bcc26e5
feat: make rings treated as branch
anko9801 Sep 24, 2025
91aa2e9
fix: fix some bugs
anko9801 Sep 26, 2025
1a1848c
refactor: edge test cases
anko9801 Sep 26, 2025
a69a013
feat: implement atom processing and angle calculations for molecule t…
anko9801 Dec 17, 2025
77d74c3
feat: replace molecule with fragment in various test files for consis…
anko9801 Dec 17, 2025
b880719
feat: add edge case tests for molecule parsing and introduce .gitigno…
anko9801 Dec 17, 2025
9651fe3
feat: adjust angle calculations for main chain branches to account fo…
anko9801 Dec 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib.typ
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#import "@preview/cetz:0.4.1"
#import "src/default.typ": default
#import "src/utils/utils.typ"
#import "src/elements/molecule/molecule.typ": molecule
#import "src/drawer.typ"
#import "src/drawer.typ": skeletize, draw-skeleton, skeletize-config, draw-skeleton-config
#import "src/elements/links.typ": *
Expand Down Expand Up @@ -78,7 +79,6 @@
),
)
}
#let molecule(name: none, links: (:), lewis: (), vertical: false, mol) = fragment(name: name, links: links, lewis: lewis, vertical: vertical, mol)

/// === Hooks
/// Create a hook in the fragment. It allows to connect links to the place where the hook is.
Expand Down
163 changes: 163 additions & 0 deletions src/elements/molecule/generator.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#import "../links.typ": single, double, triple, cram-filled-right, cram-filled-left, cram-dashed-right, cram-dashed-left, cram-hollow-right, cram-hollow-left

// ============================ Atom Processing ============================

/// Convert parsed atom structure to Typst math content
#let process-atom(parts) = {
let type = parts.type

if type == "atoms" {
let base = parts.parts.map(process-atom)
if parts.charge != none {
(math.attach(base.join(), tr: eval("$" + parts.charge + "$")),)
} else {
base
}
} else if type == "abbreviation" {
text(parts.value)
} else if type == "math-text" {
eval(parts.value)
} else if type == "element-group" {
math.attach(parts.element, tl: [#parts.isotope], br: [#parts.subscript])
} else if type == "parenthetical" {
let inner = process-atom(parts.atoms)
math.attach([(#inner.join())], br: [#parts.subscript])
} else if type == "complex" {
let inner = process-atom(parts.atoms)
[\[#inner.join()\]]
} else {
"unknown type: " + type
}
}

/// Extract element names from parsed content and find the first non-H index
/// Returns the index of the first non-H element (0 if all are H or empty)
#let calc-main-index(parts) = {
// Extract element names recursively
let extract(p) = {
if p.type == "atoms" { p.parts.map(extract).flatten() }
else if p.type == "element-group" { (p.element,) }
else if p.type == "parenthetical" or p.type == "complex" { extract(p.atoms) }
else if p.type == "abbreviation" or p.type == "math-text" { (p.value,) }
else { () }
}
let elements = extract(parts)

// Find first non-H index
for (idx, el) in elements.enumerate() {
if el != "H" { return idx }
}
0
}

// ============================ Molecule ============================

#let generate_fragment(node) = (
(
type: "fragment",
atoms: node.atoms,
name: node.at("name", default: none),
links: node.at("links", default: (:)),
lewis: node.options.at("lewis", default: ()),
vertical: node.options.at("vertical", default: false),
count: node.atoms.len(),
colors: node.options.at("colors", default: none),
label: node.at("name", default: none),
..node.options,
),
)

#let generate_bond(bond, angle, options) = {
let symbol = bond.symbol
let name = bond.at("name", default: none)
let absolute = if angle != none { angle } else { bond.at("absolute", default: none) }
let relative = bond.at("relative", default: none)
let options = if options != (:) { options } else { bond.options }

let bond-fn = if symbol == "-" {
single
} else if symbol == "=" {
double
} else if symbol == "#" {
triple
} else if symbol == ">" {
cram-filled-right
} else if symbol == "<" {
cram-filled-left
} else if symbol == ":>" {
cram-dashed-right
} else if symbol == "<:" {
cram-dashed-left
} else if symbol == "|>" {
cram-hollow-right
} else if symbol == "<|" {
cram-hollow-left
} else {
single
}

if absolute != none and relative != none {
bond-fn(relative: relative, absolute: absolute, name: name, ..options)
} else if relative != none {
bond-fn(relative: relative, name: name, ..options)
} else if absolute != none {
bond-fn(absolute: absolute, name: name, ..options)
} else {
bond-fn(name: name, ..options)
}
}

#let generate_branch(bond, body) = (
(
type: "branch",
body: {bond; body},
args: (:),
),
)

#let generate_cycle(cycle, body) = (
type: "cycle",
faces: cycle.faces,
body: body,
args: (:),
)

#let generate_molecule(molecule) = {
if molecule == none { return () }
if type(molecule) == array { return molecule }
if molecule.type != "molecule" { return () }

let elements = ()
elements += generate_unit(molecule.first)
for item in molecule.rest {
elements += generate_bond(item.bond)
elements += generate_unit(item.unit)
}
return elements
}

// ============================ Reaction ============================

#let generate_operator(operator) = {
let op = if operator.op == "->" {
sym.arrow.r
} else if operator.op == "<->" {
sym.arrow.l.r
} else if operator.op == "<=>" {
sym.harpoons.ltrb
} else {
eval("$" + operator.op + "$")
}

op = math.attach(
math.stretch(op, size: 100% + 2em),
t: [#term.condition-before], b: [#term.condition-after]
)

return (
type: "operator",
name: none,
op: op,
margin: 0.7em,
)
}
149 changes: 149 additions & 0 deletions src/elements/molecule/iupac-angle.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// relative angles
#let IUPAC_ANGLES = (
main_chain_initial: chain_length => if chain_length >= 2 { 30deg } else { 0deg } - 60deg,
zigzag: idx => if calc.rem(idx, 2) == 1 { 60deg } else { -60deg },
incoming: -180deg,
straight: 0deg,

sp3: (60deg, -60deg, -120deg, -180deg),
sp2: (60deg, -60deg, -180deg),
sp: (0deg, -180deg),

branch_angles: (n, idx) => 180deg - (idx + 1) * 360deg / n,
cycle_edge_angles: n => 360deg / n,
cycle_branch_angles: n => -150deg + 180deg / n,
)

// Calculate the angles for the hybridization of the bonds
#let hybridization_angles(bonds, branches_len) = {
let n = bonds.len()
let triple = bonds.filter(b => b.symbol == "#").len()
let double = bonds.filter(b => b.symbol == "=").len()
let other = bonds.filter(b => b.symbol != "#" and b.symbol != "=").len()

if n == 2 and (triple >= 1 or double >= 2) { IUPAC_ANGLES.sp }
else if branches_len <= 1 and (double >= 1 or other >= 2) { IUPAC_ANGLES.sp2 }
else if branches_len <= 2 { IUPAC_ANGLES.sp3 }
else { range(n).map(i => (IUPAC_ANGLES.branch_angles)(n, i)) }
}

#let bond-angle(ctx, bond) = {
let (n, idx) = ctx.position.last()

let angle = if ctx.parent_type == "unit" or ctx.parent_type == none {
ctx.current_angle + (IUPAC_ANGLES.zigzag)(idx)
} else if ctx.parent_type == "cycle" {
let (faces, _) = ctx.position.at(-2)
ctx.current_angle + (IUPAC_ANGLES.cycle_edge_angles)(faces)
} else if ctx.parent_type == "branch" {
ctx.current_angle
} else {
panic("Unknown parent type: " + ctx.parent_type)
}

return (ctx + (current_angle: angle), angle)
}

// Calculate relative angle for a ring attached to a main chain unit
// Returns (angle, absolute) tuple, or (none, false) if default behavior should be used
#let ring-angle(ctx, ring, rings_count, idx) = {
if ctx.parent_type == "cycle" {
// Inside a cycle - use context info for polycyclic vs hetero detection
let outer_faces = ctx.at("outer_cycle_faces", default: none)
let outer_bonds = ctx.at("outer_cycle_body_len", default: none)

// Also check inner ring's bonds vs faces
let inner_faces = ring.faces
let inner_bonds = if ring.body != none and ring.body.type == "molecule" and ring.body.rest != none {
ring.body.rest.len()
} else { 0 }

// Polycyclic: outer or inner has fewer bonds than faces
let is_polycyclic = outer_bonds < outer_faces or inner_bonds < inner_faces

if is_polycyclic {
(none, false)
} else {
// Hetero - use branch angle
((IUPAC_ANGLES.cycle_branch_angles)(outer_faces), false)
}
} else if ctx.prev_bond != none and ctx.next_bond != none {
// MIDDLE of chain - ring goes as a branch
let base = 0deg
if rings_count > 1 {
base = base + 60deg * (idx - (rings_count - 1) / 2)
}
(base, false)
} else if ctx.prev_bond != none or ctx.next_bond != none {
// START or END of chain - ring extends parallel to chain direction
let (_, chain_idx) = ctx.position.last()
let edge = 180deg / ring.faces
let base = if ctx.prev_bond == none {
// START: extend opposite to chain direction
ctx.current_angle + 150deg + edge
} else {
// END: continue in chain direction
// Uses main_chain_initial pattern for consistency
let offset = 120deg + (IUPAC_ANGLES.main_chain_initial)(chain_idx) + (IUPAC_ANGLES.zigzag)(chain_idx) / 2
ctx.current_angle - offset + edge
}
if rings_count > 1 {
base = base + 60deg * (idx - (rings_count - 1) / 2)
}
(base, false)
} else {
(none, false)
}
}

#let branch-angles(ctx, branches) = {
let (n, idx) = ctx.position.last()

if branches.len() == 0 { return () }

if ctx.parent_type == "cycle" {
let (faces, _) = ctx.position.at(-2)
let base_angle = (IUPAC_ANGLES.cycle_branch_angles)(faces)

let branch_count = branches.len()
if branch_count == 1 {
return (base_angle,)
}

// For multiple branches, spread them symmetrically
let spread = 60deg
return range(branch_count).map(i => {
base_angle + spread * (i - (branch_count - 1) / 2)
})
}

let bonds = branches.map(b => b.bond)
if ctx.prev_bond != none { bonds.push(ctx.prev_bond) }
if ctx.next_bond != none { bonds.push(ctx.next_bond) }

let angles = hybridization_angles(bonds, branches.len()).filter(
angle => (ctx.prev_bond == none or angle != IUPAC_ANGLES.incoming)
and (ctx.next_bond == none or angle != (IUPAC_ANGLES.zigzag)(idx + 1))
)

// first branches of the main chain
// Offset by 180deg + zigzag to face opposite to outgoing direction
if ctx.prev_bond == none and ctx.parent_type == none {
let outgoing = (IUPAC_ANGLES.zigzag)(idx + 1)
angles = angles.map(angle => angle + 180deg + outgoing)
}

return angles
}

#let initial-angle(ctx, molecule) = {
return (IUPAC_ANGLES.main_chain_initial)(molecule.rest.len())
}

/// Check if angle is vertical (around 90deg or 270deg)
/// Used to determine when to connect to main atom instead of H
#let is-vertical-angle(angle) = {
let a = calc.rem(angle / 1deg, 360) * 1deg
if a < 0deg { a += 360deg }
(a > 60deg and a < 120deg) or (a > 240deg and a < 300deg)
}
44 changes: 44 additions & 0 deletions src/elements/molecule/molecule.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Molecule parser and transformer module
//
// This module provides a high-level declarative syntax for chemical structures.
//
// Example usage:
// #skeletize(molecule("CH3-CH2-OH")) // Ethanol
// #skeletize(molecule("@6(-=-=-=)")) // Benzene
//
// Supported syntax:
// - Atoms: C, H, O, N, Cl, etc.
// - Bonds: - (single), = (double), # (triple), > < (wedge), :> <: (dashed wedge)
// - Branches: (bond content) e.g., CH3-CH(-OH)-CH3
// - Rings: @n e.g., @6 for hexagon, @5 for pentagon
// - Labels: :name e.g., CH3:start
// - Charges: ^+ ^- ^2+ ^3- e.g., NH4^+
// - Isotopes: ^14C, ^235U
//
// Limitations:
// - Maximum nesting depth: ~11 levels due to Typst's recursion limit
// Deeply nested structures like "-(-(-(-(...)))) " beyond 11 levels will fail
// - This is a limitation of the parser combinator approach in Typst
//
#import "parser.typ": alchemist-parser
#import "transformer.typ": transform

/// Parse and transform a molecule string into alchemist elements.
///
/// - content (string): The molecule string to parse
/// - name (string): Optional name for the molecule group
/// - ..args: Additional arguments (reserved for future use)
///
/// Returns: Array of alchemist elements or error content
#let molecule(content, name: none, ..args) = {
let parsed = alchemist-parser(content)
if not parsed.success {
// Display error inline
return text(fill: red)[
Failed to parse "#content": #parsed.error
]
}

let reaction = parsed.value
transform(reaction)
}
Loading