Skip to content

Commit

Permalink
Implement 3D lattices
Browse files Browse the repository at this point in the history
  • Loading branch information
frostburn committed Jun 9, 2024
1 parent 9eb0131 commit 54a3c53
Show file tree
Hide file tree
Showing 6 changed files with 549 additions and 197 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Change log

## 0.1.0
* Feature: 3-dimensional prime lattices.

## 0.0.3
* Bug fix: Connect nodes that are one unit apart within epsilon tolerance. [#12](https://github.com/xenharmonic-devs/ji-lattice/issues/12)
* Bug fix: Don't create "diagonal" edges even if the nodes are one unit apart. [#13](https://github.com/xenharmonic-devs/ji-lattice/issues/13)
Expand Down
64 changes: 64 additions & 0 deletions src/__tests__/lattice-3d.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {describe, it, expect} from 'vitest';
import {Fraction, LOG_PRIMES, toMonzo} from 'xen-dev-utils';
import {WGP9, primeSphere, spanLattice3D} from '../lattice-3d';

describe('Wilson-Grady-Pakkanen lattice', () => {
it('works for a 7-limit box', () => {
const monzos: number[][] = [];
for (let i = 0; i < 3; ++i) {
const fifths = new Fraction(3).pow(i)!.geoMod(2);
monzos.push(toMonzo(fifths));
monzos.push(toMonzo(fifths.mul(5).geoMod(2)));
monzos.push(toMonzo(fifths.mul(7).geoMod(2)));
}
monzos[0].push(1);
const {vertices, edges} = spanLattice3D(monzos, WGP9());
expect(vertices).toEqual([
{index: 0, x: 0, y: 0, z: 0},
{index: 1, x: 0, y: -40, z: 0},
{index: 2, x: 0, y: 0, z: 40},
{index: 3, x: 40, y: 0, z: 0},
{index: 4, x: 40, y: -40, z: 0},
{index: 5, x: 40, y: 0, z: 40},
{index: 6, x: 80, y: 0, z: 0},
{index: 7, x: 80, y: -40, z: 0},
{index: 8, x: 80, y: 0, z: 40},
]);
expect(edges).toEqual([
{x1: 0, y1: 0, z1: 0, x2: 0, y2: -40, z2: 0, type: 'primary'}, // 1 -> 5
{x1: 0, y1: 0, z1: 0, x2: 0, y2: 0, z2: 40, type: 'primary'}, // 1 -> 7
{x1: 0, y1: 0, z1: 0, x2: 40, y2: 0, z2: 0, type: 'primary'}, // 1 -> 3
{x1: 0, y1: -40, z1: 0, x2: 40, y2: -40, z2: 0, type: 'primary'}, // 5 -> 15
{x1: 0, y1: 0, z1: 40, x2: 40, y2: 0, z2: 40, type: 'primary'}, // 7 -> 21
{x1: 40, y1: 0, z1: 0, x2: 40, y2: -40, z2: 0, type: 'primary'}, // 3 -> 15
{x1: 40, y1: 0, z1: 0, x2: 40, y2: 0, z2: 40, type: 'primary'}, // 3 -> 21
{x1: 40, y1: 0, z1: 0, x2: 80, y2: 0, z2: 0, type: 'primary'}, // 3 -> 9
{x1: 40, y1: -40, z1: 0, x2: 80, y2: -40, z2: 0, type: 'primary'}, // 15 -> 45
{x1: 40, y1: 0, z1: 40, x2: 80, y2: 0, z2: 40, type: 'primary'}, // 21 -> 63
{x1: 80, y1: 0, z1: 0, x2: 80, y2: -40, z2: 0, type: 'primary'}, // 9 -> 45
{x1: 80, y1: 0, z1: 0, x2: 80, y2: 0, z2: 40, type: 'primary'}, // 9 -> 63
]);
});
});

describe('Prime sphere coordinates', () => {
it('produces coordinates for the 11-limit', () => {
const {horizontalCoordinates, verticalCoordinates, depthwiseCoordinates} =
primeSphere(LOG_PRIMES.slice(0, 5));
const coords: string[] = [];
for (let i = 0; i < 5; ++i) {
coords.push(
`${horizontalCoordinates[i].toFixed(3)}, ${verticalCoordinates[
i
].toFixed(3)}, ${depthwiseCoordinates[i].toFixed(3)}`
);
}
expect(coords).toEqual([
'0.000, -0.000, 0.000',
'1.861, 0.509, 0.000', // 3/2 points right and a little down
'1.437, -0.900, -0.000', // 5/4 points right-up
'0.647, 0.211, 0.912', // 7/4 points into the screen
'1.968, 0.086, -0.237', // 11/8 does whatever
]);
});
});
206 changes: 9 additions & 197 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,8 @@
import {LOG_PRIMES, dot, mmod, monzosEqual, sub} from 'xen-dev-utils';

// Small radius of tolerance to accept near unit distances between fractional coordinates as edges.
const EPSILON = 1e-6;

/**
* The type of an edge connecting two vertices or a gridline.
*
* `"primary"`: Prime-wise connection between two vertices.
*
* `"custom"`: User-defined connection between two vertices.
*
* `"auxiliary"`: Connection where at least one vertex is auxiliary.
*
* `"gridline"`: Line extending across the screen.
*/
export type EdgeType = 'primary' | 'custom' | 'auxiliary' | 'gridline';
import {EdgeType} from './types';
import {connect, project, unproject} from './utils';
export * from './types';
export * from './lattice-3d';

/**
* A vertex of a 2D graph.
Expand Down Expand Up @@ -56,12 +44,6 @@ export type Edge = {
type: EdgeType;
};

type Connection = {
index1: number;
index2: number;
type: EdgeType;
};

/**
* Options for {@link spanLattice}.
*/
Expand Down Expand Up @@ -204,178 +186,6 @@ export function mergeEdges(edges: Edge[]) {
return result;
}

/**
* Calculate the taxicab norm / Manhattan distance between two integral vectors.
* Restrict movement to whole steps for fractional vectors.
* Has a tolerance for small errors.
* @param a Prime exponents of a musical interval.
* @param b Prime exponents of a musical interval.
* @returns Integer representing the number of "moves" required to reach `b`from `a`. `NaN` if no legal moves exist.
*/
function taxicabDistance(
a: number[],
b: number[],
tolerance = EPSILON
): number {
if (a.length > b.length) {
return taxicabDistance(b, a);
}
let result = 0;
for (let i = 0; i < a.length; ++i) {
const distance = Math.abs(a[i] - b[i]);
const move = Math.round(distance);
if (Math.abs(distance - move) <= tolerance) {
result += move;
} else {
return NaN;
}
}
for (let i = a.length; i < b.length; ++i) {
const distance = Math.abs(b[i]);
const move = Math.round(distance);
if (Math.abs(distance - move) <= tolerance) {
result += move;
} else {
return NaN;
}
}
return result;
}

/**
* Connect monzos that are pre-processed to ignore equaves.
* @param monzos Array of arrays of prime exponents of musical intervals (usually without prime 2).
* @param maxDistance Maximum taxicab distance to connect.
* @returns An array of connections and an array of auxillary nodes.
*/
function connect(monzos: number[][], maxDistance: number) {
if (maxDistance > 2) {
throw new Error('Only up to max distance = 2 implemented.');
}

const connections: Connection[] = [];
const connectingMonzos: number[][] = [];

if (maxDistance > 1) {
for (let i = 0; i < monzos.length; ++i) {
for (let j = i + 1; j < monzos.length; ++j) {
const distance = taxicabDistance(monzos[i], monzos[j]);
if (distance > 1 && distance <= maxDistance) {
const len = Math.max(monzos[i].length, monzos[j].length);
gapSearch: for (let k = 0; k < len; ++k) {
const gap = (monzos[i][k] ?? 0) - (monzos[j][k] ?? 0);
if (Math.abs(gap) === 2) {
const monzo = [...monzos[j]];
for (let l = monzos[j].length; l < monzos[i].length; ++l) {
monzo[l] = 0;
}
monzo[k] += gap / 2;
for (const existing of monzos.concat(connectingMonzos)) {
if (monzosEqual(monzo, existing)) {
break gapSearch;
}
}
connectingMonzos.push(monzo);
break;
} else if (Math.abs(gap) === 1) {
for (let l = k + 1; l < len; ++l) {
const otherGap = (monzos[i][l] ?? 0) - (monzos[j][l] ?? 0);
const monzo = [...monzos[j]];
for (let m = monzos[j].length; m < monzos[i].length; ++m) {
monzo[m] = 0;
}
const otherWay = [...monzo];
monzo[k] += gap;
otherWay[l] += otherGap;
let monzoUnique = true;
let otherUnique = true;
for (const existing of monzos.concat(connectingMonzos)) {
if (monzosEqual(monzo, existing)) {
monzoUnique = false;
}
if (monzosEqual(otherWay, existing)) {
otherUnique = false;
}
}
if (monzoUnique) {
connectingMonzos.push(monzo);
}
if (otherUnique) {
connectingMonzos.push(otherWay);
}
break gapSearch;
}
}
}
}
}
}
}
if (maxDistance >= 1) {
const primaryLength = monzos.length;
monzos = monzos.concat(connectingMonzos);
for (let i = 0; i < monzos.length; ++i) {
for (let j = i + 1; j < monzos.length; ++j) {
const distance = taxicabDistance(monzos[i], monzos[j]);
if (distance === 1) {
connections.push({
index1: i,
index2: j,
type:
i < primaryLength && j < primaryLength ? 'primary' : 'auxiliary',
});
}
}
}
}
return {
connections,
connectingMonzos,
};
}

function project(monzos: number[][], options: LatticeOptions) {
const {horizontalCoordinates, verticalCoordinates} = options;
const projected = monzos.map(m => [...m]);
const limit = Math.max(
horizontalCoordinates.length,
verticalCoordinates.length
);
for (const m of projected) {
m.length = Math.min(limit, m.length);
}
for (let i = limit - 1; i >= 0; --i) {
if (horizontalCoordinates[i] || verticalCoordinates[i]) {
continue;
}
for (const m of projected) {
m.splice(i, 1);
}
}
return projected;
}

function unproject(monzos: number[][], options: LatticeOptions) {
if (!monzos.length) {
return [];
}
const unprojected = monzos.map(m => [...m]);
const {horizontalCoordinates, verticalCoordinates} = options;
const limit = Math.max(
horizontalCoordinates.length,
verticalCoordinates.length
);
for (let i = 0; i < limit; ++i) {
if (horizontalCoordinates[i] || verticalCoordinates[i]) {
continue;
}
for (const u of unprojected) {
u.splice(i, 0, 0);
}
}
return unprojected;
}

/**
* Compute vertices and edges for a 2D graph representing the lattice of a musical scale in just intonation.
* @param monzos Prime exponents of the musical intervals in the scale.
Expand All @@ -386,11 +196,13 @@ export function spanLattice(monzos: number[][], options: LatticeOptions) {
const {horizontalCoordinates, verticalCoordinates} = options;
const maxDistance = options.maxDistance ?? 1;

let projected = project(monzos, options);
const coordss = [horizontalCoordinates, verticalCoordinates];

let projected = project(monzos, coordss);

const {connections, connectingMonzos} = connect(projected, maxDistance);

const unprojected = unproject(connectingMonzos, options);
const unprojected = unproject(connectingMonzos, coordss);

const vertices: Vertex[] = [];
let edges: Edge[] = [];
Expand Down Expand Up @@ -424,7 +236,7 @@ export function spanLattice(monzos: number[][], options: LatticeOptions) {

if (options.edgeMonzos) {
projected = projected.concat(connectingMonzos);
let ems = project(options.edgeMonzos, options);
let ems = project(options.edgeMonzos, coordss);
ems = ems.concat(ems.map(em => em.map(e => -e)));
for (let i = 0; i < projected.length; ++i) {
for (let j = i + 1; j < projected.length; ++j) {
Expand Down
Loading

0 comments on commit 54a3c53

Please sign in to comment.