git-hash-art 0.4.0 → 0.5.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/ALGORITHM.md CHANGED
@@ -9,27 +9,32 @@ Hash String
9
9
 
10
10
  ├─► Seed (mulberry32 PRNG)
11
11
 
12
- ├─► Color Scheme (hash-driven variation + scheme type selection)
12
+ ├─► Color Scheme (hash-driven variation + scheme type + temperature mode)
13
13
 
14
14
  └─► Rendering Pipeline
15
15
 
16
- 1. Background Layer (radial gradient)
16
+ 1. Background Layer (radial gradient, temperature-shifted)
17
17
  1b. Layered Background (faint shapes + concentric rings)
18
18
  2. Composition Mode Selection
19
- 3. Focal Points + Void Zones (negative space)
19
+ 2b. Symmetry Mode Selection (none / bilateral / quad)
20
+ 3. Focal Points (rule-of-thirds biased) + Void Zones
20
21
  4. Flow Field Initialization
22
+ 4b. Hero Shape (large focal anchor, ~60% of images)
21
23
  5. Shape Layers (× N layers)
22
24
  │ ├─ Blend Mode (per-layer compositing)
23
- │ ├─ Render Style (fill+stroke, wireframe, dashed, watercolor, etc.)
25
+ │ ├─ Render Style (fill+stroke, wireframe, dashed, watercolor, hatched, incomplete, etc.)
24
26
  │ ├─ Position (composition mode + focal bias + density check)
25
27
  │ ├─ Shape Selection (layer-weighted)
26
28
  │ ├─ Atmospheric Depth (desaturation on later layers)
29
+ │ ├─ Temperature Contrast (foreground opposite to background)
27
30
  │ ├─ Styling (transparency, glow, gradients, color jitter)
28
31
  │ ├─ Organic Edges (~15% watercolor bleed)
29
32
  │ └─ Recursive Nesting (~15% of large shapes)
30
33
  6. Flow-Line Pass (tapered brush strokes)
34
+ 6b. Symmetry Mirroring (bilateral-x, bilateral-y, or quad)
31
35
  7. Noise Texture Overlay
32
- 8. Organic Connecting Curves
36
+ 8. Vignette (radial edge darkening)
37
+ 9. Organic Connecting Curves
33
38
  ```
34
39
 
35
40
  ## 1. Deterministic RNG
@@ -53,7 +58,19 @@ The `SacredColorScheme` class derives three harmonious palettes from the hash:
53
58
  | Complementary | hue = seed + 180°, contrasting variation | Contrast accents |
54
59
  | Triadic | hue = seed + 120° | Additional variety |
55
60
 
56
- These are merged and deduplicated into a single 6-8 color palette. Background colors are darkened variants (65% and 55% brightness) of the base scheme.
61
+ These are merged and deduplicated into a single 6-8 color palette. Background colors are darkened variants (65% and 55% brightness) of the base scheme, with optional temperature shifting.
62
+
63
+ ### Temperature Contrast
64
+
65
+ The hash deterministically selects a **temperature mode** that creates warm/cool tension across the image:
66
+
67
+ | Mode | Probability | Background | Foreground |
68
+ |------|-------------|------------|------------|
69
+ | `warm-bg` | ~40% | Hues shifted toward orange (30°) | Hues shifted toward blue (210°) |
70
+ | `cool-bg` | ~40% | Hues shifted toward blue (210°) | Hues shifted toward orange (30°) |
71
+ | `neutral` | ~20% | No temperature shift | No temperature shift |
72
+
73
+ The shift amount is subtle (15-25%) and increases on later layers, creating progressive temperature separation between foreground and background elements. This produces the kind of warm/cool interplay seen in classical painting.
57
74
 
58
75
  ### Hash-Driven Color Variation
59
76
 
@@ -65,7 +82,7 @@ The hash deterministically selects both a **scheme type** and a **color variatio
65
82
  | `hard` | High saturation, vivid |
66
83
  | `pastel` | Light, airy |
67
84
  | `light` | Bright, open |
68
- | `dark` | Deep, moody |
85
+ | `pale` | Washed out, ethereal |
69
86
  | `default` | Balanced, neutral |
70
87
 
71
88
  Scheme types also vary: `analogic`, `mono`, `contrast`, `triade`, `tetrade`. The complementary palette uses a contrasting variation (e.g., if base is `soft`, complementary uses `hard`) to create intentional color tension.
@@ -75,6 +92,7 @@ Scheme types also vary: `analogic`, `mono`, `contrast`, `triade`, `tetrade`. The
75
92
  - **`hexWithAlpha(hex, alpha)`** — converts hex to `rgba()` for transparency
76
93
  - **`jitterColor(hex, rng, amount)`** — applies ±amount RGB jitter per channel for organic variation
77
94
  - **`desaturate(hex, amount)`** — blends toward luminance gray for atmospheric depth
95
+ - **`shiftTemperature(hex, target, amount)`** — shifts hue toward warm (orange) or cool (blue)
78
96
  - **Positional blending** — shape fill color is biased by canvas position, creating smooth color flow across the image
79
97
 
80
98
  ## 3. Background
@@ -104,9 +122,31 @@ The hash deterministically selects one of five composition strategies that contr
104
122
 
105
123
  Each mode produces fundamentally different visual character from the same shape set.
106
124
 
125
+ ### Symmetry Modes
126
+
127
+ ~25% of hashes trigger a symmetry mode that mirrors the rendered content:
128
+
129
+ | Mode | Probability | Effect |
130
+ |------|-------------|--------|
131
+ | `bilateral-x` | 10% | Left half mirrored onto right half |
132
+ | `bilateral-y` | 10% | Top half mirrored onto bottom half |
133
+ | `quad` | 5% | Both axes mirrored (4-fold symmetry) |
134
+ | `none` | 75% | No mirroring |
135
+
136
+ Symmetry is applied after shape layers and flow lines but before post-processing (noise, vignette, connecting curves). This means the mirrored content gets the same noise texture and vignette as the original, maintaining visual consistency. Symmetrical generative art is disproportionately appealing to humans due to our innate preference for bilateral symmetry.
137
+
107
138
  ## 5. Focal Points & Negative Space
108
139
 
109
- 1-2 focal points are placed on the canvas (kept away from edges). Every shape position is pulled toward the nearest focal point by a strength factor (30-70%), creating areas of visual density and intentional-looking composition rather than uniform scatter.
140
+ 1-2 focal points are placed on the canvas. **70% of the time**, focal points snap to **rule-of-thirds intersection points** (with slight jitter to avoid a mechanical look), creating compositions that feel intentionally designed. The remaining 30% use free placement within the central 60% of the canvas. Every shape position is pulled toward the nearest focal point by a strength factor (30-70%).
141
+
142
+ ### Hero Shape
143
+
144
+ ~60% of images receive a **hero shape** — a large sacred or complex geometry piece anchored at the primary focal point. The hero shape:
145
+ - Uses sacred/complex shape types (flower of life, fibonacci spiral, merkaba, fractal, etc.)
146
+ - Is sized at 80-130% of the maximum shape size for visual dominance
147
+ - Gets glow effects, gradient fills, and often watercolor rendering
148
+ - Is drawn before the main shape layers so other shapes layer on top of it
149
+ - Creates a clear center of gravity that anchors the entire composition
110
150
 
111
151
  ### Void Zones (Negative Space)
112
152
 
@@ -142,12 +182,14 @@ Instead of always `fill()` + `stroke()`, each shape gets a rendering treatment:
142
182
 
143
183
  | Style | Description | Probability |
144
184
  |-------|-------------|-------------|
145
- | `fill-and-stroke` | Classic solid fill with outline | ~29% (weighted) |
146
- | `fill-only` | Soft shapes with no outline | ~14% |
147
- | `stroke-only` | Wireframe with ghost fill at 30% alpha | ~14% |
148
- | `double-stroke` | Outer stroke at 2× width + inner stroke in fill color | ~14% |
149
- | `dashed` | Dashed outline (5% size dash, 3% gap) | ~14% |
150
- | `watercolor` | 3-4 slightly offset passes at low opacity for bleed effect | ~14% |
185
+ | `fill-and-stroke` | Classic solid fill with outline | ~22% (weighted) |
186
+ | `fill-only` | Soft shapes with no outline | ~11% |
187
+ | `stroke-only` | Wireframe with ghost fill at 30% alpha | ~11% |
188
+ | `double-stroke` | Outer stroke at 2× width + inner stroke in fill color | ~11% |
189
+ | `dashed` | Dashed outline (5% size dash, 3% gap) | ~11% |
190
+ | `watercolor` | 3-4 slightly offset passes at low opacity for bleed effect | ~11% |
191
+ | `hatched` | Cross-hatch texture fill clipped to shape boundary | ~11% |
192
+ | `incomplete` | Only 60-85% of outline drawn via dash patterns | ~11% |
151
193
 
152
194
  70% of shapes in a layer use the layer's dominant style; 30% pick independently. Additionally, ~15% of `fill-and-stroke` shapes are upgraded to `watercolor` for organic edge effects.
153
195
 
@@ -213,6 +255,15 @@ A dedicated noise RNG (seeded separately from the main RNG to avoid affecting sh
213
255
  - Very low opacity (1-4%)
214
256
  - Creates subtle film-grain texture that adds organic depth
215
257
 
258
+ ## 8b. Vignette
259
+
260
+ A radial gradient overlay darkens the edges of the canvas, drawing the viewer's eye toward the center:
261
+
262
+ - Strength varies by hash: 25-45% maximum edge darkening
263
+ - The vignette begins fading at 60% of the canvas radius from center
264
+ - Applied after noise but before connecting curves, so the curves remain visible at edges
265
+ - Creates a natural "spotlight" effect that makes compositions feel more focused and photographic
266
+
216
267
  ## 9. Organic Connecting Curves
217
268
 
218
269
  Quadratic bezier curves connect nearby shapes:
package/CHANGELOG.md CHANGED
@@ -4,9 +4,27 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ #### [0.5.0](https://github.com/gfargo/git-hash-art/compare/0.4.1...0.5.0)
8
+
9
+ - feat: visual improvements — composition, color, and rendering upgrades [`#11`](https://github.com/gfargo/git-hash-art/pull/11)
10
+ - feat: warm/cool temperature contrast in color system [`550bc6a`](https://github.com/gfargo/git-hash-art/commit/550bc6a87929d7ebafa973079845e688a00de929)
11
+ - docs: update ALGORITHM.md with all new visual features [`a05de15`](https://github.com/gfargo/git-hash-art/commit/a05de1586684b91266f5a26ea7279fc601dd2202)
12
+ - feat: hatched and incomplete render styles for shape variety [`4f42f7e`](https://github.com/gfargo/git-hash-art/commit/4f42f7e30bac60239b69468e36ef5481dca5d007)
13
+
14
+ #### [0.4.1](https://github.com/gfargo/git-hash-art/compare/0.4.0...0.4.1)
15
+
16
+ > 19 March 2026
17
+
18
+ - chore: release v0.4.1 [`c453061`](https://github.com/gfargo/git-hash-art/commit/c4530616bc40a540ce4d87a8124a1d2665f77376)
19
+ - update README with new feature details [`1e3fcd7`](https://github.com/gfargo/git-hash-art/commit/1e3fcd771ea602b21c9c90cd3809d0d08ace5d3f)
20
+ - update color variations and complementary logic [`9c8f4ba`](https://github.com/gfargo/git-hash-art/commit/9c8f4ba51e1a5730e4c4ddd59218db23d4719129)
21
+
7
22
  #### [0.4.0](https://github.com/gfargo/git-hash-art/compare/0.3.0...0.4.0)
8
23
 
24
+ > 19 March 2026
25
+
9
26
  - feat: artistic enhancements for richer generative output [`#10`](https://github.com/gfargo/git-hash-art/pull/10)
27
+ - chore: release v0.4.0 [`42f955d`](https://github.com/gfargo/git-hash-art/commit/42f955d314230ebbff4fb0d097847a47d8d1554b)
10
28
 
11
29
  #### [0.3.0](https://github.com/gfargo/git-hash-art/compare/0.2.0...0.3.0)
12
30
 
package/README.md CHANGED
@@ -7,11 +7,18 @@ Works in both Node.js and browser environments.
7
7
  ## Features
8
8
 
9
9
  - Deterministic output — same hash always produces the same image
10
- - Hash-derived harmonious color schemes (analogic, complementary, and triadic palettes)
10
+ - Hash-driven color palettes 6 variation modes × 5 scheme types for dramatic palette diversity
11
+ - Per-layer blend modes — `screen`, `multiply`, `overlay`, `soft-light`, and more for painterly depth
12
+ - 6 shape render styles — fill+stroke, wireframe, dashed, double-stroke, watercolor bleed, fill-only
11
13
  - 20+ shape types across three categories: basic, complex, and sacred geometry
12
14
  - Layered composition with depth — early layers use simple shapes, later layers use intricate ones
15
+ - Atmospheric perspective — later layers desaturate for foreground/background separation
16
+ - Negative space — void zones and density-aware placement create intentional breathing room
13
17
  - Watercolor-style transparency with semi-transparent fills and color blending
18
+ - Organic edges — ~15% of shapes get multi-pass watercolor bleed for hand-drawn quality
14
19
  - Glow effects on sacred geometry shapes for an ethereal quality
20
+ - Layered backgrounds — faint shapes and concentric rings add texture before the main layers
21
+ - Tapered flow lines — brush-stroke curves with width and opacity tapering
15
22
  - Radial gradient fills and organic color jitter for a hand-painted feel
16
23
  - Organic bezier curves connecting nearby shapes
17
24
  - Configurable canvas size, layers, shape sizes, and opacity
package/dist/browser.js CHANGED
@@ -88,7 +88,7 @@ const $b5a262d09b87e373$var$COLOR_VARIATIONS = [
88
88
  "hard",
89
89
  "pastel",
90
90
  "light",
91
- "dark",
91
+ "pale",
92
92
  "default"
93
93
  ];
94
94
  /**
@@ -109,6 +109,27 @@ const $b5a262d09b87e373$var$COLOR_VARIATIONS = [
109
109
  function $b5a262d09b87e373$var$pickSchemeType(seed) {
110
110
  return $b5a262d09b87e373$var$SCHEME_TYPES[Math.abs(seed >> 4) % $b5a262d09b87e373$var$SCHEME_TYPES.length];
111
111
  }
112
+ function $b5a262d09b87e373$var$classifyHue(hue) {
113
+ if (hue >= 0 && hue <= 60 || hue >= 300) return "warm";
114
+ if (hue >= 150 && hue <= 270) return "cool";
115
+ return "neutral";
116
+ }
117
+ /**
118
+ * Shift a hue toward a target temperature zone.
119
+ * Returns a new hue biased warm or cool.
120
+ */ function $b5a262d09b87e373$var$shiftHueToward(hue, target, amount) {
121
+ if (target === "warm") {
122
+ // Pull toward 30 (orange) — the warmest point
123
+ const warmTarget = 30;
124
+ const diff = (warmTarget - hue + 540) % 360 - 180;
125
+ return (hue + diff * amount + 360) % 360;
126
+ } else {
127
+ // Pull toward 210 (blue) — the coolest point
128
+ const coolTarget = 210;
129
+ const diff = (coolTarget - hue + 540) % 360 - 180;
130
+ return (hue + diff * amount + 360) % 360;
131
+ }
132
+ }
112
133
  class $b5a262d09b87e373$export$ab958c550f521376 {
113
134
  constructor(gitHash){
114
135
  this.seed = (0, $616009579e3d72c5$export$39a95c82b20fdf81)(gitHash);
@@ -116,6 +137,9 @@ class $b5a262d09b87e373$export$ab958c550f521376 {
116
137
  // Hash-driven variation and scheme type for palette diversity
117
138
  this.variation = $b5a262d09b87e373$var$pickVariation(this.seed);
118
139
  this.schemeType = $b5a262d09b87e373$var$pickSchemeType(this.seed);
140
+ // ~40% warm-bg, ~40% cool-bg, ~20% neutral (no temperature bias)
141
+ const tempRoll = this.rng();
142
+ this.temperatureMode = tempRoll < 0.4 ? "warm-bg" : tempRoll < 0.8 ? "cool-bg" : "neutral";
119
143
  this.baseScheme = this.generateBaseScheme();
120
144
  this.complementaryScheme = this.generateComplementaryScheme();
121
145
  this.triadicScheme = this.generateTriadicScheme();
@@ -127,7 +151,7 @@ class $b5a262d09b87e373$export$ab958c550f521376 {
127
151
  generateComplementaryScheme() {
128
152
  const complementaryHue = (this.seed + 180) % 360;
129
153
  // Complementary uses a contrasting variation for tension
130
- const compVariation = this.variation === "soft" ? "hard" : this.variation === "dark" ? "light" : this.variation;
154
+ const compVariation = this.variation === "soft" ? "hard" : this.variation === "pale" ? "light" : this.variation;
131
155
  const scheme = new (0, $4wRzV$colorscheme)();
132
156
  return scheme.from_hue(complementaryHue).scheme("mono").variation(compVariation).colors().map((hex)=>`#${hex}`);
133
157
  }
@@ -153,14 +177,34 @@ class $b5a262d09b87e373$export$ab958c550f521376 {
153
177
  }
154
178
  /**
155
179
  * Returns two background colors derived from the hash — darker variants
156
- * of the base scheme for gradient backgrounds.
180
+ * of the base scheme, temperature-shifted for warm/cool contrast.
157
181
  */ getBackgroundColors() {
182
+ let bg0 = this.baseScheme[0];
183
+ let bg1 = this.baseScheme[1];
184
+ if (this.temperatureMode !== "neutral") {
185
+ const bgTemp = this.temperatureMode === "warm-bg" ? "warm" : "cool";
186
+ bg0 = this.shiftColorTemperature(bg0, bgTemp, 0.3);
187
+ bg1 = this.shiftColorTemperature(bg1, bgTemp, 0.25);
188
+ }
158
189
  return [
159
- this.darken(this.baseScheme[0], 0.65),
160
- this.darken(this.baseScheme[1], 0.55)
190
+ this.darken(bg0, 0.65),
191
+ this.darken(bg1, 0.55)
161
192
  ];
162
193
  }
163
194
  /**
195
+ * Returns the temperature mode so the renderer can apply
196
+ * contrasting temperature to foreground elements.
197
+ */ getTemperatureMode() {
198
+ return this.temperatureMode;
199
+ }
200
+ /**
201
+ * Shift a hex color's hue toward warm or cool.
202
+ */ shiftColorTemperature(hex, target, amount) {
203
+ const [h, s, l] = $b5a262d09b87e373$var$hexToHsl(hex);
204
+ const shifted = $b5a262d09b87e373$var$shiftHueToward(h, target, amount);
205
+ return $b5a262d09b87e373$var$hslToHex(shifted, s, l);
206
+ }
207
+ /**
164
208
  * Simple hex color darkening by a factor (0 = black, 1 = unchanged).
165
209
  */ darken(hex, factor) {
166
210
  const c = hex.replace("#", "");
@@ -183,6 +227,55 @@ class $b5a262d09b87e373$export$ab958c550f521376 {
183
227
  const clamp = (v)=>Math.max(0, Math.min(255, Math.round(v)));
184
228
  return `#${clamp(r).toString(16).padStart(2, "0")}${clamp(g).toString(16).padStart(2, "0")}${clamp(b).toString(16).padStart(2, "0")}`;
185
229
  }
230
+ /** Convert hex to HSL [h 0-360, s 0-1, l 0-1]. */ function $b5a262d09b87e373$var$hexToHsl(hex) {
231
+ const [r, g, b] = $b5a262d09b87e373$var$hexToRgb(hex).map((v)=>v / 255);
232
+ const max = Math.max(r, g, b);
233
+ const min = Math.min(r, g, b);
234
+ const l = (max + min) / 2;
235
+ if (max === min) return [
236
+ 0,
237
+ 0,
238
+ l
239
+ ];
240
+ const d = max - min;
241
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
242
+ let h = 0;
243
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
244
+ else if (max === g) h = ((b - r) / d + 2) / 6;
245
+ else h = ((r - g) / d + 4) / 6;
246
+ return [
247
+ h * 360,
248
+ s,
249
+ l
250
+ ];
251
+ }
252
+ /** Convert HSL [h 0-360, s 0-1, l 0-1] back to hex. */ function $b5a262d09b87e373$var$hslToHex(h, s, l) {
253
+ h = (h % 360 + 360) % 360;
254
+ const c = (1 - Math.abs(2 * l - 1)) * s;
255
+ const x = c * (1 - Math.abs(h / 60 % 2 - 1));
256
+ const m = l - c / 2;
257
+ let r = 0, g = 0, b = 0;
258
+ if (h < 60) {
259
+ r = c;
260
+ g = x;
261
+ } else if (h < 120) {
262
+ r = x;
263
+ g = c;
264
+ } else if (h < 180) {
265
+ g = c;
266
+ b = x;
267
+ } else if (h < 240) {
268
+ g = x;
269
+ b = c;
270
+ } else if (h < 300) {
271
+ r = x;
272
+ b = c;
273
+ } else {
274
+ r = c;
275
+ b = x;
276
+ }
277
+ return $b5a262d09b87e373$var$rgbToHex((r + m) * 255, (g + m) * 255, (b + m) * 255);
278
+ }
186
279
  function $b5a262d09b87e373$export$f2121afcad3d553f(hex, alpha) {
187
280
  const [r, g, b] = $b5a262d09b87e373$var$hexToRgb(hex);
188
281
  return `rgba(${r},${g},${b},${alpha.toFixed(3)})`;
@@ -198,6 +291,10 @@ function $b5a262d09b87e373$export$fb75607d98509d9(hex, amount) {
198
291
  const mix = (c)=>c + (gray - c) * amount;
199
292
  return $b5a262d09b87e373$var$rgbToHex(mix(r), mix(g), mix(b));
200
293
  }
294
+ function $b5a262d09b87e373$export$51ea55f869b7e0d3(hex, target, amount) {
295
+ const [h, s, l] = $b5a262d09b87e373$var$hexToHsl(hex);
296
+ return $b5a262d09b87e373$var$hslToHex($b5a262d09b87e373$var$shiftHueToward(h, target, amount), s, l);
297
+ }
201
298
 
202
299
 
203
300
 
@@ -1006,7 +1103,9 @@ const $e0f99502ff383dd8$var$RENDER_STYLES = [
1006
1103
  "stroke-only",
1007
1104
  "double-stroke",
1008
1105
  "dashed",
1009
- "watercolor"
1106
+ "watercolor",
1107
+ "hatched",
1108
+ "incomplete"
1010
1109
  ];
1011
1110
  function $e0f99502ff383dd8$export$9fd4e64b2acd410e(rng) {
1012
1111
  return $e0f99502ff383dd8$var$RENDER_STYLES[Math.floor(rng() * $e0f99502ff383dd8$var$RENDER_STYLES.length)];
@@ -1083,6 +1182,76 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
1083
1182
  ctx.globalAlpha /= 0.4;
1084
1183
  break;
1085
1184
  }
1185
+ case "hatched":
1186
+ {
1187
+ // Fill normally at reduced opacity, then overlay cross-hatch lines
1188
+ const savedAlphaH = ctx.globalAlpha;
1189
+ ctx.globalAlpha = savedAlphaH * 0.3;
1190
+ ctx.fill();
1191
+ ctx.globalAlpha = savedAlphaH;
1192
+ // Clip to shape, then draw hatch lines
1193
+ ctx.save();
1194
+ ctx.clip();
1195
+ const hatchSpacing = Math.max(3, size * 0.06);
1196
+ const hatchAngle = rng ? rng() * Math.PI : Math.PI / 4;
1197
+ ctx.lineWidth = Math.max(0.5, strokeWidth * 0.4);
1198
+ ctx.globalAlpha = savedAlphaH * 0.6;
1199
+ // Draw parallel lines across the bounding box
1200
+ const extent = size * 0.8;
1201
+ const cos = Math.cos(hatchAngle);
1202
+ const sin = Math.sin(hatchAngle);
1203
+ for(let d = -extent; d <= extent; d += hatchSpacing){
1204
+ ctx.beginPath();
1205
+ ctx.moveTo(d * cos - extent * sin, d * sin + extent * cos);
1206
+ ctx.lineTo(d * cos + extent * sin, d * sin - extent * cos);
1207
+ ctx.stroke();
1208
+ }
1209
+ // Second pass at perpendicular angle for cross-hatch (~50% chance)
1210
+ if (!rng || rng() < 0.5) {
1211
+ const crossAngle = hatchAngle + Math.PI / 2;
1212
+ const cos2 = Math.cos(crossAngle);
1213
+ const sin2 = Math.sin(crossAngle);
1214
+ ctx.globalAlpha = savedAlphaH * 0.35;
1215
+ for(let d = -extent; d <= extent; d += hatchSpacing * 1.4){
1216
+ ctx.beginPath();
1217
+ ctx.moveTo(d * cos2 - extent * sin2, d * sin2 + extent * cos2);
1218
+ ctx.lineTo(d * cos2 + extent * sin2, d * sin2 - extent * cos2);
1219
+ ctx.stroke();
1220
+ }
1221
+ }
1222
+ ctx.restore();
1223
+ ctx.globalAlpha = savedAlphaH;
1224
+ // Outline stroke
1225
+ ctx.globalAlpha *= 0.5;
1226
+ ctx.stroke();
1227
+ ctx.globalAlpha /= 0.5;
1228
+ break;
1229
+ }
1230
+ case "incomplete":
1231
+ {
1232
+ // Draw the fill at low opacity, then a dashed stroke that
1233
+ // simulates drawing only part of the outline
1234
+ const savedAlphaI = ctx.globalAlpha;
1235
+ ctx.globalAlpha = savedAlphaI * 0.25;
1236
+ ctx.fill();
1237
+ ctx.globalAlpha = savedAlphaI;
1238
+ // Use a long dash pattern where gaps create the "incomplete" look
1239
+ const completeness = rng ? 0.6 + rng() * 0.25 : 0.7; // 60-85%
1240
+ const segLen = size * 0.12;
1241
+ const gapLen = segLen * ((1 - completeness) / completeness);
1242
+ ctx.setLineDash([
1243
+ segLen,
1244
+ gapLen
1245
+ ]);
1246
+ // Offset the dash so each shape starts at a different point
1247
+ ctx.lineDashOffset = rng ? rng() * segLen * 4 : 0;
1248
+ // Slightly thicker stroke for hand-drawn feel
1249
+ ctx.lineWidth = strokeWidth * 1.3;
1250
+ ctx.stroke();
1251
+ ctx.setLineDash([]);
1252
+ ctx.lineDashOffset = 0;
1253
+ break;
1254
+ }
1086
1255
  case "fill-and-stroke":
1087
1256
  default:
1088
1257
  ctx.fill();
@@ -1288,6 +1457,9 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
1288
1457
  const colorScheme = new (0, $b5a262d09b87e373$export$ab958c550f521376)(gitHash);
1289
1458
  const colors = colorScheme.getColors();
1290
1459
  const [bgStart, bgEnd] = colorScheme.getBackgroundColors();
1460
+ const tempMode = colorScheme.getTemperatureMode();
1461
+ // Foreground shapes get the opposite temperature for contrast
1462
+ const fgTempTarget = tempMode === "warm-bg" ? "cool" : tempMode === "cool-bg" ? "warm" : null;
1291
1463
  const shapeNames = Object.keys((0, $e41b41d8dcf837ad$export$4ff7fc6f1af248b5));
1292
1464
  const scaleFactor = Math.min(width, height) / 1024;
1293
1465
  const adjustedMinSize = minShapeSize * scaleFactor;
@@ -1331,10 +1503,40 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
1331
1503
  ctx.globalCompositeOperation = "source-over";
1332
1504
  // ── 2. Composition mode ────────────────────────────────────────
1333
1505
  const compositionMode = $1f63dc64b5593c73$var$COMPOSITION_MODES[Math.floor(rng() * $1f63dc64b5593c73$var$COMPOSITION_MODES.length)];
1506
+ const symRoll = rng();
1507
+ const symmetryMode = symRoll < 0.10 ? "bilateral-x" : symRoll < 0.20 ? "bilateral-y" : symRoll < 0.25 ? "quad" : "none";
1334
1508
  // ── 3. Focal points + void zones ───────────────────────────────
1509
+ // Rule-of-thirds intersection points for intentional composition
1510
+ const THIRDS_POINTS = [
1511
+ {
1512
+ x: 1 / 3,
1513
+ y: 1 / 3
1514
+ },
1515
+ {
1516
+ x: 2 / 3,
1517
+ y: 1 / 3
1518
+ },
1519
+ {
1520
+ x: 1 / 3,
1521
+ y: 2 / 3
1522
+ },
1523
+ {
1524
+ x: 2 / 3,
1525
+ y: 2 / 3
1526
+ }
1527
+ ];
1335
1528
  const numFocal = 1 + Math.floor(rng() * 2);
1336
1529
  const focalPoints = [];
1337
- for(let f = 0; f < numFocal; f++)focalPoints.push({
1530
+ for(let f = 0; f < numFocal; f++)// 70% chance to snap to a rule-of-thirds point, 30% free placement
1531
+ if (rng() < 0.7) {
1532
+ const tp = THIRDS_POINTS[Math.floor(rng() * THIRDS_POINTS.length)];
1533
+ // Small jitter around the thirds point so it's not robotic
1534
+ focalPoints.push({
1535
+ x: width * (tp.x + (rng() - 0.5) * 0.08),
1536
+ y: height * (tp.y + (rng() - 0.5) * 0.08),
1537
+ strength: 0.3 + rng() * 0.4
1538
+ });
1539
+ } else focalPoints.push({
1338
1540
  x: width * (0.2 + rng() * 0.6),
1339
1541
  y: height * (0.2 + rng() * 0.6),
1340
1542
  strength: 0.3 + rng() * 0.4
@@ -1369,8 +1571,45 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
1369
1571
  function flowAngle(x, y) {
1370
1572
  return fieldAngleBase + Math.sin(x / width * fieldFreq * Math.PI * 2) * Math.PI * 0.5 + Math.cos(y / height * fieldFreq * Math.PI * 2) * Math.PI * 0.5;
1371
1573
  }
1372
- // ── 5. Shape layers ────────────────────────────────────────────
1574
+ // Track all placed shapes for density checks and connecting curves
1373
1575
  const shapePositions = [];
1576
+ // ── 4b. Hero shape — a dominant focal element ───────────────────
1577
+ // ~60% of images get a hero shape anchored at the primary focal point.
1578
+ // It's a large sacred/complex shape that gives the composition a center of gravity.
1579
+ if (rng() < 0.6) {
1580
+ const heroFocal = focalPoints[0];
1581
+ const heroPool = [
1582
+ ...$1f63dc64b5593c73$var$SACRED_SHAPES,
1583
+ "fibonacciSpiral",
1584
+ "merkaba",
1585
+ "fractal"
1586
+ ];
1587
+ const heroShape = heroPool.filter((s)=>shapeNames.includes(s))[Math.floor(rng() * heroPool.filter((s)=>shapeNames.includes(s)).length)] || shapeNames[Math.floor(rng() * shapeNames.length)];
1588
+ const heroSize = adjustedMaxSize * (0.8 + rng() * 0.5);
1589
+ const heroRotation = rng() * 360;
1590
+ const heroFill = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$59539d800dbe6858)(colors[Math.floor(rng() * colors.length)], rng, 0.05), 0.15 + rng() * 0.2);
1591
+ const heroStroke = (0, $b5a262d09b87e373$export$59539d800dbe6858)(colors[Math.floor(rng() * colors.length)], rng, 0.05);
1592
+ ctx.globalAlpha = 0.5 + rng() * 0.2;
1593
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, heroShape, heroFocal.x, heroFocal.y, {
1594
+ fillColor: heroFill,
1595
+ strokeColor: heroStroke,
1596
+ strokeWidth: (1.5 + rng() * 2) * scaleFactor,
1597
+ size: heroSize,
1598
+ rotation: heroRotation,
1599
+ proportionType: "GOLDEN_RATIO",
1600
+ glowRadius: (12 + rng() * 20) * scaleFactor,
1601
+ glowColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(heroStroke, 0.4),
1602
+ gradientFillEnd: (0, $b5a262d09b87e373$export$59539d800dbe6858)(colors[Math.floor(rng() * colors.length)], rng, 0.1),
1603
+ renderStyle: rng() < 0.4 ? "watercolor" : "fill-and-stroke",
1604
+ rng: rng
1605
+ });
1606
+ shapePositions.push({
1607
+ x: heroFocal.x,
1608
+ y: heroFocal.y,
1609
+ size: heroSize
1610
+ });
1611
+ }
1612
+ // ── 5. Shape layers ────────────────────────────────────────────
1374
1613
  const densityCheckRadius = Math.min(width, height) * 0.08;
1375
1614
  const maxLocalDensity = Math.ceil(finalConfig.shapesPerLayer * 0.15);
1376
1615
  for(let layer = 0; layer < layers; layer++){
@@ -1409,6 +1648,8 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
1409
1648
  const strokeBase = colors[Math.floor(rng() * colors.length)];
1410
1649
  // Feature D: desaturate colors on later layers for depth
1411
1650
  if (atmosphericDesat > 0) fillBase = (0, $b5a262d09b87e373$export$fb75607d98509d9)(fillBase, atmosphericDesat);
1651
+ // Temperature contrast: shift foreground shapes opposite to background
1652
+ if (fgTempTarget) fillBase = (0, $b5a262d09b87e373$export$51ea55f869b7e0d3)(fillBase, fgTempTarget, 0.15 + layerRatio * 0.1);
1412
1653
  const fillColor = (0, $b5a262d09b87e373$export$59539d800dbe6858)(fillBase, rng, 0.06);
1413
1654
  const strokeColor = (0, $b5a262d09b87e373$export$59539d800dbe6858)(strokeBase, rng, 0.05);
1414
1655
  // Semi-transparent fill
@@ -1506,6 +1747,31 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
1506
1747
  prevY = fy;
1507
1748
  }
1508
1749
  }
1750
+ // ── 6b. Apply symmetry mirroring ─────────────────────────────────
1751
+ // Mirror the rendered content (shapes + flow lines) before post-processing.
1752
+ // Uses ctx.canvas which is available in both Node (@napi-rs/canvas) and browsers.
1753
+ if (symmetryMode !== "none") {
1754
+ const canvas = ctx.canvas;
1755
+ ctx.save();
1756
+ if (symmetryMode === "bilateral-x" || symmetryMode === "quad") {
1757
+ // Mirror left half onto right half
1758
+ ctx.save();
1759
+ ctx.translate(width, 0);
1760
+ ctx.scale(-1, 1);
1761
+ // Draw the left half (0 to cx) onto the mirrored right side
1762
+ ctx.drawImage(canvas, 0, 0, Math.ceil(cx), height, 0, 0, Math.ceil(cx), height);
1763
+ ctx.restore();
1764
+ }
1765
+ if (symmetryMode === "bilateral-y" || symmetryMode === "quad") {
1766
+ // Mirror top half onto bottom half
1767
+ ctx.save();
1768
+ ctx.translate(0, height);
1769
+ ctx.scale(1, -1);
1770
+ ctx.drawImage(canvas, 0, 0, width, Math.ceil(cy), 0, 0, width, Math.ceil(cy));
1771
+ ctx.restore();
1772
+ }
1773
+ ctx.restore();
1774
+ }
1509
1775
  // ── 7. Noise texture overlay ───────────────────────────────────
1510
1776
  const noiseRng = (0, $616009579e3d72c5$export$eaf9227667332084)((0, $616009579e3d72c5$export$e9cc707de01b7042)(gitHash, 777));
1511
1777
  const noiseDensity = Math.floor(width * height / 800);
@@ -1518,7 +1784,16 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
1518
1784
  ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
1519
1785
  ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
1520
1786
  }
1521
- // ── 8. Organic connecting curves ───────────────────────────────
1787
+ // ── 8. Vignette darken edges to draw the eye inward ───────────
1788
+ ctx.globalAlpha = 1;
1789
+ const vignetteStrength = 0.25 + rng() * 0.2; // 25-45% edge darkening
1790
+ const vigGrad = ctx.createRadialGradient(cx, cy, Math.min(width, height) * 0.3, cx, cy, bgRadius);
1791
+ vigGrad.addColorStop(0, "rgba(0,0,0,0)");
1792
+ vigGrad.addColorStop(0.6, "rgba(0,0,0,0)");
1793
+ vigGrad.addColorStop(1, `rgba(0,0,0,${vignetteStrength.toFixed(3)})`);
1794
+ ctx.fillStyle = vigGrad;
1795
+ ctx.fillRect(0, 0, width, height);
1796
+ // ── 9. Organic connecting curves ───────────────────────────────
1522
1797
  if (shapePositions.length > 1) {
1523
1798
  const numCurves = Math.floor(8 * (width * height) / 1048576);
1524
1799
  ctx.lineWidth = 0.8 * scaleFactor;