これはTypst Advent Calendar20日目の記事です。
先日、私は化学のエネルギー準位図を描画するTypstパッケージ energy-diaを公開しました。
このパッケージでは、エネルギー値や電子配置を自動計算して描画を行っています。
本記事では、この開発プロセスで得られた複雑な描画ロジックをTypstで実装するための6つのテクニックを詳しく解説します。Typstで独自のライブラリや関数を作りたいと考えている方の一助になれば幸いです。
1. Argumentsを使う。
エネルギー準位図を描く場合、「軌道の数」は原子によって異なります。1s軌道、2s軌道、2p軌道・・・と軌道の数は多数あり、描画したい個数は分かりません。
このような可変長のデータを受け取るために、TypstのArguments(..name)という機能を活用しました。
受け取ったArguments levels は、内部で .pos() メソッドを使うことで配列として処理できます。
実装例
#let ao(width: 5, height: 5, ..levels) = {
cetz.canvas({
ao-cetz(..., ..levels)
})
}
Argumentsを使うことで、ユーザーは以下のように自然にデータを列挙できます。
#ao(
(energy: -10, ...),
(energy: -5, ...),
...
)
2. デフォルト値
ユーザーの入力を減らせる設計にしています。energy-dia では、関数の引数と辞書データの読み取りの2段階でデフォルト値を設定することで、省略可能な設計にしています。
関数の引数レベルでのデフォルト値
ユーザーが呼び出すトップレベル関数 (ao, mo, band) では、引数にデフォルト値を設定しています。
#let ao(
width: 5,
height: 5,
harpoon: true,
name: none,
exclude-energy: false,
..levels
) = { ... }
これにより、ユーザーは width や name を気にせず、ユーザーが設定したいところだけを書くことができます。
辞書データレベルでのデフォルト値
次に、可変長引数(..levels)として渡される各軌道データの処理です。 各軌道にはエネルギー値以外にも、電子数、縮退度、キャプションなど多くのパラメータが存在します。これら全てをユーザーに入力させるのは酷です。
そこで、データを取り出す際に.at()メソッドのdefaultオプションを活用しています。
draw-energy-level-ao(
...,
degeneracy: level.at("degeneracy", default: 1),
caption: level.at("caption", default: none),
)
draw-electron-ao(
...,
number: level.at("electrons", default: 0),
// スピン指定がなければ none (自動計算モード)
up: level.at("up", default: none),
)
3. cetzのインポートを直接行わない
これはパッケージの保守性を高めるためのテクニックです。
描画ロジックを担当する modules.typ の中では import cetz.draw: * を行っていません。その代わり、cetzパッケージの描画を行う関数そのもの(line, content)を引数として受け取る設計にしています。
実装例
※以下の実装は実際のものとは異なります。実際にはもう一段階入っています。詳しくはtips6を見てください。
modules.typ
// line-fn, content-fn という関数を受け取って使う
#let draw-axis(line-fn, content-fn, width, height)={
...
}
lib.typ
#let ao(...) = {
cetz.canvas({
import cetz.draw: *
// ここで CeTZ の line, content 関数を実体として渡す
draw-axis(
line,
content,
...
)
})
}
これにより、ロジック関数を分けることができ、全体の関数の複雑化を防ぎ、コードの再利用可能性や保守性を高めることができます。
4. ループと剰余演算によるスピンの自動化
電子配置図を描く際、電子は「上向き」「下向き」と交互に入っていくのが一般的です。これを毎回手動で指定するのは手間です。
そこで、range ループと剰余演算 calc.rem を組み合わせて自動化しました。
実装例
for i in range(number) {
// 偶数番目(0, 2...)は上向き、奇数番目(1, 3...)は下向き
let is-up = calc.rem(i, 2) == 0
if is-up {
line-fn((x, y - h), (x, y + h), mark: (end: "straight", harpoon: harpoon))
} else {
line-fn((x, y + h), (x, y - h), mark: (end: "straight", harpoon: harpoon))
}
}
さらに、フントの規則を無視して強制的にスピンの向きを指定したいケースに対応するため、if up == none による条件分岐を入れ、カスタム描画ループへ切り替えるロジックも実装しています。
5. ラベルによる特定の軌道の座標の取得
分子軌道(MO)図の作成には、左右の原子軌道と中央の分子軌道を点線で結ぶ処理が必要です。しかし、ユーザーにもう一度エネルギー準位を入力させ、その準位間をつなぐという仕様は不親切であり、軌道番号を指定してその番号間をつなぐ仕様のほうが簡単になります。
そこで、各軌道データに label というIDを持たせ、IDから計算済みの座標を逆引きする関数を作成しました。
実装例
#let get-position-by-label(label, atom1, molecule, atom2, ...) = {
for level in atom1 {
if level.at("label", default: none) == label {
let y = scale-y(level.at("energy"), min, max, height)
return (left-x, y)
}
}
}
これにより、ユーザーは (1, 3) のようにIDペアを指定するだけで、自動的に正しい高さ同士を結ぶ点線を描画できます。
6. ユーザーによる拡張
Tip3ではもう一段階間に挟んでいると述べました。具体的には、個々の描画ロジックをmodules.typ内の関数で作り、それらをまとめた関数をao-cetz()として作成しています。そして、最後にao()内のcetz環境でao-cetz()を呼び出す仕様にしています。これにより、まだパッケージとして提供していない機能をユーザーが独自に書いて作る事を許容しています。
例えば、「励起状態への電子遷移を表す矢印を描きたい」とユーザーが思ったとします。しかし、現在のパッケージにはその機能はありません。 もし'ao-cetz()'がなければ、ユーザーはアップデートを待つしかありません。
しかし、'ao-cetz()としてcetz環境を含まない描画関数単体が公開されていれば、ユーザーは以下のように自分でcetz.canvas`を定義し、その中でパッケージの描画関数と独自の描画を組み合わせることができます。
#import "@preview/energy-dia:0.1.0": ao-cetz
#cetz.canvas({
import cetz.draw: *
// 1. まずパッケージの機能で基本の図を描く
ao-cetz(line, content, width: 5, ...)
// 2. 足りない機能(例:遷移矢印)を自分で書き足す
line((2.5, -2), (2.5, 0), mark: (end: ">"), stroke: red)
})
多少の手間はかかりますが、これによって「作者が機能を実装するまで何もできない」という状況を防ぎ、パワーユーザーが自由にカスタマイズできる可能性を提供しています。
(この機能は、現在Typst Universeには登録されていません。アップデートをお待ちいただくか、githubのenergy-diaのレポジトリからダウンロードして開発版をご利用ください。)
おわりに
今回紹介したテクニックは、化学以外の分野のライブラリ開発にも応用できる汎用的なものもあると思います。
もし興味を持たれましたら、ぜひenergy-dia のソースコードも覗いてみてください。改善点は多々あると思うので、使って気になった点などがあればぜひissueに書いていただけると嬉しいです。
また、化学パッケージはまだ足りていないものが多いのでぜひ開発、公開していただけると嬉しいです。
参考文献