badgecraft 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +406 -0
  4. package/cli/index.mjs +143 -0
  5. package/cli/render.d.ts +20 -0
  6. package/cli/render.mjs +87 -0
  7. package/cli/studio.mjs +98 -0
  8. package/dist/badge-B-g_9WFj.d.ts +79 -0
  9. package/dist/badge-CCjUxNIG.d.cts +79 -0
  10. package/dist/chunk-3OMN635U.js +44 -0
  11. package/dist/chunk-3OMN635U.js.map +1 -0
  12. package/dist/chunk-BRT5S5PF.cjs +3418 -0
  13. package/dist/chunk-BRT5S5PF.cjs.map +1 -0
  14. package/dist/chunk-EBDSIGZR.cjs +48 -0
  15. package/dist/chunk-EBDSIGZR.cjs.map +1 -0
  16. package/dist/chunk-RRYBMB25.js +3410 -0
  17. package/dist/chunk-RRYBMB25.js.map +1 -0
  18. package/dist/cli-browser.js +526 -0
  19. package/dist/element.cjs +176 -0
  20. package/dist/element.cjs.map +1 -0
  21. package/dist/element.d.cts +31 -0
  22. package/dist/element.d.ts +31 -0
  23. package/dist/element.js +174 -0
  24. package/dist/element.js.map +1 -0
  25. package/dist/index.cjs +47 -0
  26. package/dist/index.cjs.map +1 -0
  27. package/dist/index.d.cts +58 -0
  28. package/dist/index.d.ts +58 -0
  29. package/dist/index.js +17 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/react.cjs +60 -0
  32. package/dist/react.cjs.map +1 -0
  33. package/dist/react.d.cts +29 -0
  34. package/dist/react.d.ts +29 -0
  35. package/dist/react.js +55 -0
  36. package/dist/react.js.map +1 -0
  37. package/dist/studio/assets/badge-9Zq6e9Ht.js +524 -0
  38. package/dist/studio/assets/base-ItCYnbux.css +1 -0
  39. package/dist/studio/assets/base-LFa9cTPp.js +1 -0
  40. package/dist/studio/assets/element-CCTxyf61.js +1 -0
  41. package/dist/studio/assets/index-BVuSTaz-.css +1 -0
  42. package/dist/studio/assets/index-CTTcx2pV.js +28 -0
  43. package/dist/studio/assets/option-keys-CKi71y0n.js +1 -0
  44. package/dist/studio/assets/react-Cq5Erey2.js +9 -0
  45. package/dist/studio/assets/react-DyJFYpWJ.js +1 -0
  46. package/dist/studio/assets/studio-C6plMNOe.css +1 -0
  47. package/dist/studio/assets/studio-DBcSY6tV.js +1 -0
  48. package/dist/studio/assets/style-C2sJ2Xy3.css +1 -0
  49. package/dist/studio/assets/vue-BpGwr3iF.js +1 -0
  50. package/dist/studio/element.html +31 -0
  51. package/dist/studio/env/studio.hdr +5 -0
  52. package/dist/studio/index.html +24 -0
  53. package/dist/studio/logos/apple.svg +1 -0
  54. package/dist/studio/logos/atlassian.svg +1 -0
  55. package/dist/studio/logos/google.svg +1 -0
  56. package/dist/studio/logos/meta.svg +1 -0
  57. package/dist/studio/react.html +16 -0
  58. package/dist/studio/studio.html +27 -0
  59. package/dist/studio/vue.html +15 -0
  60. package/dist/types-CHM3-ZZV.d.cts +204 -0
  61. package/dist/types-CHM3-ZZV.d.ts +204 -0
  62. package/dist/vue.cjs +93 -0
  63. package/dist/vue.cjs.map +1 -0
  64. package/dist/vue.d.cts +115 -0
  65. package/dist/vue.d.ts +115 -0
  66. package/dist/vue.js +87 -0
  67. package/dist/vue.js.map +1 -0
  68. package/docs/cli.md +146 -0
  69. package/docs/gallery.jpg +0 -0
  70. package/docs/performance.md +151 -0
  71. package/docs/render-quality.md +220 -0
  72. package/docs/studio.jpg +0 -0
  73. package/package.json +163 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ - Rename the package from `metallic-badge` to `badgecraft`, since finishes now go well beyond metal. Install `badgecraft`, import `Badgecraft` (formerly `MetallicBadge`) from `badgecraft`, `badgecraft/react`, or `badgecraft/vue`, and use `<badge-craft>` in HTML (`defineBadgecraft` registers it under another tag). The CLI is `npx badgecraft`, its default output is `<input>.badge.png`, and it reads `BADGECRAFT_CHROME_PATH` (`METALLIC_BADGE_CHROME_PATH` still works). Saved Studio drafts carry over.
6
+ - Redesign the website. The landing page leads with a live badge you can drag and restyle, explains the three-step flow, and mounts gallery badges only as they scroll into view.
7
+ - Rebuild the Studio in React around three steps: artwork, finish, fine-tune. Samples are visual tiles, materials and frames are swatches, and advanced controls use plain-language labels in collapsed groups.
8
+ - Add undo and redo (including for Reset), drop-anywhere SVG import, a code dialog with per-framework install steps, and a split PNG export button.
9
+ - Keep the preview responsive by debouncing only the settings that trigger a bake; material and lighting changes apply instantly.
10
+ - Add Studio controls for artwork inset and inner ring (under the frame picker), wire width, bevel, edge rounding, enamel fill and dome spread, smoothing, hole filling, tilt range, roll, icon colour, and locked colour. Export PNGs at 128 and 256 px too.
11
+ - Preview relief changes live while dragging, with a fast low-resolution bake that sharpens when the slider rests.
12
+ - Use Apple, Atlassian, Google, and Meta logo marks as the site's examples, and show framework icons on code tabs. The previous sample artwork now lives in development-only fixtures and is no longer published.
13
+ - Migrate existing Studio drafts automatically.
14
+
15
+ ## 0.2.0
16
+
17
+ - Launch the bundled web Studio with `npx metallic-badge studio`, with no checkout or build tools.
18
+ - Upload SVG artwork, adjust materials, lighting, relief, frames, and pose, then export PNGs or copy integration code.
19
+ - Add `--port` and optional `--open` in Google Chrome; the server binds only to this computer.
20
+ - Offer a plain HTML/CDN embed alongside React, Vue, Web Component, and vanilla JavaScript snippets.
21
+ - Publish the web Studio to GitHub Pages while keeping the source repository private.
22
+ - Test the built editor, local server, and framework-free browser modules from the installed npm tarball.
23
+
24
+ ## 0.1.0
25
+
26
+ - Render SVG artwork as physically based metallic and enamel badges with 23 material presets.
27
+ - Use React, Vue, Web Components, or vanilla JavaScript with ESM, CommonJS, and TypeScript declarations.
28
+ - Convert local or piped SVG files to PNG, WebP, or JPEG with the `metallic-badge` CLI.
29
+ - Render images programmatically with `renderBadge` from `metallic-badge/node`.
30
+ - Customize materials, frames, environments, pose, relief quality, and export background.
31
+ - Try the local Studio for importing SVG, editing materials, copying integration snippets, and exporting images.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 badgecraft contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,406 @@
1
+ # badgecraft
2
+
3
+ Turn **any SVG** into a physically based, interactive **3D badge** in metal, enamel, candy paint, glitter, pearl or holographic finishes, like the Apple Fitness award medals. Strokes become polished metal wires, fills become glossy enamel, and the whole thing is lit by a real PBR material system: metalness, roughness, clear-coat, image-based reflections, iridescence, metallic flakes and brushed metal.
4
+
5
+ Works with **React**, **Vue**, as a **Web Component** (Svelte, Angular, Solid, Astro, plain HTML), or from vanilla JS.
6
+
7
+ **[Open the web Studio](https://parthjadhav.github.io/badgecraft/studio.html)** · Upload an SVG, tweak its materials and lighting, then download a PNG or copy code for your site.
8
+
9
+ ![Gallery](https://cdn.jsdelivr.net/npm/badgecraft@0.3.0/docs/gallery.jpg)
10
+
11
+ ```tsx
12
+ import { Badgecraft } from 'badgecraft/react'
13
+
14
+ <Badgecraft svg="/awards/new-year.svg" metal="gold" size={200} />
15
+ ```
16
+
17
+ - **Any SVG in:** paths, text, gradients, `<use>`/`<symbol>`, `<style>` classes, clip paths, masks, `currentColor`, embedded images. The browser's own SVG renderer is used, so what you see in the SVG is what gets sculpted.
18
+ - **Real materials:** GGX microfacet specular with multi-scattering, split-sum image-based lighting, clear-coat, thin-film iridescence, sparkle flakes and brushed metal. 23 presets (gold, rose-gold, silver, chrome, copper, candy, glitter, pearl, holographic…) or fully custom.
19
+ - **Real 3D:** the relief is displaced geometry with a rounded edge and a back plate. Tilt it on hover, drag-spin it with inertia, or call `spin()`.
20
+ - **Fast:** about 27 KB gzipped with no browser runtime dependencies. The Node CLI uses `playwright-core`. One shared WebGL2 context for any number of badges, and baking runs in a Web Worker. 57 badges render in about 0.3 s on an Apple M-series laptop.
21
+ - **Safe everywhere:** SSR-safe imports, a sized placeholder on the server, a flat SVG fallback without WebGL2, `prefers-reduced-motion` respected, and badges exposed as `role="img"`.
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ npm install badgecraft
27
+ # or: pnpm add badgecraft / yarn add badgecraft / bun add badgecraft
28
+ ```
29
+
30
+ Node.js 22+ is required for installation and the CLI. React (≥17) and Vue (≥3.3) are optional peer dependencies. Only install the one you use. Google Chrome is needed only for CLI/Node image exports.
31
+
32
+ ## Web Studio
33
+
34
+ Use the [hosted Studio](https://parthjadhav.github.io/badgecraft/studio.html), or start the same app locally:
35
+
36
+ ```sh
37
+ npx badgecraft studio
38
+ # Optional: open Google Chrome automatically
39
+ npx badgecraft studio --open
40
+ # Choose a port (0 picks an available one)
41
+ npx badgecraft studio --port 8080
42
+ ```
43
+
44
+ Open the printed URL. Upload, drop, or paste an SVG; adjust metal, enamel, relief,
45
+ lighting, frame, and pose; export a transparent PNG at 512, 1024, or 2048 pixels.
46
+ Copy the finished recipe as React, Vue, Web Component, vanilla JS, or plain HTML
47
+ with a CDN import. Artwork and saved drafts stay in your browser.
48
+
49
+ The local app is included in the npm package: no checkout, build tools, or extra
50
+ dependencies to install. It serves only on `127.0.0.1`; press Ctrl+C to stop it.
51
+ After installation it can run offline. Browser rendering requires WebGL2.
52
+
53
+ ## CLI: SVG to badge image
54
+
55
+ ```sh
56
+ npx badgecraft icon.svg --output badge.png --metal gold
57
+ npx badgecraft icon.svg --output silver.webp --metal silver --frame circle
58
+ ```
59
+
60
+ The CLI uses installed **Google Chrome** to render the same badge effect as
61
+ the library. Export transparent PNG/WebP or JPEG, up to 2048 × 2048, with material,
62
+ lighting, frame, background, and pose controls. It works with local files or piped
63
+ SVG markup, without uploading artwork. Use `--help` for all flags.
64
+
65
+ For scripted exports, import `renderBadge` from `badgecraft/node`.
66
+ See the [CLI and Node API guide](https://unpkg.com/badgecraft@0.3.0/docs/cli.md) for setup, examples, and limitations.
67
+
68
+ ## Usage
69
+
70
+ ### Plain HTML — no build tools
71
+
72
+ Paste this into an HTML page served by your site. Replace `src` with your SVG URL;
73
+ the custom element handles rendering and interaction.
74
+
75
+ ```html
76
+ <script type="module" src="https://cdn.jsdelivr.net/npm/badgecraft@0.3.0/dist/element.js"></script>
77
+ <badge-craft
78
+ src="/awards/star.svg"
79
+ metal="gold"
80
+ alt="Star award"
81
+ style="width:240px;height:240px"
82
+ ></badge-craft>
83
+ ```
84
+
85
+ For a self-contained snippet with your SVG and custom settings, choose **HTML · CDN**
86
+ in the Studio's code panel. CDN imports need internet access; bundled npm imports
87
+ work with your site's own assets.
88
+
89
+ ### React
90
+
91
+ ```tsx
92
+ import { useRef } from 'react'
93
+ import { Badgecraft, type BadgecraftRef } from 'badgecraft/react'
94
+
95
+ export function Award() {
96
+ const badge = useRef<BadgecraftRef>(null)
97
+ return (
98
+ <>
99
+ <Badgecraft
100
+ ref={badge}
101
+ svg="/awards/lightning.svg"
102
+ metal="gold"
103
+ base="onyx"
104
+ interaction="drag"
105
+ size={240}
106
+ alt="Lightning award"
107
+ onReady={() => console.log('ready')}
108
+ />
109
+ <button onClick={() => badge.current?.spin()}>Spin</button>
110
+ </>
111
+ )
112
+ }
113
+ ```
114
+
115
+ Props are the [options](#options) plus `size` (number = px, or any CSS length), `alt`, `className`, `style` and any `div` attribute. Changing props is cheap: only what changed is recomputed.
116
+
117
+ ### Vue 3
118
+
119
+ ```vue
120
+ <script setup lang="ts">
121
+ import { ref } from 'vue'
122
+ import { Badgecraft } from 'badgecraft/vue'
123
+ const badge = ref()
124
+ </script>
125
+
126
+ <template>
127
+ <Badgecraft ref="badge" svg="/awards/parks.svg" metal="silver" :size="240" idle="float" @ready="..." />
128
+ <button @click="badge.spin()">Spin</button>
129
+ </template>
130
+ ```
131
+
132
+ To register it globally, use `app.use(BadgecraftPlugin)`.
133
+
134
+ ### Web Component (any framework or plain HTML)
135
+
136
+ ```html
137
+ <script type="module">
138
+ import 'badgecraft/element'
139
+ </script>
140
+
141
+ <badge-craft src="/awards/new-year.svg" metal="gold" style="width: 200px; height: 200px"></badge-craft>
142
+
143
+ <!-- inline artwork, JSON for object options -->
144
+ <badge-craft frame="hexagon" metal='{"preset":"gold","roughness":0.15}' relief='{"depth":0.06}'>
145
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 18 12 4l8 14z" /></svg>
146
+ </badge-craft>
147
+ ```
148
+
149
+ Attributes are kebab-case versions of the options (`tilt-max`, `tone-mapping`, `fill-holes`…). Object options accept JSON, or you can set `element.options = {...}` from JS. Events: `ready` and `error`. Methods: `spin()`, `glint()`, `toDataURL()`, `toBlob()`. Style the size with CSS (default 160×160).
150
+
151
+ ### Vanilla JS
152
+
153
+ ```ts
154
+ import { Badgecraft } from 'badgecraft'
155
+
156
+ const badge = new Badgecraft(document.querySelector('#award')!, { svg: svgMarkup, metal: 'rose-gold' })
157
+ await badge.ready
158
+ badge.update({ metal: 'silver' }) // merge options
159
+ badge.spin(2)
160
+ const png = await badge.toBlob()
161
+ badge.destroy()
162
+ ```
163
+
164
+ The host element needs a size. If it has none, it gets `160px` and `aspect-ratio: 1`.
165
+
166
+ ## How an SVG becomes a badge
167
+
168
+ | SVG | Badge |
169
+ | ------------------------------------- | ---------------------------------------------------------------------------- |
170
+ | Strokes | Raised, round **metal wires** (the `metal` material) |
171
+ | Fills | Recessed, slightly domed **enamel pools** in the fill colour (gradients too) |
172
+ | Areas enclosed by the artwork | The **backplate** (`base` material), unless `fillHoles: false` |
173
+ | The silhouette | A **metal rim** (`rim`), a rounded edge and a solid back plate |
174
+
175
+ `mode: 'auto'` (the default) picks a sensible mapping:
176
+
177
+ - Artwork with strokes, images or several colours → **cloisonné** (as above). Multi-colour art *without* strokes gets automatic wires around each shape (`outline: 'auto'`), so flat illustrations, flags and emoji work out of the box.
178
+ - Single-colour artwork (logos, solid icons) → **embossed**: every shape becomes raised, bevelled metal.
179
+
180
+ Wrap icons in a badge shape with `frame`: `'circle' | 'hexagon' | 'octagon' | 'shield' | 'square' | 'diamond' | 'star'`, or `{ shape, padding, innerRing, path }`.
181
+
182
+ ### Per-element control
183
+
184
+ Annotate SVG elements (or groups) with data attributes:
185
+
186
+ | Attribute | Values | Effect |
187
+ | ----------------------------------------------------- | ---------------------------------------- | ------------------------------------------ |
188
+ | `data-material` | `metal` `enamel` `base` `accent` | Material slot for the element's fill and stroke |
189
+ | `data-fill-material` / `data-stroke-material` | same | Slot for just the fill or the stroke |
190
+ | `data-relief` | `raised` `inset` `flat` `none` | Sculpting of the fill |
191
+ | `data-stroke-relief` | same | Sculpting of the stroke (default `raised`) |
192
+
193
+ ```html
194
+ <circle r="40" fill="#c6f400" data-fill-material="accent" /> <!-- candy-metal ring with accent="candy" -->
195
+ <path d="…" fill="#fff" data-relief="raised" /> <!-- embossed metal numerals -->
196
+ ```
197
+
198
+ ## Options
199
+
200
+ | Option | Type | Default |
201
+ | --------------------- | ------------------------------------------------------------------------- | ----------- |
202
+ | `svg` | SVG markup, URL, data URL or `SVGElement` | (required) |
203
+ | `mode` | `'auto' \| 'cloisonne' \| 'embossed'` | `'auto'` |
204
+ | `metal` | [material](#materials): wires and rim | `'gold'` |
205
+ | `enamel` | material: fills (uses SVG colours) | `'enamel'` |
206
+ | `base` | material: backplate | `'onyx'` |
207
+ | `accent` | material: for `data-material="accent"` | `'silver'` |
208
+ | `color` | value of `currentColor` in the SVG | `'#ffffff'` |
209
+ | `frame` | badge shape around the artwork | none |
210
+ | `rim` | outer metal rim width (fraction of badge, `0` = off) | `0.03` |
211
+ | `outline` | wires around filled shapes (fraction), or `'auto'` | `'auto'` |
212
+ | `fillHoles` | fill enclosed areas with the backplate | `true` |
213
+ | `relief` | [relief options](#relief) | |
214
+ | `environment` | [environment](#lighting--environments) preset, custom lights, or HDR URL | `'studio'` |
215
+ | `environmentRotation` | degrees | `0` |
216
+ | `exposure` | linear exposure | `1` |
217
+ | `toneMapping` | `'neutral' \| 'aces' \| 'agx' \| 'none'` | `'neutral'` |
218
+ | `interaction` | `'tilt'` (hover) `\| 'drag'` (spin with inertia) `\| 'none'` | `'tilt'` |
219
+ | `tiltMax` | max tilt in degrees | `18` |
220
+ | `pose` | resting `{ pitch, yaw, roll }` in degrees (a slight 3/4 view shows the edge) | `{ pitch: -5, yaw: -8 }` |
221
+ | `idle` | `'none' \| 'float' \| 'shine'` | `'none'` |
222
+ | `locked` | flat grey outline for awards not yet earned | `false` |
223
+ | `lockedColor` | colour of the locked outline | `'#3a3a3c'` |
224
+ | `shadow` | contact shadow opacity (`0` = off) | `0.35` |
225
+ | `quality` | relief map resolution in px, or `'auto'` (matches display size) | `'auto'` |
226
+ | `padding` | space around the badge for tilting (fraction of canvas) | `0.06` |
227
+ | `onReady` / `onError` | callbacks | |
228
+
229
+ Instance methods (also on the React ref, Vue ref and custom element): `spin(turns = 1)`, `glint()`, `toDataURL(type?, quality?)` and `toBlob(type?, quality?)`. The vanilla class adds `update(partial)`, `setOptions(all)`, `destroy()`, and `ready`, a promise that resolves after the first draw and rejects if the artwork can't be loaded or baked.
230
+
231
+ Destroying a badge before its first draw rejects `ready` with an `AbortError`. Destruction cancels callbacks from pending work. `spin()` and `glint()` respect reduced motion and locked badges. Nonfinite numeric options use safe defaults; bounded material and geometry values are clamped.
232
+
233
+ ### Materials
234
+
235
+ Every material slot takes a preset name, a full material, or a preset with overrides:
236
+
237
+ ```ts
238
+ metal: 'rose-gold'
239
+ metal: { preset: 'gold', roughness: 0.12, brushed: 0.4 }
240
+ enamel: { metalness: 0.8, roughness: 0.25, clearcoat: 1 } // candy-paint enamel in SVG colours
241
+ base: { preset: 'onyx', color: '#14233f' }
242
+ ```
243
+
244
+ | Parameter | Meaning |
245
+ | ------------------------------------- | --------------------------------------------------------------------------- |
246
+ | `color` | base colour; for metals, the specular (F0) tint |
247
+ | `useSvgColor` | take the colour from the SVG (default for the enamel slot) |
248
+ | `metalness` | 0 = dielectric (enamel, paint), 1 = metal |
249
+ | `roughness` | 0 = mirror … 1 = diffuse |
250
+ | `clearcoat`, `clearcoatRoughness` | glossy lacquer layer on top |
251
+ | `ior` | dielectric index of refraction (reflectance at normal incidence) |
252
+ | `iridescence`, `iridescenceThickness` | thin-film interference (oil slick, anodised, holographic) |
253
+ | `flakes`, `flakeScale` | sparkling metallic flakes |
254
+ | `brushed`, `brushDirection` | brushed streaks: `circular`, `radial`, `horizontal`, `vertical` |
255
+ | `emissive` | self-illumination of the base colour |
256
+
257
+ **Presets:** `gold`, `rose-gold`, `white-gold`, `matte-gold`, `brushed-gold`, `silver`, `brushed-silver`, `platinum`, `chrome`, `copper`, `bronze`, `brass`, `titanium`, `gunmetal`, `black-chrome`, `enamel`, `matte-enamel`, `candy`, `glitter`, `pearl`, `holographic`, `onyx`, `obsidian`. They're exported as `materials` if you want to build on them.
258
+
259
+ ### Relief
260
+
261
+ ```ts
262
+ relief: {
263
+ depth: 0.05, // max relief height (fraction of badge); wires are shaped as round tubes up to this
264
+ thickness: 0.03, // back plate thickness
265
+ floor: 0.3, // backplate height relative to wire tops
266
+ profile: 'round', // wire cross-section: 'round' | 'soft' | 'chamfer'
267
+ bevel: 0.02, // bevel width of raised (embossed) shapes
268
+ edge: 0.014, // rounding of the outer edge
269
+ enamelLevel: 0.16, // how full the enamel pools are
270
+ enamelDome: 0.12, // doming of enamel (negative = concave)
271
+ domeWidth: 0.05,
272
+ smoothing: 0.9,
273
+ ao: 0.45, // ambient occlusion in crevices
274
+ }
275
+ ```
276
+
277
+ ### Lighting & environments
278
+
279
+ Presets are procedural HDR studios: `studio` (default), `soft`, `sunset`, `night`, `neon`. You can also design your own lights or use an equirectangular HDR image:
280
+
281
+ ```ts
282
+ environment: 'sunset'
283
+
284
+ environment: {
285
+ top: '#7c7c80', horizon: '#2e2e32', bottom: '#121214',
286
+ lights: [
287
+ // azimuth 0 = behind the viewer (what the badge face reflects), elevation above the horizon
288
+ { azimuth: -40, elevation: 38, size: [70, 50], intensity: 4, color: '#fff6e8', softness: 0.7 },
289
+ { azimuth: 135, elevation: 30, size: 30, shape: 'circle', intensity: 3 },
290
+ ],
291
+ }
292
+
293
+ environment: { url: '/hdri/studio_small_08_1k.hdr', intensity: 1.2 } // .hdr (RGBE) or any image format
294
+ ```
295
+
296
+ Environments are pre-filtered once per page (GGX importance sampling into a roughness mip chain plus a diffuse irradiance map) and shared by all badges.
297
+
298
+ ## Technology choices
299
+
300
+ The goal was photoreal metal for **arbitrary** SVGs, cheap enough to show dozens on a page, in any framework.
301
+
302
+ | Approach | Verdict |
303
+ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
304
+ | CSS / SVG lighting filters (`feSpecularLighting`) | Tiny and SSR-able, but not physically based: no environment reflections, and it looks like a 2010-era bevel. |
305
+ | three.js `SVGLoader` + `ExtrudeGeometry` + `MeshPhysicalMaterial` | Real PBR out of the box, but ~150 KB gzip. Triangulating arbitrary SVGs (strokes, holes, self-intersections, text) is fragile, there's no control over a cloisonné wire profile, and one context per badge hits the browser's ~16 WebGL-context limit. |
306
+ | WebGPU | The future, but not yet available everywhere. |
307
+ | **Chosen: SVG → distance-field relief bake + custom WebGL2 PBR** | **Robust for any SVG** (the browser rasterises it), **exact control** over the look, **~27 KB**, **one shared context** for unlimited badges, universal WebGL2 support. |
308
+
309
+ How it works:
310
+
311
+ 1. **Classify.** The SVG is attached to a hidden shadow root so the browser's style engine resolves classes, inheritance and `currentColor`. `<use>` is inlined, and every painted shape is assigned a relief role and a material slot.
312
+ 2. **Rasterise.** Four passes are rendered by the browser's SVG renderer: role masks, slot masks (colour-coded channels on black, so each channel is exact anti-aliased coverage that respects paint order) and the colour pass.
313
+ 3. **Bake** (in a Web Worker). Exact Euclidean distance transforms with sub-pixel edge points (Felzenszwalb–Huttenlocher, refined with Gustavson's anti-aliased edge model) produce a smooth height field: round tube wires sized from each wire's local width, domed enamel pools, a rounded outer edge, cavity occlusion and dilated colours.
314
+ 4. **Render** (WebGL2). A displaced grid mesh (front relief plus back plate) is shaded with GGX, multi-scatter energy compensation, split-sum IBL from pre-filtered environments, clear-coat, flakes, thin-film iridescence, brushed anisotropy and Khronos PBR Neutral tone mapping. Every badge draws through a single shared context into its own 2D canvas.
315
+
316
+ ## Performance notes
317
+
318
+ - Baking is cached by artwork, options and resolution. Identical badges share GPU textures, and remounts within a few seconds (React StrictMode, route changes) reuse them.
319
+ - `quality: 'auto'` bakes at roughly the badge's device-pixel size (256–1024 px) and re-bakes only if the badge grows a lot.
320
+ - Static badges draw once. Only moving badges (hover, drag, spin, idle) draw per frame, and idle animation pauses off-screen.
321
+ - Reflection highlights and flakes are filtered at pixel scale to reduce shimmer. The mirror environment level skips convolution; rough reflections still use the cached GGX mip chain. These refinements add no textures or draw passes.
322
+ - Material, lighting and pose changes are just uniforms, so they're free to animate. Artwork and relief changes trigger a re-bake: about 5–15 ms of main-thread work (the browser rasterises the SVG), then the distance-field bake in a Web Worker (~75–100 ms at 512 px, ~0.4 s at 1024 px on an M-series laptop).
323
+
324
+ ## Limitations
325
+
326
+ - SVGs are rendered as images, so **external resources** (linked images, web fonts) aren't loaded. Inline images as data URLs and convert text to paths (or accept system fonts).
327
+ - Artwork is static: scripts, event handlers, embedded HTML, animation elements, and external SVG references are removed before classification.
328
+ - Badges are square. Non-square artwork is centred.
329
+ - WebGL2 is required for the 3D render. Without it, the flat SVG is shown.
330
+
331
+ ## Development
332
+
333
+ The playground's landing page (`index.html`) and **Studio** (`studio.html`) are
334
+ React apps in `playground/site/`. The Studio walks through three steps (artwork,
335
+ finish, fine-tune) next to a live preview:
336
+
337
+ - Open any landing-page example in the Studio with its full material and frame recipe.
338
+ - Pick a sample, upload or drop an SVG anywhere on the page (up to 1 MB), or paste
339
+ SVG markup. Invalid edits show an error and keep the previous preview and saved draft.
340
+ - Choose metals, enamels, backplates and frames from visual swatches. Advanced
341
+ material, depth, lighting and motion controls are grouped and collapsed.
342
+ Material sliders show actual preset values and can be restored individually.
343
+ - Every change can be undone (⌘Z / Ctrl+Z, ⇧⌘Z / Ctrl+Y), including a full reset.
344
+ - Copy complete React/TSX, Vue, Web Component, plain HTML or vanilla JS code. The
345
+ snippet includes your SVG, so it works without the playground's sample files.
346
+ - Download a transparent PNG at 512, 1024 or 2048 pixels. Exports use the
347
+ configured resting pose, independent of preview motion and display density.
348
+ - Valid drafts save locally in your browser. The Studio still works when local
349
+ storage is unavailable.
350
+
351
+ The site's examples use the Apple, Atlassian, Google, and Meta logo marks in
352
+ `playground/public/logos/` (trademarks of their owners, shown as sample artwork).
353
+
354
+ `gallery.html` is a development render harness for `scripts/`
355
+ (`?only=id,id&size=N&opts=<JSON>&light`). It and `test.html` use the artwork in
356
+ `playground/fixtures/`, which the render-quality baselines depend on; neither is
357
+ part of the published site.
358
+
359
+ ![Studio](https://cdn.jsdelivr.net/npm/badgecraft@0.3.0/docs/studio.jpg)
360
+
361
+ ```sh
362
+ pnpm install
363
+ pnpm dev # playground at http://localhost:5199 (landing, studio, React, Vue, Web Component)
364
+ pnpm test # unit tests (distance transforms, bake)
365
+ pnpm test:browser # end-to-end checks in installed Google Chrome
366
+ pnpm test:studio # import, drafts, code, clipboard, export and mobile workflows
367
+ pnpm test:bugbash # adversarial API, loading, lifecycle and fallback regressions
368
+ pnpm test:package # build + ESM/CJS imports and React/Vue SSR checks
369
+ pnpm test:cli # real Chrome CLI and Node API export regressions
370
+ pnpm test:tarball # install/test the actual npm tarball in a clean project
371
+ pnpm test:quality artifacts/reference # capture 108 reference images before a render edit
372
+ pnpm test:quality artifacts/current artifacts/reference # compare after the edit
373
+ pnpm benchmark # five Chrome runs: timing, triangles, long tasks, idle/context checks
374
+ pnpm perf:profile artifacts/profile # phase timings, GPU queries, CPU profiles, frame pacing
375
+ pnpm typecheck
376
+ pnpm doctor # full React Doctor scan; complete 100/100 required
377
+ pnpm verify # types, unit tests, package build/SSR, CLI checks, Doctor 100
378
+ pnpm build # ESM + CJS + .d.ts + CLI renderer into dist/
379
+ pnpm build:studio # build the gallery and Studio into dist/studio/
380
+ ```
381
+
382
+ See [profiling and paired A/B comparisons](https://unpkg.com/badgecraft@0.3.0/docs/performance.md) for baseline
383
+ snapshots, measurement boundaries, raw evidence, and quality checks.
384
+
385
+ Maintainers: see [release setup and publishing](https://github.com/ParthJadhav/badgecraft/blob/main/docs/releasing.md).
386
+
387
+ ## License
388
+
389
+ MIT
390
+
391
+ React Doctor runs `npx react-doctor@latest` without scan caches and requires network access. Missing or
392
+ incomplete scans fail verification. The JSON report is saved in
393
+ `node_modules/.cache/react-doctor/report.json`. Reviewed inline exceptions cover
394
+ dedicated-worker messages and existing object-URL cleanup lifecycles.
395
+ `doctor.config.json` excludes generated bundles; all
396
+ authored source remains in the full scan.
397
+
398
+ Run browser suites sequentially, after build commands have finished: rebuilding
399
+ the worker while Vite is serving tests triggers a page reload. Screenshots from
400
+ `pnpm test:studio` are saved in `artifacts/studio-check/`. See the
401
+ [maintainer product review](https://github.com/ParthJadhav/badgecraft/blob/main/docs/product-opportunities.md) for the
402
+ remaining opportunities and the reasons behind this Studio pass.
403
+
404
+ The pnpm policy delays new releases for 24 hours, rejects trust downgrades, and
405
+ blocks exotic transitive dependencies. Two exact, integrity-pinned tsup/Vitest
406
+ dependencies have documented trust-policy exceptions; other versions remain checked.
package/cli/index.mjs ADDED
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util'
3
+ import { createReadStream } from 'node:fs'
4
+ import { readFile, writeFile, mkdir, lstat, stat } from 'node:fs/promises'
5
+ import { resolve, parse, extname, dirname } from 'node:path'
6
+ import { renderBadge } from './render.mjs'
7
+
8
+ const help = `badgecraft — turn an SVG into a badge image
9
+
10
+ Usage:
11
+ badgecraft icon.svg [options]
12
+ badgecraft studio [--port 5199] [--open]
13
+ badgecraft icon.svg -o badge.png --metal gold --frame circle
14
+ cat icon.svg | badgecraft - -o badge.webp
15
+
16
+ Options:
17
+ -o, --output <path> Output file (default: <input>.badge.png); - for stdout
18
+ -s, --size <pixels> Square image size, 16–2048 (default: 1024)
19
+ -m, --metal <preset> Metal preset (default: gold)
20
+ --base <preset> Backplate material (default: onyx)
21
+ --enamel <preset> Fill material (default: enamel)
22
+ --frame <shape> circle, hexagon, octagon, shield, square, diamond, star
23
+ --mode <mode> auto, cloisonne, embossed
24
+ --environment <name> studio, soft, sunset, night, neon
25
+ --background <color> CSS color (default: transparent; JPEG: white)
26
+ --color <color> SVG currentColor (default: white)
27
+ --format <format> png, webp, jpeg (default: inferred from output)
28
+ --quality <number> PNG/WebP/JPEG relief resolution, 64–2048 (default: auto)
29
+ --image-quality <0–1> WebP/JPEG encoding quality (default: 0.92)
30
+ --pitch <degrees> Pitch, -89–89 (default: -5)
31
+ --yaw <degrees> Yaw, -180–180 (default: -8)
32
+ --roll <degrees> Roll, -180–180 (default: 0)
33
+ --exposure <number> Exposure, 0–16 (default: 1)
34
+ --no-shadow Disable the drop shadow
35
+ --chrome-path <path> Google Chrome executable (or BADGECRAFT_CHROME_PATH)
36
+ --no-sandbox Disable Chrome sandbox in restricted containers only
37
+ --timeout <ms> Startup/render timeout, 100–300000 (default: 60000)
38
+ -f, --force Overwrite an existing output file
39
+ --list-presets List material presets
40
+ -h, --help Show this help
41
+ -v, --version Show version
42
+
43
+ Studio opens an interactive SVG editor locally. Run badgecraft studio --help.
44
+ Image exports require Node.js 22+ and Google Chrome. Input must be a self-contained SVG
45
+ (maximum 10 MiB). External resources are blocked. PNG/WebP preserve transparency.
46
+ Lighting is rasterized; the output is not an editable vector SVG.
47
+ `
48
+
49
+ function number(value, name, lo, hi, integer = false) {
50
+ const n = value?.trim() ? Number(value) : NaN
51
+ if (!Number.isFinite(n) || n < lo || n > hi || (integer && !Number.isInteger(n))) {
52
+ throw new Error(`--${name} must be ${integer ? 'an integer' : 'a number'} from ${lo} to ${hi}.`)
53
+ }
54
+ return n
55
+ }
56
+
57
+ async function main() {
58
+ if (process.argv[2] === 'studio') {
59
+ const { runStudio } = await import('./studio.mjs')
60
+ return runStudio(process.argv.slice(3))
61
+ }
62
+ const strings = ['base', 'enamel', 'frame', 'mode', 'environment', 'background', 'color', 'format', 'quality', 'image-quality', 'pitch', 'yaw', 'roll', 'exposure', 'chrome-path', 'timeout']
63
+ const { values: args, positionals } = parseArgs({
64
+ allowPositionals: true,
65
+ options: {
66
+ ...Object.fromEntries(strings.map((key) => [key, { type: 'string' }])),
67
+ output: { type: 'string', short: 'o' }, size: { type: 'string', short: 's' }, metal: { type: 'string', short: 'm' },
68
+ force: { type: 'boolean', short: 'f' }, help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
69
+ 'no-shadow': { type: 'boolean' }, 'no-sandbox': { type: 'boolean' }, 'list-presets': { type: 'boolean' },
70
+ },
71
+ })
72
+ if (args.help) return process.stdout.write(help)
73
+ if (args.version) return process.stdout.write(`${JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version}\n`)
74
+ const { materials, framePaths, environments } = await import('../dist/index.js')
75
+ if (args['list-presets']) return process.stdout.write(`${Object.keys(materials).join('\n')}\n`)
76
+ if (positionals.length !== 1) throw new Error('Provide one SVG file, or - for stdin. Run badgecraft --help for usage.')
77
+ const input = positionals[0]
78
+ const parsed = parse(input)
79
+ const output = args.output ?? (input === '-' ? 'badge.png' : resolve(parsed.dir, `${parsed.name}.badge.png`))
80
+ if (input !== '-' && output !== '-' && resolve(input) === resolve(output)) throw new Error('The output path must differ from the input SVG.')
81
+ const extension = extname(output).slice(1).toLowerCase()
82
+ const format = args.format ?? (output === '-' || !extension ? 'png' : extension === 'jpg' ? 'jpeg' : extension)
83
+ if (!['png', 'webp', 'jpeg'].includes(format)) throw new Error('Use a .png, .webp, or .jpg output path, or --format png|webp|jpeg.')
84
+ if (args.format && extension && output !== '-' && extension !== format && !(extension === 'jpg' && format === 'jpeg')) {
85
+ throw new Error('The output extension must match --format.')
86
+ }
87
+ const options = { format }
88
+ for (const key of ['metal', 'base', 'enamel', 'frame', 'mode', 'environment']) {
89
+ if (args[key] === undefined) continue
90
+ const presets = key === 'frame' ? Object.keys(framePaths) : key === 'mode' ? ['auto', 'cloisonne', 'embossed'] : key === 'environment' ? Object.keys(environments) : Object.keys(materials)
91
+ if (!presets.includes(args[key])) throw new Error(`Unknown ${key} '${args[key]}'. Choose: ${presets.join(', ')}.`)
92
+ options[key] = args[key]
93
+ }
94
+ for (const [flag, key, lo, hi, integer] of [
95
+ ['size', 'size', 16, 2048, true], ['quality', 'quality', 64, 2048, true],
96
+ ['image-quality', 'imageQuality', 0, 1, false], ['exposure', 'exposure', 0, 16, false],
97
+ ['timeout', 'timeout', 100, 300000, true],
98
+ ]) if (args[flag] !== undefined) options[key] = number(args[flag], flag, lo, hi, integer)
99
+ for (const key of ['pitch', 'yaw', 'roll']) if (args[key] !== undefined) {
100
+ options.pose ??= {}
101
+ options.pose[key] = number(args[key], key, key === 'pitch' ? -89 : -180, key === 'pitch' ? 89 : 180)
102
+ }
103
+ for (const key of ['background', 'color']) if (args[key] !== undefined) options[key] = args[key]
104
+ if (args['chrome-path']) options.executablePath = args['chrome-path']
105
+ if (args['no-shadow']) options.shadow = 0
106
+ if (args['no-sandbox']) options.sandbox = false
107
+ if (output === '-' && process.stdout.isTTY) throw new Error('Refusing to write an image to your terminal. Redirect stdout to a file or pipe.')
108
+ if (input === '-' && process.stdin.isTTY) throw new Error('Pipe SVG markup into stdin, or provide a file path.')
109
+ const chunks = []
110
+ let bytes = 0
111
+ for await (const chunk of input === '-' ? process.stdin : createReadStream(input)) {
112
+ bytes += chunk.length
113
+ if (bytes > 10 * 1024 * 1024) throw new Error('SVG exceeds the 10 MiB input limit.')
114
+ chunks.push(chunk)
115
+ }
116
+ const svg = Buffer.concat(chunks).toString('utf8')
117
+ // Exclusive creation prevents accidental overwrite, including concurrent conversions.
118
+ // Render before opening the output so failures never leave empty files.
119
+ if (output !== '-') {
120
+ try {
121
+ const target = await lstat(output)
122
+ if (!args.force) throw new Error(`Output already exists: ${output}. Use --force to overwrite.`)
123
+ if (!target.isFile()) throw new Error('The output must be a regular file, not a symlink or directory.')
124
+ if (input !== '-') {
125
+ const source = await stat(input)
126
+ if (source.dev === target.dev && source.ino === target.ino) throw new Error('The output must not refer to the input SVG.')
127
+ }
128
+ } catch (error) { if (error.code !== 'ENOENT') throw error }
129
+ }
130
+ const image = await renderBadge(svg, options)
131
+ if (output === '-') {
132
+ await new Promise((resolve, reject) => process.stdout.write(image, (error) => error ? reject(error) : resolve()))
133
+ } else {
134
+ await mkdir(dirname(resolve(output)), { recursive: true })
135
+ await writeFile(output, image, { flag: args.force ? 'w' : 'wx' })
136
+ process.stderr.write(`Created ${output} (${options.size ?? 1024} × ${options.size ?? 1024}, ${format.toUpperCase()})\n`)
137
+ }
138
+ }
139
+
140
+ main().catch((error) => {
141
+ process.stderr.write(`badgecraft: ${error.message}\n`)
142
+ process.exitCode = 1
143
+ })
@@ -0,0 +1,20 @@
1
+ import type { BadgeOptions, EnvironmentPreset } from '../dist/index.js'
2
+
3
+ export interface RenderOptions extends Omit<BadgeOptions, 'svg' | 'onReady' | 'onError' | 'idle' | 'interaction' | 'environment'> {
4
+ /** Output width and height in pixels (16–2048). Default 1024. */
5
+ size?: number
6
+ format?: 'png' | 'webp' | 'jpeg'
7
+ /** CSS color. PNG/WebP default to transparent; JPEG defaults to white. */
8
+ background?: string
9
+ /** Lossy encoding quality (0–1). Default 0.92. */
10
+ imageQuality?: number
11
+ environment?: EnvironmentPreset
12
+ /** Milliseconds for Chrome startup and separately for rendering. Default 60000. */
13
+ timeout?: number
14
+ executablePath?: string
15
+ /** Disable only in containers that cannot run Chrome's sandbox. Default true. */
16
+ sandbox?: boolean
17
+ }
18
+
19
+ /** Convert self-contained SVG markup to a badge image. Requires Google Chrome. */
20
+ export function renderBadge(svg: string, options?: RenderOptions): Promise<Buffer>