smiles-js 2.0.2 → 2.1.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.
- package/API.md +206 -9
- package/README.md +39 -0
- package/docs/MIRROR_PLAN.md +204 -0
- package/package.json +1 -1
- package/scripts/coverage-summary.js +1 -1
- package/src/codegen/branch-crossing-ring.js +27 -6
- package/src/codegen/interleaved-fused-ring.js +24 -0
- package/src/decompiler.js +53 -123
- package/src/decompiler.test.js +56 -8
- package/src/manipulation.js +443 -17
- package/src/manipulation.test.js +359 -1
- package/src/method-attachers.js +29 -0
- package/src/node-creators.js +7 -0
- package/src/parser/ast-builder.js +23 -8
- package/src/parser/ring-group-builder.js +14 -2
- package/src/parser/ring-utils.js +28 -0
- package/test-integration/__snapshots__/adjuvant-analgesics.test.js.snap +1 -1
- package/test-integration/__snapshots__/cholesterol-drugs.test.js.snap +189 -23
- package/test-integration/__snapshots__/endocannabinoids.test.js.snap +2 -2
- package/test-integration/__snapshots__/hypertension-medication.test.js.snap +1 -1
- package/test-integration/__snapshots__/nsaids-otc.test.js.snap +1 -1
- package/test-integration/__snapshots__/nsaids-prescription.test.js.snap +2 -2
- package/test-integration/__snapshots__/opioids.test.js.snap +4 -4
- package/test-integration/__snapshots__/steroids.test.js.snap +2 -2
- package/test-integration/cholesterol-drugs.test.js +41 -0
- package/test-integration/cholesterol.test.js +112 -0
- package/test-integration/mirror.test.js +151 -0
- package/test-integration/polymer.test.js +148 -0
- package/todo +0 -2
package/API.md
CHANGED
|
@@ -79,9 +79,11 @@ const hydroxyl = Linear(['O']);
|
|
|
79
79
|
const ethanol = ethyl.attach(2, hydroxyl);
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
### `FusedRing(rings)`
|
|
82
|
+
### `FusedRing(rings)` / `FusedRing({ metadata })`
|
|
83
83
|
|
|
84
|
-
Create fused ring systems like naphthalene.
|
|
84
|
+
Create fused ring systems like naphthalene. Supports two formats:
|
|
85
|
+
|
|
86
|
+
**Array format** (simple fused rings):
|
|
85
87
|
|
|
86
88
|
| Parameter | Type | Description |
|
|
87
89
|
|-----------|------|-------------|
|
|
@@ -94,6 +96,30 @@ const naphthalene = FusedRing([
|
|
|
94
96
|
]);
|
|
95
97
|
```
|
|
96
98
|
|
|
99
|
+
**Metadata format** (complex interleaved rings with position data):
|
|
100
|
+
|
|
101
|
+
| Parameter | Type | Description |
|
|
102
|
+
|-----------|------|-------------|
|
|
103
|
+
| `metadata.rings` | `object[]` | Per-ring metadata with `ring`, `start`, `end`, and `atoms` |
|
|
104
|
+
| `metadata.atoms` | `object[]` | Standalone atoms not belonging to any ring |
|
|
105
|
+
| `metadata.leadingBond` | `string` | Optional leading bond (e.g., `'='`) |
|
|
106
|
+
|
|
107
|
+
Each ring entry: `{ ring, start, end, atoms: [{ position, depth, value?, bond?, rings?, attachments? }] }`
|
|
108
|
+
|
|
109
|
+
Each standalone atom entry: `{ position, depth, value?, bond?, attachments? }`
|
|
110
|
+
|
|
111
|
+
```javascript
|
|
112
|
+
const ring1 = Ring({ atoms: 'C', size: 6 });
|
|
113
|
+
const ring2 = Ring({ atoms: 'C', size: 6, ringNumber: 2, offset: 1 });
|
|
114
|
+
const fused = FusedRing({ metadata: {
|
|
115
|
+
rings: [
|
|
116
|
+
{ ring: ring1, start: 0, end: 9, atoms: [{ position: 0, depth: 0 }, { position: 5, depth: 0 }] },
|
|
117
|
+
{ ring: ring2, start: 1, end: 6, atoms: [{ position: 1, depth: 0 }, { position: 6, depth: 0 }] }
|
|
118
|
+
],
|
|
119
|
+
atoms: [{ position: 12, depth: 0, value: 'N' }]
|
|
120
|
+
} });
|
|
121
|
+
```
|
|
122
|
+
|
|
97
123
|
### `Molecule(components)`
|
|
98
124
|
|
|
99
125
|
Combine multiple structural components.
|
|
@@ -164,6 +190,70 @@ Fuse this ring with another ring. `offset` is how many positions into this ring
|
|
|
164
190
|
|
|
165
191
|
Return a deep copy of the ring.
|
|
166
192
|
|
|
193
|
+
#### `ring.repeat(n, leftId, rightId)`
|
|
194
|
+
|
|
195
|
+
Repeat the ring `n` times to build polymer chains. Each copy gets unique ring numbers automatically.
|
|
196
|
+
|
|
197
|
+
| Parameter | Type | Description |
|
|
198
|
+
|-----------|------|-------------|
|
|
199
|
+
| `n` | `number` | Number of repeating units (>= 1) |
|
|
200
|
+
| `leftId` | `number` | 1-indexed left (incoming) attachment point |
|
|
201
|
+
| `rightId` | `number` | 1-indexed right (outgoing) attachment point |
|
|
202
|
+
|
|
203
|
+
```javascript
|
|
204
|
+
// Biphenyl (two linked benzene rings)
|
|
205
|
+
const benzene = Ring({ atoms: 'c', size: 6 });
|
|
206
|
+
const biphenyl = benzene.repeat(2, 1, 6);
|
|
207
|
+
console.log(biphenyl.smiles); // c1ccccc1c2ccccc2
|
|
208
|
+
|
|
209
|
+
// Terphenyl (three linked benzene rings)
|
|
210
|
+
const terphenyl = benzene.repeat(3, 1, 6);
|
|
211
|
+
console.log(terphenyl.smiles); // c1ccccc1c2ccccc2c3ccccc3
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
#### `ring.fusedRepeat(n, offset)`
|
|
215
|
+
|
|
216
|
+
Repeat a ring `n` times by fusing, creating acene-like edge-sharing systems (naphthalene, anthracene, tetracene).
|
|
217
|
+
|
|
218
|
+
| Parameter | Type | Description |
|
|
219
|
+
|-----------|------|-------------|
|
|
220
|
+
| `n` | `number` | Total number of rings (>= 1) |
|
|
221
|
+
| `offset` | `number` | Fusion offset (number of shared atom positions between adjacent rings) |
|
|
222
|
+
|
|
223
|
+
```javascript
|
|
224
|
+
const benzene = Ring({ atoms: 'c', size: 6 });
|
|
225
|
+
|
|
226
|
+
// Naphthalene (2 fused rings)
|
|
227
|
+
const naphthalene = benzene.fusedRepeat(2, 4);
|
|
228
|
+
|
|
229
|
+
// Anthracene (3 fused rings)
|
|
230
|
+
const anthracene = benzene.fusedRepeat(3, 4);
|
|
231
|
+
|
|
232
|
+
// Tetracene (4 fused rings)
|
|
233
|
+
const tetracene = benzene.fusedRepeat(4, 4);
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
#### `ring.mirror(pivotId?)`
|
|
237
|
+
|
|
238
|
+
Mirror a ring's attachments and substitutions to create symmetric patterns. The pivot defines the axis of symmetry on the ring.
|
|
239
|
+
|
|
240
|
+
| Parameter | Type | Default | Description |
|
|
241
|
+
|-----------|------|---------|-------------|
|
|
242
|
+
| `pivotId` | `number` | `1` | 1-indexed ring position that serves as the symmetry axis |
|
|
243
|
+
|
|
244
|
+
```javascript
|
|
245
|
+
const benzene = Ring({ atoms: 'c', size: 6 });
|
|
246
|
+
|
|
247
|
+
// meta-dimethylbenzene: attach at 2, mirror around pivot 3
|
|
248
|
+
const mono = benzene.attach(2, Linear(['C']));
|
|
249
|
+
const meta = mono.mirror(3);
|
|
250
|
+
console.log(meta.smiles); // c1c(C)cc(C)cc1
|
|
251
|
+
|
|
252
|
+
// Symmetric nitrogen substitution
|
|
253
|
+
const pyridine = Ring({ atoms: 'c', size: 6, substitutions: { 2: 'n' } });
|
|
254
|
+
const diazine = pyridine.mirror(1); // n at 2 and 6
|
|
255
|
+
```
|
|
256
|
+
|
|
167
257
|
### Linear Methods
|
|
168
258
|
|
|
169
259
|
```javascript
|
|
@@ -199,6 +289,53 @@ Attach one or more branches at a position.
|
|
|
199
289
|
|
|
200
290
|
Attach branches at multiple positions. `branchMap` is `{ position: node | [nodes] }`.
|
|
201
291
|
|
|
292
|
+
#### `linear.repeat(n, leftId, rightId)`
|
|
293
|
+
|
|
294
|
+
Repeat the linear chain `n` times to build polymer chains.
|
|
295
|
+
|
|
296
|
+
| Parameter | Type | Description |
|
|
297
|
+
|-----------|------|-------------|
|
|
298
|
+
| `n` | `number` | Number of repeating units (>= 1) |
|
|
299
|
+
| `leftId` | `number` | 1-indexed left (incoming) attachment point |
|
|
300
|
+
| `rightId` | `number` | 1-indexed right (outgoing) attachment point |
|
|
301
|
+
|
|
302
|
+
```javascript
|
|
303
|
+
// Polyethylene trimer
|
|
304
|
+
const ethylene = Linear(['C', 'C']);
|
|
305
|
+
const PE = ethylene.repeat(3, 1, 2);
|
|
306
|
+
console.log(PE.smiles); // CCCCCC
|
|
307
|
+
|
|
308
|
+
// Polystyrene dimer
|
|
309
|
+
const styrene = Linear(['C', 'C']).attach(2, Ring({ atoms: 'c', size: 6 }));
|
|
310
|
+
const PS = styrene.repeat(2, 1, 2);
|
|
311
|
+
console.log(PS.smiles); // CC(c1ccccc1)CC(c2ccccc2)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
#### `linear.mirror(pivotId?)`
|
|
315
|
+
|
|
316
|
+
Mirror a linear chain around a pivot atom to create palindromic (A-B-A) patterns. The pivot atom appears once at the center.
|
|
317
|
+
|
|
318
|
+
| Parameter | Type | Default | Description |
|
|
319
|
+
|-----------|------|---------|-------------|
|
|
320
|
+
| `pivotId` | `number` | `atoms.length` | 1-indexed pivot position (center of symmetry) |
|
|
321
|
+
|
|
322
|
+
```javascript
|
|
323
|
+
// Diethyl ether: mirror ethanol around the oxygen
|
|
324
|
+
const chain = Linear(['C', 'C', 'O']);
|
|
325
|
+
const ether = chain.mirror(); // pivot defaults to last atom
|
|
326
|
+
console.log(ether.smiles); // CCOCC
|
|
327
|
+
|
|
328
|
+
// Symmetric alkene: mirror C=C around position 2
|
|
329
|
+
const vinyl = Linear(['C', 'C'], ['=']);
|
|
330
|
+
const symAlkene = vinyl.mirror(2);
|
|
331
|
+
console.log(symAlkene.smiles); // C=CC
|
|
332
|
+
|
|
333
|
+
// With attachments: phenyl pendants are mirrored too
|
|
334
|
+
const base = Linear(['C', 'C', 'C']).attach(1, Ring({ atoms: 'c', size: 6 }));
|
|
335
|
+
const mirrored = base.mirror();
|
|
336
|
+
console.log(mirrored.smiles); // C(c1ccccc1)CCC(c2ccccc2)C
|
|
337
|
+
```
|
|
338
|
+
|
|
202
339
|
### Molecule Methods
|
|
203
340
|
|
|
204
341
|
```javascript
|
|
@@ -218,6 +355,41 @@ const modified = mol.replaceComponent(0, Linear(['N', 'N']));
|
|
|
218
355
|
|
|
219
356
|
// Concatenate molecules
|
|
220
357
|
const combined = mol.concat(Molecule([Ring({ atoms: 'c', size: 6 })]));
|
|
358
|
+
|
|
359
|
+
// Repeat the molecule as a polymer unit
|
|
360
|
+
const unit = Molecule([Linear(['C']), Ring({ atoms: 'c', size: 6 })]);
|
|
361
|
+
const dimer = unit.repeat(2, 1, 1);
|
|
362
|
+
console.log(dimer.smiles); // Cc1ccccc1Cc2ccccc2
|
|
363
|
+
|
|
364
|
+
// Mirror molecule for ABA patterns
|
|
365
|
+
const A = Linear(['C', 'C']);
|
|
366
|
+
const B = Ring({ atoms: 'c', size: 6 });
|
|
367
|
+
const AB = Molecule([A, B]);
|
|
368
|
+
const ABA = AB.mirror(); // pivot defaults to last component
|
|
369
|
+
console.log(ABA.smiles); // CCc1ccccc1CC
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
#### `molecule.mirror(pivotComponent?)`
|
|
373
|
+
|
|
374
|
+
Mirror a molecule's component sequence to create ABA or ABCBA patterns. The pivot component appears once at the center.
|
|
375
|
+
|
|
376
|
+
| Parameter | Type | Default | Description |
|
|
377
|
+
|-----------|------|---------|-------------|
|
|
378
|
+
| `pivotComponent` | `number` | `components.length - 1` | 0-indexed pivot component (center of symmetry) |
|
|
379
|
+
|
|
380
|
+
```javascript
|
|
381
|
+
// ABA triblock: Linear-Ring-Linear
|
|
382
|
+
const A = Linear(['C', 'C']);
|
|
383
|
+
const B = Ring({ atoms: 'c', size: 6 });
|
|
384
|
+
const AB = Molecule([A, B]);
|
|
385
|
+
const ABA = AB.mirror();
|
|
386
|
+
console.log(ABA.smiles); // CCc1ccccc1CC
|
|
387
|
+
|
|
388
|
+
// ABCBA pentablock
|
|
389
|
+
const C = Linear(['N']);
|
|
390
|
+
const ABC = Molecule([A, B, C]);
|
|
391
|
+
const ABCBA = ABC.mirror();
|
|
392
|
+
console.log(ABCBA.smiles); // CCc1ccccc1NCCc2ccccc2CC... (mirrored)
|
|
221
393
|
```
|
|
222
394
|
|
|
223
395
|
### FusedRing Methods
|
|
@@ -240,9 +412,8 @@ const decorated = fused.attachToRing(1, 4, Linear(['O']));
|
|
|
240
412
|
// Renumber rings
|
|
241
413
|
const renumbered = fused.renumber(10);
|
|
242
414
|
|
|
243
|
-
// Add sequential continuation rings with
|
|
244
|
-
const withSeq = fused.addSequentialRings([ring3, ring4], {
|
|
245
|
-
depths: [1, 2],
|
|
415
|
+
// Add sequential continuation rings with colocated depth
|
|
416
|
+
const withSeq = fused.addSequentialRings([{ ring: ring3, depth: 1 }, { ring: ring4, depth: 2 }], {
|
|
246
417
|
chainAtoms: [
|
|
247
418
|
{ atom: 'C', depth: 2, position: 'before' },
|
|
248
419
|
{ atom: 'C', depth: 2, position: 'after', attachments: [Linear(['O'], ['='])] },
|
|
@@ -251,6 +422,9 @@ const withSeq = fused.addSequentialRings([ring3, ring4], {
|
|
|
251
422
|
|
|
252
423
|
// Add attachment to a sequential atom position
|
|
253
424
|
const withAtt = fused.addSequentialAtomAttachment(25, Linear(['O']));
|
|
425
|
+
|
|
426
|
+
// Repeat the fused ring system as a polymer unit
|
|
427
|
+
const fusedDimer = fused.repeat(2, 1, 1);
|
|
254
428
|
```
|
|
255
429
|
|
|
256
430
|
#### `fusedRing.addSequentialRings(rings, options?)`
|
|
@@ -259,10 +433,16 @@ Add continuation rings to a fused ring system. Computes all position metadata in
|
|
|
259
433
|
|
|
260
434
|
| Parameter | Type | Description |
|
|
261
435
|
|-----------|------|-------------|
|
|
262
|
-
| `rings` | `Ring[]` |
|
|
263
|
-
| `options.depths` | `number[]` |
|
|
436
|
+
| `rings` | `Ring[]` or `object[]` | Sequential rings — plain `Ring` nodes or `{ ring, depth? }` objects |
|
|
437
|
+
| `options.depths` | `number[]` | Legacy: per-ring branch depth. Prefer colocated `{ ring, depth }` format instead |
|
|
264
438
|
| `options.chainAtoms` | `object[]` | Standalone atoms between rings (see below) |
|
|
265
|
-
|
|
439
|
+
|
|
440
|
+
**rings entries (object format):**
|
|
441
|
+
|
|
442
|
+
| Property | Type | Description |
|
|
443
|
+
|----------|------|-------------|
|
|
444
|
+
| `ring` | `Ring` | The Ring node to add |
|
|
445
|
+
| `depth` | `number` | Branch depth for this ring (default: `0`, omit if zero) |
|
|
266
446
|
|
|
267
447
|
**chainAtoms entries:**
|
|
268
448
|
|
|
@@ -400,10 +580,23 @@ import {
|
|
|
400
580
|
moleculeConcat,
|
|
401
581
|
moleculeGetComponent,
|
|
402
582
|
moleculeReplaceComponent,
|
|
583
|
+
repeat,
|
|
584
|
+
fusedRepeat,
|
|
585
|
+
linearMirror,
|
|
586
|
+
moleculeMirror,
|
|
587
|
+
ringMirror,
|
|
403
588
|
} from 'smiles-js/manipulation';
|
|
404
589
|
|
|
405
590
|
const benzene = Ring({ atoms: 'c', size: 6 });
|
|
406
591
|
const toluene = ringAttach(benzene, 1, Linear(['C']));
|
|
592
|
+
|
|
593
|
+
// Polymer construction
|
|
594
|
+
const biphenyl = repeat(benzene, 2, 1, 6);
|
|
595
|
+
const naphthalene = fusedRepeat(benzene, 2, 4);
|
|
596
|
+
|
|
597
|
+
// Mirror symmetry
|
|
598
|
+
const chain = Linear(['C', 'C', 'O']);
|
|
599
|
+
const ether = linearMirror(chain); // CCOCC
|
|
407
600
|
```
|
|
408
601
|
|
|
409
602
|
---
|
|
@@ -458,4 +651,8 @@ Some complex molecules may have minor notation differences during round-trip:
|
|
|
458
651
|
|
|
459
652
|
### toCode() Generated Code
|
|
460
653
|
|
|
461
|
-
The `.toCode()` method generates JavaScript constructor code that reconstructs the molecule
|
|
654
|
+
The `.toCode()` method generates JavaScript constructor code that reconstructs the molecule:
|
|
655
|
+
|
|
656
|
+
- **Interleaved fused rings** emit `FusedRing({ metadata: { rings: [...], atoms: [...] } })` with hierarchical, colocated atom data (position, depth, value, bond, rings, attachments) instead of scattered Maps
|
|
657
|
+
- **Sequential continuation rings** use `const`-only declarations and `addSequentialRings([{ ring: v, depth }])` with colocated depth per ring
|
|
658
|
+
- All generated variable declarations use `const` (no `let` + reassignment)
|
package/README.md
CHANGED
|
@@ -16,6 +16,8 @@ Build complex molecules programmatically with an intuitive, composable API. Pars
|
|
|
16
16
|
|
|
17
17
|
- **Parse complex SMILES** - Handles real-world pharmaceutical molecules (60-80+ characters)
|
|
18
18
|
- **Programmatic construction** - Build molecules using composable Ring, Linear, and Molecule constructors
|
|
19
|
+
- **Polymer construction** - Build repeating units with `.repeat()` and fused acene systems with `.fusedRepeat()`
|
|
20
|
+
- **Mirror symmetry** - Create palindromic chains and ABA block patterns with `.mirror()`
|
|
19
21
|
- **Round-trip fidelity** - Parse SMILES -> AST -> SMILES with structure preservation
|
|
20
22
|
- **Code generation** - Auto-generate JavaScript construction code from SMILES strings
|
|
21
23
|
- **Pharmaceutical validated** - Tested with Atorvastatin, Sildenafil, Ritonavir, and 30+ other drugs
|
|
@@ -85,6 +87,43 @@ const pyridine = benzene.substitute(5, 'n');
|
|
|
85
87
|
console.log(pyridine.smiles); // c1cccnc1
|
|
86
88
|
```
|
|
87
89
|
|
|
90
|
+
### Build Polymers
|
|
91
|
+
|
|
92
|
+
```javascript
|
|
93
|
+
import { Ring, Linear } from 'smiles-js';
|
|
94
|
+
|
|
95
|
+
// Polyethylene trimer: repeat ethylene unit 3 times
|
|
96
|
+
const ethylene = Linear(['C', 'C']);
|
|
97
|
+
const PE = ethylene.repeat(3, 1, 2);
|
|
98
|
+
console.log(PE.smiles); // CCCCCC
|
|
99
|
+
|
|
100
|
+
// Polystyrene dimer: repeat styrene unit with phenyl branch
|
|
101
|
+
const styrene = Linear(['C', 'C']).attach(2, Ring({ atoms: 'c', size: 6 }));
|
|
102
|
+
const PS = styrene.repeat(2, 1, 2);
|
|
103
|
+
console.log(PS.smiles); // CC(c1ccccc1)CC(c2ccccc2)
|
|
104
|
+
|
|
105
|
+
// Acene series via fused repeat
|
|
106
|
+
const benzene = Ring({ atoms: 'c', size: 6 });
|
|
107
|
+
const naphthalene = benzene.fusedRepeat(2, 4); // 2 fused rings
|
|
108
|
+
const anthracene = benzene.fusedRepeat(3, 4); // 3 fused rings
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Mirror Symmetry
|
|
112
|
+
|
|
113
|
+
```javascript
|
|
114
|
+
import { Ring, Linear, Molecule } from 'smiles-js';
|
|
115
|
+
|
|
116
|
+
// Diethyl ether: mirror C-C-O around oxygen
|
|
117
|
+
const ether = Linear(['C', 'C', 'O']).mirror();
|
|
118
|
+
console.log(ether.smiles); // CCOCC
|
|
119
|
+
|
|
120
|
+
// ABA triblock copolymer
|
|
121
|
+
const A = Linear(['C', 'C']);
|
|
122
|
+
const B = Ring({ atoms: 'c', size: 6 });
|
|
123
|
+
const ABA = Molecule([A, B]).mirror();
|
|
124
|
+
console.log(ABA.smiles); // CCc1ccccc1CC
|
|
125
|
+
```
|
|
126
|
+
|
|
88
127
|
### Generate Construction Code
|
|
89
128
|
|
|
90
129
|
```javascript
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Plan: `.mirror()` API for Symmetric Molecule Construction
|
|
2
|
+
|
|
3
|
+
## Motivation
|
|
4
|
+
|
|
5
|
+
Many molecules and polymers are symmetric — their structure reads the same forwards and backwards around a central point. Building these today requires manually constructing both halves. A `.mirror()` method would let users define one half and automatically produce the symmetric whole.
|
|
6
|
+
|
|
7
|
+
### Use Cases
|
|
8
|
+
|
|
9
|
+
1. **ABA triblock copolymers** — The most common symmetric polymer architecture. Define the A and B blocks, get A-B-A automatically.
|
|
10
|
+
2. **Palindromic linear chains** — e.g., `CCCOCCC` from `CCCO` mirrored at the O.
|
|
11
|
+
3. **Symmetric branched molecules** — e.g., diethyl ether `CCOCC` from `CCO` mirrored.
|
|
12
|
+
4. **Symmetric ring-bearing chains** — e.g., a chain with a ring in the center and identical arms on each side.
|
|
13
|
+
5. **Dendrimers** — Symmetric branching structures built outward from a core.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## API Design
|
|
18
|
+
|
|
19
|
+
### `linear.mirror(pivotId?)`
|
|
20
|
+
|
|
21
|
+
Mirror a linear chain around a pivot atom to create a palindromic structure.
|
|
22
|
+
|
|
23
|
+
| Parameter | Type | Default | Description |
|
|
24
|
+
|-----------|------|---------|-------------|
|
|
25
|
+
| `pivotId` | `number` | `atoms.length` | 1-indexed position of the pivot atom (included once in the output) |
|
|
26
|
+
|
|
27
|
+
**Returns:** A new `Linear` (for simple chains) or `Molecule` (if attachments are present).
|
|
28
|
+
|
|
29
|
+
**Behavior:** Takes atoms `[1..pivotId]`, then appends atoms `[pivotId-1..1]` in reverse. The pivot atom appears once in the center. Attachments on mirrored atoms are also mirrored.
|
|
30
|
+
|
|
31
|
+
```javascript
|
|
32
|
+
// Diethyl ether: CCO + mirror → CCOCC
|
|
33
|
+
const half = Linear(['C', 'C', 'O']);
|
|
34
|
+
const ether = half.mirror(); // pivot defaults to last atom (O)
|
|
35
|
+
console.log(ether.smiles); // CCOCC
|
|
36
|
+
|
|
37
|
+
// Palindromic chain: CCCNCCC
|
|
38
|
+
const half2 = Linear(['C', 'C', 'C', 'N']);
|
|
39
|
+
const palindrome = half2.mirror(); // pivot at N (position 4)
|
|
40
|
+
console.log(palindrome.smiles); // CCCNCCC
|
|
41
|
+
|
|
42
|
+
// Mirror at a specific pivot
|
|
43
|
+
const chain = Linear(['C', 'C', 'C', 'O', 'C']);
|
|
44
|
+
const mirrored = chain.mirror(4); // pivot at O (position 4)
|
|
45
|
+
console.log(mirrored.smiles); // CCCOCCC
|
|
46
|
+
// atoms [1,2,3,4] + reverse of [3,2,1] → C,C,C,O,C,C,C
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### `ring.mirror(pivotId?)`
|
|
50
|
+
|
|
51
|
+
Mirror a ring's attachments to create symmetric substitution patterns.
|
|
52
|
+
|
|
53
|
+
| Parameter | Type | Default | Description |
|
|
54
|
+
|-----------|------|---------|-------------|
|
|
55
|
+
| `pivotId` | `number` | `1` | 1-indexed ring position that serves as the symmetry axis |
|
|
56
|
+
|
|
57
|
+
**Returns:** A new `Ring` with attachments mirrored around the pivot.
|
|
58
|
+
|
|
59
|
+
**Behavior:** For a ring with an attachment at position `p`, also adds the same attachment at the "mirror" position relative to the pivot. The mirror position for `p` around pivot `v` on a ring of size `s` is: `((2*v - p) mod s)`, adjusted to 1-indexed range.
|
|
60
|
+
|
|
61
|
+
```javascript
|
|
62
|
+
// Symmetric toluene → xylene (1,4-dimethylbenzene)
|
|
63
|
+
const benzene = Ring({ atoms: 'c', size: 6 });
|
|
64
|
+
const toluene = benzene.attach(1, Linear(['C']));
|
|
65
|
+
const xylene = toluene.mirror(1); // mirror around position 1
|
|
66
|
+
// Attachment at position 1 stays, mirrored attachment appears at position 4
|
|
67
|
+
console.log(xylene.smiles); // c1(C)ccc(C)cc1
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### `molecule.mirror(pivotComponent?)`
|
|
71
|
+
|
|
72
|
+
Mirror a molecule's component sequence to create an ABA-like structure.
|
|
73
|
+
|
|
74
|
+
| Parameter | Type | Default | Description |
|
|
75
|
+
|-----------|------|---------|-------------|
|
|
76
|
+
| `pivotComponent` | `number` | `components.length - 1` | 0-indexed component that serves as the center |
|
|
77
|
+
|
|
78
|
+
**Returns:** A new `Molecule` with components mirrored.
|
|
79
|
+
|
|
80
|
+
**Behavior:** Takes components `[0..pivot]`, then appends components `[pivot-1..0]` in reverse (with ring renumbering to avoid collisions).
|
|
81
|
+
|
|
82
|
+
```javascript
|
|
83
|
+
// ABA triblock copolymer
|
|
84
|
+
const A = Linear(['C', 'C']);
|
|
85
|
+
const B = Ring({ atoms: 'c', size: 6 });
|
|
86
|
+
const AB = Molecule([A, B]);
|
|
87
|
+
const ABA = AB.mirror(); // pivot at last component (B)
|
|
88
|
+
console.log(ABA.smiles); // CCc1ccccc1CC
|
|
89
|
+
|
|
90
|
+
// More complex: A-B-C-B-A from A-B-C
|
|
91
|
+
const block = Molecule([
|
|
92
|
+
Linear(['C', 'C']),
|
|
93
|
+
Ring({ atoms: 'c', size: 6 }),
|
|
94
|
+
Linear(['O']),
|
|
95
|
+
]);
|
|
96
|
+
const symmetric = block.mirror(); // pivot at last (O)
|
|
97
|
+
console.log(symmetric.smiles); // CCc1ccccc1Oc2ccccc2CC
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### `fusedRing.mirror()`
|
|
101
|
+
|
|
102
|
+
For fused rings, mirror could create a symmetric fused system by adding rings on both sides of the base. This is more complex and may be deferred to a later iteration.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Functional API
|
|
107
|
+
|
|
108
|
+
```javascript
|
|
109
|
+
import {
|
|
110
|
+
linearMirror,
|
|
111
|
+
ringMirror,
|
|
112
|
+
moleculeMirror,
|
|
113
|
+
} from 'smiles-js/manipulation';
|
|
114
|
+
|
|
115
|
+
const half = Linear(['C', 'C', 'O']);
|
|
116
|
+
const ether = linearMirror(half); // CCOCC
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Implementation Strategy
|
|
122
|
+
|
|
123
|
+
### Phase 1: `linear.mirror(pivotId?)`
|
|
124
|
+
|
|
125
|
+
The simplest and highest-value case. Pure atom/bond array manipulation.
|
|
126
|
+
|
|
127
|
+
**Algorithm:**
|
|
128
|
+
1. Validate `pivotId` is within `[1, atoms.length]`.
|
|
129
|
+
2. Take `leftAtoms = atoms[0..pivotId-1]` (the left half including pivot).
|
|
130
|
+
3. Take `rightAtoms = atoms[0..pivotId-2].reverse()` (the left half excluding pivot, reversed).
|
|
131
|
+
4. Concatenate: `leftAtoms + rightAtoms`.
|
|
132
|
+
5. Handle bonds: mirror the bond array similarly. Bond between atoms `i` and `i+1` maps to the corresponding mirrored position.
|
|
133
|
+
6. Handle attachments: clone attachments from mirrored positions. Attachments at position `p` (where `p < pivotId`) also appear at the mirror position `2*pivotId - p`.
|
|
134
|
+
|
|
135
|
+
**Files:**
|
|
136
|
+
- `src/manipulation.js` — Add `linearMirror()` function
|
|
137
|
+
- `src/method-attachers.js` — Attach `.mirror()` to Linear
|
|
138
|
+
|
|
139
|
+
### Phase 2: `molecule.mirror(pivotComponent?)`
|
|
140
|
+
|
|
141
|
+
Component-level mirroring for ABA block copolymers.
|
|
142
|
+
|
|
143
|
+
**Algorithm:**
|
|
144
|
+
1. Take `leftComponents = components[0..pivot]`.
|
|
145
|
+
2. Take `rightComponents = components[0..pivot-1].reverse()`, each deep-cloned with shifted ring numbers.
|
|
146
|
+
3. Return `Molecule([...leftComponents, ...rightComponents])`.
|
|
147
|
+
|
|
148
|
+
**Files:**
|
|
149
|
+
- `src/manipulation.js` — Add `moleculeMirror()` function (reuses `shiftRingNumbers` and `maxRingNumber` from the `repeat` implementation)
|
|
150
|
+
- `src/method-attachers.js` — Attach `.mirror()` to Molecule
|
|
151
|
+
|
|
152
|
+
### Phase 3: `ring.mirror(pivotId?)`
|
|
153
|
+
|
|
154
|
+
Attachment-level mirroring for symmetric ring substitution patterns.
|
|
155
|
+
|
|
156
|
+
**Algorithm:**
|
|
157
|
+
1. For each attachment at position `p`, compute mirror position `mp = 2*pivotId - p` (mod ring size, 1-indexed).
|
|
158
|
+
2. If `mp` doesn't already have the attachment, add it.
|
|
159
|
+
3. Similarly mirror substitutions.
|
|
160
|
+
|
|
161
|
+
**Files:**
|
|
162
|
+
- `src/manipulation.js` — Add `ringMirror()` function
|
|
163
|
+
- `src/method-attachers.js` — Attach `.mirror()` to Ring
|
|
164
|
+
|
|
165
|
+
### Phase 4 (Future): `fusedRing.mirror()`
|
|
166
|
+
|
|
167
|
+
Defer to a later iteration. FusedRing has complex position metadata that makes mirroring non-trivial.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Edge Cases
|
|
172
|
+
|
|
173
|
+
1. **Odd-length palindrome:** `mirror()` on `Linear(['C', 'O', 'C'])` with `pivotId=2` → `COCOC` (the pivot O appears once)
|
|
174
|
+
2. **Single atom:** `Linear(['O']).mirror()` → `Linear(['O'])` (nothing to mirror)
|
|
175
|
+
3. **Already symmetric:** Mirroring an already-symmetric molecule should produce the same result (idempotent on symmetric inputs)
|
|
176
|
+
4. **Bonds in mirror:** Double bonds in the left half should appear in the mirrored right half at the corresponding position
|
|
177
|
+
5. **Attachments with rings:** Mirrored ring attachments need unique ring numbers (reuse `shiftRingNumbers`)
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Test Plan
|
|
182
|
+
|
|
183
|
+
### Unit Tests (`manipulation.test.js`)
|
|
184
|
+
- `linear.mirror()` — simple chain, with pivot, with bonds, with attachments
|
|
185
|
+
- `molecule.mirror()` — ABA, ABCBA, with rings
|
|
186
|
+
- `ring.mirror()` — symmetric substitutions, symmetric attachments
|
|
187
|
+
- Edge cases: n=1, already symmetric, single atom
|
|
188
|
+
|
|
189
|
+
### Integration Tests (`test-integration/mirror.test.js`)
|
|
190
|
+
- Diethyl ether: `CCO.mirror()` → `CCOCC`
|
|
191
|
+
- ABA block copolymer with polystyrene/polyethylene blocks
|
|
192
|
+
- Symmetric biphenyl-bridged molecule
|
|
193
|
+
- Palindromic peptide-like chain
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Relationship to `.repeat()`
|
|
198
|
+
|
|
199
|
+
`.mirror()` and `.repeat()` are complementary:
|
|
200
|
+
- `.repeat(n, left, right)` — Produces **A-A-A-A** (homopolymer)
|
|
201
|
+
- `.mirror()` — Produces **A-B-A** (symmetric structure)
|
|
202
|
+
- Combined: `.repeat(2).mirror()` could produce **A-A-B-A-A** (repeated then mirrored)
|
|
203
|
+
|
|
204
|
+
Together they cover the main polymer architectures: homopolymer, block copolymer, and symmetric block copolymer.
|
package/package.json
CHANGED
|
@@ -76,14 +76,35 @@ export function buildBranchCrossingRingSMILES(ring, buildSMILES) {
|
|
|
76
76
|
|
|
77
77
|
if (attachments[i] && attachments[i].length > 0) {
|
|
78
78
|
if (hasInlineBranchAfter) {
|
|
79
|
-
//
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
// Determine the branchId of the inline continuation (next ring position)
|
|
80
|
+
const metaBranchIds = ring.metaBranchIds || [];
|
|
81
|
+
const inlineBranchId = i < size ? (metaBranchIds[i] || Infinity) : Infinity;
|
|
82
|
+
|
|
83
|
+
// Separate: siblings that come BEFORE the inline branch (emit now)
|
|
84
|
+
// vs siblings that come AFTER (delay), and non-siblings (delay)
|
|
85
|
+
const emitNow = [];
|
|
86
|
+
const delay = [];
|
|
87
|
+
attachments[i].forEach((att) => {
|
|
88
|
+
const isSibling = att.metaIsSibling !== undefined ? att.metaIsSibling : true;
|
|
89
|
+
if (!isSibling) {
|
|
90
|
+
delay.push(att);
|
|
91
|
+
} else if (att.metaBranchId !== undefined && att.metaBranchId < inlineBranchId) {
|
|
92
|
+
emitNow.push(att);
|
|
93
|
+
} else if (att.metaBeforeInline === true) {
|
|
94
|
+
emitNow.push(att);
|
|
95
|
+
} else {
|
|
96
|
+
// Default: delay sibling (beforeInline defaults to false)
|
|
97
|
+
delay.push({ ...att, metaIsSibling: true });
|
|
83
98
|
}
|
|
84
|
-
return { ...att, metaIsSibling: true };
|
|
85
99
|
});
|
|
86
|
-
|
|
100
|
+
// Emit siblings that come before the inline branch
|
|
101
|
+
emitNow.forEach((attachment) => {
|
|
102
|
+
emitAttachment(parts, attachment, buildSMILES);
|
|
103
|
+
});
|
|
104
|
+
// Delay the rest
|
|
105
|
+
if (delay.length > 0) {
|
|
106
|
+
pendingAttachments.set(i, { depth: posDepth, attachments: delay });
|
|
107
|
+
}
|
|
87
108
|
} else {
|
|
88
109
|
// Output attachments immediately
|
|
89
110
|
attachments[i].forEach((attachment) => {
|
|
@@ -103,11 +103,33 @@ export function buildInterleavedFusedRingSMILES(fusedRing, buildSMILES) {
|
|
|
103
103
|
// Pending attachments: depth -> attachment[]
|
|
104
104
|
const pendingAttachments = new Map();
|
|
105
105
|
|
|
106
|
+
const branchIdMap = fusedRing.metaBranchIdMap || new Map();
|
|
107
|
+
let prevBranchId = null;
|
|
108
|
+
|
|
106
109
|
allPositions.forEach((pos, idx) => {
|
|
107
110
|
const entry = atomSequence[pos];
|
|
108
111
|
if (!entry) return;
|
|
109
112
|
|
|
110
113
|
const posDepth = branchDepthMap.get(pos) || 0;
|
|
114
|
+
const posBranchId = branchIdMap.get(pos);
|
|
115
|
+
|
|
116
|
+
// Detect sibling branch switch: same depth, different branch ID
|
|
117
|
+
// Need to close current branch and reopen a new one
|
|
118
|
+
if (posDepth > 0 && posDepth === depthRef.value && prevBranchId !== null
|
|
119
|
+
&& posBranchId !== prevBranchId && posBranchId !== null) {
|
|
120
|
+
// Close the current branch and reopen
|
|
121
|
+
parts.push(')');
|
|
122
|
+
// Emit any pending attachments at the parent depth
|
|
123
|
+
const parentDepth = posDepth - 1;
|
|
124
|
+
if (pendingAttachments.has(parentDepth)) {
|
|
125
|
+
const attachmentsToOutput = pendingAttachments.get(parentDepth);
|
|
126
|
+
attachmentsToOutput.forEach((attachment) => {
|
|
127
|
+
emitAttachment(parts, attachment, buildSMILES);
|
|
128
|
+
});
|
|
129
|
+
pendingAttachments.delete(parentDepth);
|
|
130
|
+
}
|
|
131
|
+
parts.push('(');
|
|
132
|
+
}
|
|
111
133
|
|
|
112
134
|
// Handle branch depth changes
|
|
113
135
|
openBranches(parts, depthRef, posDepth);
|
|
@@ -164,6 +186,8 @@ export function buildInterleavedFusedRingSMILES(fusedRing, buildSMILES) {
|
|
|
164
186
|
});
|
|
165
187
|
}
|
|
166
188
|
}
|
|
189
|
+
|
|
190
|
+
prevBranchId = posBranchId;
|
|
167
191
|
});
|
|
168
192
|
|
|
169
193
|
// Close any remaining open branches
|