diff --git a/.changeset/relief-presets-component.md b/.changeset/relief-presets-component.md new file mode 100644 index 0000000..d7338a4 --- /dev/null +++ b/.changeset/relief-presets-component.md @@ -0,0 +1,5 @@ +--- +"holosticker": minor +--- + +Vinyl relief lip at the kiss-cut edge, finish presets (Holo / Glossy / Matte / Chrome / Glitter) as a segmented control, glossy layer material, shadcn-style single-file component export with integration instructions, custom icon set, and new defaults diff --git a/src/App.tsx b/src/App.tsx index 02bee19..44efe16 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react" -import { - Box, - ChevronDown, - Clapperboard, - Download, - History, - Image, - Moon, - Sun, -} from "lucide-react" +import { ChevronDown, History, Moon, Sun } from "lucide-react" import { DropdownMenu } from "radix-ui" import { Analytics } from "@vercel/analytics/react" import { Button } from "@/components/ui/button" @@ -20,11 +11,13 @@ import { SelectValue, } from "@/components/ui/select" import { ChangelogDialog } from "@/components/ChangelogDialog" +import { ComponentExportDialog } from "@/components/ComponentExportDialog" import { GifExportDialog } from "@/components/GifExportDialog" import { currentVersion } from "@/lib/changelog" import { Sidebar } from "@/components/Sidebar" import { StickerCanvas } from "@/components/StickerCanvas" import { loadImageFile } from "@/lib/load-image" +import { buildReactComponent } from "@/lib/react-export" import type { HoloRenderer } from "@/lib/three-renderer" import { defaultSettings, type StickerSettings } from "@/lib/settings" @@ -37,6 +30,7 @@ export default function App() { const [gifProgress, setGifProgress] = useState(null) const [gifDialogOpen, setGifDialogOpen] = useState(false) const [changelogOpen, setChangelogOpen] = useState(false) + const [componentDialogOpen, setComponentDialogOpen] = useState(false) const [dragging, setDragging] = useState(false) const [dark, setDark] = useState( () => @@ -150,6 +144,29 @@ export default function App() { [settings, imgAspect, imageName], ) + const handleExportComponent = useCallback(() => { + if (!image) return + const download = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + } + // the artwork as its own file, referenced by path from the component + const c = document.createElement("canvas") + c.width = image.width + c.height = image.height + c.getContext("2d")!.drawImage(image, 0, 0) + c.toBlob((b) => { + if (b) download(b, "holo-sticker-art.png") + }, "image/png") + const source = buildReactComponent(settings, imageName ?? "sticker") + download(new Blob([source], { type: "text/plain" }), "holo-sticker.tsx") + setComponentDialogOpen(true) + }, [image, settings, imageName]) + const handleExportGLB = useCallback(async () => { const renderer = rendererRef.current if (!renderer) return @@ -275,7 +292,15 @@ export default function App() { - +
{changelog.map((entry, i) => (
diff --git a/src/components/ComponentExportDialog.tsx b/src/components/ComponentExportDialog.tsx new file mode 100644 index 0000000..1a8f714 --- /dev/null +++ b/src/components/ComponentExportDialog.tsx @@ -0,0 +1,71 @@ +import { useState } from "react" +import { Dialog } from "radix-ui" +import { Check, Copy, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { buildIntegrationInstructions } from "@/lib/react-export" + +interface Props { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function ComponentExportDialog({ open, onOpenChange }: Props) { + const [copied, setCopied] = useState(false) + const instructions = buildIntegrationInstructions() + + return ( + + + + +
+ + Component downloaded + + + + +
+

+ Two files: holo-sticker.tsx and{" "} + holo-sticker-art.png. Paste these instructions to + your agent, or follow them yourself. +

+
+
+ + Instructions + + +
+ +
+                {instructions}
+              
+
+
+
+
+
+ ) +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 098f6fd..4e1bfe4 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from "react" -import { FileDown, FileUp, ImagePlus, RotateCcw, X } from "lucide-react" +import { FileDown, FileUp, RotateCcw, X } from "lucide-react" import { HoloLogo } from "@/components/HoloLogo" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -16,6 +16,7 @@ import { Separator } from "@/components/ui/separator" import { Switch } from "@/components/ui/switch" import { Slider } from "@/components/ui/slider" import type { + Finish, HoloOverlay, HoloPattern, LayerMaterial, @@ -23,6 +24,19 @@ import type { StickerSettings, } from "@/lib/settings" +// one-tap finish presets; each patches the finish plus the foil sliders +const finishPresets: { + id: Finish + label: string + patch: Partial +}[] = [ + { id: "holo", label: "Holo", patch: { finish: "holo", holoIntensity: 0.85, grain: 0.35 } }, + { id: "gloss", label: "Glossy", patch: { finish: "gloss", holoIntensity: 0, grain: 0.05 } }, + { id: "matte", label: "Matte", patch: { finish: "matte", holoIntensity: 0, grain: 0.05 } }, + { id: "chrome", label: "Chrome", patch: { finish: "chrome", holoIntensity: 0.2, grain: 0 } }, + { id: "glitter", label: "Glitter", patch: { finish: "glitter", holoIntensity: 1, grain: 1 } }, +] + interface Props { settings: StickerSettings imageName: string | null @@ -115,7 +129,15 @@ function Dropzone({ : "border-input text-muted-foreground hover:border-ring/60 hover:bg-accent/50 hover:text-foreground", )} > - + {imageName ? "Replace artwork" : "Upload artwork"} @@ -147,6 +169,38 @@ export function Sidebar({
+ {/* Finish presets */} +
+

+ Finish +

+
+ {finishPresets.map((p) => ( + + ))} +
+
+ + + {/* Artwork */}

@@ -213,6 +267,15 @@ export function Sidebar({ format={(v) => `${Math.round(v * 100)}%`} onChange={(v) => onChange({ ink: v })} /> + `${Math.round(v * 100)}%`} + onChange={(v) => onChange({ relief: v })} + /> Holo foil (auto) + Glossy Glitter Chrome Matte diff --git a/src/lib/react-export.ts b/src/lib/react-export.ts new file mode 100644 index 0000000..f1488c1 --- /dev/null +++ b/src/lib/react-export.ts @@ -0,0 +1,161 @@ +import settingsSrc from "./settings.ts?raw" +import rendererSrc from "./three-renderer.ts?raw" +import type { StickerSettings } from "./settings" + +/** Integration instructions shown (and copyable) after download. */ +export function buildIntegrationInstructions(): string { + return `Integrate the HoloSticker component (generated by holosticker.dev): + +1. Save holo-sticker.tsx into your components directory + (e.g. components/holo-sticker.tsx). +2. Save holo-sticker-art.png into public/ so it serves from + /holo-sticker-art.png. To use a different location, pass + artSrc="/your/path.png". +3. Install the only dependency: + npm install three +4. Render it - the canvas fills its container, so size it via className: + + +Optional props: +- artSrc: string - path to the artwork image (default "/holo-sticker-art.png") +- settings: Partial - override any dial, e.g. {{ peelAmount: 0 }} +- interactive: boolean - pointer tilt on/off (default true) + +Notes for agents: the file is fully self-contained (renderer + types inlined), +renders a transparent WebGL canvas, and needs no CSS. Do not split the file.` +} + +/** + * Build a self-contained shadcn-style component: the full renderer and + * settings module inlined, artwork loaded from a public path, and the + * current dials as the default settings. Only dependency: three. + */ +export function buildReactComponent( + settings: StickerSettings, + name: string, +): string { + // strip relative imports (the modules are concatenated into one file) + // and hoist the three import to the top of the generated file + // drop the GIF/GLB export methods: they'd pull gifenc and the three + // examples into the generated file, and the component doesn't need them + const stripped = rendererSrc.replace( + /\n {2}\/\* react-export:strip-start[\s\S]*?react-export:strip-end \*\//, + "", + ) + const renderer = stripped + .split("\n") + .filter( + (l) => + !l.startsWith("import * as THREE") && + !l.startsWith('import { peelAngles } from "./settings"') && + !l.startsWith("import type") && + !l.startsWith("import {"), + ) + .join("\n") + + return `/** + * HoloSticker - generated by Holosticker (https://holosticker.dev) + * + * A holographic sticker of "${name}" as a single-file React component, + * shadcn style: copy this file into your components directory. + * + * Install the only dependency: + * npm install three + * + * Setup: + * 1. Put holo-sticker-art.png in public/ (or pass artSrc) + * 2. npm install three + * + * Usage: + * + * + */ +import * as React from "react" +import * as THREE from "three" + +/* ------------------------------------------------------------------ */ +/* settings */ +/* ------------------------------------------------------------------ */ +${settingsSrc} + +/* ------------------------------------------------------------------ */ +/* renderer */ +/* ------------------------------------------------------------------ */ +${renderer} + +/* ------------------------------------------------------------------ */ +/* component */ +/* ------------------------------------------------------------------ */ + +/** The dials as they were at export time. */ +const STICKER_SETTINGS: StickerSettings = ${JSON.stringify(settings, null, 2)} + +export interface HoloStickerProps { + className?: string + /** Path to the artwork image. Default "/holo-sticker-art.png". */ + artSrc?: string + /** Override any generated setting. */ + settings?: Partial + /** Tilt toward the pointer. Default true. */ + interactive?: boolean +} + +export default function HoloSticker({ + className, + artSrc = "/holo-sticker-art.png", + settings, + interactive = true, +}: HoloStickerProps) { + const ref = React.useRef(null) + const aspectRef = React.useRef(1) + + React.useEffect(() => { + const canvas = ref.current + if (!canvas) return + const renderer = new HoloRenderer(canvas) + const merged = { ...STICKER_SETTINGS, ...settings } + let disposed = false + let raf = 0 + void (async () => { + const blob = await (await fetch(artSrc)).blob() + const bitmap = await createImageBitmap(blob) + if (disposed) return + aspectRef.current = bitmap.width / bitmap.height + renderer.setImage(bitmap) + })() + const draw = () => { + raf = requestAnimationFrame(draw) + const dpr = Math.min(window.devicePixelRatio || 1, 2) + const w = Math.round(canvas.clientWidth * dpr) + const h = Math.round(canvas.clientHeight * dpr) + if (w > 0 && h > 0 && (canvas.width !== w || canvas.height !== h)) { + canvas.width = w + canvas.height = h + } + if (canvas.width === 0) return + renderer.render({ settings: merged, imgAspect: aspectRef.current }) + } + draw() + const onMove = (e: PointerEvent) => { + if (!interactive) return + const rect = canvas.getBoundingClientRect() + renderer.setTilt( + ((e.clientX - rect.left) / rect.width) * 2 - 1, + 1 - ((e.clientY - rect.top) / rect.height) * 2, + ) + } + const onLeave = () => renderer.setTilt(0, 0) + canvas.addEventListener("pointermove", onMove) + canvas.addEventListener("pointerleave", onLeave) + return () => { + disposed = true + cancelAnimationFrame(raf) + canvas.removeEventListener("pointermove", onMove) + canvas.removeEventListener("pointerleave", onLeave) + } + }, [artSrc, settings, interactive]) + + return +} +` +} diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 98e5552..94c2bff 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -1,8 +1,10 @@ export type HoloPattern = "linear" | "radial" | "patches" +export type Finish = "holo" | "gloss" | "matte" | "chrome" | "glitter" export type HoloOverlay = "none" | "triangles" | "squares" | "stripes" export type LayerMaterial = | "auto" | "glitter" + | "gloss" | "chrome" | "matte" | "prism" @@ -18,6 +20,8 @@ export type PeelDirection = | "left" export interface StickerSettings { + /** Surface finish preset: drives metalness/roughness in the shader */ + finish: Finish /** Sticker scale within the canvas, 0.3–1 */ size: number /** Die-cut white/foil border width, 0–0.08 (uv units) */ @@ -28,6 +32,8 @@ export interface StickerSettings { overlay: HoloOverlay /** Ink visibility: 0 = foil only, 1 = as uploaded, up to 2 = densified */ ink: number + /** Embossed print relief: the ink sits proud with a shaded bevel, 0–1 */ + relief: number /** Exploded view: backing paper, kiss-cut foil blank, and artwork */ layersOn: boolean /** Separation between the exploded layers, 0–0.3 */ @@ -61,21 +67,23 @@ export interface StickerSettings { } export const defaultSettings: StickerSettings = { - size: 0.83, - border: 0.028, - cutTolerance: 0.04, - overlay: "triangles", + finish: "holo", + size: 0.74, + border: 0.019, + cutTolerance: 0.03, + overlay: "none", ink: 1, + relief: 0.22, layersOn: true, layerDepth: 0.002, - layerMaterials: ["matte", "auto", "matte"], - holoIntensity: 0.85, + layerMaterials: ["gloss", "auto", "matte"], + holoIntensity: 0.6, bands: 9, hueShift: 0, - grain: 0.35, + grain: 0, pattern: "linear", peelDirection: "top-right", - peelAmount: 0.39, + peelAmount: 0.31, curl: 0.09, shadow: 0, light: { x: 0.65, y: 0.7 }, diff --git a/src/lib/three-renderer.ts b/src/lib/three-renderer.ts index 0f083ad..7083337 100644 --- a/src/lib/three-renderer.ts +++ b/src/lib/three-renderer.ts @@ -171,6 +171,8 @@ uniform float uOverlay; uniform float uInk; uniform float uSeed; // per-layer texture variation uniform float uBackStyle; // 0 = glossy foil liner, 1 = the layer's own map +uniform sampler2D uHeight; // ink coverage, mipmapped, drives the emboss +uniform float uRelief; uniform float uPeelOn; uniform vec2 uLight; @@ -196,6 +198,25 @@ vec2 equirectUv(vec3 d) { float theta = acos(clamp(d.y, -1.0, 1.0)); return vec2((phi + 3.14159265) / 6.2831853, theta / 3.14159265); } +/* + * Embossed print relief (concept from konvert.design's sticker tool): + * the ink coverage, blurred via mip levels, acts as a height field whose + * gradient tilts the normal - the mark sits proud with a real bevel. + * Cotangent-frame normal perturbation after Schüler. + */ +vec3 perturbNormal(vec3 N, vec3 P, vec2 uv, vec2 dGrad) { + vec3 dp1 = dFdx(P); + vec3 dp2 = dFdy(P); + vec2 duv1 = dFdx(uv); + vec2 duv2 = dFdy(uv); + vec3 dp2perp = cross(dp2, N); + vec3 dp1perp = cross(N, dp1); + vec3 T = dp2perp * duv1.x + dp1perp * duv2.x; + vec3 B = dp2perp * duv1.y + dp1perp * duv2.y; + float invmax = inversesqrt(max(dot(T, T), dot(B, B)) + 1e-12); + return normalize(N - (T * dGrad.x + B * dGrad.y) * invmax); +} + vec3 envSample(vec3 R, float rough) { return srgb2lin(textureLod(uEnv, equirectUv(R), rough * uMaxMip).rgb); } @@ -310,6 +331,16 @@ void main() { return; } + // embossed ink: bevel the normal along the blurred coverage gradient + if (uRelief > 0.005) { + float e = 2.5 / 1024.0; + float hC = textureLod(uHeight, vUv, 1.6).r; + float hX = textureLod(uHeight, vUv + vec2(e, 0.0), 1.6).r; + float hY = textureLod(uHeight, vUv + vec2(0.0, e), 1.6).r; + N = perturbNormal(N, vWorldPos, vUv, + vec2(hX - hC, hY - hC) * (uRelief * 5.0)); + } + vec3 R = reflect(-V, N); float cosT = clamp(dot(N, V), 0.0, 1.0); float brightness = lum(base.rgb); @@ -396,6 +427,7 @@ const LAYER_MATERIAL_PRESETS: Record< Record | undefined > = { auto: undefined, + gloss: { uGrain: 0.0, uRough: 0.06, uMetal: 0.3, uHolo: 0.0 }, glitter: { uGrain: 1.0, uRough: 0.45, uMetal: 0.8, uHolo: 1.0 }, chrome: { uGrain: 0.0, uRough: 0.04, uMetal: 1.0, uHolo: 0.25 }, matte: { uGrain: 0.08, uRough: 0.85, uMetal: 0.05, uHolo: 0.15 }, @@ -403,6 +435,15 @@ const LAYER_MATERIAL_PRESETS: Record< satin: { uRough: 0.35, uMetal: 0.5, uHolo: 0.5, uGrain: 0.15 }, } +/** Metal/roughness per finish preset. */ +const FINISH_PARAMS: Record = { + holo: { metal: 0.6, rough: 0.18 }, + glitter: { metal: 0.65, rough: 0.3 }, + gloss: { metal: 0.3, rough: 0.06 }, + matte: { metal: 0.05, rough: 0.8 }, + chrome: { metal: 1.0, rough: 0.045 }, +} + export type GifAnim = "sweep" | "peel" /** @@ -479,6 +520,8 @@ export class HoloRenderer { uOverlay: { value: 0 }, uInk: { value: 1 }, uSeed: { value: 0 }, + uHeight: { value: null }, + uRelief: { value: 0 }, uBackStyle: { value: 0 }, uLift: { value: 0 }, uPeelOn: { value: 0 }, @@ -602,6 +645,39 @@ export class HoloRenderer { this.material.uniforms.uMap.value = tex this.mapAspect = ow / oh + // height map for the relief: the vinyl plateaus over the artwork and + // ramps down across the border, reaching zero at the kiss-cut edge + const hout = new Uint8ClampedArray(new ArrayBuffer(ow * oh * 4)) + // the vinyl plateau covers artwork AND border, stepping down with a + // narrow rounded shoulder just inside the kiss-cut edge + const shoulder = Math.min(9, borderPx * 0.3 + 3) + for (let y = 0; y < oh; y++) { + for (let x = 0; x < ow; x++) { + const ax = x - off + const ay = y - off + const d = + ax >= -pad && ax < w + pad && ay >= -pad && ay < h + pad + ? dist[(ay + pad) * W + ax + pad] + : MAXD + // 1 across the sticker body, rolling to 0 at the cut line + const t = Math.max(0, Math.min(1, (borderPx - d) / shoulder)) + const o = (y * ow + x) * 4 + hout[o] = Math.round(255 * t * t * (3 - 2 * t)) + hout[o + 3] = 255 + } + } + const hc = document.createElement("canvas") + hc.width = ow + hc.height = oh + hc.getContext("2d")!.putImageData(new ImageData(hout, ow, oh), 0, 0) + const htex = new THREE.CanvasTexture(hc) + htex.colorSpace = THREE.NoColorSpace + htex.generateMipmaps = true + htex.minFilter = THREE.LinearMipmapLinearFilter + const oldH = this.material.uniforms.uHeight.value as THREE.Texture | null + oldH?.dispose() + this.material.uniforms.uHeight.value = htex + // ---- exploded production layers: backing / kiss-cut blank / art ---- for (const m of this.layerMeshes) { this.mesh.remove(m) @@ -680,6 +756,9 @@ export class HoloRenderer { }, }) const lmesh = new THREE.Mesh(this.geometry, lmat) + // remember the preset's holo level so render() can scale it by + // the global intensity (a matte global must stay matte) + lmesh.userData.presetHolo = preset.uHolo lmesh.renderOrder = li + 1 this.mesh.add(lmesh) this.layerMeshes.push(lmesh) @@ -788,6 +867,13 @@ export class HoloRenderer { const u = (this.layerMeshes[i].material as THREE.ShaderMaterial) .uniforms u.uLift.value = 0.004 + i * s.layerDepth + const presetHolo = this.layerMeshes[i].userData.presetHolo as + | number + | undefined + if (presetHolo !== undefined) { + u.uHolo.value = + presetHolo * Math.min(1, s.holoIntensity / 0.85) + } } // fly-off: continue the peel motion out of frame, toward the peel @@ -821,6 +907,10 @@ export class HoloRenderer { : 3 u.uPeelOn.value = s.peelAmount > 0.001 ? 1 : 0 u.uInk.value = s.ink + u.uRelief.value = s.relief + const fin = FINISH_PARAMS[s.finish] ?? FINISH_PARAMS.holo + u.uMetal.value = fin.metal + u.uRough.value = fin.rough ;(u.uLight.value as THREE.Vector2).set(s.light.x, s.light.y) // blob shadow follows sticker size, offset away from the light @@ -870,6 +960,7 @@ export class HoloRenderer { return blob } + /* react-export:strip-start (needs gifenc / GLTFExporter deps) */ /** * Export an animated GIF: a seamless loop where the sticker tilts in a * circle so the holofoil bands sweep, like turning it in your hand. @@ -994,4 +1085,5 @@ export class HoloRenderer { mat.dispose() return new Blob([result as ArrayBuffer], { type: "model/gltf-binary" }) } + /* react-export:strip-end */ }