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 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.1.4")
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:v0.1.4")
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. Browsers with Canvas `Path2D` get the full packed-hole layout. Server-side rendering uses the built-in deterministic fallback layout.
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": "1.0.0",
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 (!context.isPointInPath(path, x, y)) return false;
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
- const context = getPackingContext();
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 context = getPackingContext();
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 (!context.isPointInPath(path, x, y)) continue;
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 (!context.isPointInPath(path, point.x, point.y)) return 0;
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 => context.isPointInPath(path,
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 (!candidates.length) {
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;