@logomarks/core 0.1.4 → 0.1.7
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/CHANGELOG.md +26 -0
- package/package.json +1 -1
- package/src/edit/index.d.ts +1 -1
- package/src/edit/index.js +1 -1
- package/src/edit/pixelate.d.ts +6 -0
- package/src/edit/pixelate.js +43 -6
- package/src/geometry/index.d.ts +1 -1
- package/src/geometry/index.js +1 -1
- package/src/geometry/needle.js +3 -2
- package/src/geometry/offset.js +2 -2
- package/src/geometry/path.js +4 -1
- package/src/geometry/polygon.d.ts +4 -0
- package/src/geometry/polygon.js +28 -1
- package/src/skill/bundle.js +1 -1
- package/src/svg/index.d.ts +1 -0
- package/src/svg/index.js +1 -0
- package/src/svg/nonzero.d.ts +12 -0
- package/src/svg/nonzero.js +166 -0
- package/src/svg/shape.js +5 -29
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ All notable changes to `@logomarks/core` are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.1.7] — 2026-11-07
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- `toPath()` and `deneedle()` keep every corner of an open ring. Both sliced the last point off each ring on the assumption it repeated the first, which holds for the booleans' rings (`U`, `D`, `I`, `grow`, `lens`, `drop`) but not the builders' (`poly`, `rect`, `circle`, `ellipse`, `rr`, `roundPoly`, `splineClosed`), so a hand-built `toPath([rect(…)])` lost a corner and `deneedle()` on an open ring lost one before trimming. They now drop the last point only when it lies within 1e-6 of the first. Every one of the 3,967 Logomarks SVGs round-trips (`svgShape` then `toPath`, `deneedle` and a sample through `grow`) to byte-identical path data as in 0.1.6, and 14 of the site's generator batches (247 marks) write identical files.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `openRing(ring, eps = 1e-6)` and `closeRing(ring, eps = 1e-6)` in `/geometry`: a ring without or with its closing point, whichever way it came in. `grow()`'s ring reading uses `openRing` too (with an exact match, as before).
|
|
17
|
+
|
|
18
|
+
## [0.1.6] — 2026-11-07
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `filledShape()` and `svgShape()` read non-zero outlines whose subpaths overlap (font glyph outlines, as Bodoni Moda and DM Serif Display draw them) correctly. Each ring's winding was judged beside its longest edge, so an outer whose longest edge lay inside another contour was dropped and whole strokes vanished. When any two rings cross or touch, the fill now comes from the rings' summed winding (`windingFill`, exact however they overlap; opposite windings cancel, as a browser draws them); where the old reading already covered the same ground its polygons are kept. Every one of the 3,967 Logomarks SVGs (392 non-zero outlines among them) reads to identical polygons as in 0.1.5. Even-odd outlines are unchanged. The skill's generated `kit.mjs` carries the same reading.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `nonzeroFill(rings, ops)`, `windingFill(rings, ops)` and `ringsMeet(rings)` in `/svg`: non-zero fill straight from rings, for glyph outlines and other non-zero path data.
|
|
27
|
+
|
|
28
|
+
## [0.1.5] — 2026-11-06
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- `pixelCoverage(paths, grid)` measures how much of each pixel cell a mark covers from its rings, by scanline, so pixelate runs without a canvas (a server, a Worker); `pixelCells(cov, grid)` returns the filled cells themselves, for drawing them one by one (`@arraypress/covers` uses both for its pixel mosaic). `pixelPath` is unchanged.
|
|
33
|
+
|
|
8
34
|
## [0.1.4] — 2026-11-06
|
|
9
35
|
|
|
10
36
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@logomarks/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Geometry, SVG reading, fitting, analysis and similarity for logomarks on a 512 grid: the kit the Logomarks set is drawn with (shapes, real booleans, strokes, tracks, iso solids, badges, letter skeletons), one SVG path parser, the 448 fit, speck and shape-tag analysis, outline signatures, and the industry-symbol parts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/edit/index.d.ts
CHANGED
|
@@ -3,4 +3,4 @@ export type { Adjust, Frame, Similarity, AdjustEnv } from './adjust.js';
|
|
|
3
3
|
export type { PixelGrid } from './pixelate.js';
|
|
4
4
|
export { loadShaper, shaperReady, hasCorners, reshape, pieceCount, shapeInfo, smallSizeInfo } from './reshape.js';
|
|
5
5
|
export { NO_ADJUST, isAdjusted, adjustSuffix, transformPath, adjustPaths } from './adjust.js';
|
|
6
|
-
export { SS, pixelGrid, pixelPath } from './pixelate.js';
|
|
6
|
+
export { SS, pixelGrid, pixelPath, pixelCells, pixelCoverage } from './pixelate.js';
|
package/src/edit/index.js
CHANGED
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export { loadShaper, shaperReady, hasCorners, reshape, pieceCount, shapeInfo, smallSizeInfo } from './reshape.js';
|
|
6
6
|
export { NO_ADJUST, isAdjusted, adjustSuffix, transformPath, adjustPaths } from './adjust.js';
|
|
7
|
-
export { SS, pixelGrid, pixelPath } from './pixelate.js';
|
|
7
|
+
export { SS, pixelGrid, pixelPath, pixelCells, pixelCoverage } from './pixelate.js';
|
package/src/edit/pixelate.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { FillPath } from '../raster/scanline.js';
|
|
2
|
+
|
|
1
3
|
/** A pixel grid over a mark's box. */
|
|
2
4
|
export interface PixelGrid {
|
|
3
5
|
cols: number;
|
|
@@ -11,5 +13,9 @@ export interface PixelGrid {
|
|
|
11
13
|
export const SS: 6;
|
|
12
14
|
/** The grid for a box: 48 cells across its longest side at amount 0, 14 at amount 1, centred. */
|
|
13
15
|
export function pixelGrid(amount: number, box: { x: number; y: number; width: number; height: number }): PixelGrid;
|
|
16
|
+
/** How much of each cell the mark's rings cover (0–1, row by row), by scanline at SS × SS samples a cell. */
|
|
17
|
+
export function pixelCoverage(paths: readonly FillPath[], grid: PixelGrid): Float32Array;
|
|
18
|
+
/** The filled cells (1 each, row by row): half covered, or a fifth where a cell stands alone or bridges two. */
|
|
19
|
+
export function pixelCells(cov: ArrayLike<number>, grid: Pick<PixelGrid, 'cols' | 'rows'>): Uint8Array;
|
|
14
20
|
/** The pixelated mark as one path's d, from each cell's coverage; null when nothing is filled. */
|
|
15
21
|
export function pixelPath(cov: ArrayLike<number>, grid: PixelGrid): string | null;
|
package/src/edit/pixelate.js
CHANGED
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
* alone (a thin line or a small dot would vanish) or when it bridges two filled cells across it (a diagonal would
|
|
12
12
|
* break into steps that don't touch).
|
|
13
13
|
* - Filled cells are merged into as few rectangles as possible, so the SVG stays small and clean.
|
|
14
|
+
*
|
|
15
|
+
* Where there's no canvas to measure with (a server, a Worker), `pixelCoverage` measures the cells from the mark's
|
|
16
|
+
* rings by scanline; `pixelCells` gives the filled cells themselves, for drawing them one by one (a mosaic).
|
|
14
17
|
*/
|
|
18
|
+
import { fill } from '../raster/scanline.js';
|
|
19
|
+
|
|
15
20
|
/** Samples a cell is measured at, along each side (6 × 6 a cell). */
|
|
16
21
|
export const SS = 6;
|
|
17
22
|
|
|
@@ -32,20 +37,38 @@ export function pixelGrid(amount, box) {
|
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
40
|
+
* How much of each cell the mark covers, measured from its rings by scanline at `SS` × `SS` samples a cell: what a
|
|
41
|
+
* canvas would measure, without one.
|
|
42
|
+
*
|
|
43
|
+
* @param {readonly import('../raster/scanline.js').FillPath[]} paths The mark's paths (rings and fill rules).
|
|
44
|
+
* @param {import('./index.js').PixelGrid} grid The grid.
|
|
45
|
+
* @returns {Float32Array} Coverage 0–1 per cell, row by row.
|
|
46
|
+
*/
|
|
47
|
+
export function pixelCoverage(paths, { cols, rows, cell, x0, y0 }) {
|
|
48
|
+
const bmp = fill(paths, { x0, y0, scale: SS / cell, width: cols * SS, height: rows * SS });
|
|
49
|
+
const cov = new Float32Array(cols * rows);
|
|
50
|
+
for (let j = 0; j < bmp.height; j++)
|
|
51
|
+
for (let i = 0; i < bmp.width; i++)
|
|
52
|
+
if (bmp.data[j * bmp.width + i])
|
|
53
|
+
cov[Math.floor(j / SS) * cols + Math.floor(i / SS)]++;
|
|
54
|
+
for (let k = 0; k < cov.length; k++)
|
|
55
|
+
cov[k] /= SS * SS;
|
|
56
|
+
return cov;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The filled cells, from how much of each the mark covers: half or more, or a fifth or more where a cell stands alone
|
|
61
|
+
* (a thin line or a small dot would vanish) or bridges two filled cells across it (a diagonal would break).
|
|
38
62
|
*
|
|
39
63
|
* @param {ArrayLike<number>} cov Coverage 0–1 per cell, row by row.
|
|
40
64
|
* @param {import('./index.js').PixelGrid} grid The grid.
|
|
41
|
-
* @returns {
|
|
65
|
+
* @returns {Uint8Array} 1 per filled cell, row by row.
|
|
42
66
|
*/
|
|
43
|
-
export function
|
|
67
|
+
export function pixelCells(cov, { cols, rows }) {
|
|
44
68
|
const at = (m, q, r) => (q >= 0 && r >= 0 && q < cols && r < rows ? m[r * cols + q] : 0);
|
|
45
69
|
const on = new Uint8Array(cols * rows);
|
|
46
70
|
for (let i = 0; i < on.length; i++)
|
|
47
71
|
on[i] = cov[i] >= 0.5 ? 1 : 0;
|
|
48
|
-
// keep thin lines and small dots, and bridge diagonals
|
|
49
72
|
const keep = on.slice();
|
|
50
73
|
for (let r = 0; r < rows; r++)
|
|
51
74
|
for (let q = 0; q < cols; q++) {
|
|
@@ -64,6 +87,20 @@ export function pixelPath(cov, { cols, rows, cell, x0, y0 }) {
|
|
|
64
87
|
if (!near || (bridges && cov[i] >= 0.3))
|
|
65
88
|
keep[i] = 1;
|
|
66
89
|
}
|
|
90
|
+
return keep;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The pixelated mark as one path's d, from how much of each cell the mark covers: cells covered half or more are
|
|
95
|
+
* filled, cells covered a fifth or more too when they stand alone or bridge two filled cells, then merged into as few
|
|
96
|
+
* rectangles as possible. Null when nothing is filled.
|
|
97
|
+
*
|
|
98
|
+
* @param {ArrayLike<number>} cov Coverage 0–1 per cell, row by row.
|
|
99
|
+
* @param {import('./index.js').PixelGrid} grid The grid.
|
|
100
|
+
* @returns {string | null} The path data.
|
|
101
|
+
*/
|
|
102
|
+
export function pixelPath(cov, { cols, rows, cell, x0, y0 }) {
|
|
103
|
+
const keep = pixelCells(cov, { cols, rows });
|
|
67
104
|
const done = [];
|
|
68
105
|
let open = new Map();
|
|
69
106
|
for (let r = 0; r <= rows; r++) {
|
package/src/geometry/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export type { SkelEnd, SkelStroke, Skeleton } from './letters.js';
|
|
|
12
12
|
export type { GlitchOptions } from './glitch.js';
|
|
13
13
|
export { C, GAP, polar, poly, mapPts, rot, move, scale, flipX, sample, closedCurve, polygonPath, TAU, rad } from './core.js';
|
|
14
14
|
export { U, I, robust, robustOps, OPS, D, X } from './booleans.js';
|
|
15
|
-
export { signedArea, area, perimeter, ringBox, bbox, winding, dedupe } from './polygon.js';
|
|
15
|
+
export { signedArea, area, perimeter, ringBox, bbox, winding, dedupe, openRing, closeRing } from './polygon.js';
|
|
16
16
|
export { seeded } from './random.js';
|
|
17
17
|
export { circle, ellipse, rect, regular, annulus, sector, arcBand, rotN, bar, ring, roundPoly, rr, lens, drop, squircle, pill, egg } from './shapes.js';
|
|
18
18
|
export { stroke, weave, offsetPts, fillet, tracks, filletClosed, trackLoop, walk, balloon, linePts, arcPts, joinPts } from './stroke.js';
|
package/src/geometry/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export { C, GAP, polar, poly, mapPts, rot, move, scale, flipX, sample, closedCurve, polygonPath, TAU, rad } from './core.js';
|
|
7
7
|
export { U, I, robust, robustOps, OPS, D, X } from './booleans.js';
|
|
8
|
-
export { signedArea, area, perimeter, ringBox, bbox, winding, dedupe } from './polygon.js';
|
|
8
|
+
export { signedArea, area, perimeter, ringBox, bbox, winding, dedupe, openRing, closeRing } from './polygon.js';
|
|
9
9
|
export { seeded } from './random.js';
|
|
10
10
|
export { circle, ellipse, rect, regular, annulus, sector, arcBand, rotN, bar, ring, roundPoly, rr, lens, drop, squircle, pill, egg } from './shapes.js';
|
|
11
11
|
export { stroke, weave, offsetPts, fillet, tracks, filletClosed, trackLoop, walk, balloon, linePts, arcPts, joinPts } from './stroke.js';
|
package/src/geometry/needle.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Needle trimming for iso solids: where a round-ended gap meets a silhouette corner at a shallow angle, the face runs on
|
|
3
3
|
* past the gap's end as a sliver about 1 wide; deneedle() cuts it off so the face ends square on the gap.
|
|
4
4
|
*/
|
|
5
|
+
import { openRing } from './polygon.js';
|
|
5
6
|
|
|
6
7
|
/** Where line a–b meets line c–d. */
|
|
7
8
|
const meet = (a, b, c, d) => { const r = [b[0] - a[0], b[1] - a[1]], s = [d[0] - c[0], d[1] - c[1]], den = r[0] * s[1] - r[1] * s[0];
|
|
@@ -9,9 +10,9 @@ const meet = (a, b, c, d) => { const r = [b[0] - a[0], b[1] - a[1]], s = [d[0] -
|
|
|
9
10
|
/** Cuts the needles off a shape. A needle is a corner sharper than `deg` with one edge shorter than `len`: where a round-ended
|
|
10
11
|
gap meets a silhouette corner at a shallow angle (the hex solids' 60° corners), the face runs on past the gap's end as a
|
|
11
12
|
sliver about 1 wide. The tip and the short edge's far end become one point, where the long edge into the tip meets the
|
|
12
|
-
edge out of the short one, so the face ends square on the gap. */
|
|
13
|
+
edge out of the short one, so the face ends square on the gap. Rings may come open or closed; they go back closed. */
|
|
13
14
|
export const deneedle = (shape, { deg = 12, len = 16 } = {}) => shape.map((pg) => pg.map((ring) => {
|
|
14
|
-
const q = ring.slice(
|
|
15
|
+
const q = openRing(ring).slice(), d = (u, v) => Math.hypot(u[0] - v[0], u[1] - v[1]);
|
|
15
16
|
for (let i = 0; i < q.length && q.length > 4; i++) { const n = q.length, w = (k) => q[(k + n) % n], v = q[i], a = w(i - 1), b = w(i + 1);
|
|
16
17
|
const ang = Math.abs(Math.atan2((a[0] - v[0]) * (b[1] - v[1]) - (a[1] - v[1]) * (b[0] - v[0]), (a[0] - v[0]) * (b[0] - v[0]) + (a[1] - v[1]) * (b[1] - v[1])));
|
|
17
18
|
if (ang * 180 / Math.PI >= deg) continue;
|
package/src/geometry/offset.js
CHANGED
|
@@ -7,11 +7,11 @@ import { U, D, I } from './booleans.js';
|
|
|
7
7
|
import { circle, rect, bar } from './shapes.js';
|
|
8
8
|
import { stroke } from './stroke.js';
|
|
9
9
|
import { dp } from './path.js';
|
|
10
|
-
import { dedupe } from './polygon.js';
|
|
10
|
+
import { dedupe, openRing } from './polygon.js';
|
|
11
11
|
|
|
12
12
|
const polys = (g) => (g.length && typeof g[0][0][0] === 'number' ? [g] : g);
|
|
13
13
|
/** A ring's corner points, simplified (an open copy, split at its farthest point so the simplifier never sees first = last). */
|
|
14
|
-
const ringPts = (r, tol = 0.2) => { const q =
|
|
14
|
+
const ringPts = (r, tol = 0.2) => { const q = openRing(r, 0); let far = 0, fd = -1;
|
|
15
15
|
q.forEach((p, i) => { const d = Math.hypot(p[0] - q[0][0], p[1] - q[0][1]); if (d > fd) { fd = d; far = i; } });
|
|
16
16
|
return [...dp(q.slice(0, far + 1), tol).slice(0, -1), ...dp([...q.slice(far), q[0]], tol).slice(0, -1)]; };
|
|
17
17
|
/** A true round offset: grows a shape by d (shrinks it when d < 0), every corner rounded. */
|
package/src/geometry/path.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shapes out: SVG path data (Douglas-Peucker simplified, one decimal), the SVG file, and writing it.
|
|
3
3
|
*/
|
|
4
|
+
import { openRing } from './polygon.js';
|
|
4
5
|
|
|
5
6
|
// Douglas-Peucker on each ring, then path string (evenodd)
|
|
6
7
|
export function dp(pts, tol) {
|
|
@@ -13,8 +14,10 @@ export function dp(pts, tol) {
|
|
|
13
14
|
if (md > tol) { keep[mi] = 1; st.push([a, mi], [mi, b]); } }
|
|
14
15
|
return pts.filter((_, i) => keep[i]);
|
|
15
16
|
}
|
|
17
|
+
/** A shape as SVG path data (even-odd): each ring opened (closed or open, every corner kept), split at its farthest point,
|
|
18
|
+
simplified within `tol` and written to one decimal. */
|
|
16
19
|
export function toPath(mp, tol = 0.12) {
|
|
17
|
-
return mp.map(pg => pg.map(ring => { let pts = ring
|
|
20
|
+
return mp.map(pg => pg.map(ring => { let pts = openRing(ring); let far = 0, fd = -1;
|
|
18
21
|
pts.forEach((q, i) => { const d = Math.hypot(q[0] - pts[0][0], q[1] - pts[0][1]); if (d > fd) { fd = d; far = i; } });
|
|
19
22
|
pts = [...dp(pts.slice(0, far + 1), tol).slice(0, -1), ...dp([...pts.slice(far), pts[0]], tol).slice(0, -1)];
|
|
20
23
|
return pts.length < 3 ? '' : 'M' + pts.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join('L') + 'Z'; }).join('')).join('');
|
|
@@ -16,3 +16,7 @@ export function bbox(g: Shape): Box;
|
|
|
16
16
|
export function winding(r: readonly Point[], p: Point): number;
|
|
17
17
|
/** The points without repeated consecutive ones. */
|
|
18
18
|
export function dedupe<P extends Point>(pts: readonly P[]): P[];
|
|
19
|
+
/** A ring without its closing point: the last goes only when within `eps` (default 1e-6) of the first, so open rings keep every corner. */
|
|
20
|
+
export function openRing<P extends Point>(ring: readonly P[], eps?: number): P[];
|
|
21
|
+
/** A ring with its closing point: the first repeated at the end, unless the last is already within `eps` (default 1e-6) of it. */
|
|
22
|
+
export function closeRing<P extends Point>(ring: readonly P[], eps?: number): P[];
|
package/src/geometry/polygon.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Measures of a ring (a list of [x, y] points, closed implicitly) and of a shape: signed area, area, perimeter,
|
|
3
|
-
* bounding box, winding number round a point,
|
|
3
|
+
* bounding box, winding number round a point, consecutive duplicates dropped, and a ring opened or closed.
|
|
4
|
+
*
|
|
5
|
+
* Rings come both ways: the shape builders (poly, rect, circle, ellipse, rr, roundPoly, …) return them open (no repeated
|
|
6
|
+
* first point), the booleans (U, D, I, grow, lens, drop, …) closed (the first point repeated at the end). Code that wants
|
|
7
|
+
* one or the other asks for it with openRing() or closeRing() rather than slicing the last point off blind.
|
|
4
8
|
*/
|
|
5
9
|
|
|
6
10
|
/**
|
|
@@ -91,3 +95,26 @@ export const dedupe = (pts) => pts.filter((p, i) => {
|
|
|
91
95
|
const q = pts[(i + 1) % pts.length];
|
|
92
96
|
return Math.hypot(p[0] - q[0], p[1] - q[1]) > 1e-6;
|
|
93
97
|
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A ring without its closing point: the last point goes only when it lies within `eps` of the first (a sampled full turn
|
|
101
|
+
* ends near, not exactly on, its start), so an open ring comes back as it is, every corner kept.
|
|
102
|
+
*
|
|
103
|
+
* @template {import('./types.js').Point} P
|
|
104
|
+
* @param {readonly P[]} ring The ring, open or closed.
|
|
105
|
+
* @param {number} [eps] How near the last point must be to the first to count as its repeat.
|
|
106
|
+
* @returns {P[]} The ring, open (the input itself when it already was).
|
|
107
|
+
*/
|
|
108
|
+
export const openRing = (ring, eps = 1e-6) =>
|
|
109
|
+
/** @type {P[]} */ (ring.length > 1 && Math.hypot(ring[0][0] - ring[ring.length - 1][0], ring[0][1] - ring[ring.length - 1][1]) <= eps ? ring.slice(0, -1) : ring);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A ring with its closing point: the first point repeated at the end, unless the last already lies within `eps` of it.
|
|
113
|
+
*
|
|
114
|
+
* @template {import('./types.js').Point} P
|
|
115
|
+
* @param {readonly P[]} ring The ring, open or closed.
|
|
116
|
+
* @param {number} [eps] How near the last point must be to the first to count as its repeat.
|
|
117
|
+
* @returns {P[]} The ring, closed (the input itself when it already was).
|
|
118
|
+
*/
|
|
119
|
+
export const closeRing = (ring, eps = 1e-6) =>
|
|
120
|
+
/** @type {P[]} */ (ring.length && openRing(ring, eps) === ring ? [...ring, ring[0]] : ring);
|
package/src/skill/bundle.js
CHANGED
|
@@ -15,7 +15,7 @@ const SRC = new URL('../', import.meta.url);
|
|
|
15
15
|
/** The geometry kit's modules, in dependency order. */
|
|
16
16
|
const KIT = ['geometry/core.js', 'geometry/booleans.js', 'geometry/polygon.js', 'geometry/random.js', 'geometry/shapes.js', 'geometry/stroke.js',
|
|
17
17
|
'geometry/path.js', 'geometry/offset.js', 'geometry/iso.js', 'geometry/modular.js', 'geometry/badges.js', 'geometry/letters.js',
|
|
18
|
-
'svg/path.js', 'svg/shapes.js', 'svg/rings.js', 'svg/shape.js', 'geometry/glitch.js', 'geometry/needle.js'];
|
|
18
|
+
'svg/path.js', 'svg/shapes.js', 'svg/rings.js', 'svg/nonzero.js', 'svg/shape.js', 'geometry/glitch.js', 'geometry/needle.js'];
|
|
19
19
|
|
|
20
20
|
/** The fit's modules, in dependency order. */
|
|
21
21
|
const FIT = ['svg/path.js', 'svg/shapes.js', 'svg/outline.js', 'util/number.js', 'fit/index.js'];
|
package/src/svg/index.d.ts
CHANGED
|
@@ -6,3 +6,4 @@ export { attr, shapes, viewBox, body } from './shapes.js';
|
|
|
6
6
|
export { outlinePoints, bounds } from './outline.js';
|
|
7
7
|
export { rings } from './rings.js';
|
|
8
8
|
export { filledShape, svgShape, pathRings } from './shape.js';
|
|
9
|
+
export { nonzeroFill, windingFill, ringsMeet } from './nonzero.js';
|
package/src/svg/index.js
CHANGED
|
@@ -7,3 +7,4 @@ export { attr, shapes, viewBox, body } from './shapes.js';
|
|
|
7
7
|
export { outlinePoints, bounds } from './outline.js';
|
|
8
8
|
export { rings } from './rings.js';
|
|
9
9
|
export { filledShape, svgShape, pathRings } from './shape.js';
|
|
10
|
+
export { nonzeroFill, windingFill, ringsMeet } from './nonzero.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BooleanOps } from '../geometry/booleans.js';
|
|
2
|
+
import type { Point, Ring, Shape } from '../geometry/types.js';
|
|
3
|
+
|
|
4
|
+
/** Whether any two rings meet: an edge of one crosses or touches an edge of another. */
|
|
5
|
+
export function ringsMeet(rs: readonly (readonly Point[])[]): boolean;
|
|
6
|
+
/** Exact non-zero fill by winding levels, however the rings overlap (each ring taken as simple). */
|
|
7
|
+
export function windingFill(rs: Ring[], b: BooleanOps): Shape;
|
|
8
|
+
/**
|
|
9
|
+
* Non-zero fill from rings: classed one by one when no two meet; by winding levels when any do, keeping the classed
|
|
10
|
+
* polygons when they cover the same ground.
|
|
11
|
+
*/
|
|
12
|
+
export function nonzeroFill(rs: Ring[], b: BooleanOps): Shape;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @logomarks/core/svg/nonzero
|
|
3
|
+
*
|
|
4
|
+
* Non-zero fill from rings. polygon-clipping (the booleans' library) has no non-zero fill rule across rings: it takes
|
|
5
|
+
* a polygon's first ring as its outside and the rest as holes. So the fill is built from booleans: rings that never
|
|
6
|
+
* meet are classed one by one (union or cut), and rings that cross or touch (overlapping subpaths, as font glyphs
|
|
7
|
+
* draw) go through winding levels, which are exact however they overlap. (clipper-lib has a non-zero fill type, but it
|
|
8
|
+
* is only an optional peer here, for the shape edits.)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { area, ringBox, signedArea, winding } from '../geometry/polygon.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether segments ab and cd meet (crossing, touching or overlapping along a line).
|
|
15
|
+
*
|
|
16
|
+
* @param {import('../geometry/types.js').Point} a
|
|
17
|
+
* @param {import('../geometry/types.js').Point} b
|
|
18
|
+
* @param {import('../geometry/types.js').Point} c
|
|
19
|
+
* @param {import('../geometry/types.js').Point} d
|
|
20
|
+
* @returns {boolean} True when they share a point.
|
|
21
|
+
*/
|
|
22
|
+
function segmentsMeet(a, b, c, d) {
|
|
23
|
+
const side = (/** @type {number[]} */ p, /** @type {number[]} */ q, /** @type {number[]} */ r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
|
|
24
|
+
const within = (/** @type {number[]} */ p, /** @type {number[]} */ q, /** @type {number[]} */ r) =>
|
|
25
|
+
Math.min(p[0], q[0]) <= r[0] && r[0] <= Math.max(p[0], q[0]) && Math.min(p[1], q[1]) <= r[1] && r[1] <= Math.max(p[1], q[1]);
|
|
26
|
+
const d1 = side(c, d, a), d2 = side(c, d, b), d3 = side(a, b, c), d4 = side(a, b, d);
|
|
27
|
+
if (d1 * d2 < 0 && d3 * d4 < 0) return true;
|
|
28
|
+
return (d1 === 0 && within(c, d, a)) || (d2 === 0 && within(c, d, b)) || (d3 === 0 && within(a, b, c)) || (d4 === 0 && within(a, b, d));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether two boxes overlap (edges touching count).
|
|
33
|
+
*
|
|
34
|
+
* @param {import('../geometry/types.js').Box} p
|
|
35
|
+
* @param {import('../geometry/types.js').Box} q
|
|
36
|
+
* @returns {boolean} True when they share a point.
|
|
37
|
+
*/
|
|
38
|
+
const boxesMeet = (p, q) => p[0] <= q[2] && q[0] <= p[2] && p[1] <= q[3] && q[1] <= p[3];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The edges of a ring that reach into a box, each as its two ends and its own box.
|
|
42
|
+
*
|
|
43
|
+
* @param {readonly import('../geometry/types.js').Point[]} r The ring.
|
|
44
|
+
* @param {import('../geometry/types.js').Box} box The box.
|
|
45
|
+
* @returns {[import('../geometry/types.js').Point, import('../geometry/types.js').Point, import('../geometry/types.js').Box][]} The edges.
|
|
46
|
+
*/
|
|
47
|
+
function edgesIn(r, box) {
|
|
48
|
+
/** @type {[import('../geometry/types.js').Point, import('../geometry/types.js').Point, import('../geometry/types.js').Box][]} */
|
|
49
|
+
const out = [];
|
|
50
|
+
r.forEach((p, i) => {
|
|
51
|
+
const q = r[(i + 1) % r.length];
|
|
52
|
+
/** @type {import('../geometry/types.js').Box} */
|
|
53
|
+
const eb = [Math.min(p[0], q[0]), Math.min(p[1], q[1]), Math.max(p[0], q[0]), Math.max(p[1], q[1])];
|
|
54
|
+
if (boxesMeet(eb, box)) out.push([p, q, eb]);
|
|
55
|
+
});
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether any two of the rings meet: an edge of one crosses or touches an edge of another.
|
|
61
|
+
*
|
|
62
|
+
* @param {readonly (readonly import('../geometry/types.js').Point[])[]} rs The rings.
|
|
63
|
+
* @returns {boolean} True when two rings meet.
|
|
64
|
+
*/
|
|
65
|
+
export function ringsMeet(rs) {
|
|
66
|
+
const boxes = rs.map(ringBox);
|
|
67
|
+
for (let i = 0; i < rs.length; i++) {
|
|
68
|
+
for (let j = i + 1; j < rs.length; j++) {
|
|
69
|
+
if (!boxesMeet(boxes[i], boxes[j])) continue;
|
|
70
|
+
/** @type {import('../geometry/types.js').Box} */
|
|
71
|
+
const both = [Math.max(boxes[i][0], boxes[j][0]), Math.max(boxes[i][1], boxes[j][1]), Math.min(boxes[i][2], boxes[j][2]), Math.min(boxes[i][3], boxes[j][3])];
|
|
72
|
+
const ei = edgesIn(rs[i], both), ej = edgesIn(rs[j], both);
|
|
73
|
+
for (const [a, b, ab] of ei) for (const [c, d, cb] of ej) if (boxesMeet(ab, cb) && segmentsMeet(a, b, c, d)) return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Non-zero fill for rings that never meet (nested or apart): largest first, each ring is added or cut by the winding
|
|
81
|
+
* just either side of its longest edge. With no other ring touching it, one test point pair speaks for the whole ring.
|
|
82
|
+
*
|
|
83
|
+
* @param {import('../geometry/types.js').Ring[]} rs The rings.
|
|
84
|
+
* @param {import('../geometry/booleans.js').BooleanOps} b The booleans.
|
|
85
|
+
* @returns {import('../geometry/types.js').Shape} The filled shape.
|
|
86
|
+
*/
|
|
87
|
+
function classedFill(rs, b) {
|
|
88
|
+
const sorted = rs.slice().sort((p, q) => Math.abs(signedArea(q)) - Math.abs(signedArea(p)));
|
|
89
|
+
const total = (/** @type {import('../geometry/types.js').Point} */ pt) => rs.reduce((s, r) => s + winding(r, pt), 0);
|
|
90
|
+
/** @type {import('../geometry/types.js').Shape} */
|
|
91
|
+
let g = [];
|
|
92
|
+
for (const r of sorted) {
|
|
93
|
+
let k = 0, longest = 0;
|
|
94
|
+
r.forEach((p, i) => {
|
|
95
|
+
const q = r[(i + 1) % r.length], L = Math.hypot(q[0] - p[0], q[1] - p[1]);
|
|
96
|
+
if (L > longest) { k = i; longest = L; }
|
|
97
|
+
});
|
|
98
|
+
const p = r[k], q = r[(k + 1) % r.length], e = 0.05;
|
|
99
|
+
const nx = ((q[1] - p[1]) / longest) * e, ny = ((q[0] - p[0]) / longest) * e;
|
|
100
|
+
const m = [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2];
|
|
101
|
+
/** @type {import('../geometry/types.js').Point} */
|
|
102
|
+
const a = [m[0] - nx, m[1] + ny];
|
|
103
|
+
/** @type {import('../geometry/types.js').Point} */
|
|
104
|
+
const c = [m[0] + nx, m[1] - ny];
|
|
105
|
+
const [inside, outside] = winding(r, a) ? [a, c] : [c, a];
|
|
106
|
+
const fi = total(inside) !== 0, fo = total(outside) !== 0;
|
|
107
|
+
if (fi && !fo) g = b.union(g, [r]);
|
|
108
|
+
else if (!fi && fo) g = b.difference(g, [r]);
|
|
109
|
+
}
|
|
110
|
+
return g;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Exact non-zero fill for rings however they overlap. The plane is kept as winding levels (the region where the rings
|
|
115
|
+
* so far sum to each non-zero winding); each ring moves the part of every level it covers one step, up or down by its
|
|
116
|
+
* direction. The fill is every non-zero level unioned. Each ring is taken as simple, wound one way.
|
|
117
|
+
*
|
|
118
|
+
* @param {import('../geometry/types.js').Ring[]} rs The rings.
|
|
119
|
+
* @param {import('../geometry/booleans.js').BooleanOps} b The booleans.
|
|
120
|
+
* @returns {import('../geometry/types.js').Shape} The filled shape.
|
|
121
|
+
*/
|
|
122
|
+
export function windingFill(rs, b) {
|
|
123
|
+
/** @type {Map<number, import('../geometry/types.js').Shape>} */
|
|
124
|
+
let levels = new Map();
|
|
125
|
+
for (const r of rs) {
|
|
126
|
+
const s = Math.sign(signedArea(r)), R = b.union([r]);
|
|
127
|
+
/** @type {Map<number, import('../geometry/types.js').Shape>} */
|
|
128
|
+
const next = new Map();
|
|
129
|
+
const add = (/** @type {number} */ k, /** @type {import('../geometry/types.js').Shape} */ g) => {
|
|
130
|
+
if (k === 0 || !g.length) return;
|
|
131
|
+
const had = next.get(k);
|
|
132
|
+
next.set(k, had ? b.union(had, g) : g);
|
|
133
|
+
};
|
|
134
|
+
add(s, levels.size ? b.difference(R, ...levels.values()) : R);
|
|
135
|
+
for (const [k, g] of levels) {
|
|
136
|
+
add(k, b.difference(g, R));
|
|
137
|
+
add(k + s, b.intersection(g, R));
|
|
138
|
+
}
|
|
139
|
+
levels = next;
|
|
140
|
+
}
|
|
141
|
+
return b.union(...levels.values());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A shape's filled area (outsides less holes).
|
|
146
|
+
*
|
|
147
|
+
* @param {import('../geometry/types.js').Shape} g The shape.
|
|
148
|
+
* @returns {number} The area.
|
|
149
|
+
*/
|
|
150
|
+
const filledArea = (g) => g.reduce((t, pg) => t + area(pg[0]) - pg.slice(1).reduce((h, r) => h + area(r), 0), 0);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Non-zero fill from rings. When no two rings meet, each is classed on its own (as filledShape always read them).
|
|
154
|
+
* When any two cross or touch, the winding levels decide; the classed polygons are still returned when they cover the
|
|
155
|
+
* same ground (their exclusive-or has no area to speak of), so an outline that always read right keeps its exact
|
|
156
|
+
* polygons, and only a misread one (an outer whose longest edge lies inside another ring) changes.
|
|
157
|
+
*
|
|
158
|
+
* @param {import('../geometry/types.js').Ring[]} rs The rings.
|
|
159
|
+
* @param {import('../geometry/booleans.js').BooleanOps} b The booleans.
|
|
160
|
+
* @returns {import('../geometry/types.js').Shape} The filled shape.
|
|
161
|
+
*/
|
|
162
|
+
export function nonzeroFill(rs, b) {
|
|
163
|
+
if (!ringsMeet(rs)) return classedFill(rs, b);
|
|
164
|
+
const exact = windingFill(rs, b), classed = classedFill(rs, b);
|
|
165
|
+
return filledArea(b.xor(classed, exact)) <= 1e-9 * Math.max(1, filledArea(exact)) ? classed : exact;
|
|
166
|
+
}
|
package/src/svg/shape.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A mark read back in as a shape: every filled outline unioned, each with its own fill rule. Even-odd outlines
|
|
3
|
-
* exclude every ring; non-zero ones
|
|
4
|
-
* and
|
|
3
|
+
* exclude every ring; non-zero ones sum the rings' windings (nonzero.js), so reversed holes, islands, same-way
|
|
4
|
+
* overlaps and overlapping subpaths (font glyphs) all come out right.
|
|
5
5
|
*/
|
|
6
6
|
import { OPS } from '../geometry/booleans.js';
|
|
7
|
-
import { signedArea
|
|
7
|
+
import { signedArea } from '../geometry/polygon.js';
|
|
8
|
+
import { nonzeroFill } from './nonzero.js';
|
|
8
9
|
import { rings } from './rings.js';
|
|
9
10
|
import { parsePath } from './path.js';
|
|
10
11
|
import { shapes } from './shapes.js';
|
|
@@ -17,32 +18,7 @@ import { shapes } from './shapes.js';
|
|
|
17
18
|
* @param {import('../geometry/booleans.js').BooleanOps} b The booleans.
|
|
18
19
|
* @returns {import('../geometry/types.js').Shape} The filled shape.
|
|
19
20
|
*/
|
|
20
|
-
|
|
21
|
-
if (evenodd) return b.xor(...rs.map((r) => [r]));
|
|
22
|
-
const sorted = rs.slice().sort((p, q) => Math.abs(signedArea(q)) - Math.abs(signedArea(p)));
|
|
23
|
-
const total = (/** @type {import('../geometry/types.js').Point} */ pt) => rs.reduce((s, r) => s + winding(r, pt), 0);
|
|
24
|
-
/** @type {import('../geometry/types.js').Shape} */
|
|
25
|
-
let g = [];
|
|
26
|
-
for (const r of sorted) {
|
|
27
|
-
let k = 0, longest = 0;
|
|
28
|
-
r.forEach((p, i) => {
|
|
29
|
-
const q = r[(i + 1) % r.length], L = Math.hypot(q[0] - p[0], q[1] - p[1]);
|
|
30
|
-
if (L > longest) { k = i; longest = L; }
|
|
31
|
-
});
|
|
32
|
-
const p = r[k], q = r[(k + 1) % r.length], e = 0.05;
|
|
33
|
-
const nx = ((q[1] - p[1]) / longest) * e, ny = ((q[0] - p[0]) / longest) * e;
|
|
34
|
-
const m = [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2];
|
|
35
|
-
/** @type {import('../geometry/types.js').Point} */
|
|
36
|
-
const a = [m[0] - nx, m[1] + ny];
|
|
37
|
-
/** @type {import('../geometry/types.js').Point} */
|
|
38
|
-
const c = [m[0] + nx, m[1] - ny];
|
|
39
|
-
const [inside, outside] = winding(r, a) ? [a, c] : [c, a];
|
|
40
|
-
const fi = total(inside) !== 0, fo = total(outside) !== 0;
|
|
41
|
-
if (fi && !fo) g = b.union(g, [r]);
|
|
42
|
-
else if (!fi && fo) g = b.difference(g, [r]);
|
|
43
|
-
}
|
|
44
|
-
return g;
|
|
45
|
-
}
|
|
21
|
+
const fillOf = (rs, evenodd, b) => (evenodd ? b.xor(...rs.map((r) => [r])) : nonzeroFill(rs, b));
|
|
46
22
|
|
|
47
23
|
/**
|
|
48
24
|
* Outlines (parsed path data with their fill rules) as one shape.
|