Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/bun-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ jobs:
run: bun install

- name: Run tests
run: bun test --timeout 15000
run: bun test --timeout 30000
35 changes: 33 additions & 2 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"": {
"name": "svg.tscircuit.com",
"dependencies": {
"@neplex/vectorizer": "^0.0.5",
"@tscircuit/simple-3d-svg": "^0.0.41",
"circuit-json-to-gltf": "^0.0.23",
"circuit-json-to-simple-3d": "^0.0.9",
Expand All @@ -17,7 +18,7 @@
"@tscircuit/create-snippet-url": "^0.0.9",
"@types/bun": "latest",
"@types/react": "19.0.8",
"bun-match-svg": "^0.0.9",
"bun-match-svg": "^0.0.14",
"comlink": "^4.4.2",
"get-port": "^7.1.0",
"looks-same": "^10.0.1",
Expand Down Expand Up @@ -181,6 +182,34 @@

"@lume/kiwi": ["@lume/[email protected]", "", {}, "sha512-ie0YTKgiZqD4TXlJ4eUbfi4UEoKs6YlLRYNTfPm5eUXwfudTBmPRs7Qcxz2SWKDpVTwThv3sWG6zwtyAA0nPpw=="],

"@neplex/vectorizer": ["@neplex/[email protected]", "", { "dependencies": { "commander": "^13.0.0", "picocolors": "^1.1.1" }, "optionalDependencies": { "@neplex/vectorizer-android-arm-eabi": "0.0.5", "@neplex/vectorizer-android-arm64": "0.0.5", "@neplex/vectorizer-darwin-arm64": "0.0.5", "@neplex/vectorizer-darwin-x64": "0.0.5", "@neplex/vectorizer-freebsd-x64": "0.0.5", "@neplex/vectorizer-linux-arm-gnueabihf": "0.0.5", "@neplex/vectorizer-linux-arm64-gnu": "0.0.5", "@neplex/vectorizer-linux-arm64-musl": "0.0.5", "@neplex/vectorizer-linux-x64-gnu": "0.0.5", "@neplex/vectorizer-linux-x64-musl": "0.0.5", "@neplex/vectorizer-win32-arm64-msvc": "0.0.5", "@neplex/vectorizer-win32-ia32-msvc": "0.0.5", "@neplex/vectorizer-win32-x64-msvc": "0.0.5" }, "bin": { "vectorizer": "cli/index.mjs" } }, "sha512-lGXsEYK3xqAQ1U/kKNr3m7ml5cFZMYPfuCD3gj/g+1fqRjO7ornjS39TjY89EgvZFLWOWQEUzo2QCEgIUPlGyg=="],

"@neplex/vectorizer-android-arm-eabi": ["@neplex/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-UqT68l9qjG1w5qeZ8g9FMGZ1YKkejyE7mHIh4otIj2lXYWQ0z0cfH1gZn4SQb47l2SLGEGJsGdqQLEM1nKdIwQ=="],

"@neplex/vectorizer-android-arm64": ["@neplex/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-OFdfqI00O1oOokeREv0EzKRQVok3Pngsrk9AbUwkV/kpoKY0bYNaRIK1JnddW3nCwgQQxzXxUnahyhPXhoL0xA=="],

"@neplex/vectorizer-darwin-arm64": ["@neplex/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1Z3PjAufy20eZsy8BOq6u32Gz2JNAWgaqNO1IDhZpA5CBZzGvpk77GM7FtcOsC/O1Tb3Q/puKHIDCYs8tKFYyQ=="],

"@neplex/vectorizer-darwin-x64": ["@neplex/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-rOASaDXqbTvipNFk2DjTjaiiqXteE54Y9DyWz4OfEDT8oOdOL66qqKM6rfn5fUEiDstxPmb30BzPDKz74JCJ3g=="],

"@neplex/vectorizer-freebsd-x64": ["@neplex/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jVC1v3645mSLwqBVuXeEY9FWas9253zwV5CsFcDNPL8Eues81m2npt/FBMD/+MluaASAAXrQG0Ree41ACBbwzw=="],

"@neplex/vectorizer-linux-arm-gnueabihf": ["@neplex/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-iF6npMyuJv75860m7Pb8mwvcBpzoVLddSPJoIpq9ocxeABgWiqt8oaxLnt9fby+OheRLMAu/+hRGT9Kvkf7Pfg=="],

"@neplex/vectorizer-linux-arm64-gnu": ["@neplex/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-qByFaIeOecgoywgyYc5KyvfeQVtTvazjSZkOegdWDCyK9KmuFps9NHS2gJIYcd/gIUnlwnkkXWCYgd7ClSBM8g=="],

"@neplex/vectorizer-linux-arm64-musl": ["@neplex/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-r2a85bAkgwSxAbQTSHnzXaDZCyABgVTYf6f0OSh1oGHHIc9pC97VUZbmQLtGFeIQLQR9j4nKjF1MlOHmnV4EDA=="],

"@neplex/vectorizer-linux-x64-gnu": ["@neplex/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-8pdPe27RNXHwkvYiK3vj5b3/Yi8rWgJzUsBdT/Jm2bjk5c32wiV454yT0fLZQjRB1DCAK2DvyHjf6eZ0R9HaJg=="],

"@neplex/vectorizer-linux-x64-musl": ["@neplex/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-VP/DHuX40I/9KzSFRctxksXzJBGwbPE/E30NCAcPA1mS6iApovWsZe3la5dA9A5kStaKh9wTJcZuVEGL8tGIMg=="],

"@neplex/vectorizer-win32-arm64-msvc": ["@neplex/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-VfQRITnqvjABiIcnx5b/9XjyktTbpDHzY2nVt5wplOqGM88f6fPn2JYiia7IEdv2BA/1+oN/Bcw75eq12mW8Ug=="],

"@neplex/vectorizer-win32-ia32-msvc": ["@neplex/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-q1R7Hnz3DK6Ls/XqRoanWEmRj3Goj5BKeayroaIzJy0uMAvpBhzcKw8hbkwIo0Cusm71Pnuninl+8MzfeQRo1w=="],

"@neplex/vectorizer-win32-x64-msvc": ["@neplex/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-At6NLzjv5pXHnXAgDjhhC7qV4suuCWK+lYBH05mdxY4dnvPyhIA3J+uvqNbvI2D5951fTXkF8JoLf6TyAQleVQ=="],

"@next/env": ["@next/[email protected]", "", {}, "sha512-KBiBKrDY6kxTQWGzKjQB7QirL3PiiOkV7KW98leHFjtVRKtft76Ra5qSA/SL75xT44dp6hOcqiiJ6iievLOYug=="],

"@next/swc-darwin-arm64": ["@next/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EAqfOTb3bTGh9+ewpO/jC59uACadRHM6TSA9DdxJB/6gxOpyV+zrbqeXiFTDy9uV6bmipFDkfpAskeaDcO+7/g=="],
Expand Down Expand Up @@ -351,7 +380,7 @@

"buffer-crc32": ["[email protected]", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],

"bun-match-svg": ["[email protected].9", "", { "dependencies": { "looks-same": "^9.0.1" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "bun-match-svg": "cli.ts" } }, "sha512-WISE8cUd3ztIEbYRymuxs+l4qwRviHIhyRQHmDz26vnhKON9g+Ur+2L3pfJrffpGiYWGF/jtWoWmYRQiaimTxg=="],
"bun-match-svg": ["[email protected].14", "", { "dependencies": { "looks-same": "^9.0.1" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "bun-match-svg": "cli.ts" } }, "sha512-6tiAOY70cwf0aptY/0etMa3/8W1JggJBk3odwPhLhcUABoS3gaEbfYe8hE2z3o/tcbZ/imcmKz94c3T/ht7M+Q=="],

"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="],

Expand Down Expand Up @@ -921,6 +950,8 @@

"@isaacs/cliui/wrap-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],

"@neplex/vectorizer/commander": ["[email protected]", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],

"@tscircuit/circuit-json-flex/@tscircuit/miniflex": ["@tscircuit/[email protected]", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-oRC0up2psp8dJD1CzXyUiFuhQZUWLdZNl9EAqOf/hHqXDhPKMU6wM79S+XQuaB0gdWNRnwcURHPPaKLw/ka3DQ=="],

"@tscircuit/core/nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
Expand Down
30 changes: 20 additions & 10 deletions handlers/three-d-svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,26 @@ export const threeDSvgHandler = async (
ctx.url.searchParams.get("background_color") ||
ctx.backgroundColor ||
"#fff"
const backgroundOpacity = parseFloat(
ctx.url.searchParams.get("background_opacity") ||
String(ctx.backgroundOpacity) ||
"0.0",
)
const zoomMultiplier = parseFloat(
ctx.url.searchParams.get("zoom_multiplier") ||
String(ctx.zoomMultiplier) ||
"1.2",
)

const rawBgOpacity = ctx.url.searchParams.get("background_opacity")
let backgroundOpacity =
rawBgOpacity != null
? Number(rawBgOpacity)
: typeof ctx.backgroundOpacity === "number"
? ctx.backgroundOpacity
: 0.0
if (!Number.isFinite(backgroundOpacity)) backgroundOpacity = 0.0
if (backgroundOpacity < 0) backgroundOpacity = 0
if (backgroundOpacity > 1) backgroundOpacity = 1

const rawZoom = ctx.url.searchParams.get("zoom_multiplier")
let zoomMultiplier =
rawZoom != null
? Number(rawZoom)
: typeof ctx.zoomMultiplier === "number"
? ctx.zoomMultiplier
: 1.2
if (!Number.isFinite(zoomMultiplier)) zoomMultiplier = 1.2

const svgContent = await renderCircuitToSvg(circuitJson, "3d", {
backgroundColor,
Expand Down
15 changes: 13 additions & 2 deletions lib/render3dPng.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { renderGLTFToPNGBufferFromGLBBuffer } from "poppygl"
export interface Render3dPngOptions {
width?: number
height?: number
zoomMultiplier?: number
}

export async function render3dPng(
Expand Down Expand Up @@ -49,8 +50,18 @@ export async function render3dPng(
}
}

const yHeight = maxDim * 1.4
const xzOffset = maxDim * 0.75
const baseY = maxDim * 1.4
const baseXZ = maxDim * 0.75

// Apply zoom: keep previous default framing when zoomMultiplier === 1.2
const zoomInput = options.zoomMultiplier
const zoom = Number.isFinite(zoomInput as number)
? (zoomInput as number)
: 1.2
const effectiveScale = 1.2 / zoom

const yHeight = baseY * effectiveScale
const xzOffset = baseXZ * effectiveScale

const camPos: [number, number, number] = [xzOffset, yHeight, xzOffset]
const lookAt: [number, number, number] = [0, 0, 0]
Expand Down
47 changes: 40 additions & 7 deletions lib/renderCircuitToSvg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
convertCircuitJsonToPinoutSvg,
convertCircuitJsonToSchematicSimulationSvg,
} from "circuit-to-svg"
import { convertCircuitJsonToSimple3dSvg } from "circuit-json-to-simple-3d/dist/index.js"
import { render3dPng } from "./render3dPng"
import { Buffer } from "node:buffer"
import * as vectorizerMod from "@neplex/vectorizer"

export interface RenderOptions {
backgroundColor?: string
Expand Down Expand Up @@ -35,6 +37,9 @@ export async function renderCircuitToSvg(
zoomMultiplier = 1.2,
} = options

const bgOpacity = Number.isFinite(backgroundOpacity) ? backgroundOpacity : 0.0
const zoom = Number.isFinite(zoomMultiplier) ? zoomMultiplier : 1.2

if (svgType === "assembly") {
return convertCircuitJsonToAssemblySvg(circuitJson)
}
Expand Down Expand Up @@ -68,13 +73,41 @@ export async function renderCircuitToSvg(
}

if (svgType === "3d") {
return await convertCircuitJsonToSimple3dSvg(circuitJson, {
background: {
color: backgroundColor,
opacity: backgroundOpacity,
},
defaultZoomMultiplier: zoomMultiplier,
const pngBinary = await render3dPng(circuitJson, {
width: 1024,
height: 1024,
zoomMultiplier: zoom,
})

try {
const vectorize = vectorizerMod.vectorize

// Add missing required properties for vectorize config
const svgResult = await vectorize(Buffer.from(pngBinary), {
mode: vectorizerMod.PathSimplifyMode.Polygon,
colorMode: vectorizerMod.ColorMode.Color,
hierarchical: vectorizerMod.Hierarchical.Stacked,
filterSpeckle: 8,
colorPrecision: 8,
layerDifference: 8,
maxIterations: 100,
// Set required threshold properties with reasonable defaults
cornerThreshold: 60,
lengthThreshold: 4,
spliceThreshold: 30,
})

if (bgOpacity > 0) {
return svgResult.replace(
/<svg([^>]*)>/,
`<svg$1><rect width="100%" height="100%" fill="${backgroundColor}" fill-opacity="${bgOpacity}"/>`,
)
}
return svgResult
} catch {
const base64 = Buffer.from(pngBinary).toString("base64")
return `<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><image href="data:image/png;base64,${base64}" width="1024" height="1024"/></svg>`
}
}

throw new Error(`Invalid SVG type: ${svgType}`)
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"@tscircuit/create-snippet-url": "^0.0.9",
"@types/bun": "latest",
"@types/react": "19.0.8",
"bun-match-svg": "^0.0.9",
"bun-match-svg": "^0.0.14",
"comlink": "^4.4.2",
"get-port": "^7.1.0",
"looks-same": "^10.0.1",
Expand All @@ -36,6 +36,7 @@
"circuit-to-svg": "^0.0.229",
"jscad-fiber": "^0.0.85",
"poppygl": "^0.0.14",
"sharp": "^0.34.4"
"sharp": "^0.34.4",
"@neplex/vectorizer": "^0.0.5"
}
}
59 changes: 59 additions & 0 deletions tests/3d-benchmark.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, test } from "bun:test"
import { Buffer } from "node:buffer"
import testCircuitJson from "./fixtures/test-circuit.json"
import { renderCircuitToSvg } from "../lib/renderCircuitToSvg"
import { convertCircuitJsonToSimple3dSvg } from "circuit-json-to-simple-3d"

test(
"3d rendering benchmark and size comparison",
async () => {
// Vectorized 3D (PNG -> @neplex/vectorizer)
const startVectorized = performance.now()
const vectorizedSvg = await renderCircuitToSvg(
testCircuitJson as any,
"3d",
{
backgroundColor: "#ffffff",
backgroundOpacity: 0.0,
zoomMultiplier: 1.2,
},
)
const durationVectorized = performance.now() - startVectorized

expect(typeof vectorizedSvg).toBe("string")
expect(vectorizedSvg).toContain("<svg")

const sizeVectorized = Buffer.byteLength(vectorizedSvg, "utf8")

// Deprecated simple 3D SVG
const startSimple3d = performance.now()
const simple3dSvg = (await convertCircuitJsonToSimple3dSvg(
testCircuitJson as any,
{
background: {
color: "#ffffff",
opacity: 0.0,
},
defaultZoomMultiplier: 1.2,
},
)) as string
const durationSimple3d = performance.now() - startSimple3d

expect(typeof simple3dSvg).toBe("string")
expect(simple3dSvg).toContain("<svg")

const sizeSimple3d = Buffer.byteLength(simple3dSvg, "utf8")

// Basic sanity checks
expect(durationVectorized).toBeGreaterThan(0)
expect(durationSimple3d).toBeGreaterThan(0)
expect(sizeVectorized).toBeGreaterThan(0)
expect(sizeSimple3d).toBeGreaterThan(0)

// Log benchmark results for visibility in CI
console.log(
`[3d benchmark] vectorized: ${durationVectorized.toFixed(1)}ms, size=${sizeVectorized}B; simple3d: ${durationSimple3d.toFixed(1)}ms, size=${sizeSimple3d}B`,
)
},
{ timeout: 30000 },
)
Loading