diff --git a/apps/proxy/package.json b/apps/proxy/package.json index 6750f5a..c71ecf2 100644 --- a/apps/proxy/package.json +++ b/apps/proxy/package.json @@ -16,7 +16,11 @@ "@hono/node-server": "^1.13.7", "@tts/shared": "workspace:*", "@tts/tts": "workspace:*", + "@visioncortex/vtracer": "1.0.0-alpha.3", + "bson": "^6.10.4", "hono": "^4.6.14", + "sharp": "^0.35.3", + "svgpath": "^2.6.0", "zod": "^3.24.1" }, "devDependencies": { diff --git a/apps/proxy/src/index.ts b/apps/proxy/src/index.ts index d6f6998..686d134 100644 --- a/apps/proxy/src/index.ts +++ b/apps/proxy/src/index.ts @@ -6,6 +6,7 @@ import asset from './routes/asset.js'; import health from './routes/health.js'; import items from './routes/items.js'; import search from './routes/search.js'; +import trace from './routes/trace.js'; const env = loadEnv(); @@ -16,6 +17,7 @@ app.route('/health', health); app.route('/search', search); app.route('/items', items); app.route('/asset', asset); +app.route('/trace', trace); serve( { diff --git a/apps/proxy/src/routes/svgShape.test.ts b/apps/proxy/src/routes/svgShape.test.ts new file mode 100644 index 0000000..bb77c47 --- /dev/null +++ b/apps/proxy/src/routes/svgShape.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { parseSvgShape } from './svgShape.js'; + +describe('parseSvgShape', () => { + it('parses a simple closed outline', () => { + const svg = + ''; + const shape = parseSvgShape(svg); + expect(shape.outline.length).toBeGreaterThanOrEqual(4); + expect(shape.holes).toBeUndefined(); + }); + + it('assigns a clockwise subpath as a hole', () => { + // Outer ring CCW, inner ring CW (hole). + const svg = + ''; + const shape = parseSvgShape(svg); + expect(shape.outline.length).toBeGreaterThanOrEqual(4); + expect(shape.holes).toBeDefined(); + expect(shape.holes!.length).toBe(1); + }); + + it('flattens cubic curves into multiple points', () => { + const svg = + ''; + const shape = parseSvgShape(svg); + // Start point + 12 curve samples + closing point, minus duplicate. + expect(shape.outline.length).toBeGreaterThan(3); + }); + + it('returns an empty outline for no paths', () => { + const shape = parseSvgShape(''); + expect(shape.outline).toEqual([]); + }); +}); \ No newline at end of file diff --git a/apps/proxy/src/routes/svgShape.ts b/apps/proxy/src/routes/svgShape.ts new file mode 100644 index 0000000..663937b --- /dev/null +++ b/apps/proxy/src/routes/svgShape.ts @@ -0,0 +1,185 @@ +import svgpath from 'svgpath'; +import type { TracedShape } from '@tts/shared'; + +/** Number of samples per cubic bezier when flattening curves to polylines. */ +const CURVE_STEPS = 12; + +/** + * Parse an SVG produced by vtracer into a `TracedShape` (outline + holes), + * matching the `Shape` interface in `@tts/mesh` so the result can be extruded + * directly. vtracer emits one `` per traced region; a region with holes + * is a single path containing multiple subpaths (one per `M` command). + * + * Subpaths are classified by winding: counter-clockwise rings become the + * outline, clockwise rings become holes. Holes are assigned to the smallest + * outline that contains them (point-in-polygon on the first vertex). + */ +export function parseSvgShape(svg: string): TracedShape { + const paths = [...svg.matchAll(/ m[1]!); + + const rings = paths.flatMap(parseRings); + if (rings.length === 0) { + return { outline: [] }; + } + + const outlines = rings.filter((r) => signedArea(r) > 0); + const holes = rings.filter((r) => signedArea(r) <= 0); + + // Assign each hole to the smallest outline that contains its first vertex. + const assigned = new Map(); + for (const hole of holes) { + const p = hole[0]! as [number, number]; + let best: number | null = null; + let bestArea = Infinity; + for (let i = 0; i < outlines.length; i++) { + const area = Math.abs(signedArea(outlines[i]!)); + if (area < bestArea && pointInPolygon(p, outlines[i]!)) { + best = i; + bestArea = area; + } + } + if (best !== null) { + const list = assigned.get(best) ?? []; + list.push(hole); + assigned.set(best, list); + } + } + + // The largest outline is the outer boundary; any remaining outlines are + // treated as additional (disjoint) regions, which vtracer emits as separate + // paths anyway. Keep the first/largest as the primary outline. + const primary = outlines.reduce((a, b) => + Math.abs(signedArea(a)) >= Math.abs(signedArea(b)) ? a : b, + ); + + return { + outline: primary, + holes: assigned.get(outlines.indexOf(primary)), + }; +} + +/** Split a path's `d` attribute into rings, one per `M` subpath. */ +function parseRings(d: string): number[][][] { + const rings: number[][][] = []; + let cur: number[][] | null = null; + let pen: [number, number] = [0, 0]; + let ctrl: [number, number] | null = null; + + svgpath(d) + .abs() + .iterate((seg) => { + const cmd = seg[0]; + switch (cmd) { + case 'M': + if (cur && cur.length) rings.push(cur); + cur = [[seg[1]!, seg[2]!]]; + pen = [seg[1]!, seg[2]!]; + ctrl = null; + break; + case 'L': + cur?.push([seg[1]!, seg[2]!]); + pen = [seg[1]!, seg[2]!]; + ctrl = null; + break; + case 'C': { + const p1: [number, number] = [seg[1]!, seg[2]!]; + const p2: [number, number] = [seg[3]!, seg[4]!]; + const p3: [number, number] = [seg[5]!, seg[6]!]; + flattenCubic(pen, p1, p2, p3, cur!); + pen = p3; + ctrl = p2; + break; + } + case 'Q': { + // Approximate a quadratic bezier with a cubic. + const q1: [number, number] = [seg[1]!, seg[2]!]; + const q2: [number, number] = [seg[3]!, seg[4]!]; + const p1: [number, number] = [ + pen[0] + (2 / 3) * (q1[0] - pen[0]), + pen[1] + (2 / 3) * (q1[1] - pen[1]), + ]; + const p2: [number, number] = [ + q2[0] + (2 / 3) * (q1[0] - q2[0]), + q2[1] + (2 / 3) * (q1[1] - q2[1]), + ]; + flattenCubic(pen, p1, p2, q2, cur!); + pen = q2; + ctrl = q1; + break; + } + case 'Z': + case 'z': + cur?.push([pen[0], pen[1]]); + ctrl = null; + break; + default: + break; + } + }); + + if (cur !== null) rings.push(cur); + + // Drop the duplicate closing vertex (first == last). + for (const ring of rings) { + const first = ring[0]!; + const last = ring[ring.length - 1]!; + if ( + Math.abs(first[0]! - last[0]!) < 1e-6 && + Math.abs(first[1]! - last[1]!) < 1e-6 + ) { + ring.pop(); + } + } + + return rings; +} + +/** Sample a cubic bezier into `CURVE_STEPS` points appended to `out`. */ +function flattenCubic( + p0: [number, number], + p1: [number, number], + p2: [number, number], + p3: [number, number], + out: number[][], +): void { + for (let i = 1; i <= CURVE_STEPS; i++) { + const t = i / CURVE_STEPS; + const mt = 1 - t; + const x = + mt * mt * mt * p0[0] + + 3 * mt * mt * t * p1[0] + + 3 * mt * t * t * p2[0] + + t * t * t * p3[0]; + const y = + mt * mt * mt * p0[1] + + 3 * mt * mt * t * p1[1] + + 3 * mt * t * t * p2[1] + + t * t * t * p3[1]; + out.push([x, y]); + } +} + +/** Signed area of a polygon; positive means counter-clockwise. */ +function signedArea(points: number[][]): number { + let area = 0; + for (let i = 0; i < points.length; i++) { + const [x1, y1] = points[i]!; + const [x2, y2] = points[(i + 1) % points.length]!; + area += x1! * y2! - x2! * y1!; + } + return area / 2; +} + +/** Ray-casting point-in-polygon test. */ +function pointInPolygon(p: [number, number], polygon: number[][]): boolean { + let inside = false; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [xi, yi] = polygon[i]!; + const [xj, yj] = polygon[j]!; + const intersects = + yi! > p[1] !== yj! > p[1] && + p[0] < ((xj! - xi!) * (p[1] - yi!)) / (yj! - yi!) + xi!; + if (intersects) inside = !inside; + } + return inside; +} \ No newline at end of file diff --git a/apps/proxy/src/routes/trace.test.ts b/apps/proxy/src/routes/trace.test.ts new file mode 100644 index 0000000..d575da5 --- /dev/null +++ b/apps/proxy/src/routes/trace.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { deserialize } from 'bson'; +import sharp from 'sharp'; +import trace from './trace.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** Build a small PNG with an opaque circle in the center on a transparent bg. */ +async function makePng(size = 20): Promise { + const rgba = Buffer.alloc(size * size * 4); + const cx = size / 2; + const cy = size / 2; + const r = size / 3; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + if (Math.hypot(x - cx, y - cy) <= r) { + const i = (y * size + x) * 4; + rgba[i] = 255; + rgba[i + 1] = 255; + rgba[i + 2] = 255; + rgba[i + 3] = 255; + } + } + } + return sharp(rgba, { raw: { width: size, height: size, channels: 4 } }) + .png() + .toBuffer(); +} + +function stubFetch(body: Buffer) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(new Uint8Array(body), { + headers: { 'content-type': 'image/png' }, + }), + ), + ); +} + +describe('trace route', () => { + it('rejects a missing url', async () => { + const res = await trace.request('/'); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Required' }); + }); + + it('rejects a non-http url', async () => { + const res = await trace.request('/?url=file%3A%2F%2F%2Fetc%2Fpasswd'); + expect(res.status).toBe(400); + }); + + it('returns 502 when the upstream fetch fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('error', { status: 500 })), + ); + const res = await trace.request('/?url=https%3A%2F%2Fexample.com%2Fa.png'); + expect(res.status).toBe(502); + }); + + it('returns a BSON shape by default', async () => { + stubFetch(await makePng()); + const res = await trace.request('/?url=https%3A%2F%2Fexample.com%2Fa.png'); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('application/octet-stream'); + + const result = deserialize(new Uint8Array(await res.arrayBuffer())); + expect(result.format).toBe('shape'); + expect(result.mode).toBe('alpha'); + expect(result.width).toBe(20); + expect(result.height).toBe(20); + expect(typeof result.svg).toBe('string'); + expect(result.svg).toContain(' { + stubFetch(await makePng()); + const res = await trace.request( + '/?url=https%3A%2F%2Fexample.com%2Fa.png&format=svg', + ); + expect(res.status).toBe(200); + const result = deserialize(new Uint8Array(await res.arrayBuffer())); + expect(result.format).toBe('svg'); + expect(result.shape).toBeUndefined(); + expect(typeof result.svg).toBe('string'); + }); + + it('supports bw mode', async () => { + stubFetch(await makePng()); + const res = await trace.request( + '/?url=https%3A%2F%2Fexample.com%2Fa.png&mode=bw', + ); + expect(res.status).toBe(200); + const result = deserialize(new Uint8Array(await res.arrayBuffer())); + expect(result.mode).toBe('bw'); + expect(result.shape.outline.length).toBeGreaterThan(3); + }); +}); \ No newline at end of file diff --git a/apps/proxy/src/routes/trace.ts b/apps/proxy/src/routes/trace.ts new file mode 100644 index 0000000..5faa094 --- /dev/null +++ b/apps/proxy/src/routes/trace.ts @@ -0,0 +1,143 @@ +import { Hono } from 'hono'; +import { serialize } from 'bson'; +import sharp from 'sharp'; +import { createRequire } from 'module'; +import { traceRequestSchema, type TraceResult } from '@tts/shared'; +import { parseSvgShape } from './svgShape.js'; + +// vtracer is a CommonJS package; load it via require so the wasm initializes +// with the correct `__dirname`. +const require = createRequire(import.meta.url); +const vtracer = require('@visioncortex/vtracer') as { + convertPixels( + rgba: Uint8Array, + width: number, + height: number, + options: Record, + ): string; +}; + +const app = new Hono(); + +/** + * Trace an image into a vector shape and return it BSON-encoded. + * + * `mode` controls how the traced region is derived: + * - `alpha` (default): the alpha channel (opaque -> shape) + * - `bw`: luminance thresholding + * - `color`: vtracer's native color clustering + * + * `format` selects the response shape: `shape` (default) returns a parsed + * `{ outline, holes }` polygon ready for `@tts/mesh`; `svg` returns the raw + * SVG string. + */ +app.get('/', async (c) => { + const parsed = traceRequestSchema.safeParse(c.req.query()); + if (!parsed.success) { + return c.json({ error: parsed.error.issues[0]?.message }, 400); + } + const { url, mode, threshold, format, simplify, maxColors } = parsed.data; + + // Only allow http(s) to avoid SSRF via file://, etc. + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + return c.json({ error: 'Only http(s) urls are allowed' }, 400); + } + + let image: Buffer; + try { + const res = await fetch(url); + if (!res.ok) { + return c.json({ error: `Image responded ${res.status}` }, 502); + } + image = Buffer.from(await res.arrayBuffer()); + } catch (err) { + return c.json({ error: `Failed to fetch image: ${String(err)}` }, 502); + } + + let svg: string; + let width: number; + let height: number; + try { + const { data, info } = await sharp(image) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + width = info.width; + height = info.height; + + const pixels = buildPixels(data, width, height, mode, threshold); + const options: Record = {}; + if (mode === 'color') { + options.clustering = 'color-cluster'; + if (maxColors !== undefined) options.maxColors = maxColors; + } else { + options.clustering = 'bw'; + options.binaryThreshold = threshold; + } + if (simplify !== undefined) options.simplify = simplify; + + svg = vtracer.convertPixels( + new Uint8Array(pixels), + width, + height, + options, + ); + } catch (err) { + return c.json({ error: `Failed to trace image: ${String(err)}` }, 500); + } + + const result: TraceResult = { + width, + height, + mode, + format, + svg, + }; + if (format === 'shape') { + result.shape = parseSvgShape(svg); + } + + return new Response(Buffer.from(serialize(result)), { + headers: { 'Content-Type': 'application/octet-stream' }, + }); +}); + +/** + * Build the RGBA buffer fed to vtracer for the given mode. + * `alpha` and `bw` produce a binary mask (black foreground, white background) + * so vtracer's `bw` clustering traces the desired region. + */ +function buildPixels( + rgba: Buffer, + width: number, + height: number, + mode: 'alpha' | 'bw' | 'color', + threshold: number, +): Buffer { + if (mode === 'color') return rgba; + + const out = Buffer.alloc(width * height * 4); + for (let i = 0; i < width * height; i++) { + const r = rgba[i * 4]!; + const g = rgba[i * 4 + 1]!; + const b = rgba[i * 4 + 2]!; + const a = rgba[i * 4 + 3]!; + + const isForeground = + mode === 'alpha' + ? a > threshold + : 0.299 * r + 0.587 * g + 0.114 * b < threshold; + + // vtracer treats intensity below `binaryThreshold` as foreground, so the + // foreground region is black and the background is white. + const v = isForeground ? 0 : 255; + out[i * 4] = v; + out[i * 4 + 1] = v; + out[i * 4 + 2] = v; + out[i * 4 + 3] = 255; + } + return out; +} + +export default app; \ No newline at end of file diff --git a/packages/shared/src/schemas.test.ts b/packages/shared/src/schemas.test.ts index 98257f2..ca60cb1 100644 --- a/packages/shared/src/schemas.test.ts +++ b/packages/shared/src/schemas.test.ts @@ -5,6 +5,7 @@ import { itemIdSchema, searchQuerySchema, searchResultSchema, + traceRequestSchema, workshopItemSchema, } from './schemas.js'; @@ -132,4 +133,56 @@ describe('searchResultSchema', () => { const result = { items: [], page: 1, hasMore: false }; expect(searchResultSchema.parse(result)).toEqual(result); }); +}); + +describe('traceRequestSchema', () => { + it('defaults mode, threshold, and format', () => { + expect(traceRequestSchema.parse({ url: 'https://example.com/a.png' })).toEqual({ + url: 'https://example.com/a.png', + mode: 'alpha', + threshold: 128, + format: 'shape', + }); + }); + + it('coerces threshold and accepts optional params', () => { + expect( + traceRequestSchema.parse({ + url: 'https://example.com/a.png', + mode: 'bw', + threshold: '200', + format: 'svg', + simplify: 1.5, + maxColors: '8', + }), + ).toEqual({ + url: 'https://example.com/a.png', + mode: 'bw', + threshold: 200, + format: 'svg', + simplify: 1.5, + maxColors: 8, + }); + }); + + it('rejects an invalid url', () => { + expect(traceRequestSchema.safeParse({ url: 'not-a-url' }).success).toBe( + false, + ); + }); + + it('rejects an unknown mode or format', () => { + expect( + traceRequestSchema.safeParse({ + url: 'https://example.com/a.png', + mode: 'nope', + }).success, + ).toBe(false); + expect( + traceRequestSchema.safeParse({ + url: 'https://example.com/a.png', + format: 'nope', + }).success, + ).toBe(false); + }); }); \ No newline at end of file diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 09e8bed..dfa6f55 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -51,4 +51,18 @@ export const searchResultSchema = z.object({ items: z.array(workshopItemSchema), page: z.number(), hasMore: z.boolean(), +}); + +export const traceModeSchema = z.enum(['alpha', 'bw', 'color']); + +export const traceFormatSchema = z.enum(['svg', 'shape']); + +/** Query params for `GET /trace`. */ +export const traceRequestSchema = z.object({ + url: z.string().url('url must be a valid URL'), + mode: traceModeSchema.default('alpha'), + threshold: z.coerce.number().int().min(0).max(255).default(128), + format: traceFormatSchema.default('shape'), + simplify: z.coerce.number().min(0).optional(), + maxColors: z.coerce.number().int().min(1).optional(), }); \ No newline at end of file diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 329ba2c..8cf7b33 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -109,4 +109,33 @@ export interface ExtractedObject { parentGuid?: string; childrenGuids: string[]; refs: AssetRef[]; +} + +/** How to derive the traced region from the source image. */ +export type TraceMode = 'alpha' | 'bw' | 'color'; + +/** Output format for a traced result. */ +export type TraceFormat = 'svg' | 'shape'; + +/** + * A closed 2D polygon described by its outline, matching the `Shape` + * interface in `@tts/mesh` so a traced result can be extruded directly. + */ +export interface TracedShape { + /** Outline vertices in order, each `[x, y]`. */ + outline: number[][]; + /** Optional holes, each a list of `[x, y]`. */ + holes?: number[][][]; +} + +/** A traced image, BSON-serialized by the proxy. */ +export interface TraceResult { + width: number; + height: number; + mode: TraceMode; + format: TraceFormat; + /** Raw SVG produced by vtracer, kept as a reference. */ + svg: string; + /** Parsed shape, present when `format` is `shape`. */ + shape?: TracedShape; } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f43adb..d2795e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,9 +26,21 @@ importers: '@tts/tts': specifier: workspace:* version: link:../../packages/tts + '@visioncortex/vtracer': + specifier: 1.0.0-alpha.3 + version: 1.0.0-alpha.3 + bson: + specifier: ^6.10.4 + version: 6.10.4 hono: specifier: ^4.6.14 version: 4.13.1 + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@22.20.1) + svgpath: + specifier: ^2.6.0 + version: 2.6.0 zod: specifier: ^3.24.1 version: 3.25.76 @@ -180,6 +192,9 @@ packages: '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -356,6 +371,168 @@ packages: '@iconify/utils@3.1.4': resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -670,6 +847,10 @@ packages: peerDependencies: react: '>= 16.8.0' + '@visioncortex/vtracer@1.0.0-alpha.3': + resolution: {integrity: sha512-82rIJylIXu6dPv4Z0+wjKZVkNsZEdFg4ycDCWytQNuSoYLp31S1iZ+VFJP41+iVUNKmS5wVehwby/brQ91Qikw==} + engines: {node: '>=16'} + '@vitejs/plugin-react@6.0.5': resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1115,9 +1296,23 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1153,6 +1348,9 @@ packages: peerDependencies: react: '>=17.0' + svgpath@2.6.0: + resolution: {integrity: sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg==} + tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -1201,6 +1399,9 @@ packages: troika-worker-utils@0.52.0: resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.11: resolution: {integrity: sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==} engines: {node: '>=18.0.0'} @@ -1373,6 +1574,11 @@ snapshots: '@dimforge/rapier3d-compat@0.12.0': {} + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true @@ -1472,6 +1678,112 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-win32-arm64@0.35.3': + optional: true + + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': + optional: true + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1731,6 +2043,8 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.8 + '@visioncortex/vtracer@1.0.0-alpha.3': {} + '@vitejs/plugin-react@6.0.5(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.11))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -2113,8 +2427,43 @@ snapshots: scheduler@0.27.0: {} + semver@7.8.5: {} + set-cookie-parser@2.7.2: {} + sharp@0.35.3(@types/node@22.20.1): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 22.20.1 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2140,6 +2489,8 @@ snapshots: dependencies: react: 19.2.8 + svgpath@2.6.0: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} @@ -2185,6 +2536,9 @@ snapshots: troika-worker-utils@0.52.0: {} + tslib@2.8.1: + optional: true + tsx@4.23.11: dependencies: esbuild: 0.28.1