asciichem 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +21 -0
  3. data/.gitignore +14 -0
  4. data/.rubocop.yml +50 -0
  5. data/ARCHITECTURE.adoc +239 -0
  6. data/CHANGELOG.md +17 -0
  7. data/CLAUDE.md +318 -0
  8. data/Gemfile +14 -0
  9. data/LICENSE +24 -0
  10. data/README.adoc +55 -0
  11. data/RELEASING.md +102 -0
  12. data/Rakefile +8 -0
  13. data/asciichem.gemspec +39 -0
  14. data/benchmarks/RESULTS.md +49 -0
  15. data/benchmarks/benchmark.rb +106 -0
  16. data/exe/asciichem +6 -0
  17. data/lib/asciichem/cli.rb +96 -0
  18. data/lib/asciichem/cml/extensions.rb +360 -0
  19. data/lib/asciichem/cml/group_extensions.rb +290 -0
  20. data/lib/asciichem/cml/translator.rb +69 -0
  21. data/lib/asciichem/cml.rb +40 -0
  22. data/lib/asciichem/errors.rb +9 -0
  23. data/lib/asciichem/formatter/base.rb +21 -0
  24. data/lib/asciichem/formatter/html.rb +111 -0
  25. data/lib/asciichem/formatter/latex.rb +173 -0
  26. data/lib/asciichem/formatter/mathml.rb +309 -0
  27. data/lib/asciichem/formatter/structural_svg.rb +286 -0
  28. data/lib/asciichem/formatter/svg.rb +141 -0
  29. data/lib/asciichem/formatter/text.rb +143 -0
  30. data/lib/asciichem/formatter.rb +43 -0
  31. data/lib/asciichem/grammar.rb +344 -0
  32. data/lib/asciichem/greek.rb +80 -0
  33. data/lib/asciichem/layout.rb +313 -0
  34. data/lib/asciichem/linter/balance_check.rb +103 -0
  35. data/lib/asciichem/linter/base.rb +52 -0
  36. data/lib/asciichem/linter/bracket_balance_check.rb +44 -0
  37. data/lib/asciichem/linter/diagnostic.rb +16 -0
  38. data/lib/asciichem/linter/element_validation_check.rb +39 -0
  39. data/lib/asciichem/linter/isotope_sanity_check.rb +47 -0
  40. data/lib/asciichem/linter/registry.rb +31 -0
  41. data/lib/asciichem/linter/unclosed_ring_check.rb +27 -0
  42. data/lib/asciichem/linter/valence_check.rb +91 -0
  43. data/lib/asciichem/linter.rb +40 -0
  44. data/lib/asciichem/model/atom.rb +73 -0
  45. data/lib/asciichem/model/bond.rb +41 -0
  46. data/lib/asciichem/model/electron_configuration.rb +36 -0
  47. data/lib/asciichem/model/embedded_math.rb +26 -0
  48. data/lib/asciichem/model/formula.rb +31 -0
  49. data/lib/asciichem/model/group.rb +50 -0
  50. data/lib/asciichem/model/identifier.rb +14 -0
  51. data/lib/asciichem/model/molecule.rb +70 -0
  52. data/lib/asciichem/model/name.rb +15 -0
  53. data/lib/asciichem/model/node.rb +100 -0
  54. data/lib/asciichem/model/reaction.rb +49 -0
  55. data/lib/asciichem/model/reaction_cascade.rb +33 -0
  56. data/lib/asciichem/model/text.rb +23 -0
  57. data/lib/asciichem/model.rb +23 -0
  58. data/lib/asciichem/model_adapter/from_canonical.rb +272 -0
  59. data/lib/asciichem/model_adapter/to_canonical.rb +429 -0
  60. data/lib/asciichem/model_adapter.rb +56 -0
  61. data/lib/asciichem/parser.rb +36 -0
  62. data/lib/asciichem/periodic_table.rb +112 -0
  63. data/lib/asciichem/ring_bonds.rb +68 -0
  64. data/lib/asciichem/transform.rb +513 -0
  65. data/lib/asciichem/version.rb +5 -0
  66. data/lib/asciichem/xml_builder.rb +9 -0
  67. data/lib/asciichem.rb +34 -0
  68. metadata +197 -0
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module AsciiChem
6
+ module Formatter
7
+ # Renders a Model tree as presentation MathML.
8
+ #
9
+ # The headline semantic fix lives here: prefix isotopes bind to the
10
+ # atom, so `^14C` renders as `<msup><mi>C</mi><mn>14</mn></msup>`
11
+ # (Carbon with 14 as superscript). AsciiMath would emit
12
+ # `<msup><mi></mi><mn>14</mn></msup><mi>C</mi>` — a phantom base
13
+ # followed by a sibling atom, which loses the binding.
14
+ class Mathml < Base
15
+ MATHML_NS = "http://www.w3.org/1998/Math/MathML".freeze
16
+
17
+ def initialize
18
+ @doc = Nokogiri::XML::Document.new
19
+ end
20
+
21
+ # Model entry point. Returns a `<math>` element as a string.
22
+ def visit_formula(formula)
23
+ math = el("math", xmlns: MATHML_NS)
24
+ mrow = el("mrow")
25
+ formula.nodes.each { |n| mrow.add_child(render_node(n)) }
26
+ math.add_child(mrow)
27
+ @doc.root = math
28
+ # Native UTF-8 output preserves unicode arrow entities so specs
29
+ # and downstream consumers see ⇌ and → instead of &#x21CC;.
30
+ @doc.to_xml(encoding: "UTF-8")
31
+ end
32
+
33
+ def visit_molecule(molecule)
34
+ mrow = el("mrow")
35
+ if molecule.stereo
36
+ mrow.add_child(mtext("(#{molecule.stereo_letter})-"))
37
+ end
38
+ mrow.add_child(mn(molecule.coefficient)) if molecule.coefficient
39
+ molecule.nodes.each { |n| mrow.add_child(render_node(n)) }
40
+ mrow
41
+ end
42
+
43
+ def visit_atom(atom)
44
+ base = mi(atom.element)
45
+ # Isotope is a LEFT superscript per IUPAC (¹⁴C). Use
46
+ # <mmultiscripts> with <mprescripts/> so the binding stays on
47
+ # the atom — the AsciiMath `<msup><mi></mi>...<mi>C</mi>`
48
+ # pattern (empty base + sibling) loses the binding.
49
+ base = attach_isotope_prefix(base, atom) if atom.isotope
50
+ base = wrap_lewis_prefix(base, atom)
51
+ # When the atom has BOTH subscript and a superscript-style
52
+ # marker (charge, oxidation state, or raw superscript), emit
53
+ # a single <msubsup> rather than nesting <msub> inside <msup>.
54
+ base = wrap_sub_and_super(base, atom)
55
+ base = wrap_lewis_suffix(base, atom)
56
+ base = wrap_ring_closures(base, atom)
57
+ base
58
+ end
59
+
60
+ def wrap_ring_closures(base, atom)
61
+ return base unless atom.ring_closures
62
+
63
+ mrow = el("mrow")
64
+ mrow.add_child(base)
65
+ mrow.add_child(mn(atom.ring_closures))
66
+ mrow
67
+ end
68
+
69
+ def attach_isotope_prefix(base, atom)
70
+ multi = el("mmultiscripts")
71
+ multi.add_child(base)
72
+ multi.add_child(el("none"))
73
+ multi.add_child(el("none"))
74
+ multi.add_child(Nokogiri::XML::Element.new("mprescripts", @doc))
75
+ multi.add_child(el("none"))
76
+ multi.add_child(mn(atom.isotope))
77
+ multi
78
+ end
79
+
80
+ def wrap_lewis_prefix(base, atom)
81
+ return base unless atom.lone_pairs
82
+
83
+ mrow = el("mrow")
84
+ mrow.add_child(mtext(":" * atom.lone_pairs))
85
+ mrow.add_child(base)
86
+ mrow
87
+ end
88
+
89
+ def wrap_lewis_suffix(base, atom)
90
+ return base unless atom.radical_electrons
91
+
92
+ mrow = el("mrow")
93
+ mrow.add_child(base)
94
+ mrow.add_child(mtext("." * atom.radical_electrons))
95
+ mrow
96
+ end
97
+
98
+ # Combine subscript + superscript-style marker into the right
99
+ # MathML element. Cases:
100
+ # sub + super -> <msubsup>
101
+ # sub only -> <msub>
102
+ # super only -> <msup> (with charge/oxidation/raw super)
103
+ # neither -> base unchanged
104
+ def wrap_sub_and_super(base, atom)
105
+ has_sub = atom.subscript && !atom.subscript.empty?
106
+ super_node = super_element(atom)
107
+ return base unless has_sub || super_node
108
+ return wrap_in_sub(base, mn(atom.subscript)) if has_sub && !super_node
109
+ return wrap_in_sup(base, super_node) if !has_sub && super_node
110
+
111
+ msubsup = el("msubsup")
112
+ msubsup.add_child(base)
113
+ msubsup.add_child(mn(atom.subscript))
114
+ msubsup.add_child(super_node)
115
+ msubsup
116
+ end
117
+
118
+ # Build the MathML element for whichever superscript-style
119
+ # marker is set (charge > oxidation_state > raw superscript).
120
+ # Returns nil if none is set.
121
+ def super_element(atom)
122
+ if atom.charge
123
+ row = charge_row(atom.charge)
124
+ return row if row
125
+ end
126
+ if atom.oxidation_state
127
+ mrow = el("mrow")
128
+ mrow.add_child(mi(atom.oxidation_state))
129
+ return mrow
130
+ end
131
+ if atom.superscript
132
+ return mn(atom.superscript)
133
+ end
134
+ nil
135
+ end
136
+
137
+ def charge_row(charge)
138
+ match = charge.match(/\A(?<n>\d*)(?<sign>[+-])\z/) ||
139
+ charge.match(/\A(?<sign>[+-])(?<n>\d*)\z/)
140
+ return nil unless match
141
+
142
+ mrow = el("mrow")
143
+ mrow.add_child(mn(match[:n])) unless match[:n].empty?
144
+ mrow.add_child(mo(match[:sign]))
145
+ mrow
146
+ end
147
+
148
+ def visit_group(group)
149
+ mrow = el("mrow")
150
+ mrow.add_child(mo(group.open_char))
151
+ group.nodes.each { |n| mrow.add_child(render_node(n)) }
152
+ mrow.add_child(mo(group.close_char))
153
+ return mrow unless group.multiplicity
154
+
155
+ msub = el("msub")
156
+ msub.add_child(mrow)
157
+ msub.add_child(mn(group.multiplicity))
158
+ msub
159
+ end
160
+
161
+ def visit_bond(bond)
162
+ mo(bond.entity)
163
+ end
164
+
165
+ def visit_reaction(reaction)
166
+ mrow = el("mrow")
167
+ add_terms(mrow, reaction.reactants)
168
+ mrow.add_child(render_arrow(reaction))
169
+ add_terms(mrow, reaction.products)
170
+ mrow
171
+ end
172
+
173
+ def visit_reaction_cascade(cascade)
174
+ mrow = el("mrow")
175
+ return mrow if cascade.steps.empty?
176
+
177
+ head = cascade.steps.first
178
+ add_terms(mrow, head.reactants)
179
+ cascade.steps.each do |step|
180
+ mrow.add_child(render_arrow(step))
181
+ add_terms(mrow, step.products)
182
+ end
183
+ mrow
184
+ end
185
+
186
+ def visit_electron_configuration(ec)
187
+ mrow = el("mrow")
188
+ ec.orbitals.each_with_index do |(orbital, occupancy), index|
189
+ mrow.add_child(mo("&#xA0;")) if index.positive?
190
+ msup = el("msup")
191
+ msup.add_child(mi(orbital))
192
+ msup.add_child(mn(occupancy))
193
+ mrow.add_child(msup)
194
+ end
195
+ mrow
196
+ end
197
+
198
+ def visit_embedded_math(em)
199
+ # Strip the outer <math> wrapper so the embedded fragment slots
200
+ # into our surrounding <mrow>.
201
+ fragment = em.formula.to_mathml
202
+ parsed = Nokogiri::XML(fragment)
203
+ math = parsed.at_xpath("//m:math", m: MATHML_NS)
204
+ return el("mrow") unless math
205
+
206
+ # Detach children into a fresh <mrow>.
207
+ mrow = el("mrow")
208
+ math.children.each { |c| mrow.add_child(c.dup) }
209
+ mrow
210
+ end
211
+
212
+ def visit_text(text)
213
+ mtext(text.content)
214
+ end
215
+
216
+ private
217
+
218
+ def render_node(node)
219
+ node.accept(self)
220
+ end
221
+
222
+ def add_terms(parent, terms)
223
+ terms.each_with_index do |term, index|
224
+ parent.add_child(mo("+")) if index.positive?
225
+ parent.add_child(render_node(term))
226
+ end
227
+ end
228
+
229
+ def render_arrow(reaction)
230
+ op = mo(reaction.arrow_entity)
231
+ return op unless reaction.conditions
232
+
233
+ above = reaction.conditions.above
234
+ below = reaction.conditions.below
235
+ return op unless above || below
236
+
237
+ moverunder = el(above && below ? "munderover" : (above ? "mover" : "munder"))
238
+ moverunder.add_child(op)
239
+ moverunder.add_child(render_condition(above)) if above
240
+ moverunder.add_child(render_condition(below)) if below
241
+ moverunder
242
+ end
243
+
244
+ # Render a reaction-condition string. The condition is captured
245
+ # as raw text by the grammar, but chemists expect `_N` and `^N`
246
+ # patterns to render as proper sub/superscripts. We parse the
247
+ # condition as AsciiChem and use its MathML output. If the parse
248
+ # fails (e.g. the condition is free-form prose), fall back to
249
+ # plain <mtext>.
250
+ def render_condition(text)
251
+ return mtext("") if text.nil? || text.empty?
252
+
253
+ begin
254
+ inner = AsciiChem.parse(text).to_mathml
255
+ parsed = Nokogiri::XML(inner)
256
+ math = parsed.at_xpath("//m:math", m: MATHML_NS)
257
+ if math
258
+ mrow = el("mrow")
259
+ math.children.each { |c| mrow.add_child(c.dup) }
260
+ return mrow
261
+ end
262
+ rescue AsciiChem::ParseError, AsciiChem::Error
263
+ # fall through to plain text
264
+ end
265
+ mtext(text)
266
+ end
267
+
268
+ def wrap_in_sub(base, sub)
269
+ msub = el("msub")
270
+ msub.add_child(base)
271
+ msub.add_child(sub)
272
+ msub
273
+ end
274
+
275
+ def wrap_in_sup(base, sup)
276
+ msup = el("msup")
277
+ msup.add_child(base)
278
+ msup.add_child(sup)
279
+ msup
280
+ end
281
+
282
+ # -- Nokogiri element factories ------------------------------------
283
+
284
+ def el(name, attrs = {})
285
+ element = Nokogiri::XML::Element.new(name, @doc)
286
+ attrs.each { |k, v| element[k.to_s] = v }
287
+ element
288
+ end
289
+
290
+ def mi(content)
291
+ e = el("mi", mathvariant: "normal")
292
+ e.content = content.to_s
293
+ e
294
+ end
295
+
296
+ def mn(content)
297
+ e = el("mn"); e.content = content.to_s; e
298
+ end
299
+
300
+ def mo(content)
301
+ e = el("mo"); e.content = content.to_s; e
302
+ end
303
+
304
+ def mtext(content)
305
+ e = el("mtext"); e.content = content.to_s; e
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module AsciiChem
6
+ module Formatter
7
+ # Renders a Model tree as a 2D structural SVG diagram using
8
+ # elkrb-computed atom positions. Bonds render as parallel lines
9
+ # (single/double/triple/quadruple), wedges, hashes, or arrows
10
+ # depending on kind.
11
+ #
12
+ # Falls back to the linear `Svg` formatter for inputs that don't
13
+ # contain a molecule with bonds (formulae, reactions, etc.).
14
+ class StructuralSvg < Base
15
+ ATOM_RADIUS = 14
16
+ BOND_SPACING = 4
17
+ WEDGE_WIDTH = 6
18
+ HASH_DASH_COUNT = 5
19
+ ATOM_COLORS = {
20
+ 'C' => '#1a1a1a',
21
+ 'H' => '#555',
22
+ 'O' => '#b91c1c',
23
+ 'N' => '#1e40af',
24
+ 'S' => '#a16207',
25
+ 'Cl' => '#16a34a',
26
+ 'F' => '#15803d',
27
+ 'Br' => '#7c2d12',
28
+ 'I' => '#6b21a8',
29
+ 'P' => '#a16207'
30
+ }.freeze
31
+ DEFAULT_ATOM_COLOR = '#0c4a3e'
32
+
33
+ def visit_formula(formula)
34
+ molecule = formula.nodes.find { |n| n.is_a?(AsciiChem::Model::Molecule) }
35
+ return visit_formula_linear(formula) unless molecule
36
+
37
+ has_bonds = molecule.nodes.any?(AsciiChem::Model::Bond)
38
+ return visit_formula_linear(formula) unless has_bonds
39
+
40
+ result = AsciiChem::Layout.layout(molecule)
41
+ return visit_formula_linear(formula) if result.empty?
42
+
43
+ render_svg(result)
44
+ end
45
+
46
+ private
47
+
48
+ def visit_formula_linear(formula)
49
+ AsciiChem::Formatter::Svg.new.render(formula)
50
+ end
51
+
52
+ def render_svg(result)
53
+ doc = Nokogiri::XML::Document.new
54
+ svg = build_svg_root(doc, result)
55
+ bonds_first_then_atoms(result, doc, svg)
56
+ doc.root = svg
57
+ doc.to_xml
58
+ end
59
+
60
+ def build_svg_root(doc, result)
61
+ svg = Nokogiri::XML::Element.new('svg', doc)
62
+ svg['xmlns'] = 'http://www.w3.org/2000/svg'
63
+ svg['width'] = result.width.to_s
64
+ svg['height'] = result.height.to_s
65
+ svg['viewBox'] = "0 0 #{result.width} #{result.height}"
66
+ svg['role'] = 'img'
67
+ title = Nokogiri::XML::Element.new('title', doc)
68
+ title.content = title_text(result)
69
+ svg.add_child(title)
70
+ svg
71
+ end
72
+
73
+ def bonds_first_then_atoms(result, doc, svg)
74
+ result.bonds.each do |bond|
75
+ from = result.atoms_by_id[bond.from_id]
76
+ to = result.atoms_by_id[bond.to_id]
77
+ next unless from && to
78
+
79
+ BondRenderer.new(doc, from, to, bond.kind).render_into(svg)
80
+ end
81
+ result.atoms.each do |atom|
82
+ AtomRenderer.new(doc, atom).render_into(svg)
83
+ end
84
+ end
85
+
86
+ def title_text(result)
87
+ elements = result.atoms.map(&:element).uniq
88
+ "#{result.atoms.length} atoms (#{elements.join(', ')})"
89
+ end
90
+
91
+ # Renders a single atom as a labeled circle. Color comes from
92
+ # the per-element palette (CPK-inspired, simplified) so common
93
+ # elements are visually distinguishable.
94
+ class AtomRenderer
95
+ def initialize(doc, atom)
96
+ @doc = doc
97
+ @atom = atom
98
+ end
99
+
100
+ def render_into(parent)
101
+ group = Nokogiri::XML::Element.new('g', @doc)
102
+ group.add_child(circle)
103
+ group.add_child(label)
104
+ parent.add_child(group)
105
+ end
106
+
107
+ private
108
+
109
+ def circle
110
+ el = Nokogiri::XML::Element.new('circle', @doc)
111
+ el['cx'] = @atom.x.to_s
112
+ el['cy'] = @atom.y.to_s
113
+ el['r'] = StructuralSvg::ATOM_RADIUS.to_s
114
+ el['fill'] = 'white'
115
+ el['stroke'] = color
116
+ el['stroke-width'] = '1.5'
117
+ el
118
+ end
119
+
120
+ def label
121
+ el = Nokogiri::XML::Element.new('text', @doc)
122
+ el['x'] = @atom.x.to_s
123
+ el['y'] = (@atom.y + 4).to_s
124
+ el['text-anchor'] = 'middle'
125
+ el['font-family'] = 'serif'
126
+ el['font-size'] = '14'
127
+ el['fill'] = color
128
+ el.content = @atom.element
129
+ el
130
+ end
131
+
132
+ def color
133
+ StructuralSvg::ATOM_COLORS.fetch(@atom.element, StructuralSvg::DEFAULT_ATOM_COLOR)
134
+ end
135
+ end
136
+ private_constant :AtomRenderer
137
+
138
+ # Renders a single bond between two positioned atoms. Dispatches
139
+ # on bond kind via a registry of Procs. Each Proc receives the
140
+ # renderer (for its public line/offset helpers) and returns an
141
+ # array of Nokogiri elements. Adding a new bond style is a new
142
+ # Proc + one registry entry — no edits to existing renderers.
143
+ class BondRenderer
144
+ def initialize(doc, from_atom, to_atom, kind)
145
+ @doc = doc
146
+ @from = from_atom
147
+ @to = to_atom
148
+ @kind = kind
149
+ end
150
+
151
+ def render_into(parent)
152
+ renderer = RENDERERS.fetch(@kind, RENDERERS[:single])
153
+ Array(renderer.call(self)).each { |node| parent.add_child(node) }
154
+ end
155
+
156
+ # -- Public helpers used by the strategy Procs ---------------
157
+
158
+ def base_line
159
+ line(@from.x, @from.y, @to.x, @to.y)
160
+ end
161
+
162
+ def offset_line(distance)
163
+ dx = @to.x - @from.x
164
+ dy = @to.y - @from.y
165
+ length = Math.sqrt((dx * dx) + (dy * dy))
166
+ return nil if length.zero?
167
+
168
+ px = -dy / length
169
+ py = dx / length
170
+ line(@from.x + (px * distance), @from.y + (py * distance),
171
+ @to.x + (px * distance), @to.y + (py * distance))
172
+ end
173
+
174
+ def line(start_x, start_y, end_x, end_y)
175
+ el = Nokogiri::XML::Element.new('line', @doc)
176
+ el['x1'] = start_x.to_s
177
+ el['y1'] = start_y.to_s
178
+ el['x2'] = end_x.to_s
179
+ el['y2'] = end_y.to_s
180
+ el['stroke'] = '#1a1a1a'
181
+ el['stroke-width'] = '1.5'
182
+ el
183
+ end
184
+
185
+ def polygon(points, fill:)
186
+ el = Nokogiri::XML::Element.new('polygon', @doc)
187
+ el['points'] = points.map { |x, y| "#{x},#{y}" }.join(' ')
188
+ el['fill'] = fill
189
+ el
190
+ end
191
+
192
+ def from_point
193
+ [@from.x, @from.y]
194
+ end
195
+
196
+ def to_point
197
+ [@to.x, @to.y]
198
+ end
199
+
200
+ def unit_perpendicular
201
+ dx = @to.x - @from.x
202
+ dy = @to.y - @from.y
203
+ length = Math.sqrt((dx * dx) + (dy * dy))
204
+ return [0.0, 0.0] if length.zero?
205
+
206
+ [-dy / length, dx / length]
207
+ end
208
+
209
+ # -- Strategy registry --------------------------------------
210
+ #
211
+ # Each entry maps a bond kind symbol to a Proc that takes the
212
+ # renderer and returns an array of Nokogiri elements. Procs
213
+ # use the public helpers above; nothing reaches into private
214
+ # state.
215
+
216
+ SPACING = StructuralSvg::BOND_SPACING
217
+ WEDGE_HALF = StructuralSvg::WEDGE_WIDTH / 2.0
218
+ HASH_COUNT = StructuralSvg::HASH_DASH_COUNT
219
+
220
+ RENDERERS = {
221
+ single: ->(r) { [r.base_line] },
222
+
223
+ double: ->(r) { [-SPACING, 0, SPACING].map { |d| r.offset_line(d) }.compact },
224
+
225
+ triple: ->(r) { [0, -SPACING * 1.5, SPACING * 1.5].map { |d| r.offset_line(d) }.compact },
226
+
227
+ quadruple: ->(r) { [0, -SPACING, SPACING, -SPACING * 2.5].map { |d| r.offset_line(d) }.compact },
228
+
229
+ wedge: lambda do |r|
230
+ px, py = r.unit_perpendicular
231
+ fx, fy = r.from_point
232
+ tx, ty = r.to_point
233
+ [r.polygon([[fx, fy],
234
+ [tx + (px * WEDGE_HALF), ty + (py * WEDGE_HALF)],
235
+ [tx - (px * WEDGE_HALF), ty - (py * WEDGE_HALF)]],
236
+ fill: '#1a1a1a')]
237
+ end,
238
+
239
+ hash: lambda do |r|
240
+ px, py = r.unit_perpendicular
241
+ fx, fy = r.from_point
242
+ tx, ty = r.to_point
243
+ dx = tx - fx
244
+ dy = ty - fy
245
+ nodes = []
246
+ (1..HASH_COUNT).each do |i|
247
+ t = i / HASH_COUNT.to_f
248
+ cx = fx + (dx * t)
249
+ cy = fy + (dy * t)
250
+ w = WEDGE_HALF * t
251
+ nodes << r.line(cx + (px * w), cy + (py * w),
252
+ cx - (px * w), cy - (py * w))
253
+ end
254
+ nodes
255
+ end,
256
+
257
+ dative: ->(r) { [r.base_line.tap { |l| l['marker-end'] = 'url(#aci-dative-arrow)' }] },
258
+
259
+ wavy: lambda do |r|
260
+ fx, fy = r.from_point
261
+ tx, ty = r.to_point
262
+ dx = tx - fx
263
+ dy = ty - fy
264
+ px, py = r.unit_perpendicular
265
+ segments = 8
266
+ amplitude = 3.0
267
+ prev_x = fx
268
+ prev_y = fy
269
+ nodes = []
270
+ (1..segments).each do |i|
271
+ t = i / segments.to_f
272
+ offset = Math.sin(t * Math::PI * 2) * amplitude
273
+ x = fx + (dx * t) + (px * offset)
274
+ y = fy + (dy * t) + (py * offset)
275
+ nodes << r.line(prev_x, prev_y, x, y)
276
+ prev_x = x
277
+ prev_y = y
278
+ end
279
+ nodes
280
+ end
281
+ }.freeze
282
+ end
283
+ private_constant :BondRenderer
284
+ end
285
+ end
286
+ end