@altpsyche/maths 0.2.0 → 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.
package/README.md CHANGED
@@ -42,6 +42,14 @@ marks onto a two-dimensional canvas, which is what a recording needs, because an
42
42
  one surface. A test holds the two to emitting the same geometry and the same style for every
43
43
  mark.
44
44
 
45
+ ## The way in
46
+
47
+ `pathFromData` reads an SVG `d` attribute as a path, which is the inverse of what the SVG painter
48
+ writes. Without it the only shapes that exist are the ones the builders here make, so a glyph from
49
+ a typesetter or an outline from a drawing program could not be trimmed, aligned or walked into
50
+ another shape, and those are the operations this package is for. Every command is read, elliptical
51
+ arcs included, and a command it does not know stops the read rather than being skipped.
52
+
45
53
  ## The line through the package
46
54
 
47
55
  **Values and timing** are below it: vectors, a transform, the four curves a change can travel
@@ -0,0 +1,29 @@
1
+ /**
2
+ * An SVG path string read as geometry, which is the way in to everything above.
3
+ *
4
+ * `pathData` writes a path out and this reads one back. Without it the only
5
+ * paths that exist are the ones the builders beside this file make, so a glyph
6
+ * from a typesetter, an icon from a designer or anything else a drawing program
7
+ * exported cannot be trimmed, aligned or walked into another shape, and those
8
+ * are the operations this package is for.
9
+ *
10
+ * Everything becomes a cubic, the way it does everywhere here. A straight run
11
+ * takes the controls a third and two thirds along, a quadratic elevates exactly,
12
+ * and an elliptical arc is cut into pieces of at most a quarter turn each. So no
13
+ * segment read here is an approximation of the one the string described, apart
14
+ * from the arc, which no Bézier can be exactly.
15
+ *
16
+ * A command it does not know stops the read rather than being skipped. Skipping
17
+ * leaves a shape with a piece missing, and a piece missing from a letter or an
18
+ * outline reads as a mistake in the drawing rather than in the reading of it.
19
+ */
20
+ import { type Path } from './path.js';
21
+ /**
22
+ * The path a `d` attribute describes.
23
+ *
24
+ * Both cases of every command are read, so a relative run is resolved against
25
+ * where the last one ended. A command letter followed by more numbers than it
26
+ * takes repeats, which is the shorthand the grammar allows and which a moveto
27
+ * repeats as a lineto.
28
+ */
29
+ export declare function pathFromData(d: string): Path;
@@ -0,0 +1,283 @@
1
+ /**
2
+ * An SVG path string read as geometry, which is the way in to everything above.
3
+ *
4
+ * `pathData` writes a path out and this reads one back. Without it the only
5
+ * paths that exist are the ones the builders beside this file make, so a glyph
6
+ * from a typesetter, an icon from a designer or anything else a drawing program
7
+ * exported cannot be trimmed, aligned or walked into another shape, and those
8
+ * are the operations this package is for.
9
+ *
10
+ * Everything becomes a cubic, the way it does everywhere here. A straight run
11
+ * takes the controls a third and two thirds along, a quadratic elevates exactly,
12
+ * and an elliptical arc is cut into pieces of at most a quarter turn each. So no
13
+ * segment read here is an approximation of the one the string described, apart
14
+ * from the arc, which no Bézier can be exactly.
15
+ *
16
+ * A command it does not know stops the read rather than being skipped. Skipping
17
+ * leaves a shape with a piece missing, and a piece missing from a letter or an
18
+ * outline reads as a mistake in the drawing rather than in the reading of it.
19
+ */
20
+ import { vec2 } from '../values/vec2.js';
21
+ import { mat3 } from '../values/mat3.js';
22
+ import { straight } from './path.js';
23
+ /** Every command in the path grammar. */
24
+ const DRAWN = 'MLHVCSQTAZ';
25
+ /** A letter, a number, a run of separators, or one character that is none of
26
+ * those. The last group is what turns an unexpected character into a refusal
27
+ * rather than leaving it in the string unread. */
28
+ const SCANNER = /([A-Za-z])|([+-]?(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?)|([,\s]+)|([\s\S])/g;
29
+ function tokenize(d) {
30
+ const tokens = [];
31
+ SCANNER.lastIndex = 0;
32
+ for (let match = SCANNER.exec(d); match; match = SCANNER.exec(d)) {
33
+ if (match[1])
34
+ tokens.push({ command: match[1] });
35
+ else if (match[2] !== undefined)
36
+ tokens.push({ number: Number(match[2]) });
37
+ else if (match[3] === undefined)
38
+ throw new Error(`path data holds "${match[4]}", which is neither a command nor a number`);
39
+ }
40
+ return tokens;
41
+ }
42
+ /** A quadratic written as a cubic, both controls two thirds of the way from an
43
+ * end towards the single control. The two describe the same curve. */
44
+ const elevate = (from, control, to) => ({
45
+ control1: vec2.lerp(from, control, 2 / 3),
46
+ control2: vec2.lerp(to, control, 2 / 3),
47
+ to,
48
+ });
49
+ /** The control a smooth segment uses, which is the previous one turned through
50
+ * the point the two segments share. */
51
+ const reflect = (control, about) => vec2.sub(vec2.scale(about, 2), control);
52
+ /** How far a cubic's controls sit from the ends of a piece of a unit arc, along
53
+ * the tangent. It is the same derivation `arc` uses and it holds for any sweep
54
+ * short enough, which is why the sweep is cut into quarters first. */
55
+ const reachFor = (sweep) => (4 / 3) * Math.tan(sweep / 4);
56
+ /**
57
+ * An SVG elliptical arc as cubics, from the endpoint form the string carries.
58
+ *
59
+ * SVG gives an arc as where it ends plus two radii, a rotation and two flags,
60
+ * and every one of the four arcs that fit those endpoints is selected by the
61
+ * flags. The centre and the two angles are recovered first, which is the
62
+ * conversion in the SVG specification, and then the sweep is walked in pieces
63
+ * of at most a quarter turn on a unit circle. The ellipse is an affine map of
64
+ * that circle, and an affine map takes a cubic to a cubic, so the control
65
+ * points can be mapped straight through rather than derived again.
66
+ */
67
+ function arcCurves(from, radii, rotation, largeArc, sweep, to) {
68
+ // A radius of zero is a straight line by the specification, and so is an arc
69
+ // whose endpoints are the same point, which the specification drops entirely.
70
+ if (radii.x === 0 || radii.y === 0)
71
+ return [straight(from, to)];
72
+ const angle = (rotation * Math.PI) / 180;
73
+ const cos = Math.cos(angle);
74
+ const sin = Math.sin(angle);
75
+ const half = vec2.scale(vec2.sub(from, to), 0.5);
76
+ const turned = vec2(cos * half.x + sin * half.y, -sin * half.x + cos * half.y);
77
+ // Radii too small to reach both endpoints are scaled up until they just do,
78
+ // which the specification asks for rather than treating as an error.
79
+ let rx = Math.abs(radii.x);
80
+ let ry = Math.abs(radii.y);
81
+ const short = (turned.x * turned.x) / (rx * rx) + (turned.y * turned.y) / (ry * ry);
82
+ if (short > 1) {
83
+ rx *= Math.sqrt(short);
84
+ ry *= Math.sqrt(short);
85
+ }
86
+ const denominator = rx * rx * turned.y * turned.y + ry * ry * turned.x * turned.x;
87
+ const numerator = Math.max(0, rx * rx * ry * ry - denominator);
88
+ const spread = (largeArc === sweep ? -1 : 1) * Math.sqrt(numerator / denominator);
89
+ const centreTurned = vec2((spread * rx * turned.y) / ry, (-spread * ry * turned.x) / rx);
90
+ const centre = vec2.add(vec2(cos * centreTurned.x - sin * centreTurned.y, sin * centreTurned.x + cos * centreTurned.y), vec2.scale(vec2.add(from, to), 0.5));
91
+ const at = (point) => vec2((point.x - centreTurned.x) / rx, (point.y - centreTurned.y) / ry);
92
+ const opening = at(turned);
93
+ const closing = at(vec2.scale(turned, -1));
94
+ const first = vec2.angle(opening);
95
+ let swept = vec2.angle(closing) - first;
96
+ if (!sweep && swept > 0)
97
+ swept -= 2 * Math.PI;
98
+ if (sweep && swept < 0)
99
+ swept += 2 * Math.PI;
100
+ // The whole ellipse, placed and turned, as one matrix. Every point of the
101
+ // unit arc goes through it, controls included.
102
+ const place = mat3.multiply(mat3.translation(centre), mat3.multiply(mat3.rotation(angle), mat3.scaling(vec2(rx, ry))));
103
+ const pieces = Math.max(1, Math.ceil(Math.abs(swept) / (Math.PI / 2)));
104
+ const step = swept / pieces;
105
+ const reach = reachFor(step);
106
+ const curves = [];
107
+ for (let piece = 0; piece < pieces; piece++) {
108
+ const a0 = first + step * piece;
109
+ const a1 = a0 + step;
110
+ const p0 = vec2(Math.cos(a0), Math.sin(a0));
111
+ const p1 = vec2(Math.cos(a1), Math.sin(a1));
112
+ const t0 = vec2(-Math.sin(a0), Math.cos(a0));
113
+ const t1 = vec2(-Math.sin(a1), Math.cos(a1));
114
+ curves.push({
115
+ control1: mat3.transformPoint(place, vec2.add(p0, vec2.scale(t0, reach))),
116
+ control2: mat3.transformPoint(place, vec2.sub(p1, vec2.scale(t1, reach))),
117
+ // The endpoint the string named rather than the one the angles give back,
118
+ // so a run of arcs cannot drift away from where it said it ends.
119
+ to: piece === pieces - 1 ? to : mat3.transformPoint(place, p1),
120
+ });
121
+ }
122
+ return curves;
123
+ }
124
+ /**
125
+ * The path a `d` attribute describes.
126
+ *
127
+ * Both cases of every command are read, so a relative run is resolved against
128
+ * where the last one ended. A command letter followed by more numbers than it
129
+ * takes repeats, which is the shorthand the grammar allows and which a moveto
130
+ * repeats as a lineto.
131
+ */
132
+ export function pathFromData(d) {
133
+ const tokens = tokenize(d);
134
+ const subpaths = [];
135
+ let curves = [];
136
+ let start = vec2.ZERO;
137
+ let at = vec2.ZERO;
138
+ let open = false;
139
+ let moved = false;
140
+ let command = '';
141
+ let index = 0;
142
+ // Held per kind because an S reflects a cubic's second control and a T a
143
+ // quadratic's only one, and either falls back to the current point when the
144
+ // segment before it was neither.
145
+ let lastCubic;
146
+ let lastQuadratic;
147
+ const take = (count) => {
148
+ const values = [];
149
+ while (values.length < count) {
150
+ const token = tokens[index++];
151
+ if (!token || !('number' in token))
152
+ throw new Error(`path command "${command}" wants ${count} numbers and the run ends short`);
153
+ values.push(token.number);
154
+ }
155
+ return values;
156
+ };
157
+ const flush = (closed) => {
158
+ if (open)
159
+ subpaths.push({ start, curves, closed });
160
+ open = false;
161
+ };
162
+ const segment = (...added) => {
163
+ if (!moved)
164
+ throw new Error('path data draws before it moves to a starting point');
165
+ // A segment after a close begins again where the closed subpath began,
166
+ // which is the point the close left as the current one.
167
+ if (!open) {
168
+ start = at;
169
+ curves = [];
170
+ open = true;
171
+ }
172
+ curves.push(...added);
173
+ at = added[added.length - 1]?.to ?? at;
174
+ };
175
+ while (index < tokens.length) {
176
+ const token = tokens[index];
177
+ if (token && 'command' in token) {
178
+ command = token.command;
179
+ index++;
180
+ if (!DRAWN.includes(command.toUpperCase()))
181
+ throw new Error(`path data holds command "${command}", which is not one of "${DRAWN}"`);
182
+ }
183
+ else if (!command) {
184
+ throw new Error('path data opens on a number rather than a command');
185
+ }
186
+ const relative = command === command.toLowerCase();
187
+ const absolute = (x, y) => (relative ? vec2(at.x + x, at.y + y) : vec2(x, y));
188
+ switch (command.toUpperCase()) {
189
+ case 'M': {
190
+ const [x = 0, y = 0] = take(2);
191
+ flush(false);
192
+ at = absolute(x, y);
193
+ start = at;
194
+ curves = [];
195
+ open = true;
196
+ moved = true;
197
+ lastCubic = undefined;
198
+ lastQuadratic = undefined;
199
+ // A second pair under one moveto is a line rather than a second move,
200
+ // and the repetition carries the case the moveto was written in.
201
+ command = relative ? 'l' : 'L';
202
+ break;
203
+ }
204
+ case 'L': {
205
+ const [x = 0, y = 0] = take(2);
206
+ segment(straight(at, absolute(x, y)));
207
+ lastCubic = undefined;
208
+ lastQuadratic = undefined;
209
+ break;
210
+ }
211
+ case 'H': {
212
+ const [x = 0] = take(1);
213
+ segment(straight(at, relative ? vec2(at.x + x, at.y) : vec2(x, at.y)));
214
+ lastCubic = undefined;
215
+ lastQuadratic = undefined;
216
+ break;
217
+ }
218
+ case 'V': {
219
+ const [y = 0] = take(1);
220
+ segment(straight(at, relative ? vec2(at.x, at.y + y) : vec2(at.x, y)));
221
+ lastCubic = undefined;
222
+ lastQuadratic = undefined;
223
+ break;
224
+ }
225
+ case 'C': {
226
+ const [x1 = 0, y1 = 0, x2 = 0, y2 = 0, x = 0, y = 0] = take(6);
227
+ const control2 = absolute(x2, y2);
228
+ segment({ control1: absolute(x1, y1), control2, to: absolute(x, y) });
229
+ lastCubic = control2;
230
+ lastQuadratic = undefined;
231
+ break;
232
+ }
233
+ case 'S': {
234
+ const [x2 = 0, y2 = 0, x = 0, y = 0] = take(4);
235
+ const control1 = lastCubic ? reflect(lastCubic, at) : at;
236
+ const control2 = absolute(x2, y2);
237
+ segment({ control1, control2, to: absolute(x, y) });
238
+ lastCubic = control2;
239
+ lastQuadratic = undefined;
240
+ break;
241
+ }
242
+ case 'Q': {
243
+ const [qx = 0, qy = 0, x = 0, y = 0] = take(4);
244
+ const control = absolute(qx, qy);
245
+ segment(elevate(at, control, absolute(x, y)));
246
+ lastCubic = undefined;
247
+ lastQuadratic = control;
248
+ break;
249
+ }
250
+ case 'T': {
251
+ const [x = 0, y = 0] = take(2);
252
+ const control = lastQuadratic ? reflect(lastQuadratic, at) : at;
253
+ segment(elevate(at, control, absolute(x, y)));
254
+ lastCubic = undefined;
255
+ lastQuadratic = control;
256
+ break;
257
+ }
258
+ case 'A': {
259
+ const [rx = 0, ry = 0, turn = 0, large = 0, sweep = 0, x = 0, y = 0] = take(7);
260
+ const to = absolute(x, y);
261
+ // An arc that ends where it began is dropped rather than drawn, which
262
+ // is the specification's own wording: there is no such ellipse to pick.
263
+ if (to.x !== at.x || to.y !== at.y)
264
+ segment(...arcCurves(at, vec2(rx, ry), turn, large !== 0, sweep !== 0, to));
265
+ lastCubic = undefined;
266
+ lastQuadratic = undefined;
267
+ break;
268
+ }
269
+ case 'Z': {
270
+ flush(true);
271
+ at = start;
272
+ lastCubic = undefined;
273
+ lastQuadratic = undefined;
274
+ // A close ends the run of one command, so a number after it has nothing
275
+ // to repeat and is refused rather than read as a line.
276
+ command = '';
277
+ break;
278
+ }
279
+ }
280
+ }
281
+ flush(false);
282
+ return subpaths;
283
+ }
package/dist/index.d.ts CHANGED
@@ -20,6 +20,7 @@ export { SAME_TIME, keyAt, sampleTrack, sampleTracks, withKey, withoutKey } from
20
20
  export type { Key, Track, TrackValue, Tracks } from './timing/track.js';
21
21
  export { arc, circle, line, polygon, polyline, pointCount, pointOn, rect, straight, transformPath } from './figure/path.js';
22
22
  export type { Cubic, Path, Subpath } from './figure/path.js';
23
+ export { pathFromData } from './figure/path-data.js';
23
24
  export type { Colour, Fill, Mark, PathMark, Stroke, TextMark } from './figure/mark.js';
24
25
  export { byAspect, fractionOf, matchingAspect, resolveExtent, viewMatrix } from './figure/extent.js';
25
26
  export type { Extent, ExtentChoice, Fit } from './figure/extent.js';
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ export { vec3 } from './values/vec3.js';
14
14
  export { mat3 } from './values/mat3.js';
15
15
  export { SAME_TIME, keyAt, sampleTrack, sampleTracks, withKey, withoutKey } from './timing/track.js';
16
16
  export { arc, circle, line, polygon, polyline, pointCount, pointOn, rect, straight, transformPath } from './figure/path.js';
17
+ export { pathFromData } from './figure/path-data.js';
17
18
  export { byAspect, fractionOf, matchingAspect, resolveExtent, viewMatrix } from './figure/extent.js';
18
19
  export { flatten, group, shape, text } from './figure/node.js';
19
20
  export { fadeIn, fadeOut, fadeTo, draw, morph, moveBy } from './figure/animation.js';
@@ -37,21 +37,26 @@ export declare function svgElements(marks: readonly Mark[], view: Mat3): SvgElem
37
37
  export declare function svgMarkup(marks: readonly Mark[], view: Mat3, width: number, height: number): string;
38
38
  /**
39
39
  * Only what a painter needs from a document, named here rather than taken from
40
- * the DOM types.
40
+ * the DOM types, so this package declares no browser library at all: it can be
41
+ * checked and tested without one, and a caller can hand in a stand-in.
41
42
  *
42
- * A real `SVGElement` and a real `Document` both satisfy these already, and
43
- * writing them out means this package declares no browser library at all: it can
44
- * be checked and tested without one, and a caller can hand in a stand-in.
43
+ * The element a maker makes is the element the target is handed, and that type
44
+ * travels through rather than being flattened to the two members named below. An
45
+ * element in a real document takes whole nodes and text where the painter's own
46
+ * type takes neither, so a target written in terms of the painter's type is a
47
+ * target no real element can be: what a document offers and what the painter
48
+ * would ask for are each missing something the other has, and neither signature
49
+ * is assignable to the other in either direction.
45
50
  */
46
51
  export interface PaintNode {
47
52
  setAttribute(name: string, value: string): void;
48
53
  textContent: string | null;
49
54
  }
50
- export interface PaintTarget {
51
- replaceChildren(...nodes: PaintNode[]): void;
55
+ export interface PaintTarget<Made extends PaintNode = PaintNode> {
56
+ replaceChildren(...nodes: Made[]): void;
52
57
  }
53
- export interface ElementMaker {
54
- createElementNS(namespace: string, tag: string): PaintNode;
58
+ export interface ElementMaker<Made extends PaintNode = PaintNode> {
59
+ createElementNS(namespace: string, tag: string): Made;
55
60
  }
56
61
  /**
57
62
  * The marks put into an element that is already on the page.
@@ -61,4 +66,4 @@ export interface ElementMaker {
61
66
  * and matching them up first would cost more than it saved while adding a way for
62
67
  * two frames to disagree.
63
68
  */
64
- export declare function paintSvg(into: PaintTarget, marks: readonly Mark[], view: Mat3, maker: ElementMaker): void;
69
+ export declare function paintSvg<Made extends PaintNode>(into: PaintTarget<NoInfer<Made>>, marks: readonly Mark[], view: Mat3, maker: ElementMaker<Made>): void;
package/dist/paint/svg.js CHANGED
@@ -114,7 +114,11 @@ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
114
114
  * and matching them up first would cost more than it saved while adding a way for
115
115
  * two frames to disagree.
116
116
  */
117
- export function paintSvg(into, marks, view, maker) {
117
+ export function paintSvg(
118
+ // The maker alone says what kind of element this is. Read from the target as
119
+ // well, a real element would offer the whole union its own call accepts, text
120
+ // included, and that union is not a thing this painter can set an attribute on.
121
+ into, marks, view, maker) {
118
122
  const elements = svgElements(marks, view);
119
123
  into.replaceChildren(...elements.map((element) => {
120
124
  const node = maker.createElementNS(SVG_NAMESPACE, element.tag);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@altpsyche/maths",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The mathematics AltPsyche's figures are drawn from: vectors, matrices, curves, and a value walked over time.",
5
5
  "license": "MIT",
6
6
  "author": "Siva",