holey-shapes 1.0.0 → 2.0.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 +34 -3
- package/geometry.js +95 -0
- package/package.json +3 -2
- package/script.js +10 -28
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ In Xcode, add `https://github.com/skyaara/holey-shapes` as a package dependency,
|
|
|
18
18
|
|
|
19
19
|
```swift
|
|
20
20
|
dependencies: [
|
|
21
|
-
.package(url: "https://github.com/skyaara/holey-shapes.git", from: "0.
|
|
21
|
+
.package(url: "https://github.com/skyaara/holey-shapes.git", from: "2.0.0")
|
|
22
22
|
]
|
|
23
23
|
```
|
|
24
24
|
|
|
@@ -62,7 +62,7 @@ Then add the Compose SDK:
|
|
|
62
62
|
|
|
63
63
|
```kotlin
|
|
64
64
|
dependencies {
|
|
65
|
-
implementation("com.github.skyaara:holey-shapes:
|
|
65
|
+
implementation("com.github.skyaara:holey-shapes:v2.0.0")
|
|
66
66
|
}
|
|
67
67
|
```
|
|
68
68
|
|
|
@@ -81,6 +81,37 @@ HoleyShapeView(
|
|
|
81
81
|
|
|
82
82
|
The Android SDK requires API 26 or newer and a consuming project compiled with API 35 or newer. It renders through the native Android canvas and caches geometry and packed layouts. Mouse and stylus hover close and reopen individual holes; touch-only avatars remain still. Android's animation scale applies automatically.
|
|
83
83
|
|
|
84
|
+
## Generate layouts in Swift and JVM processes
|
|
85
|
+
|
|
86
|
+
Swift packing uses Foundation and plain geometry. It needs no SwiftUI view or CoreGraphics context. The package conditionally includes its SwiftUI renderer on supported Apple platforms.
|
|
87
|
+
|
|
88
|
+
```swift
|
|
89
|
+
import HoleyShapes
|
|
90
|
+
|
|
91
|
+
let holes = HoleyShapes.layout(shape: .prism, count: 6, seed: 2)
|
|
92
|
+
// Each hole exposes cx, cy, rx, ry, and rotation.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The Kotlin `core` module is a standalone JVM library with no Android or Compose dependencies. The Android SDK uses this same core. For a local JVM consumer, run `./gradlew :core:publishToMavenLocal`, enable `mavenLocal()`, and add `com.github.skyaara:holey-shapes-core:2.0.0`. For JitPack, the standalone module coordinate is `com.github.skyaara.holey-shapes:holey-shapes-core:v2.0.0`.
|
|
96
|
+
|
|
97
|
+
```kotlin
|
|
98
|
+
import com.aakashreddy.holeyshapes.core.HoleyPacking
|
|
99
|
+
import com.aakashreddy.holeyshapes.core.HoleyShape
|
|
100
|
+
|
|
101
|
+
val holes = HoleyPacking.layout(HoleyShape.Prism, count = 6, seed = 2)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Both APIs use full equal-area packing, clamp counts to 0–8 and seeds to ±1,000,000, and cache layouts. They never substitute preset layouts. Floating-point differences between language runtimes can produce different positions; byte-identical layouts across Swift, Kotlin, and JavaScript are not guaranteed.
|
|
105
|
+
|
|
106
|
+
Run the process checks locally:
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
swift run --package-path tests/native/swift -c release Verify /tmp/swift-layouts.json
|
|
110
|
+
./gradlew :core:verifyPacking
|
|
111
|
+
# Independent Canvas checks require Playwright and installed Google Chrome.
|
|
112
|
+
node scripts/verify-native-layouts.mjs /tmp/swift-layouts.json core/build/packing-layouts.json
|
|
113
|
+
```
|
|
114
|
+
|
|
84
115
|
## Render an SVG string
|
|
85
116
|
|
|
86
117
|
```js
|
|
@@ -159,6 +190,6 @@ for (const { key, name, color } of shapeNames) {
|
|
|
159
190
|
|
|
160
191
|
## Browser support
|
|
161
192
|
|
|
162
|
-
The package has no runtime dependencies.
|
|
193
|
+
The package has no runtime dependencies. Node processes, server-side rendering, and browsers use the same JavaScript geometry and full hole-packing algorithm. SVG generation needs no DOM, Canvas, or `Path2D`; identical options produce identical SVG markup. Only `mountHoleyShape` and interactive animation require a browser.
|
|
163
194
|
|
|
164
195
|
The hover animation respects `prefers-reduced-motion`.
|
package/geometry.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Geometry for the absolute SVG commands used by the built-in shapes.
|
|
2
|
+
// Curves are flattened within 0.01 viewBox units, well below the packing rim.
|
|
3
|
+
// Both Node and browsers use this code so layout never depends on Canvas.
|
|
4
|
+
export function compileShapePath(source) {
|
|
5
|
+
const tokens = source.match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/g) || [];
|
|
6
|
+
const edges = [];
|
|
7
|
+
let i = 0, command, x = 0, y = 0, startX = 0, startY = 0;
|
|
8
|
+
const number = () => {
|
|
9
|
+
const value = Number(tokens[i++]);
|
|
10
|
+
if (!Number.isFinite(value)) throw new Error('Invalid shape path');
|
|
11
|
+
return value;
|
|
12
|
+
};
|
|
13
|
+
const line = (nx, ny) => {
|
|
14
|
+
if (y !== ny) edges.push({ x, y, nx, ny });
|
|
15
|
+
x = nx; y = ny;
|
|
16
|
+
};
|
|
17
|
+
function quadratic(x0, y0, cx, cy, x1, y1) {
|
|
18
|
+
// The curve's deviation from its chord is at most half this distance.
|
|
19
|
+
if (Math.hypot(cx - (x0 + x1) / 2, cy - (y0 + y1) / 2) <= 0.02) {
|
|
20
|
+
line(x1, y1);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const ax = (x0 + cx) / 2, ay = (y0 + cy) / 2;
|
|
24
|
+
const bx = (cx + x1) / 2, by = (cy + y1) / 2;
|
|
25
|
+
const mx = (ax + bx) / 2, my = (ay + by) / 2;
|
|
26
|
+
quadratic(x0, y0, ax, ay, mx, my);
|
|
27
|
+
quadratic(mx, my, bx, by, x1, y1);
|
|
28
|
+
}
|
|
29
|
+
while (i < tokens.length) {
|
|
30
|
+
if (/^[a-zA-Z]$/.test(tokens[i])) command = tokens[i++];
|
|
31
|
+
switch (command) {
|
|
32
|
+
case 'M':
|
|
33
|
+
x = number(); y = number(); startX = x; startY = y; command = 'L';
|
|
34
|
+
break;
|
|
35
|
+
case 'L': line(number(), number()); break;
|
|
36
|
+
case 'H': line(number(), y); break;
|
|
37
|
+
case 'V': line(x, number()); break;
|
|
38
|
+
case 'Q': {
|
|
39
|
+
const cx = number(), cy = number(), nx = number(), ny = number();
|
|
40
|
+
quadratic(x, y, cx, cy, nx, ny);
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
case 'A': {
|
|
44
|
+
let rx = Math.abs(number()), ry = Math.abs(number());
|
|
45
|
+
const rotation = number() * Math.PI / 180;
|
|
46
|
+
const large = number(), sweep = number(), nx = number(), ny = number();
|
|
47
|
+
if (!rx || !ry) { line(nx, ny); break; }
|
|
48
|
+
if (x === nx && y === ny) break;
|
|
49
|
+
const c = Math.cos(rotation), s = Math.sin(rotation);
|
|
50
|
+
const dx = (x - nx) / 2, dy = (y - ny) / 2;
|
|
51
|
+
const px = c * dx + s * dy, py = -s * dx + c * dy;
|
|
52
|
+
const scale = Math.hypot(px / rx, py / ry);
|
|
53
|
+
if (scale > 1) { rx *= scale; ry *= scale; }
|
|
54
|
+
const factor = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0,
|
|
55
|
+
(rx * rx * ry * ry - rx * rx * py * py - ry * ry * px * px) /
|
|
56
|
+
(rx * rx * py * py + ry * ry * px * px)));
|
|
57
|
+
const cx = factor * rx * py / ry, cy = -factor * ry * px / rx;
|
|
58
|
+
const centerX = c * cx - s * cy + (x + nx) / 2;
|
|
59
|
+
const centerY = s * cx + c * cy + (y + ny) / 2;
|
|
60
|
+
const angle = Math.atan2((py - cy) / ry, (px - cx) / rx);
|
|
61
|
+
let delta = Math.atan2((-py - cy) / ry, (-px - cx) / rx) - angle;
|
|
62
|
+
if (sweep && delta < 0) delta += Math.PI * 2;
|
|
63
|
+
if (!sweep && delta > 0) delta -= Math.PI * 2;
|
|
64
|
+
const steps = Math.ceil(Math.abs(delta) / (2 * Math.acos(1 - Math.min(1, 0.01 / Math.max(rx, ry)))));
|
|
65
|
+
for (let step = 1; step < steps; step++) {
|
|
66
|
+
const a = angle + delta * step / steps;
|
|
67
|
+
line(centerX + c * rx * Math.cos(a) - s * ry * Math.sin(a),
|
|
68
|
+
centerY + s * rx * Math.cos(a) + c * ry * Math.sin(a));
|
|
69
|
+
}
|
|
70
|
+
line(nx, ny);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case 'Z': line(startX, startY); command = null; break;
|
|
74
|
+
default: throw new Error(`Unsupported shape path command: ${command}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Index edges by vertical band to keep the optimizer's many point queries cheap.
|
|
78
|
+
const bands = new Map();
|
|
79
|
+
for (const edge of edges) {
|
|
80
|
+
for (let band = Math.floor(Math.min(edge.y, edge.ny) / 8); band <= Math.floor(Math.max(edge.y, edge.ny) / 8); band++) {
|
|
81
|
+
if (!bands.has(band)) bands.set(band, []);
|
|
82
|
+
bands.get(band).push(edge);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
contains(px, py) {
|
|
87
|
+
let winding = 0;
|
|
88
|
+
for (const e of bands.get(Math.floor(py / 8)) || []) {
|
|
89
|
+
if ((e.y > py) === (e.ny > py)) continue;
|
|
90
|
+
if (px < e.x + (py - e.y) * (e.nx - e.x) / (e.ny - e.y)) winding += e.ny > e.y ? 1 : -1;
|
|
91
|
+
}
|
|
92
|
+
return winding !== 0;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "holey-shapes",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Colorful perforated SVG shapes with solid extrusion and animated holes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"files": [
|
|
32
32
|
"index.js",
|
|
33
33
|
"script.js",
|
|
34
|
+
"geometry.js",
|
|
34
35
|
"index.d.ts",
|
|
35
36
|
"README.md",
|
|
36
37
|
"LICENSE"
|
|
@@ -57,7 +58,7 @@
|
|
|
57
58
|
"dev": "vite --host 127.0.0.1",
|
|
58
59
|
"build": "vite build",
|
|
59
60
|
"preview": "vite preview --host 127.0.0.1",
|
|
60
|
-
"test": "node tests/package.test.mjs && node tests/site.test.mjs",
|
|
61
|
+
"test": "node tests/package.test.mjs && node tests/packing.test.mjs && node tests/site.test.mjs",
|
|
61
62
|
"prepublishOnly": "npm test && npm run build"
|
|
62
63
|
},
|
|
63
64
|
"devDependencies": {
|
package/script.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { compileShapePath } from './geometry.js';
|
|
2
|
+
|
|
1
3
|
const h = (cx, cy, rx, ry, rotate = 0) => ({ cx, cy, rx, ry, rotate });
|
|
2
4
|
|
|
3
5
|
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
@@ -123,17 +125,7 @@ const SHADOW_X = 18;
|
|
|
123
125
|
const SHADOW_Y = 21;
|
|
124
126
|
const SHADOW_STEPS = 12;
|
|
125
127
|
const states = new Map();
|
|
126
|
-
let packingContext = null;
|
|
127
128
|
const packingCache = new Map();
|
|
128
|
-
function getPackingContext() {
|
|
129
|
-
if (packingContext) return packingContext;
|
|
130
|
-
if (!isBrowser || typeof Path2D === 'undefined') return null;
|
|
131
|
-
const packingCanvas = document.createElement('canvas');
|
|
132
|
-
packingCanvas.width = 420;
|
|
133
|
-
packingCanvas.height = 420;
|
|
134
|
-
packingContext = packingCanvas.getContext('2d');
|
|
135
|
-
return packingContext;
|
|
136
|
-
}
|
|
137
129
|
|
|
138
130
|
const holeMarkup = ({ cx, cy, rx, ry, rotate }) =>
|
|
139
131
|
`<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}"${rotate ? ` transform="rotate(${rotate} ${cx} ${cy})"` : ''}/>`;
|
|
@@ -153,20 +145,17 @@ function darkShadow(color, factor = 0.52) {
|
|
|
153
145
|
}
|
|
154
146
|
|
|
155
147
|
function circleFits(path, cx, cy, radius) {
|
|
156
|
-
const context = getPackingContext();
|
|
157
|
-
if (!context) return false;
|
|
158
148
|
for (let step = 0; step < 32; step += 1) {
|
|
159
149
|
const angle = (Math.PI * 2 * step) / 32;
|
|
160
150
|
const x = cx + Math.cos(angle) * radius;
|
|
161
151
|
const y = cy + Math.sin(angle) * radius;
|
|
162
|
-
if (!
|
|
152
|
+
if (!path.contains(x, y)) return false;
|
|
163
153
|
}
|
|
164
154
|
return true;
|
|
165
155
|
}
|
|
166
156
|
|
|
167
157
|
function boundaryClearance(path, cx, cy) {
|
|
168
|
-
|
|
169
|
-
if (!context || !context.isPointInPath(path, cx, cy)) return 0;
|
|
158
|
+
if (!path.contains(cx, cy)) return 0;
|
|
170
159
|
let low = 0;
|
|
171
160
|
let high = 180;
|
|
172
161
|
for (let pass = 0; pass < 9; pass += 1) {
|
|
@@ -179,13 +168,11 @@ function boundaryClearance(path, cx, cy) {
|
|
|
179
168
|
|
|
180
169
|
function packingCandidates(shape) {
|
|
181
170
|
if (packingCache.has(shape.key)) return packingCache.get(shape.key);
|
|
182
|
-
const
|
|
183
|
-
if (!context) return { path: null, candidates: [] };
|
|
184
|
-
const path = new Path2D(shape.path);
|
|
171
|
+
const path = compileShapePath(shape.path);
|
|
185
172
|
const candidates = [];
|
|
186
173
|
for (let y = 50; y <= 370; y += 12) {
|
|
187
174
|
for (let x = 50; x <= 370; x += 12) {
|
|
188
|
-
if (!
|
|
175
|
+
if (!path.contains(x, y)) continue;
|
|
189
176
|
const clearance = boundaryClearance(path, x, y);
|
|
190
177
|
if (clearance >= 17) candidates.push({ x, y, clearance });
|
|
191
178
|
}
|
|
@@ -245,9 +232,7 @@ const optimizedLayoutCache = new Map();
|
|
|
245
232
|
|
|
246
233
|
function maximizeHoleAreas(shape, holes) {
|
|
247
234
|
if (!holes.length) return holes;
|
|
248
|
-
const context = getPackingContext();
|
|
249
235
|
const { path } = packingCandidates(shape);
|
|
250
|
-
if (!context || !path) return holes;
|
|
251
236
|
const gap = 12;
|
|
252
237
|
const profiles = holes.map(hole => {
|
|
253
238
|
const a = Math.sqrt(hole.rx / hole.ry);
|
|
@@ -267,12 +252,12 @@ function maximizeHoleAreas(shape, holes) {
|
|
|
267
252
|
});
|
|
268
253
|
const points = holes.map(h => ({ x: h.cx, y: h.cy }));
|
|
269
254
|
function boundaryLimit(point, profile) {
|
|
270
|
-
if (!
|
|
255
|
+
if (!path.contains(point.x, point.y)) return 0;
|
|
271
256
|
let low = 0, high = 180;
|
|
272
257
|
for (let pass = 0; pass < 11; pass++) {
|
|
273
258
|
const radius = (low + high) / 2;
|
|
274
259
|
// An extra rim unit covers the space between perimeter samples.
|
|
275
|
-
const fits = profile.edge.every(e =>
|
|
260
|
+
const fits = profile.edge.every(e => path.contains(
|
|
276
261
|
point.x + radius * e.x + (gap + 1) * e.nx, point.y + radius * e.y + (gap + 1) * e.ny));
|
|
277
262
|
if (fits) low = radius; else high = radius;
|
|
278
263
|
}
|
|
@@ -330,10 +315,7 @@ function packedHoleLayout(shape, count, seed) {
|
|
|
330
315
|
function initialHoleLayout(shape, count, seed) {
|
|
331
316
|
if (count === 0) return [];
|
|
332
317
|
const { candidates } = packingCandidates(shape);
|
|
333
|
-
if (
|
|
334
|
-
const start = ((Math.trunc(seed) % shape.holes.length) + shape.holes.length) % shape.holes.length;
|
|
335
|
-
return equalizeHoleAreas(Array.from({ length: count }, (_, index) => ({ ...shape.holes[(start + index) % shape.holes.length] })));
|
|
336
|
-
}
|
|
318
|
+
if (candidates.length < count) throw new Error(`Insufficient packing space for ${shape.key}`);
|
|
337
319
|
const maximumClearance = Math.max(...candidates.map(({ clearance }) => clearance));
|
|
338
320
|
const starterPool = candidates.filter(({ clearance }) => clearance >= maximumClearance * 0.72);
|
|
339
321
|
const first = seed === 0
|
|
@@ -381,7 +363,7 @@ function initialHoleLayout(shape, count, seed) {
|
|
|
381
363
|
? Infinity
|
|
382
364
|
: Math.min(...selected.filter((_, otherIndex) => otherIndex !== index).map((other) =>
|
|
383
365
|
Math.hypot(point.x - other.x, point.y - other.y)));
|
|
384
|
-
const style = shape.holes[(index + seed) % shape.holes.length];
|
|
366
|
+
const style = shape.holes[((index + seed) % shape.holes.length + shape.holes.length) % shape.holes.length];
|
|
385
367
|
const styleRadius = Math.max(style.rx, style.ry);
|
|
386
368
|
const aspectArea = (style.rx / styleRadius) * (style.ry / styleRadius);
|
|
387
369
|
const territoryArea = cells[index].length * 144;
|